n4nAI

Function calling with LangChain.js: a TypeScript tutorial

A hands-on TypeScript tutorial for langchainjs typescript function calling: define typed tools, bind to a chat model, and run the agent loop.

n4n Team3 min read616 words

Audio narration

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

This tutorial walks through langchainjs typescript function calling from scratch: defining typed tools, binding them to a chat model, and executing the returned calls in a TypeScript Node app. You’ll end up with a small but real agent loop that handles multi-step tool use without a heavy framework.

Prerequisites

  • Node.js 18 or newer (fetch is global)
  • TypeScript 5.4+
  • An OpenAI API key, or any OpenAI-compatible endpoint credentials
  • Comfort with async/await and basic Zod schemas

Project setup

Create a directory and install the minimal set of packages. LangChain.js splits core abstractions from provider packages, so you need both.

mkdir lc-fn-call && cd lc-fn-call
npm init -y
npm install @langchain/core@^0.3.0 @langchain/openai@^0.3.0 zod@^3.23.8
npm install -D typescript@^5.5.0 tsx@^4.16.0

Create a tsconfig.json that targets modern Node:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "outDir": "dist"
  },
  "include": ["src/**/*.ts"]
}

Put your API key in .env or export it inline. For OpenAI:

export OPENAI_API_KEY=sk-...

Define tools with Zod

LangChain.js treats a tool as a callable with a name, description, and JSON schema. The tool helper from @langchain/core/tools wraps an async function and infers the schema from Zod.

import { tool } from "@langchain/core/tools";
import { z } from "zod";

const getWeather = tool(
  async ({ city }: { city: string }) => {
    // Mock external API call
    const fake: Record<string, string> = {
      paris: "18°C, partly cloudy",
      london: "12°C, rain",
    };
    return fake[city.toLowerCase()] ?? "unknown";
  },
  {
    name: "get_weather",
    schema: z.object({ city: z.string().describe("City name") }),
  }
);

const calculate = tool(
  async ({ expression }: { expression: string }) => {
    // Safe-ish eval for demo only; use a real math parser in prod
    const sanitized = expression.replace(/[^0-9+\-*/().\s]/g, "");
    // eslint-disable-next-line no-eval
    return eval(sanitized);
  },
  {
    name: "calculate",
    schema: z.object({ expression: z.string() }),
  }
);

The schema is what the model sees. Be explicit in describe calls; vague schemas produce garbage arguments.

Bind tools to the chat model

Create a ChatOpenAI instance and bind the tools. bindTools translates the Zod schemas into the provider’s function-calling format (OpenAI JSON schema, Anthropic, etc.).

import { ChatOpenAI } from "@langchain/openai";

const model = new ChatOpenAI({
  model: "gpt-4o-mini",
  temperature: 0,
});

const modelWithTools = model.bindTools([getWeather, calculate]);

If you want a single OpenAI-compatible endpoint that addresses 240+ models with automatic fallback when a provider is rate-limited, point baseURL at n4n.ai and keep the same bindTools call. The gateway forwards cache-control hints and meters per token.

Controlling tool choice

You can force or restrict which tool the model picks by passing options to bindTools. This is useful for deterministic pipelines.

const forced = model.bindTools([getWeather], { tool_choice: "get_weather" });

The default is tool_choice: "auto", letting the model decide.

Typing the tool map

Avoid any in your loop. Define a record of tools:

import type { DynamicTool } from "@langchain/core/tools";
const toolMap: Record<string, DynamicTool> = { get_weather: getWeather, calculate };

This gives you compile-time safety on selected.invoke.

Run the agent loop

LangChain does not force you into a prebuilt agent. For many production systems, an explicit loop is easier to debug. Below is a minimal version.

import { AIMessage, HumanMessage, ToolMessage } from "@langchain/core/messages";

