The patterns for typescript openai sdk function calling have stabilized around the tools API and JSON-schema parameter definitions. This tutorial builds a runnable Node.js script that defines a weather lookup tool, sends it to the model, executes the returned call against a local function, and feeds the result back for a final answer.
Prerequisites
- Node.js 18 or newer (global
fetchis required) - An OpenAI API key, or any key for an OpenAI-compatible endpoint
- Working TypeScript knowledge and comfort with
async/await
echo "OPENAI_API_KEY=sk-..." > .env
Project setup
mkdir ts-fn-demo && cd ts-fn-demo
npm init -y
npm install openai zod zod-to-json-schema tsx
npm install -D typescript
Create a minimal tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"esModuleInterop": true
}
}
We use tsx to run TypeScript directly without a build step.
Define the function schema
The OpenAI SDK accepts tools as objects with a function field. Parameters must be a valid JSON Schema object. Define a single get_weather tool:
import type { ChatCompletionTool } from "openai/resources/chat/completions";
const getWeatherTool: ChatCompletionTool = {
type: "function",
function: {
name: "get_weather",
parameters: {
type: "object",
properties: {
city: { type: "string", description: "City name, e.g. 'Berlin'" },
units: { type: "string", enum: ["celsius", "fahrenheit"], default: "celsius" }
},
required: ["city"]
}
}
};
Implement the local function
Keep the actual logic isolated so it can be unit-tested without the model. Here we stub a backend call:
interface WeatherResult { city: string; tempC: number; }
async function getWeather(city: string, units: "celsius" | "fahrenheit" = "celsius"): Promise<WeatherResult> {
// Replace with a real HTTP call to your weather provider
const base = 21;
const tempC = base + (city.length % 5);
return { city, tempC };
}
Run the first completion
Send the user message and the tool. The model may reply with a tool_calls array instead of text content.
import OpenAI from "openai";
import "dotenv/config";
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "user", content: "What's the temperature in Tokyo?" }
];
const res = await client.chat.completions.create({
model: "gpt-4o-mini",
messages,
tools: [getWeatherTool],
tool_choice: "auto"
});
const msg = res.choices[0].message;
console.log(JSON.stringify(msg, null, 2));
Expected output (abridged):
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_abc123",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"city\":\"Tokyo\"}"
}
}
]
}
If you see content with natural language instead, the model decided a tool wasn’t needed. That’s valid; handle both branches.
Execute the tool call
When tool_calls is present, map each call to your local function. Parse arguments defensively—models occasionally emit empty strings for optional fields.
if (msg.tool_calls) {
messages.push(msg); // retain assistant message with tool_calls
for (const call of msg.tool_calls) {
if (call.function.name === "get_weather") {
const args = JSON.parse(call.function.arguments) as {
city: string;
units?: "celsius" | "fahrenheit"
};
const data = await getWeather(args.city, args.units ?? "celsius");
messages.push({
role: "tool",
tool_call_id: call.id,
content: JSON.stringify(data)
});
}
}
}
Send the results back
Now ask the model to produce the final answer using the tool output:
const final = await client.chat.completions.create({
model: "gpt-4o-mini",
messages
});
console.log(final.choices[0].message.content);
Expected output:
The temperature in Tokyo is currently 23°C.
Full conversation loop
Production code usually wraps this in a bounded loop to handle multiple tool rounds. Here is a minimal version:
async function runConversation(userInput: string) {
const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "user", content: userInput }
];
const tools = [getWeatherTool];
for (let i = 0; i < 3; i++) {
const res = await client.chat.completions.create({
model: "gpt-4o-mini",
messages,
tools
});
const msg = res.choices[0].message;
messages.push(msg);
if (!msg.tool_calls) return msg.content;
for (const call of msg.tool_calls) {
if (call.function.name === "get_weather") {
const args = JSON.parse(call.function.arguments);
const data = await getWeather(args.city, args.units);
messages.push({
role: "tool",
tool_call_id: call.id,
content: JSON.stringify(data)
});
}
}
}
return "Max tool rounds exceeded";
}
Type safety without schema duplication
Hand-written JSON Schema drifts from runtime types. Use zod to define arguments once, generate the schema, and parse model output at the boundary:
import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
const WeatherArgs = z.object({
city: z.string(),
units: z.enum(["celsius", "fahrenheit"]).default("celsius")
});
const tool: ChatCompletionTool = {
type: "function",
function: {
name: "get_weather",
parameters: zodToJsonSchema(WeatherArgs)
}
};
// Later, when handling a call:
const args = WeatherArgs.parse(JSON.parse(call.function.arguments));
This throws on malformed arguments before they reach your backend.
Parallel tool calls
The model can return multiple tool_calls in one message. Execute them concurrently, but push the tool messages after they resolve. Order does not matter because each carries its tool_call_id.
if (msg.tool_calls) {
messages.push(msg);
await Promise.all(msg.tool_calls.map(async (call) => {
if (call.function.name === "get_weather") {
const args = WeatherArgs.parse(JSON.parse(call.function.arguments));
const data = await getWeather(args.city, args.units);
messages.push({
role: "tool",
tool_call_id: call.id,
content: JSON.stringify(data)
});
}
}));
}
Error handling inside tools
A thrown error in a tool should not crash the loop. Catch it and return a structured error as the tool content:
try {
const data = await getWeather(args.city, args.units);
messages.push({ role: "tool", tool_call_id: call.id, content: JSON.stringify(data) });
} catch (err) {
messages.push({
role: "tool",
tool_call_id: call.id,
content: JSON.stringify({ error: (err as Error).message })
});
}
The model can then explain the failure or retry with different arguments.
Message role rules
The API enforces strict ordering:
- An
assistantmessage containingtool_callsmust precede anytoolmessages that reference those IDs. toolmessages cannot appear without a preceding matchingtool_callsentry.- System and user messages can interleave normally, but tool results only attach to the specific call ID.
Forgetting to push the assistant message is the most common 400 error in typescript openai sdk function calling integrations.
Routing through an OpenAI-compatible gateway
If you route through an OpenAI-compatible gateway such as n4n.ai, the same client code works unchanged and you get automatic fallback when a provider is rate-limited or degraded. Point the SDK at the gateway by setting baseURL:
const client = new OpenAI({
baseURL: "https://api.n4n.ai/v1",
apiKey: process.env.N4N_API_KEY
});
The tools payload, tool_calls handling, and retry loop stay identical. The gateway forwards provider cache-control hints, so repeated schemas cost fewer tokens.
Testing locally
Mock the model in unit tests by stubbing client.chat.completions.create to return a fixed tool_calls payload, then assert your executor calls getWeather with parsed args. This catches schema drift without spending tokens.
// pseudocode with vitest
expect(await runConversation("Tokyo")).toContain("Tokyo");
Checkpoint: end-to-end run
npx tsx index.ts
You should see the assistant’s tool-call JSON, then a final natural-language sentence. A 401 means the key is missing. A 400 on the second call means a tool_call_id mismatch or missing assistant message.
That is the complete, runnable pattern for typescript openai sdk function calling in a Node.js service: define schemas, dispatch, execute, and feed results back inside a bounded loop.