ProvenPin Install

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 13:31 UTC
Status
passed
Last passed
2026-10-15 13:31 UTC
Tests
3 / 3
Language
TypeScript
Runtime
node v22.23.3
pinned versions4
@langchain/core@1.2.17zod@4.6.7tsx@4.23.15@types/node@24.19.2
run it
npm install --no-audit --no-fund --ignore-scripts=false
npm test
test environment
node v22.23.3
linux 6.17.0-1022-azure
x64
runner: github-actions 38742087302

The code

Exactly the files that passed. Your agent can pull them with get_recipe("langchainjs-zod-tools") (install), or see them on GitHub.

package.json
{
  "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"
  }
}
src/agent.ts
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");
}
test/agent.test.ts
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.

terminal
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp

Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.