Skip to content
AI Full-Stack Wiki

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.

Difficulty: Intermediate19 min readUpdated
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#

The tool-use loopThe user asks your app a question; your app sends the conversation and tool definitions to the model; the model replies with a tool_use request; your code runs the tool against your data and sends a tool_result back; the model then writes the final answer, which your app streams to the user.questionmessages + toolscontent blocksrun toolresultanswerUserYour appNode.js serverLLMMessages APITool codegetOrderStatus()Orders DBYour data

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?"
The tool-use (function calling) loop: the model requests a tool with a tool_use block, your Node.js code runs it and returns a tool_result, and the model uses the result to answer. Press Play or step through it.
Show 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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  1. You send the conversation plus a list of tools: a name, a description, and a JSON schema for the input.
  2. If the model decides a tool helps, it replies with one or more tool_use blocks and stop_reason: "tool_use".
  3. Your code runs the requested tools.
  4. You send the results back as tool_result blocks in a user message, matched by tool_use_id.
  5. 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:

lib/tools.ts
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: true to 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:

support.ts
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_use must get a tool_result with the same tool_use_id in 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.

Two tool calls in one turnThe model returns two tool_use blocks; the app executes both with Promise.all and sends both results back in one message; the model then answers using both.Your appModelYour tools“Compare orders#4821 and#4822”tool_useget_order(4821),tool_useget_order(4822)Promise.all([run(a), run(b)])two resultsone usermessage:[tool_result a,tool_result b]final answercomparing both
Parallel tool calls: the model requests two tools in one response, your app runs them concurrently, and returns both tool_result blocks in a single user message.
Show 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.

  1. Your app → Model: “Compare orders #4821 and #4822”
  2. Model → Your app: tool_use get_order(4821), tool_use get_order(4822)
  3. Your app → Your tools: Promise.all([run(a), run(b)])
  4. Your tools → Your app: two results
  5. Your app → Model: one user message: [tool_result a, tool_result b]
  6. 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:

runner.ts
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#

GuidelineWhy
Few, purposeful toolsMany overlapping tools confuse the choice. Merge near-duplicates.
Descriptions that say when to use itThe model chooses tools by description.
Return concise, relevant dataTool results consume context and cost tokens on every later turn. Return the 5 fields that matter, not the whole row.
Clear error messagesThe model can recover from "order not found"; it cannot recover from a stack trace.
Read-only by defaultTools that change data (refunds, emails) need confirmation; see human-in-the-loop.
Idempotent where possibleA 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_use message. Each tool_result must 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_error results instead.

Try it

Add a cancel_order tool safely

  1. Add a cancel_order tool that only works for orders in processing status.
  2. 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.
  3. 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

Who executes a tool when the model returns a tool_use block?
The model requests three tools in one response. How do you send the results?
A tool call fails because the order does not exist. What should the loop do?

Key takeaways

  • Tool use is a loop: the model requests tool_use, your code runs it and returns tool_result, until end_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_error results.
  • Use the SDK tool runner with betaZodTool by 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.