What is tool calling? Letting a café chatbot look up orders

What is tool calling and how does it work? Describe tools to an LLM, read the tool_use block, run your real function and send a tool_result back, in TypeScript.

What is tool calling? It is how an LLM asks to call a function in your code. You describe the tools, the model replies with a tool name and arguments, and your code runs the real function and sends the result back. The model never runs anything itself. Let's teach the Mộc chatbot to look up orders.

Where did the Mộc chatbot stop?

In the previous post, I called Claude's API from TypeScript to answer the common questions of Mộc, a small coffee shop in Vietnam that sells online (the shop and all its data in this series are illustrative). The chatbot got the opening hours and the prices right. When a customer asked "Has my order 1041 been delivered yet?", it no longer made things up, but all it could say was "a member of staff will check and message you back".

The details of order 1041 are sitting right there in the shop's system. This post connects the two. By the end, the chatbot looks up an order by its number on its own, and you'll see it still gets stuck on a different kind of question.

Why not just put every order in the system prompt?

That was my first idea, and I'd guess it's yours too. The system prompt already holds the opening hours and the price list, so adding today's orders would let the model read everything, with no new technique to learn.

To see how much that costs, I wrote a small script. It creates 200 fake orders (the same 200 messages a day Mộc gets in the first post), appends them to the current system prompt and compares the lengths:

node src/all-in-prompt.ts
Current system prompt:     733 characters
With 200 orders of the day: 21,046 characters (28.7x)
Customer phone numbers in the prompt: 200
One line of it: {"order_id":"5041","phone":"0901000040","items":["milk coffee","milk coffee"],"status":"out for delivery"}

Look at the second and third lines. This approach has three problems, and any one of them is enough to drop it.

The first is cost. The system prompt is sent again, word for word, with every message, because the API remembers nothing between two calls. A customer asking about opening hours also drags along 21,000 characters of orders the answer doesn't need, and the API charges by input tokens.

The second is privacy. The prompt holds the phone numbers and orders of 200 customers. Anyone who types "whose order is 5041?" might get the model to read out someone else's details, because to the model, everything in the prompt is something it's allowed to use.

The third is stale data. The prompt is built when the request is sent, while order statuses change by the minute. An order still being made at 8:12 may be on a motorbike by 8:20, and the model keeps answering from the old snapshot.

The right way is to flip it around. Instead of handing the model all the data, you tell it which tools exist and let it ask for exactly what it needs, at the moment it needs it.

How does tool calling work?

According to Anthropic's tool use docs, one use of a tool goes through four steps. The names differ a little between providers (OpenAI calls it function calling, Anthropic calls it tool use), but the mechanism is the same.

  1. You send a normal request with an extra tools field: a list of tools, each with a name, a description and a schema for its input.
  2. If the model decides it needs a tool, it doesn't answer in text. It returns a tool_use block with the tool name and arguments, and stop_reason is "tool_use".
  3. Your code reads that block, runs the real function (reads the database, calls another API…), and sends the result back in a tool_result block.
  4. The model reads the result and writes the answer for the customer.

The part I most want you to remember is step 3. The model only writes a request. Whether it runs, and with what data, is decided by your code. That's how Mộc stays in control: the model only sees the order that was asked about, and the data is always fresh because it is read at the moment of the question.

If you read the first post, this is the agent loop that post ran with a fake model. This time the tool_use and tool_result blocks have exactly the shape of the real API.

What files does the project have?

This post builds on the chatbot from the previous one, but every file is here so you can type it all in without opening the old post. The folder looks like this:

Folder structure
03-tool-calling/
  package.json
  src/
    shop-info.ts         opening hours, delivery fee, prices
    orders.ts            order data and two lookup functions
    tools.ts             tool descriptions sent to the model + the code that runs them
    prompts.ts           system prompt
    offline.ts           scripted model, so you can run it without an API key
    call-llm.ts          calls the real Claude (or the scripted one)
    ask-moc.ts           asks 4 sample questions
    inspect-request.ts   prints the whole exchange
    all-in-prompt.ts     the length script from the previous section

