LangChain.js tools with Zod schemas and a manual tool-calling loop: working versions (tested 2026-10-15)
Define LangChain.js tools with tool() and Zod schemas, run model tool_calls through them, and feed ToolMessages back, tested with a scripted fake chat model.
passed3 passed · 0 failed2026-10-15 11:55 UTC- Status
- passed
- Last passed
- 2026-10-15 11:55 UTC
- Tests
- 3 / 3
- Language
- TypeScript
- Runtime
- node v22.23.3
npm install --no-audit --no-fund --ignore-scripts=false npm test
node v22.23.3 linux 6.17.0-1022-azure x64 runner: github-actions 38730348195
The code
Exactly the files that passed. Your agent can pull them with get_recipe("langchainjs-zod-tools") (install), or see them on GitHub.
{
"name": "langchainjs-zod-tools",
"private": true,
"type": "module",
"scripts": {
"test": "node --import tsx --test test/*.test.ts"
},
"dependencies": {
"@langchain/core": "1.2.17",
"zod": "4.6.7"
},
"devDependencies": {
"tsx": "4.23.15",
"@types/node": "24.19.2"
}
}
import { tool } from "@langchain/core/tools";
import { AIMessage, HumanMessage, ToolMessage, type BaseMessage } from "@langchain/core/messages";
import { z } from "zod";
export const convert = tool(async ({ amount, from, to }) => {
const rates: Record<string, number> = { USD: 1, EUR: 0.9, GBP: 0.8 };
return String(Math.round((amount / rates[from]) * rates[to] * 100) / 100);
}, {
name: "convert_currency",
description: "Convert an amount between USD, EUR and GBP",
schema: z.object({ amount: z.number().positive(), from: z.enum(["USD", "EUR", "GBP"]), to: z.enum(["USD", "EUR", "GBP"]) }),
});
export const tools = [convert];
export type ChatModel = { invoke: (msgs: BaseMessage[]) => Promise<AIMessage> }; // e.g. new ChatOpenAI().bindTools(tools)
export async function runAgent(model: ChatModel, question: string, maxTurns = 4) {
const msgs: BaseMessage[] = [new HumanMessage(question)];
for (let i = 0; i < maxTurns; i++) {
const ai = await model.invoke(msgs);
msgs.push(ai);
if (!ai.tool_calls?.length) return { answer: String(ai.content), msgs };
for (const call of ai.tool_calls) {
const t = tools.find((x) => x.name === call.name);
let content: string;
try { content = t ? String(await t.invoke(call.args as any)) : `unknown tool ${call.name}`; }
catch (e) { content = `error: ${(e as Error).message}`; } // schema errors go back to the model
msgs.push(new ToolMessage({ content, tool_call_id: call.id! }));
}
}
throw new Error("too many turns");
}
import { test } from "node:test";
import assert from "node:assert/strict";
import { AIMessage, ToolMessage } from "@langchain/core/messages";
import { convert, runAgent, type ChatModel } from "../src/agent.ts";
const scripted = (...replies: AIMessage[]): ChatModel => ({ invoke: async () => replies.shift()! });
test("tool validates and runs directly", async () => {
assert.equal(await convert.invoke({ amount: 100, from: "USD", to: "EUR" }), "90");
await assert.rejects(convert.invoke({ amount: -1, from: "USD", to: "EUR" } as any));
});
test("loop executes tool calls and returns final answer", async () => {
const { answer, msgs } = await runAgent(scripted(
new AIMessage({ content: "", tool_calls: [{ id: "c1", name: "convert_currency", args: { amount: 10, from: "EUR", to: "USD" } }] }),
new AIMessage("10 EUR is about 11.11 USD."),
), "10 EUR in USD?");
assert.equal(answer, "10 EUR is about 11.11 USD.");
const tm = msgs.find((m) => m instanceof ToolMessage) as ToolMessage;
assert.equal(tm.content, "11.11");
assert.equal(tm.tool_call_id, "c1");
});
test("bad args are returned to the model as an error", async () => {
const { msgs } = await runAgent(scripted(
new AIMessage({ content: "", tool_calls: [{ id: "c1", name: "convert_currency", args: { amount: 5, from: "JPY", to: "USD" } }] }),
new AIMessage("Sorry, unsupported currency."),
), "?");
assert.match(String((msgs[2] as ToolMessage).content), /^error:/);
});
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.
Related stacks
LangChain.js prompt + model + parser chain tested with a fake chat model
Compose a ChatPromptTemplate, chat model and output parser with LCEL in @langchain/core, tested offline with FakeListChatModel.
Anthropic TypeScript SDK tool use loop, tested with a mocked fetch
Run a Claude tool_use loop with the official Anthropic SDK and Zod-validated tool inputs, tested offline with an injected fetch.
CSV import with PapaParse + Zod row validation
Parse CSV uploads with PapaParse (headers, BOM, quoted fields), validate each row with Zod and report row-numbered errors instead of failing the whole file.
Drizzle ORM + drizzle-zod validated inserts on SQLite
Generate Zod insert and select schemas from Drizzle tables with drizzle-zod, refine them, and validate input before inserting into SQLite.