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
fetchand built-instructuredClone) - TypeScript 5.x and
tsxfor running TS directly openainpm 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.