package.json only needs the official SDK. I run the .ts files directly with Node.js 22.18 or later, so there's no build tool to install:

package.json
{
  "name": "moc-ai-agent-03",
  "private": true,
  "type": "module",
  "dependencies": {
    "@anthropic-ai/sdk": "1.5.0"
  }
}

The shop's fixed information is the same as in the previous post. Prices are in Vietnamese đồng; 25,000 đồng is roughly one US dollar.

src/shop-info.ts
export const MENU: Record<string, number> = {
  "black coffee": 25000,
  "milk coffee": 29000,
  "bac xiu": 32000,
  "salt coffee": 35000,
  "egg banh mi": 25000,
};

export const OPENING_HOURS = "Mộc is open from 6:30 a.m. to 10 p.m. every day.";
export const SHIPPING = "Mộc delivers within 5 km for 15,000 đồng; orders of 150,000 đồng or more ship free.";

// Turns the menu into lines like "- salt coffee: 35,000 đồng" for the prompt.
export function priceList(): string {
  return Object.entries(MENU)
    .map(([item, price]) => `- ${item}: ${price.toLocaleString("en-US")} đồng`)
    .join("\n");
}

The order data comes from the first post: two orders. In real life, the two lookup functions would read a database; here they read an array so it's easy to run. Phone numbers in Vietnam have 10 digits and start with 0, which is why the data looks like this:

src/orders.ts
export type Order = {
  orderId: string;
  phone: string;
  items: string[];
  status: "being made" | "out for delivery" | "delivered";
  placedAt: string;
};

const ORDERS: Order[] = [
  { orderId: "1041", phone: "0901000001", items: ["milk coffee", "egg banh mi"], status: "being made", placedAt: "today 8:12" },
  { orderId: "1042", phone: "0901000002", items: ["bac xiu"], status: "out for delivery", placedAt: "today 9:40" },
];

export function findOrderById(orderId: string): Order | undefined {
  return ORDERS.find((order) => order.orderId === orderId);
}

export function findOrderByPhone(phone: string): Order | undefined {
  return ORDERS.find((order) => order.phone === phone);
}

How do you describe a tool to the model?

This is the most important file in the post. It has two parts: the TOOLS array is the description sent to the model, and the runTool function is the real code that runs on the shop's side.

src/tools.ts
import type Anthropic from "@anthropic-ai/sdk";

import { findOrderById, findOrderByPhone } from "./orders.ts";

export const TOOLS: Anthropic.Tool[] = [
  {
    name: "get_order",
    description:
      "Get the items and current status of an order by its order number (4 digits, e.g. 1041). " +
      "Call this whenever a customer asks whether an order has been delivered, where it is, or when it will arrive.",
    strict: true,
    input_schema: {
      type: "object",
      properties: { order_id: { type: "string", description: "The 4-digit order number, without a # sign" } },
      required: ["order_id"],
      additionalProperties: false,
    },
  },
  {
    name: "find_order_by_phone",
    description:
      "Find a customer's most recent order number by phone number (10 digits, starting with 0). " +
      "Returns only the order number; to get the status, call get_order next.",
    strict: true,
    input_schema: {
      type: "object",
      properties: { phone: { type: "string", description: "10-digit phone number" } },
      required: ["phone"],
      additionalProperties: false,
    },
  },
];

export type ToolResult = { content: string; isError: boolean };

