Anthropic TypeScript SDK tool use loop, tested with a mocked fetch: working versions (tested 2026-10-15)
Run a Claude tool_use loop with the official Anthropic SDK and Zod-validated tool inputs, tested offline with an injected fetch.
passed2 passed · 0 failed2026-10-15 13:27 UTC- Status
- passed
- Last passed
- 2026-10-15 13:27 UTC
- Tests
- 2 / 2
- 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 38742087302
The code
Exactly the files that passed. Your agent can pull them with get_recipe("anthropic-sdk-mocked-tool-use") (install), or see them on GitHub.
{
"name": "anthropic-sdk-mocked-tool-use",
"private": true,
"type": "module",
"scripts": {
"test": "node --import tsx --test test/*.test.ts"
},
"dependencies": {
"@anthropic-ai/sdk": "0.134.0",
"zod": "4.6.7"
},
"devDependencies": {
"tsx": "4.23.15",
"@types/node": "24.19.2"
}
}
import Anthropic from "@anthropic-ai/sdk";
import { z } from "zod";
const LookupInput = z.object({ sku: z.string().regex(/^[A-Z]+-\d+$/) });
export const tools: Anthropic.Tool[] = [{
name: "lookup_stock",
description: "Units in stock for a SKU",
input_schema: { type: "object", properties: { sku: { type: "string" } }, required: ["sku"] },
}];
export async function ask(client: Anthropic, question: string, stock: (sku: string) => number) {
const messages: Anthropic.MessageParam[] = [{ role: "user", content: question }];
for (let i = 0; i < 5; i++) {
const res = await client.messages.create({ model: "claude-sonnet-4-5", max_tokens: 512, messages, tools });
messages.push({ role: "assistant", content: res.content });
if (res.stop_reason !== "tool_use") return res.content.filter((b) => b.type === "text").map((b) => b.text).join("");
const results: Anthropic.ToolResultBlockParam[] = [];
for (const block of res.content) {
if (block.type !== "tool_use") continue;
const p = LookupInput.safeParse(block.input);
results.push(p.success
? { type: "tool_result", tool_use_id: block.id, content: String(stock(p.data.sku)) }
: { type: "tool_result", tool_use_id: block.id, content: "invalid sku", is_error: true });
}
messages.push({ role: "user", content: results });
}
throw new Error("too many turns");
}
import { test } from "node:test";
import assert from "node:assert/strict";
import Anthropic from "@anthropic-ai/sdk";
import { ask } from "../src/agent.ts";
const msg = (content: object[], stop_reason: string) => ({ id: "m", type: "message", role: "assistant", model: "claude-sonnet-4-5", content, stop_reason, stop_sequence: null, usage: { input_tokens: 1, output_tokens: 1 } });
function fake(replies: object[], seen: any[]) {
const fetch = async (_u: any, init: any) => {
seen.push(JSON.parse(init.body));
return new Response(JSON.stringify(replies.shift()), { status: 200, headers: { "content-type": "application/json" } });
};
return new Anthropic({ apiKey: "test", baseURL: "http://mock.local", fetch: fetch as any, maxRetries: 0 });
}
test("runs the tool and returns the final text", async () => {
const seen: any[] = [];
const c = fake([
msg([{ type: "tool_use", id: "tu1", name: "lookup_stock", input: { sku: "MUG-1" } }], "tool_use"),
msg([{ type: "text", text: "There are 7 mugs." }], "end_turn"),
], seen);
assert.equal(await ask(c, "How many mugs?", () => 7), "There are 7 mugs.");
assert.deepEqual(seen[1].messages[2].content[0], { type: "tool_result", tool_use_id: "tu1", content: "7" });
});
test("invalid tool input is returned as is_error", async () => {
const seen: any[] = [];
const c = fake([
msg([{ type: "tool_use", id: "tu1", name: "lookup_stock", input: { sku: "bad" } }], "tool_use"),
msg([{ type: "text", text: "Need a valid SKU." }], "end_turn"),
], seen);
await ask(c, "?", () => 0);
assert.equal(seen[1].messages[2].content[0].is_error, true);
});
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
Anthropic SDK message streaming with text deltas, tested with a mocked SSE fetch
Stream Claude responses with the Anthropic TypeScript SDK (messages.stream, text events, finalMessage), tested offline with a fake fetch returning Anthropic SSE events.
better-sqlite3 transactions with Zod-validated inputs
A small ledger on better-sqlite3 using db.transaction for atomic transfers, nested transactions as savepoints for all-or-nothing batches, and Zod 4 to validate inputs and rows, tested on in-memory SQLite.
Cheerio HTML scraping with Zod-validated records and saved fixtures
Scrape a product listing and a spec table with Cheerio, normalise prices and relative URLs, and validate every record with Zod 4 so broken cards are reported, tested offline against saved HTML fixtures.
Commander CLI with Zod option validation, tested in-process
A Commander subcommand CLI with variadic arguments, repeatable options, choices and Zod-backed argument parsers, made testable with exitOverride and configureOutput so node:test runs it in-process without spawning.