n4nAI

Function calling with TypeScript and the OpenAI SDK

A hands-on tutorial for implementing TypeScript OpenAI SDK function calling in Node.js, from tool schema definition to executing local functions.

n4n Team3 min read580 words

Audio narration

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

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 fetch is 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 assistant message containing tool_calls must precede any tool messages that reference those IDs.
  • tool messages cannot appear without a preceding matching tool_calls entry.
  • 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.

Tagstypescriptopenai-sdkfunction-callingtutorial

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 →