// The model wrote this input: always check it before using it, like data a user submits.
export function runTool(name: string, rawInput: unknown): ToolResult {
  const input = (rawInput ?? {}) as Record<string, unknown>;
  if (name === "get_order") {
    const id = String(input.order_id ?? "").trim();
    if (!/^\d{4}$/.test(id)) return { content: `Order number "${id}" is invalid, it must be exactly 4 digits.`, isError: true };
    const order = findOrderById(id);
    if (!order) return { content: `There is no order ${id}.`, isError: true };
    return { content: JSON.stringify({ order_id: order.orderId, items: order.items, status: order.status, placed_at: order.placedAt }), isError: false };
  }
  if (name === "find_order_by_phone") {
    const phone = String(input.phone ?? "").replace(/\s/g, "");
    if (!/^0\d{9}$/.test(phone)) return { content: `Phone number "${phone}" is invalid.`, isError: true };
    const order = findOrderByPhone(phone);
    if (!order) return { content: "No order found for this number.", isError: true };
    return { content: JSON.stringify({ order_id: order.orderId }), isError: false };
  }
  return { content: `There is no tool called "${name}".`, isError: true };
}

The description doesn't only say what the tool does. It also says when to call it ("whenever a customer asks whether an order has been delivered…"). The model chooses tools mostly from this text, and Anthropic's docs recommend spelling out when to call a tool, not just what it does. The description of find_order_by_phone also says plainly that it only returns the order number, so the model knows it has to call get_order next if the customer asks about the status. That sentence will matter at the end of the post.

strict: true together with additionalProperties: false asks the API to guarantee that the input of the tool_use block matches the schema exactly: there is always an order_id string and no unexpected fields.

So why does runTool still check it? Because the schema only guarantees the type, not that the value makes sense. "#1099" and "one zero four one" are perfectly valid strings. I treat input written by the model like data a user submits through a form: you can trust its shape, but you check its content before it goes near the database.

When something is wrong, the function doesn't throw. It returns isError: true with a sentence describing the problem. The model reads that sentence and apologizes or asks the customer again, instead of the whole program stopping halfway.

What does the code for one round of tool calling look like?

The system prompt is the same as last time, except the "you can't see orders" paragraph is replaced by instructions for using the tools:

src/prompts.ts
import { OPENING_HOURS, SHIPPING, priceList } from "./shop-info.ts";

export const SYSTEM_V3 = `You are a customer service assistant for Mộc, a coffee shop. Keep answers short and friendly, in English.

Only use the information in the <shop_info> tags to answer general questions.
<shop_info>
${OPENING_HOURS}
${SHIPPING}
Prices:
${priceList()}
</shop_info>

When a customer asks about an order, use the tools to look it up, and only state the status exactly as the tool returns it.
If a tool reports an error or finds no order, say so honestly and ask the customer to check the order number or phone number.`;

The function that calls the API differs from the previous post in two places. It takes the whole messages array instead of a single question, because the exchange is now longer than one turn. And it sends tools: TOOLS along. Without an API key, it uses the scripted model in offline.ts, which I'll come back to later:

src/call-llm.ts
import Anthropic from "@anthropic-ai/sdk";

import { offlineModel, type Reply } from "./offline.ts";
import { TOOLS } from "./tools.ts";

export const MODEL = "claude-opus-5-5";

export function hasKey(): boolean {
  return Boolean(process.env.ANTHROPIC_API_KEY) && process.env.MOC_OFFLINE !== "1";
}

export async function callModel(system: string, messages: Anthropic.MessageParam[]): Promise<Reply> {
  if (!hasKey()) return offlineModel(system, messages);
  const client = new Anthropic(); // reads ANTHROPIC_API_KEY from the environment
  const response = await client.messages.create({
    model: MODEL,
    max_tokens: 16000,
    output_config: { effort: "low" },
    system,
    tools: TOOLS, // new in this post: send the tool descriptions along
    messages,
  });
  return {
    content: response.content,
    stop_reason: response.stop_reason,
    inputTokens: response.usage.input_tokens,
    outputTokens: response.usage.output_tokens,
  };
}

export function textOf(content: Anthropic.ContentBlock[]): string {
  return content
    .filter((b) => b.type === "text")
    .map((b) => b.text)
    .join("");
}

