n4nAI

Building an LLM tool-calling agent in TypeScript

Hands-on tutorial for building an LLM tool-calling agent in TypeScript: set up the OpenAI SDK, define JSON Schema tools, and run a ReAct loop with real code.

n4n Team2 min read530 words

Audio narration

Coming soon — every post will get a voice note here.

Building an llm tool-calling agent typescript application requires more than a single chat completion call. You need a loop that sends model output to your code, executes functions, and feeds results back until the model finishes. This tutorial walks through a minimal but production-shaped implementation using the OpenAI Node SDK and TypeScript.

Prerequisites

  • Node.js 18+ (for global fetch and built-in structuredClone)
  • TypeScript 5.x and tsx for running TS directly
  • openai npm package (v4+)
  • An API key from a provider or an OpenAI-compatible gateway

If you would rather not manage provider outages yourself, point the client at n4n.ai’s OpenAI-compatible endpoint that addresses 240+ models; it falls back automatically when a provider is rate-limited or degraded. The rest of the code stays identical.

Project Setup

Create the project and install dependencies:

mkdir ts-agent && cd ts-agent
npm init -y
npm install openai dotenv
npm install -D typescript tsx @types/node
npx tsc --init --target ES2022 --module NodeNext

Create a .env file with your key:

OPENAI_API_KEY=sk-...
# Or if using a gateway:
# OPENAI_API_KEY=your-gateway-key
# BASE_URL=https://api.n4n.ai/v1

A bare tsconfig.json with NodeNext modules is enough. Keep strict on; the types catch malformed tool calls early.

Defining Tools as JSON Schema

The model doesn’t call your functions directly. It returns a structured request; your code executes it. Define tools with strict JSON Schema so the model knows what arguments to produce.

type ToolDefinition = {
  name: string;
  parameters: Record<string, unknown>;
};

const tools: ToolDefinition[] = [
  {
    name: 'get_weather',
    parameters: {
      type: 'object',
      properties: {
        location: { type: 'string' },
        unit: { type: 'string', enum: ['celsius', 'fahrenheit'] },
      },
      required: ['location'],
    },
  },
  {
    name: 'calculate',
    parameters: {
      type: 'object',
      properties: { expression: { type: 'string' } },
      required: ['expression'],
    },
  },
];

Map them to the OpenAI wire format. The SDK accepts tools as an array of function objects:

import OpenAI from 'openai';

const openaiTools = tools.map((t) => ({
  type: 'function' as const,
  function: {
    name: t.name,
    parameters: t.parameters,
  },
}));

Building the Agent Loop

The llm tool-calling agent typescript pattern is a ReAct loop: reason, act, observe, repeat. The loop ends when the model returns no tool_calls.

import type { ChatCompletionMessageParam } from 'openai/resources/chat/completions';

const client = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
  baseURL: process.env.BASE_URL, // optional gateway
});

async function runAgent(userInput: string, maxTurns = 5): Promise<string> {
  const messages: ChatCompletionMessageParam[] = [
    { role: 'system', content: 'You are a concise assistant with tools.' },
    { role: 'user', content: userInput },
  ];

  for (let turn = 0; turn < maxTurns; turn++) {
    const res = await client.chat.completions.create({
      model: 'gpt-4o-mini',
      messages,
      tools: openaiTools,
      tool_choice: 'auto',
    });

    const msg = res.choices[0].message;
    messages.push(msg);

    if (!msg.tool_calls || msg.tool_calls.length === 0) {
      return msg.content ?? '';
    }

    for (const call of msg.tool_calls) {
      const args = JSON.parse(call.function.arguments);
      const output = await executeTool(call.function.name, args);
      messages.push({
        role: 'tool',
        tool_call_id: call.id,
        content: JSON.stringify(output),
      });
    }
  }
  throw new Error('Agent exceeded max turns');
}

Note the tool_choice: 'auto'. Force it to 'none' if you want a pure chat response, or force a specific function for deterministic flows.

Executing Tools Safely

Tool execution is just a switch. In real systems, this is where you hit databases, APIs, or sandboxed code. Keep the interface { name, args } -> JSON-serializable result.

async function executeTool(name: string, args: Record<string, unknown>): Promise<unknown> {
  switch (name) {
    case 'get_weather': {
      const location = String(args.location);
      // Stub: replace with real geocoding + weather API
      return { location, temperature: 21, unit: args.unit ?? 'celsius' };
    }
    case 'calculate': {
      const expr = String(args.expression);
      if (!/^[0-9+\-*/().\s]+$/.test(expr)) {
        throw new Error('Invalid expression');
      }
      // eslint-disable-next-line no-eval
      const result = Function(`"use strict"; return (${expr})`)();
      return { result };
    }
    default:
      throw new Error(`Unknown tool: ${name}`);
  }
}

The calculate case uses a restricted Function constructor instead of eval directly, and validates the input with a regex. Never pass raw model output to a shell or arbitrary evaluator.

Running the Agent

Wire a main function and run with tsx:

async function main() {
  const answer = await runAgent(
    'What is 12 * (3 + 4) and the weather in Tokyo in celsius?'
  );
  console.log('Final answer:', answer);
}

main().catch((e) => {
  console.error(e);
  process.exit(1);
});

Run it:

npx tsx index.ts

Expected output at the first model turn

Before the final answer, the agent makes a completion that looks like this (trimmed):

{
  "choices": [
    {
      "message": {
        "role": "assistant",
        "content": null,
        "tool_calls": [
          {
            "id": "call_abc",
            "type": "function",
            "function": {
              "name": "calculate",
              "arguments": "{\"expression\":\"12 * (3 + 4)\"}"
            }
          },
          {
            "id": "call_def",
            "type": "function",
            "function": {
              "name": "get_weather",
              "arguments": "{\"location\":\"Tokyo\",\"unit\":\"celsius\"}"
            }
          }
        ]
      }
    }
  ]
}

The loop then pushes two role: 'tool' messages with the computed results. The second model turn consumes those and returns natural language:

Final answer: 12 * (3 + 4) equals 84. The weather in Tokyo is currently 21°C.

Error Handling and Rate Limits

Wrap the create call in try/catch. On APIError with status 429, back off and retry. If you use a gateway with automatic fallback, the retry logic can be simpler because the gateway already routes to a healthy provider.

For tool errors, return a structured error as the tool result instead of throwing out of the loop. The model can often recover:

try {
  const output = await executeTool(call.function.name, args);
  messages.push({ role: 'tool', tool_call_id: call.id, content: JSON.stringify(output) });
} catch (err) {
  messages.push({
    role: 'tool',
    tool_call_id: call.id,
    content: JSON.stringify({ error: String(err) }),
  });
}

Streaming and Latency

The example uses blocking calls. For UX, stream the final assistant message by passing stream: true on the last turn when no tool calls are present. Tool-call streaming is supported in newer models but adds parsing complexity; only do it when the latency budget demands it.

Where the llm tool-calling agent typescript code goes next

Add a zod schema per tool to validate arguments before execution. Replace the stub weather call with fetch to a real API. Persist messages to a database if you need multi-session memory. The core loop you just wrote will not change.

Keep the agent loop under 100 lines. Frameworks help at scale, but the primitive is this simple.

Tagstypescriptagentstool-usefunction-calling

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All function calling in typescript/node.js posts →