async function runAgent(input: string): Promise<string> {
  const messages = [new HumanMessage(input)];

  for (let i = 0; i < 5; i++) {
    const aiMsg: AIMessage = await modelWithTools.invoke(messages);
    messages.push(aiMsg);

    if (!aiMsg.tool_calls || aiMsg.tool_calls.length === 0) {
      return typeof aiMsg.content === "string"
        ? aiMsg.content
        : "Non-text response";
    }

    for (const call of aiMsg.tool_calls) {
      const selected = toolMap[call.name as keyof typeof toolMap];
      if (!selected) throw new Error(`Unknown tool: ${call.name}`);
      try {
        const result = await selected.invoke(call.args);
        messages.push(
          new ToolMessage({ content: String(result), tool_call_id: call.id! })
        );
      } catch (err) {
        messages.push(
          new ToolMessage({
            content: `Error: ${(err as Error).message}`,
            tool_call_id: call.id!,
          })
        );
      }
    }
  }
  throw new Error("Exceeded max iterations");
}

Key points: tool_calls is an array on AIMessage. Each call has id, name, and args. You must return a ToolMessage with matching tool_call_id or the next model call will error.

Execute and inspect output

Create src/index.ts:

async function main() {
  const answer = await runAgent(
    "What is the weather in Paris and what is 24 * 7?"
  );
  console.log("Final answer:", answer);
}

main().catch(console.error);

Run with tsx:

npx tsx src/index.ts

Expected console output (tool calls are internal, but if you log aiMsg.tool_calls you’d see):

Final answer: The weather in Paris is 18°C, partly cloudy, and 24 * 7 equals 168.

If you add a debug log before the loop returns:

console.log("Tool calls:", aiMsg.tool_calls);

You’ll see:

[
  { "id": "call_1", "name": "get_weather", "args": { "city": "Paris" } },
  { "id": "call_2", "name": "calculate", "args": { "expression": "24 * 7" } }
]

The model issued both calls in one turn. LangChain.js sends them in parallel; your loop executes them sequentially, which is fine for independent tools.

Multi-turn conversations

The messages array persists across turns. To build a CLI chat, extract the inner loop and keep messages outside:

const messages = [];
while (true) {
  const input = await prompt("> ");
  messages.push(new HumanMessage(input));
  const aiMsg = await modelWithTools.invoke(messages);
  messages.push(aiMsg);
  if (aiMsg.tool_calls) {
    for (const call of aiMsg.tool_calls) {
      const selected = toolMap[call.name];
      const res = await selected.invoke(call.args);
      messages.push(new ToolMessage({ content: String(res), tool_call_id: call.id! }));
    }
  } else {
    console.log(aiMsg.content);
  }
}

This pattern gives you full visibility into the state the model sees.

Streaming tool calls

If latency matters, stream tokens with .stream(). Tool calls arrive in the final chunk as tool_call_chunks. For a simple agent, non-streaming is clearer; add streaming once the loop is stable.

const stream = await modelWithTools.stream(messages);
let acc = "";
for await (const chunk of stream) {
  acc += chunk.content ?? "";
  if (chunk.tool_calls) console.log("Partial calls:", chunk.tool_calls);
}

When to use langchainjs typescript function calling vs raw SDK

The raw OpenAI SDK requires manual JSON schema construction and response parsing. LangChain.js gives you Zod-based schemas, typed messages, and provider portability. If you already standardized on LangChain, the bindTools pattern keeps your surface area small. For a single-model app with no other LangChain usage, the raw SDK may be lighter. Either way, the function-calling protocol is identical under the hood.

Testing your tools

Tool functions are plain async callbacks. Unit test them without the model:

import { describe, it, expect } from "vitest";

describe("calculate", () => {
  it("multiplies", async () => {
    const res = await calculate.invoke({ expression: "24 * 7" });
    expect(res).toBe(168);
  });
});

Mock the network in getWeather the same way. Keeping tools pure and tested makes the LLM integration the only unknown.

Closing notes

You now have a runnable TypeScript agent that does langchainjs typescript function calling with explicit control over the message list. Extend it by adding more Zod tools, persisting messages to a database, or swapping the model via the baseURL without changing tool code. The protocol is stable; the loop is yours to harden.

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