And here is the main part. For each question, it calls the model once. If the model asks for a tool, it runs the tool, sends the result back and calls the model a second time to write the answer:

src/ask-moc.ts
import type Anthropic from "@anthropic-ai/sdk";

import { callModel, hasKey, MODEL, textOf } from "./call-llm.ts";
import { SYSTEM_V3 } from "./prompts.ts";
import { runTool } from "./tools.ts";

const QUESTIONS = [
  "What time do you open?",
  "Has my order 1041 been delivered yet?",
  "Order #1099 still hasn't arrived",
  "I ordered this morning and nobody has called me, my number is 0901000001",
];

console.log(hasKey() ? `Calling the real Claude (${MODEL})\n` : "OFFLINE mode (scripted, not a real model)\n");

async function ask(question: string): Promise<string> {
  const messages: Anthropic.MessageParam[] = [{ role: "user", content: question }];
  const first = await callModel(SYSTEM_V3, messages);
  if (first.stop_reason !== "tool_use") return textOf(first.content);

  // The model asked for a tool: run every tool_use block, put ALL the results in ONE message.
  const results: Anthropic.ToolResultBlockParam[] = [];
  for (const block of first.content) {
    if (block.type !== "tool_use") continue;
    const result = runTool(block.name, block.input);
    console.log(`  [tool] ${block.name}(${JSON.stringify(block.input)}) → ${result.content}${result.isError ? "  (is_error)" : ""}`);
    results.push({ type: "tool_result", tool_use_id: block.id, content: result.content, is_error: result.isError });
  }
  messages.push({ role: "assistant", content: first.content }); // send the reply with the tool_use block back as-is
  messages.push({ role: "user", content: results });

  const second = await callModel(SYSTEM_V3, messages);
  if (second.stop_reason === "tool_use") {
    const wanted = second.content.filter((b) => b.type === "tool_use").map((b) => `${b.name}(${JSON.stringify(b.input)})`);
    return `(the model still wants to call ${wanted.join(", ")}, but this code only runs one round, so it stops here)`;
  }
  return textOf(second.content);
}

for (const question of QUESTIONS) {
  console.log(`Customer: ${question}`);
  const answer = await ask(question);
  console.log(`Mộc:      ${answer}\n`);
}

Three details here are easy to get wrong, so I'll go through each one.

The line messages.push({ role: "assistant", content: first.content }) sends the model's reply back exactly as it was, tool_use block included. If you only send the tool_result without this turn, the API returns a 400 error, because each result has to come right after the turn that contains the matching call. Sending the original content instead of rebuilding it yourself is also safer, because the reply can contain other blocks (such as the model's thinking) that the API needs to see again.

tool_use_id must match the id of the corresponding tool_use block. The model can ask for several tools in a single reply, and the id is how it knows which result answers which call.

All the tool_result blocks for one turn go together in a single user message. Splitting them across several messages still works, but Anthropic's docs point out that doing so teaches the model to make fewer parallel tool calls in later turns.

Running it with four questions

I ran the file without an API key, so the program used the scripted model (more on that later):

node src/ask-moc.ts
OFFLINE mode (scripted, not a real model)

Customer: What time do you open?
Mộc:      Hi! Mộc is open from 6:30 a.m. to 10 p.m. every day.

Customer: Has my order 1041 been delivered yet?
  [tool] get_order({"order_id":"1041"}) → {"order_id":"1041","items":["milk coffee","egg banh mi"],"status":"being made","placed_at":"today 8:12"}
Mộc:      Your order 1041 (milk coffee, egg banh mi), placed today 8:12, is still being made and hasn't left yet. We'll let you know as soon as it's on its way!

Customer: Order #1099 still hasn't arrived
  [tool] get_order({"order_id":"1099"}) → There is no order 1099.  (is_error)
