Tool Use (Function Calling) with Claude in TypeScript
Let the model call your functions: define tools, run the tool-use loop by hand, handle errors and parallel calls, then use the SDK tool runner with Zod-typed tools.
On this page· The tool-use loop
Why this matters
A model on its own can only produce text from what is in its context. Tool use lets it ask your code to do things: look up an order, query a database, call an API, send an email. Your app stays in control: the model only requests a call, your code decides whether and how to run it, and sends the result back. This loop is the foundation of every agent, and of the Model Context Protocol, so it is worth understanding exactly.
The tool-use loop#
Step 1 of 7
The user asks a question
The answer depends on live data the model has never seen, so a plain model call would guess. Your app receives the message like any other request.
User message
"Where is my order #4821?"Show text descriptionHide text description
The diagram shows five parts: the user, your app (a Node.js server), the LLM behind the Messages API, your tool code, and your database. The model never executes tools itself; your app runs the loop.
- The user asks a question. The answer depends on live data the model has never seen, so a plain model call would guess. Your app receives the message like any other request.
- Your app calls the model with tool definitions. Alongside the conversation, you describe each tool: a name, a description the model reads to decide when to use it, and a JSON Schema for its input.
- The model asks you to run a tool. The model cannot execute code. It stops with stop_reason "tool_use" and returns a tool_use block naming the tool and the arguments it chose.
- Your code executes the tool. Validate the input, check that this user may see this order, then run the function. Your data never leaves your control unless you return it.
- Send the tool result back. The API is stateless: append the assistant turn exactly as received, then a user turn containing a tool_result that references the tool_use id, and call the model again.
- The model writes the final answer. With the result in context, the model answers and stops with stop_reason "end_turn". If it needs more data, it returns tool_use again and the loop repeats from step 3.
- Stream the answer to the user. Show the final text in the UI. In production, cap the loop (for example at 10 iterations), log every tool call, and treat tool results as untrusted input.
- You send the conversation plus a list of tools: a name, a description, and a JSON schema for the input.
- If the model decides a tool helps, it replies with one or more
tool_useblocks andstop_reason: "tool_use". - Your code runs the requested tools.
- You send the results back as
tool_resultblocks in a user message, matched bytool_use_id. - The model continues: it may request more tools, or answer with
stop_reason: "end_turn".
The model never runs anything itself. Every action goes through your code, which is where you enforce permissions, validation, and limits.
Defining tools#
A tool definition is documentation for the model. The description is what it reads to decide when and how to call the tool, so it deserves the same care as a prompt:
import type Anthropic from "@anthropic-ai/sdk";
import { z } from "zod";
// Stand-in for your database. Orders belong to customers.
const ORDERS = new Map([
["4821", { customerId: "cus_1", status: "shipped", total: 129.0, carrier: "UPS", eta: "2026-10-09" }],
["4822", { customerId: "cus_1", status: "processing", total: 42.5, carrier: null, eta: null }],
["5900", { customerId: "cus_2", status: "delivered", total: 18.0, carrier: "USPS", eta: null }],
]);
export const tools: Anthropic.Tool[] = [
{
name: "get_order",
description:
"Look up one of the current customer's orders by ID. Returns status, total in USD, carrier, and estimated delivery date. Use it whenever the customer asks about a specific order.",
input_schema: {
type: "object",
properties: { order_id: { type: "string", description: "Numeric order ID without the # sign, such as 4821" } },
required: ["order_id"],
},
},
{
name: "list_orders",
description: "List the current customer's order IDs with status. Use it when the customer does not give an order ID.",
input_schema: { type: "object", properties: {} },
},
];
const GetOrderInput = z.object({ order_id: z.string().regex(/^\d+$/) });
// Every tool runs with the *authenticated* customer, never an ID the model supplies.
export async function runTool(name: string, input: unknown, customerId: string): Promise<string> {
switch (name) {
case "get_order": {
const parsed = GetOrderInput.safeParse(input);
if (!parsed.success) throw new Error("order_id must contain digits only, such as 4821");
const { order_id } = parsed.data;
const order = ORDERS.get(order_id);
if (!order || order.customerId !== customerId) throw new Error(`No order ${order_id} for this customer`);
const { customerId: _owner, ...visible } = order;
return JSON.stringify({ order_id, ...visible });
}
case "list_orders":
return JSON.stringify(
[...ORDERS].filter(([, o]) => o.customerId === customerId).map(([id, o]) => ({ order_id: id, status: o.status })),
);
default:
throw new Error(`Unknown tool: ${name}`);
}
}Three habits are already in this code:
- Descriptions say when to use the tool, not just what it does.
- Inputs are validated with Zod before use: the model usually sends valid input, but your code must not depend on "usually". (Add
strict: trueto a tool to have the API guarantee schema-valid inputs.) - Authorization comes from your session, not the model. The customer ID is a function argument from your auth layer. The model can ask for order 5900, and the ownership check refuses.
The loop, by hand#
Writing the loop yourself once is the best way to understand it:
import Anthropic from "@anthropic-ai/sdk";
import { tools, runTool } from "./lib/tools.ts";
const client = new Anthropic();
const MAX_TURNS = 8; // a hard limit, so a confused model cannot loop forever
export async function answer(question: string, customerId: string): Promise<string> {
const messages: Anthropic.MessageParam[] = [{ role: "user", content: question }];
for (let turn = 0; turn < MAX_TURNS; turn++) {
const message = await client.messages.create({
model: "claude-opus-5-5",
max_tokens: 4096,
system: "You are a support assistant for an online store. Use the tools to answer questions about orders.",
tools,
messages,
});
// Always keep the assistant turn exactly as returned, including tool_use blocks.
messages.push({ role: "assistant", content: message.content });
if (message.stop_reason !== "tool_use") {
return message.content.flatMap((b) => (b.type === "text" ? [b.text] : [])).join("");
}
// Run every requested tool, in parallel, and return all results in one user message.
const calls = message.content.filter((b): b is Anthropic.ToolUseBlock => b.type === "tool_use");
const results = await Promise.all(
calls.map(async (call): Promise<Anthropic.ToolResultBlockParam> => {
try {
return { type: "tool_result", tool_use_id: call.id, content: await runTool(call.name, call.input, customerId) };
} catch (error) {
// Errors go back to the model as results, so it can recover or explain.
return { type: "tool_result", tool_use_id: call.id, content: String(error), is_error: true };
}
}),
);
messages.push({ role: "user", content: results });
}
throw new Error(`No answer after ${MAX_TURNS} turns`);
}
console.log(await answer("Where is my order #4821?", "cus_1"));The rules the API enforces, and that this loop follows:
- Every
tool_usemust get atool_resultwith the sametool_use_idin the next user message. - Results for all calls in one response go in one user message. Text you add goes after the results.
- The assistant message is sent back unchanged, so the model sees which calls it made.
Parallel tool calls#
When a question needs several independent lookups, the model can request them in one response. Running them with Promise.all, as above, makes the answer arrive as fast as the slowest tool rather than the sum of all of them.
Show text descriptionHide text description
When a question needs several independent lookups, the model can ask for all of them at once. Participants, left to right: Your app, Model, Your tools.
- Your app → Model: “Compare orders #4821 and #4822”
- Model → Your app: tool_use get_order(4821), tool_use get_order(4822)
- Your app → Your tools: Promise.all([run(a), run(b)])
- Your tools → Your app: two results
- Your app → Model: one user message: [tool_result a, tool_result b]
- Model → Your app: final answer comparing both
The SDK tool runner#
Once you understand the loop, let the SDK run it. The tool runner (a beta helper) handles the cycle of calling the model, running tools, and returning results. betaZodTool defines the schema with Zod, validates inputs, and types the run function:
import Anthropic from "@anthropic-ai/sdk";
import { betaZodTool } from "@anthropic-ai/sdk/helpers/beta/zod";
import { z } from "zod";
import { runTool } from "./lib/tools.ts";
const client = new Anthropic();
const customerId = "cus_1"; // from your session in a real app
const getOrder = betaZodTool({
name: "get_order",
description: "Look up one of the current customer's orders by ID. Returns status, total in USD, carrier, and estimated delivery date.",
inputSchema: z.object({ order_id: z.string().describe("Numeric order ID without the # sign") }),
run: ({ order_id }) => runTool("get_order", { order_id }, customerId),
});
const listOrders = betaZodTool({
name: "list_orders",
description: "List the current customer's order IDs with status. Use it when the customer does not give an order ID.",
inputSchema: z.object({}),
run: () => runTool("list_orders", {}, customerId),
});
const finalMessage = await client.beta.messages.toolRunner({
model: "claude-opus-5-5",
max_tokens: 4096,
max_iterations: 8,
system: "You are a support assistant for an online store. Use the tools to answer questions about orders.",
tools: [getOrder, listOrders],
messages: [{ role: "user", content: "Compare my two most recent orders." }],
});
console.log(finalMessage.content.flatMap((b) => (b.type === "text" ? [b.text] : [])).join(""));A tool whose run throws is reported to the model as an error result, like the manual loop does. You can also iterate over the runner (for await (const message of runner)) to log or show each intermediate step.
Use the runner by default. Write the loop by hand when you need control the runner does not give you: approval before certain tools run, custom logging of each step, or changing the request between turns.
Controlling when tools are used#
tool_choice: { type: "auto" }(the default): the model decides.tool_choice: { type: "none" }: tools stay defined (so the cache stays valid) but the model answers in text.- Forcing a specific tool (
"any"or{ type: "tool", name }) is not supported on the newest models such as Claude Opus 5.5. If you need guaranteed structured data, use structured outputs; if a tool must run first, run it yourself before calling the model and include the result.
Designing good tools#
| Guideline | Why |
|---|---|
| Few, purposeful tools | Many overlapping tools confuse the choice. Merge near-duplicates. |
| Descriptions that say when to use it | The model chooses tools by description. |
| Return concise, relevant data | Tool results consume context and cost tokens on every later turn. Return the 5 fields that matter, not the whole row. |
| Clear error messages | The model can recover from "order not found"; it cannot recover from a stack trace. |
| Read-only by default | Tools that change data (refunds, emails) need confirmation; see human-in-the-loop. |
| Idempotent where possible | A retry should not charge a card twice. |
Common mistakes#
- Trusting tool input for authorization. Never let the model choose whose data to read. Scope every tool to the authenticated user.
- Dropping the assistant
tool_usemessage. Eachtool_resultmust follow the assistant message that requested it. - One user message per result. All results for one response go in a single user message.
- No iteration limit. Always cap the loop.
- Returning huge results. A 50 KB JSON dump fills the context and raises cost. Trim to what the model needs.
- Throwing on tool errors. Return them as
is_errorresults instead.
Try it
Add a cancel_order tool safely
- Add a
cancel_ordertool that only works for orders inprocessingstatus. - Make it require confirmation: the tool returns "needs_confirmation" unless the input includes
confirmed: true, and the system prompt tells the model to ask the customer first. - Test three questions: cancelling 4822 (processing), cancelling 4821 (shipped, should fail with a clear error), and cancelling 5900 (another customer's order, should be refused).
Check your understanding
Key takeaways
- Tool use is a loop: the model requests
tool_use, your code runs it and returnstool_result, untilend_turn. - Validate inputs, scope every tool to the authenticated user, and cap the number of iterations.
- Run parallel calls concurrently and return all results in one user message; return errors as
is_errorresults. - Use the SDK tool runner with
betaZodToolby default; write the loop by hand when you need approvals or custom control.
Next steps#
Finished this article? Track it to see your progress on the roadmap.
Related articles
- Structured Outputs with Zod: Typed JSON from LLMs in TypeScriptGet typed, schema-valid JSON from Claude with Zod and messages.parse, validate business rules in code, retry with feedback, and use strict tools for tool inputs.
- Build an AI Agent Loop from Scratch in TypeScriptWrite a production-ready agent loop in TypeScript: Zod-typed tools, turn, token, and time limits, parallel tool calls, error handling, events for your UI, and unit tests.
- Model Context Protocol (MCP): Build and Use MCP Servers in TypeScriptUnderstand MCP hosts, clients, servers, and transports, build a TypeScript MCP server with tools, resources, and prompts, and use it from Claude Code and your agents.