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.