Mộc:      Sorry, Mộc couldn't find that order (There is no order 1099). Could you check the order number or send us the phone number you ordered with?

Customer: I ordered this morning and nobody has called me, my number is 0901000001
  [tool] find_order_by_phone({"phone":"0901000001"}) → {"order_id":"1041"}
Mộc:      (the model still wants to call get_order({"order_id":"1041"}), but this code only runs one round, so it stops here)

The first question has no [tool] line, because the opening hours are already in the system prompt. Having tools doesn't mean every question has to use one.

The second question is the result I've been waiting for since the last post. "being made" comes from the shop's data, not from the model's guess.

The third shows why errors should go through is_error instead of an exception. The customer typed "#1099", that order doesn't exist, and they get a polite request to double-check instead of an error page.

The last one stops halfway, and I left it that way on purpose. I'll come back to it in the final section.

What is actually inside the exchange?

To see the four steps clearly, inspect-request.ts rebuilds the exchange about order 1041 and prints the tool definition and the whole messages array:

src/inspect-request.ts
import type Anthropic from "@anthropic-ai/sdk";

import { offlineModel } from "./offline.ts";
import { SYSTEM_V3 } from "./prompts.ts";
import { TOOLS, runTool } from "./tools.ts";

console.log("The first tool in the list sent with every request:");
console.log(JSON.stringify(TOOLS[0], null, 2));

const messages: Anthropic.MessageParam[] = [{ role: "user", content: "Has my order 1041 been delivered yet?" }];
const first = offlineModel(SYSTEM_V3, messages);
const toolUse = first.content.find((b) => b.type === "tool_use")!;
const result = runTool(toolUse.name, toolUse.input);
messages.push({ role: "assistant", content: first.content });
messages.push({ role: "user", content: [{ type: "tool_result", tool_use_id: toolUse.id, content: result.content }] });
const second = offlineModel(SYSTEM_V3, messages);
messages.push({ role: "assistant", content: second.content });

console.log(`\nCall 1: stop_reason = "${first.stop_reason}". Call 2: stop_reason = "${second.stop_reason}".`);
console.log("The messages array after two calls:");
for (const m of messages) console.log(`${m.role}: ${JSON.stringify(m.content)}`);

Here is the output. I left out the tool definition because it's identical to the TOOLS array above:

node src/inspect-request.ts (last part)
Call 1: stop_reason = "tool_use". Call 2: stop_reason = "end_turn".
The messages array after two calls:
user: "Has my order 1041 been delivered yet?"
assistant: [{"type":"tool_use","id":"toolu_offline_1","name":"get_order","input":{"order_id":"1041"}}]
user: [{"type":"tool_result","tool_use_id":"toolu_offline_1","content":"{\"order_id\":\"1041\",\"items\":[\"milk coffee\",\"egg banh mi\"],\"status\":\"being made\",\"placed_at\":\"today 8:12\"}"}]
assistant: [{"type":"text","text":"Your order 1041 (milk coffee, egg banh mi), placed today 8:12, is still being made and hasn't left yet. We'll let you know as soon as it's on its way!"}]

Read the last four lines from top to bottom. The customer asks. The model doesn't answer; it asks to call get_order with 1041. The shop's code sends the result back in a message with the user role, because as far as the API is concerned, anything the model didn't write comes from the user's side. Only then does the model write the answer.

One consequence worth knowing early: every question about an order costs two API calls instead of one. The second call also resends the whole history, and the tool definitions count as input tokens in every request. With two tools that's nothing, but an agent with twenty tools is a different story. Later posts in the series measure this with real numbers.

What is the scripted model in offline.ts?

Without an API key, callModel calls offlineModel. It isn't a real model. It's a hand-written script that returns replies in exactly the shape of the API (a tool_use block, stop_reason: "tool_use") for the four sample questions, so you can run the whole flow without spending money:

src/offline.ts
import type Anthropic from "@anthropic-ai/sdk";

