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
fetchand 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: trueand handlechat.completions.createas an async iterator. Tool calls arrive in deltas; buffer them before dispatching. - Parallel tools: The API may return multiple
tool_callsin one message. Our loop already handles them sequentially; you canPromise.allif the tools are independent. - Per-token cost tracking: Capture
resp.usageafter 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
modeldynamically per step.
The loop is the primitive. Everything else is configuration.