n4nAI

Building a multi-tool agent in TypeScript and Node.js

Hands-on tutorial to build a multi-tool agent in TypeScript and Node.js with function calling against OpenAI-compatible LLM endpoints, including runnable code.

n4n Team2 min read535 words

Audio narration

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

A multi-tool agent typescript nodejs project lets you orchestrate LLM calls with external functions without adopting a heavy framework. This tutorial builds a minimal agent loop that declares tools, dispatches model-requested calls, and feeds results back until the model returns a final answer, using the OpenAI Chat Completions API.

Prerequisites

  • Node.js 18+ (for built-in fetch and ESM support)
  • npm or pnpm
  • TypeScript 5.x
  • An API key for an OpenAI-compatible endpoint. You can use OpenAI directly, or point the client at a gateway like n4n.ai that exposes one endpoint for 240+ models and handles provider fallback automatically.

No prior agent framework experience required. We will use the official openai SDK.

Scaffold the project

Create the directory and install dependencies:

mkdir ts-agent && cd ts-agent
npm init -y
npm install openai typescript tsx @types/node dotenv

Set "type": "module" in package.json and add a tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "outDir": "dist"
  },
  "include": ["src"]
}

Create .env:

OPENAI_API_KEY=sk-...
OPENAI_BASE_URL=https://api.openai.com/v1
MODEL=gpt-4o-mini

If you use a gateway, set OPENAI_BASE_URL to its OpenAI-compatible path.

Define tool schemas and handlers

We will give the agent three tools: a mock weather lookup, a calculator, and a current-date reader. The model sees JSON Schema; your code sees a dispatch table.

// src/tools.ts
import type { ChatCompletionTool } from 'openai/resources/chat/completions';

export const tools: ChatCompletionTool[] = [
  {
    type: 'function',
    function: {
      name: 'get_weather',
      parameters: {
        type: 'object',
        properties: { location: { type: 'string' } },
        required: ['location'],
      },
    },
  },
  {
    type: 'function',
    function: {
      name: 'calculator',
      parameters: {
        type: 'object',
        properties: { expression: { type: 'string' } },
        required: ['expression'],
      },
    },
  },
  {
    type: 'function',
    function: {
      name: 'get_date',
      parameters: { type: 'object', properties: {} },
    },
  },
];

export const handlers: Record<string, (args: any) => Promise<string>> = {
  get_weather: async ({ location }) => {
    // Mock: real impl would call a weather API
    return JSON.stringify({ location, temp_c: 22 });
  },
  calculator: async ({ expression }) => {
    const sanitized = expression.replace(/[^0-9+\-*/().\s]/g, '');
    const result = Function(`"use strict"; return (${sanitized})`)();
    return String(result);
  },
  get_date: async () => new Date().toISOString().slice(0, 10),
};

The calculator uses a restricted Function eval. In production, use a proper expression parser.

Build the agent loop

The core loop sends messages, checks for tool_calls, executes them locally, and appends results. We cap iterations to avoid runaway loops.

// src/agent.ts
import OpenAI from 'openai';
import dotenv from 'dotenv';
import { tools, handlers } from './tools';

dotenv.config();

const client = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
  baseURL: process.env.OPENAI_BASE_URL,
});

type Msg = OpenAI.Chat.Completions.ChatCompletionMessageParam;

export async function runAgent(query: string, maxSteps = 5): Promise<string> {
  const messages: Msg[] = [
    { role: 'system', content: 'You are a concise assistant. Use tools when needed.' },
    { role: 'user', content: query },
  ];

  for (let step = 0; step < maxSteps; step++) {
    const resp = await client.chat.completions.create({
      model: process.env.MODEL!,
      messages,
      tools,
    });

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

    if (!msg.tool_calls) {
      return msg.content ?? '(no content)';
    }

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

Key detail: the assistant message with tool_calls must be persisted in the conversation, and each tool result must carry the matching tool_call_id. Drop either and the API rejects the request.

Run it

Add an entrypoint:

// src/main.ts
import { runAgent } from './agent';

const query = process.argv[2] ?? 'What is 12 * 8 and the weather in Berlin?';
runAgent(query)
  .then(console.log)
  .catch((e) => { console.error(e); process.exit(1); });

Execute with tsx:

npx tsx src/main.ts "What is 12 * 8 and the weather in Berlin? Also, what date is today?"

Expected transcript (abbreviated):

Tool call: calculator({expression: "12 * 8"}) -> "96"
Tool call: get_weather({location: "Berlin"}) -> '{"location":"Berlin","temp_c":22}'
Tool call: get_date({}) -> "2025-03-14"
Final: 12 * 8 is 96. Berlin is 22°C. Today is 2025-03-14.

The exact final phrasing depends on the model, but the tool calls and returned values will match.

Typing the handlers

The any in handlers is pragmatic but sloppy. Tighten it with a schema-derived type or a small validator:

import { z } from 'zod';
const WeatherArgs = z.object({ location: z.string() });
// inside handler:
const { location } = WeatherArgs.parse(args);

Do this before shipping. The model occasionally sends malformed arguments; validation prevents cryptic runtime errors.

Error isolation

If a tool throws, the loop currently crashes. Wrap the handler call and return the error as a tool result so the model can recover:

let output: string;
try {
  output = await handlers[name](args);
} catch (err) {
  output = `Error: ${(err as Error).message}`;
}
messages.push({ role: 'tool', tool_call_id: call.id, content: output });

This turns a hard failure into a recoverable step, which is how real agents behave.

Why this loop beats a framework (for now)

Frameworks abstract the tool_calls plumbing, but they also hide the exact request shape. When something breaks—a schema mismatch, a missing tool_call_id—you need to understand this loop anyway. Build it once by hand; later, swap in LangChain or a gateway-specific SDK only if the marginal features pay off.

If you point the client at n4n.ai, its automatic fallback when a provider is rate-limited means you can skip custom retry logic in the loop above. The same code works across providers because the request format is OpenAI-compatible.

Extending the agent

  • Streaming: Pass stream: true and handle chat.completions.create as an async iterator. Tool calls arrive in deltas; buffer them before dispatching.
  • Parallel tools: The API may return multiple tool_calls in one message. Our loop already handles them sequentially; you can Promise.all if the tools are independent.
  • Per-token cost tracking: Capture resp.usage after each step to meter spend. Gateways that report usage per token make this trivial.
  • Routing directives: Some gateways honor client-side model routing hints. If you need a specific provider for one tool, set model dynamically per step.

The loop is the primitive. Everything else is configuration.

Tagstypescriptnodejsagentsfunction-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 →