export type Reply = Pick<Anthropic.Message, "content" | "stop_reason"> & { inputTokens: number; outputTokens: number };

let count = 0;
function callTool(name: string, input: Record<string, string>): Reply {
  count += 1;
  const block = { type: "tool_use", id: `toolu_offline_${count}`, name, input } as Anthropic.ToolUseBlock;
  return { content: [block], stop_reason: "tool_use", inputTokens: 0, outputTokens: 0 };
}

function answer(text: string): Reply {
  return { content: [{ type: "text", text } as Anthropic.TextBlock], stop_reason: "end_turn", inputTokens: 0, outputTokens: 0 };
}

function lineWith(system: string, pattern: RegExp): string | undefined {
  return system.split("\n").find((line) => pattern.test(line));
}

export function offlineModel(system: string, messages: Anthropic.MessageParam[]): Reply {
  const question = String(messages[0].content).toLowerCase();
  const last = messages.at(-1)!;

  // First turn: only the customer's question.
  if (typeof last.content === "string") {
    const id = question.match(/\b\d{4}\b/)?.[0];
    if (id) return callTool("get_order", { order_id: id });
    const phone = question.match(/\b0\d{9}\b/)?.[0];
    if (phone) return callTool("find_order_by_phone", { phone });
    if (/what time|open/.test(question)) return answer(`Hi! ${lineWith(system, /is open from/) ?? ""}`);
    return answer("Sorry, Mộc doesn't have that information.");
  }

  // Later turns: our code sent back a tool result (in a message with the "user" role).
  const result = (last.content as Anthropic.ToolResultBlockParam[]).find((b) => b.type === "tool_result")!;
  const content = String(result.content);
  if (result.is_error) {
    return answer(`Sorry, Mộc couldn't find that order (${content.replace(/\.$/, "")}). Could you check the order number or send us the phone number you ordered with?`);
  }
  const order = JSON.parse(content) as { order_id: string; items?: string[]; status?: string; placed_at?: string };
  // We only have the order number and the customer asked for the status: call get_order as well.
  if (!order.status) return callTool("get_order", { order_id: order.order_id });
  return answer(`Your order ${order.order_id} (${order.items!.join(", ")}), placed ${order.placed_at}, is still ${order.status} and hasn't left yet. We'll let you know as soon as it's on its way!`);
}

I'll be upfront about its limits. This script decides to call a tool with a regex, while a real model decides from the meaning of the question and the description. A real model also words things differently every time, and it may ask the customer a follow-up question instead of calling a tool straight away. So the outputs above show you the real data flow, but not Claude's writing style.

To run it with the real model, set the environment variable and run the same command again:

Run with the real Claude
npm install
export ANTHROPIC_API_KEY="sk-ant-..."   # Windows PowerShell: $env:ANTHROPIC_API_KEY="sk-ant-..."
node src/ask-moc.ts

The code that handles the tool round doesn't change by a single line when you switch from the script to the real thing. That's why I split callModel into its own file from the start.

What about questions that need two steps?

Back to the fourth question: "I ordered this morning and nobody has called me, my number is 0901000001". The customer doesn't remember the order number, only the phone number. The model does exactly what the tool descriptions told it to. It calls find_order_by_phone, gets back 1041, then asks to call get_order to find the status.

My code, though, was only built for one round. After the second call, it sees that the model still wants a tool and has to stop. You might think about adding another if for a second round, but then a question will come along that needs three steps, say a customer who wants their order found by phone number and then an egg banh mi added if it hasn't left yet.

The fix is to let the code repeat until the model answers in text, with a limit on the number of steps so it can't loop forever. That's the agent loop, the missing piece that turns this chatbot into an actual agent, and it's the subject of the next post. You can follow the whole series at Build your first AI agent. If you're preparing for interviews, the tool calling question is in the interview question bank.

Get the next post by email.

One email a week, only when there is something worth reading. No spam.