Tool calling is a mechanism where you describe functions (name, purpose, JSON Schema of arguments), the model replies with a structured call request (tool_use), your code runs the function and returns the result (tool_result), and the model continues its answer. The model executes nothing itself: validate arguments in code, return failures with is_error: true, and confirm actions with consequences outside the model.
After this you will be able to
- define a tool with a name, a detailed description and a JSON Schema with
strict: true, and list the rules the schema cannot express and code must check - write the
tool_use→ execution →tool_resultloop with parallel calls, a step limit andstop_reasonhandling - validate arguments with
zodand return errors withis_error: trueso the model can fix its call - limit risky actions: a per-conversation tool set, human confirmation and a re-check right before writing
Know beforehand
Verified with
Node.js 24 LTS (24.18)TypeScript 7.0 (tsc 7.0.2)@anthropic-ai/sdk 0.133.0zod 4.6.6@types/node 24Claude API model id claude-opus-5-5
Checked against docs: October 12, 2026
Contents
- How tool calling works
- Defining a tool: name, description and JSON Schema
- The loop: tool_use → execution → tool_result
- Argument validation and tool errors
- Risky actions: confirmation and permissions
- Verification: a mock Messages API, the full loop and tests
- MCP, OpenAI function calling and common mistakes
How tool calling works
Tool calling (tool use in Anthropic's docs, function calling at OpenAI) gives a model access to your data and actions. You describe functions: a name, a purpose and a JSON Schema for the arguments. The model decides whether it needs one and, instead of text, returns a structured request: "call get_free_slots with { "service": "manicure", "date": "2026-10-16" }". Your code executes it, and the result goes back into the conversation.
The model never executes anything by itself. It emits a request, your code runs the operation, the result flows back into the conversation — a contract, not magic.
Anthropic's docs list when tools are worth it: actions with side effects (book a client, send an email), fresh or internal data (free slots, stock, order status), output of a guaranteed shape, and calls into existing systems such as databases and internal APIs. You don't need them when the model can answer from general knowledge (translation, summarizing) or when the extra round trip costs more than the work itself.
In this tutorial we build a booking assistant for a beauty salon in Almaty with two tools: get_free_slots (free times on a date) and create_booking (create a booking after the client confirms). It is for backend developers who have already sent a request to an LLM API (llm-api-pervyj-zapros) and know prompting basics. Environment: Node.js 24, TypeScript 7.0, @anthropic-ai/sdk 0.133.0, zod 4.6.6. The example uses claude-opus-5-5: as of October 12, 2026, Anthropic's docs recommend starting with Claude Opus 5.5. Tool definitions cost money too: names, descriptions and schemas count as input tokens on every request, and for Opus 5.5 the API adds another 286 tokens of tool-use system prompt.
Defining a tool: name, description and JSON Schema
The project is a plain Node.js application with ES modules. Node.js 24 runs .ts files directly by stripping types, so tsc is only for type checking.
mkdir tool-calling
cd tool-calling
npm init -y
npm pkg set type=module
npm pkg set private=true --json
npm pkg delete main scripts.test
npm pkg set scripts.test="node --test" scripts.typecheck="tsc" scripts.demo="node src/demo.ts"
npm install @anthropic-ai/sdk@0.133.0 zod@4.6.6
npm install -D typescript@7.0.2 @types/node@24{
"compilerOptions": {
"target": "es2024",
"module": "nodenext",
"strict": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"types": ["node"],
"skipLibCheck": true
},
"include": ["src", "test"]
}The salon schedule lives in memory: one master, slots every 30 minutes from 10:00 to 20:00. In a real project this would be a database or a CRM API — the tool doesn't care.
// In-memory salon schedule: one master, slots every 30 minutes, 10:00–20:00
export const SERVICE_IDS = ["manicure", "pedicure", "haircut"] as const;
export type ServiceId = (typeof SERVICE_IDS)[number];
export const SERVICE_MINUTES: Record<ServiceId, number> = {
manicure: 60,
pedicure: 90,
haircut: 60,
};
export type Booking = {
id: string;
service: ServiceId;
date: string; // YYYY-MM-DD, salon local time
time: string; // HH:MM
clientName: string;
phone: string;
};
export class SlotTakenError extends Error {}
const OPENS = 10 * 60;
const CLOSES = 20 * 60;
const STEP = 30;
const toMinutes = (time: string) => Number(time.slice(0, 2)) * 60 + Number(time.slice(3, 5));
const toTime = (minutes: number) =>
`${String(Math.floor(minutes / 60)).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")}`;
export class Salon {
#bookings: Booking[] = [];
#nextId = 1;
get bookings(): readonly Booking[] {
return [...this.#bookings];
}
freeSlots(service: ServiceId, date: string): string[] {
const length = SERVICE_MINUTES[service];
const busy = this.#bookings
.filter((b) => b.date === date)
.map((b) => [toMinutes(b.time), toMinutes(b.time) + SERVICE_MINUTES[b.service]] as const);
const slots: string[] = [];
for (let start = OPENS; start + length <= CLOSES; start += STEP) {
const overlaps = busy.some(([from, to]) => start < to && from < start + length);
if (!overlaps) slots.push(toTime(start));
}
return slots;
}
book(input: Omit<Booking, "id">): Booking {
// Re-check right before writing: the slot may have been taken since the model saw it
if (!this.freeSlots(input.service, input.date).includes(input.time)) {
throw new SlotTakenError(`${input.date} ${input.time} is already taken`);
}
const booking = { id: `B-${this.#nextId++}`, ...input };
this.#bookings.push(booking);
return booking;
}
}A Claude tool definition has a name (Latin letters, digits, _ and -, up to 128 characters), a description and an input_schema in JSON Schema. Anthropic's docs call a detailed description by far the most important factor in tool performance: what the tool does, when to call it and when not, what each parameter means and what the tool does not return. The recommended minimum is 3–4 sentences per tool.
import type Anthropic from "@anthropic-ai/sdk";
import { z } from "zod";
import { SERVICE_IDS, SlotTakenError, type Salon } from "./salon.ts";
// 1. What the model sees: name, description and JSON Schema of each tool
export const TOOLS: Anthropic.Tool[] = [
{
name: "get_free_slots",
description:
"Returns free start times for one salon service on one date. " +
"Call it before offering times to the client and again before create_booking. " +
"Times are local salon time (Asia/Almaty), 24-hour HH:MM; the salon works 10:00-20:00. " +
"An empty list means the day is fully booked. This tool does not book anything.",
strict: true,
input_schema: {
type: "object",
properties: {
service: { type: "string", enum: [...SERVICE_IDS], description: "Service id" },
date: { type: "string", format: "date", description: "YYYY-MM-DD, today or up to 30 days ahead" },
},
required: ["service", "date"],
additionalProperties: false,
},
},
{
name: "create_booking",
description:
"Books one appointment. Call it only for a time that get_free_slots returned and only when " +
"the client has given service, date, time, name and phone. Never invent a name or a phone number. " +
"The application shows the client a confirmation button before writing the booking; " +
"if the client declines, the tool returns an error and nothing is booked.",
strict: true,
input_schema: {
type: "object",
properties: {
service: { type: "string", enum: [...SERVICE_IDS] },
date: { type: "string", format: "date", description: "YYYY-MM-DD" },
time: { type: "string", pattern: "^([01]\\d|2[0-3]):[0-5]\\d$", description: "HH:MM from get_free_slots" },
client_name: { type: "string", description: "Client's first name as they wrote it" },
phone: { type: "string", pattern: "^\\+7\\d{10}$", description: "Phone in +7XXXXXXXXXX format" },
},
required: ["service", "date", "time", "client_name", "phone"],
additionalProperties: false,
},
},
];- The
get_free_slotsdescription says what the tool does not do ("does not book anything") and which time format it uses — that removes guesswork. - The
create_bookingdescription forbids inventing a name or phone. The docs note that when required data is missing, a model may fill in a plausible value, especially on ambiguous prompts. - An
enumfor the service instead of a free string: the model can't invent a "manicure plus design" that isn't on the price list. strict: trueturns on strict mode: Anthropic guarantees thatinputmatches the schema andnameis one of the tools you passed. It requiresadditionalProperties: falseand arequiredlist on every object.
Types, required fields, no extra fields
- Strict mode
- Supported
- Where we check it
- Schema +
zodin case of another provider or strict mode off
enum, format: date, simple pattern
- Strict mode
- Supported
- Where we check it
- Schema +
zod
minLength, maxLength, minimum, maximum
- Strict mode
- Not supported — the request gets a 400
- Where we check it
zodonly
Date not in the past, slot is free
- Strict mode
- JSON Schema can't express it
- Where we check it
- Tool code
The loop: tool_use → execution → tool_result
Tools that your application executes need a loop. Anthropic's docs describe it as a while loop keyed on stop_reason:
Request
Send
tools, the system prompt and the message history.tool_use reply
The model returns
stop_reason: "tool_use"and one or moretool_useblocks withid,nameandinput.Execution
Your code runs the functions and wraps the results in
tool_resultblocks with the sametool_use_id.Next request
Send the history, the model's whole reply and one
usermessage with all results.Repeat or exit
While
stop_reasonistool_use, go round again; any other value exits:end_turn,max_tokens,refusaland so on.
import type Anthropic from "@anthropic-ai/sdk";
import { READ_ONLY_TOOLS, TOOLS, runTool, type ToolContext } from "./tools.ts";
export type TurnResult = {
status: "done" | "stopped" | "step_limit";
text: string;
stopReason: Anthropic.StopReason | null;
steps: number;
};
export type ChatOptions = {
client: Anthropic;
ctx: ToolContext;
model?: string;
maxSteps?: number;
log?: (event: Record<string, unknown>) => void;
};
const systemPrompt = (today: string) =>
"You are the booking assistant of a beauty salon in Almaty. Answer in the client's language. " +
`Today is ${today}. Check free times with get_free_slots before offering them. ` +
"Before create_booking, make sure you know the service, date, time, name and phone; ask if anything is missing.";
// One tool call failing must not break the whole turn: unexpected errors become is_error results
async function safeRun(call: Anthropic.ToolUseBlock, ctx: ToolContext): Promise<Anthropic.ToolResultBlockParam> {
try {
return await runTool(call, ctx);
} catch (error) {
console.error(`tool ${call.name} crashed`, error); // details stay in our logs, not in the prompt
return { type: "tool_result", tool_use_id: call.id, content: "Internal error. Apologize and offer to call the salon.", is_error: true };
}
}
// Read-only calls run concurrently, calls with side effects run one by one in the model's order
async function runCalls(calls: Anthropic.ToolUseBlock[], ctx: ToolContext, log: ChatOptions["log"]) {
const results = new Map<string, Anthropic.ToolResultBlockParam>();
const timed = async (call: Anthropic.ToolUseBlock) => {
const started = performance.now();
const result = await safeRun(call, ctx);
log?.({ tool: call.name, input: call.input, error: result.is_error === true, ms: Math.round(performance.now() - started) });
results.set(call.id, result);
};
await Promise.all(calls.filter((c) => READ_ONLY_TOOLS.has(c.name)).map(timed));
for (const call of calls.filter((c) => !READ_ONLY_TOOLS.has(c.name))) await timed(call);
// One tool_result for every tool_use, in the same order, all in a single user message
return calls.map((call) => results.get(call.id)!);
}
export async function runTurn(messages: Anthropic.MessageParam[], opts: ChatOptions): Promise<TurnResult> {
const { client, ctx, model = "claude-opus-5-5", maxSteps = 6, log } = opts;
// The model only sees the tools this conversation is allowed to use
const tools = TOOLS.filter((tool) => ctx.allowedTools.has(tool.name));
for (let step = 1; step <= maxSteps; step++) {
const response = await client.messages.create({
model,
max_tokens: 16000,
system: systemPrompt(ctx.today),
tools,
messages,
});
// Keep the whole content (text, tool_use, thinking blocks) in history, not just the text
messages.push({ role: "assistant", content: response.content });
const text = response.content
.filter((block): block is Anthropic.TextBlock => block.type === "text")
.map((block) => block.text)
.join("\n");
log?.({ step, stopReason: response.stop_reason });
if (response.stop_reason === "end_turn") return { status: "done", text, stopReason: response.stop_reason, steps: step };
if (response.stop_reason !== "tool_use") {
// max_tokens, refusal and others: no tools to run, the application decides what to show
return { status: "stopped", text, stopReason: response.stop_reason, steps: step };
}
const calls = response.content.filter((block): block is Anthropic.ToolUseBlock => block.type === "tool_use");
messages.push({ role: "user", content: await runCalls(calls, ctx, log) });
}
return { status: "step_limit", text: "", stopReason: null, steps: maxSteps };
}- Push the whole
response.contentinto history, not just the text: it holds thetool_useblocks and, on models with thinking on,thinkingblocks too. Claude Opus 5.5 always runs adaptive thinking. - All results go back in one
usermessage, with thetool_resultblocks first. Text before them gets a 400; a missing result gets the error "tool_use ids were found without tool_result blocks immediately after". - The API doesn't prescribe an execution order for parallel calls. The docs suggest running independent read-only operations in parallel and operations with side effects sequentially. That is what the code does:
get_free_slotsthroughPromise.all,create_bookingone at a time. - If you decide not to run a call, it still needs a
tool_result— withis_error: trueand a short explanation. maxStepsis a fuse against an endless loop: six model–tool rounds per client message is plenty for a salon booking.
Argument validation and tool errors
Arguments from tool_use are input from an external system, like an HTTP request body. Strict mode guarantees the shape, not the meaning: the date 2026-10-01 passes the schema even though it's in the past. So the second half of src/tools.ts repeats the shape in zod and adds what the schema can't express: name length, "not in the past and no more than 30 days ahead", "the slot is actually free".
// 2. What our code accepts: the same shape plus rules JSON Schema in strict mode cannot express
const GetFreeSlotsInput = z.strictObject({
service: z.enum(SERVICE_IDS),
date: z.iso.date(),
});
const CreateBookingInput = z.strictObject({
service: z.enum(SERVICE_IDS),
date: z.iso.date(),
time: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/),
client_name: z.string().trim().min(2).max(60),
phone: z.string().regex(/^\+7\d{10}$/),
});
export const READ_ONLY_TOOLS: ReadonlySet<string> = new Set(["get_free_slots"]);
export type ToolContext = {
salon: Salon;
today: string; // YYYY-MM-DD in the salon's time zone
allowedTools: ReadonlySet<string>;
// A human decision outside the model: a button in the chat, not a model's "yes"
confirm: (summary: string) => Promise<boolean>;
};
type Result = Anthropic.ToolResultBlockParam;
const DAY_MS = 24 * 60 * 60 * 1000;
function checkDate(date: string, today: string): string | null {
const days = (Date.parse(date) - Date.parse(today)) / DAY_MS;
if (days < 0) return `Date ${date} is in the past (today is ${today}). Ask the client for another date.`;
if (days > 30) return `Date ${date} is more than 30 days ahead. Bookings open 30 days in advance.`;
return null;
}
// 3. Execute one tool call. Every failure becomes an instructive is_error result, never a crash
export async function runTool(call: Anthropic.ToolUseBlock, ctx: ToolContext): Promise<Result> {
const ok = (data: unknown): Result => ({ type: "tool_result", tool_use_id: call.id, content: JSON.stringify(data) });
const fail = (message: string): Result => ({ type: "tool_result", tool_use_id: call.id, content: message, is_error: true });
if (!ctx.allowedTools.has(call.name)) {
return fail(`Tool ${call.name} is not available in this conversation.`);
}
switch (call.name) {
case "get_free_slots": {
const parsed = GetFreeSlotsInput.safeParse(call.input);
if (!parsed.success) return fail(`Invalid arguments:\n${z.prettifyError(parsed.error)}`);
const { service, date } = parsed.data;
const dateProblem = checkDate(date, ctx.today);
if (dateProblem) return fail(dateProblem);
return ok({ service, date, slots: ctx.salon.freeSlots(service, date) });
}
case "create_booking": {
const parsed = CreateBookingInput.safeParse(call.input);
if (!parsed.success) return fail(`Invalid arguments:\n${z.prettifyError(parsed.error)}`);
const { service, date, time, client_name, phone } = parsed.data;
const dateProblem = checkDate(date, ctx.today);
if (dateProblem) return fail(dateProblem);
if (!ctx.salon.freeSlots(service, date).includes(time)) {
return fail(`${date} ${time} is not free for ${service}. Call get_free_slots and offer one of the returned times.`);
}
const approved = await ctx.confirm(`${service}, ${date} ${time}, ${client_name}, ${phone}`);
if (!approved) return fail("The client did not confirm. Nothing was booked. Ask what to change.");
try {
const booking = ctx.salon.book({ service, date, time, clientName: client_name, phone });
return ok({ booking_id: booking.id, service, date, time });
} catch (error) {
if (error instanceof SlotTakenError) {
return fail(`${date} ${time} was just taken by another client. Call get_free_slots again.`);
}
throw error;
}
}
default:
return fail(`Unknown tool ${call.name}.`);
}
}Arguments failed zod
- What the tool returns
is_error: trueand thez.prettifyErrortext with the field and reason- What the model does
- Fixes the call if it has the data, or asks the client
Date in the past or over 30 days ahead
- What the tool returns
is_error: truewith today's date and the rule- What the model does
- Asks the client for another date
Time is taken
- What the tool returns
is_error: trueand a hint to callget_free_slotsagain- What the model does
- Fetches slots and offers another time
Client didn't confirm
- What the tool returns
is_error: true: nothing was booked- What the model does
- Asks what to change
Unexpected exception
- What the tool returns
is_error: truewith a generic text; details stay in our logs- What the model does
- Apologizes and suggests calling the salon
According to Anthropic's docs, when an invalid call comes back with an error in tool_result, Claude usually retries 2–3 times with corrections before apologizing to the user. So the error text is an instruction for the model. The docs advise writing what went wrong and what to try next instead of a generic "failed", for example "Rate limit exceeded. Retry after 60 seconds.".
Risky actions: confirmation and permissions
The model can make mistakes, and user text or a tool result can push it toward a wrong action. That's why code, not the prompt, sets the boundaries. The example has five of them.
The model sees only what's allowed
runTurnfiltersTOOLSbyctx.allowedTools. An anonymous visitor can get justget_free_slots.Code checks again
runToolrejects a tool outside the list even if the model named it: protection against a bug in the filter or a tampered history.A human confirms
create_bookingcallsctx.confirm— in a chat bot, a "Confirm" button. The model saying "the client agreed" is not a confirmation.Re-check before writing
Salon.bookre-checks the slot: another client could have taken it between theget_free_slotsanswer and the confirmation.No tool, no action
There is no cancel or reschedule tool. What the bot must not do simply isn't in the tool list.
The model decides
- Which tool to call and with which arguments
- What to ask the client when data is missing
- How to word the answer
Code decides
- Which tools the conversation may use
- Whether arguments and business rules hold
- Whether a human confirmed the action
- How many steps are allowed
About tool_choice. The default is auto: the model decides whether to call a tool. The any and tool options force a call, but on Claude Opus 5.5, Sonnet 5.5 and Fable 5.1 they return a 400 — the docs recommend auto with strict mode and an explicit instruction in the prompt. Setting disable_parallel_tool_use: true inside tool_choice limits a reply to one call if parallelism gets in the way of your logic.
Verification: a mock Messages API, the full loop and tests
No real API request was made in this verification: it needs a key, and the model's answer differs every time. Instead we start a local mock of POST /v1/messages. It replays pre-recorded model turns with tool_use blocks, stores every request and, like the real API, answers 400 when a tool_use has no tool_result after it or the results don't come first. The SDK points at it through baseURL.
// Local stand-in for POST /v1/messages: replays scripted model turns and records every request.
// It also checks the history rule the real API enforces: each tool_use is answered by a tool_result
// in the very next user message, and tool_result blocks come first.
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import type Anthropic from "@anthropic-ai/sdk";
export type MockTurn = {
content: Array<
| { type: "text"; text: string }
| { type: "tool_use"; id: string; name: string; input: Record<string, unknown> }
>;
stop_reason: Anthropic.StopReason;
};
type RequestBody = Anthropic.MessageCreateParamsNonStreaming;
function historyProblem(messages: Anthropic.MessageParam[]): string | null {
for (let i = 0; i < messages.length; i++) {
const { role, content } = messages[i]!;
if (role !== "assistant" || typeof content === "string") continue;
const ids = content.flatMap((b) => (b.type === "tool_use" ? [b.id] : []));
if (ids.length === 0) continue;
const next = messages[i + 1];
if (!next) continue;
const blocks = typeof next.content === "string" ? [] : next.content;
const resultIds = blocks.flatMap((b) => (b.type === "tool_result" ? [b.tool_use_id] : []));
const firstOther = blocks.findIndex((b) => b.type !== "tool_result");
if (next.role !== "user" || ids.some((id) => !resultIds.includes(id))) {
return "tool_use ids were found without tool_result blocks immediately after";
}
if (firstOther !== -1 && firstOther < resultIds.length) return "tool_result blocks must come first";
}
return null;
}
export async function startMockAnthropic(script: MockTurn[]) {
const requests: RequestBody[] = [];
let turn = 0;
const server = createServer(async (req, res) => {
let raw = "";
for await (const chunk of req) raw += chunk;
const send = (status: number, body: unknown) => {
res.writeHead(status, { "content-type": "application/json" });
res.end(JSON.stringify(body));
};
if (req.method !== "POST" || req.url !== "/v1/messages") return send(404, {});
const body = JSON.parse(raw) as RequestBody;
requests.push(structuredClone(body));
const problem = historyProblem(body.messages);
if (problem) return send(400, { type: "error", error: { type: "invalid_request_error", message: problem } });
const next = script[turn++];
if (!next) return send(500, { type: "error", error: { type: "api_error", message: "mock script exhausted" } });
send(200, {
id: `msg_mock_${turn}`,
type: "message",
role: "assistant",
model: body.model,
content: next.content,
stop_reason: next.stop_reason,
stop_sequence: null,
usage: { input_tokens: 0, output_tokens: 0 },
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const { port } = server.address() as AddressInfo;
return {
url: `http://127.0.0.1:${port}`,
requests,
close: () => new Promise<void>((resolve) => server.close(() => resolve())),
};
}// Full tool-calling cycle against the local mock: no API key, no network
import Anthropic from "@anthropic-ai/sdk";
import { runTurn } from "./chat.ts";
import { startMockAnthropic } from "./mock-anthropic.ts";
import { Salon } from "./salon.ts";
const mock = await startMockAnthropic([
{
// The model asks for two dates at once: parallel tool calls
stop_reason: "tool_use",
content: [
{ type: "text", text: "Checking Friday and Saturday." },
{ type: "tool_use", id: "toolu_01", name: "get_free_slots", input: { service: "manicure", date: "2026-10-16" } },
{ type: "tool_use", id: "toolu_02", name: "get_free_slots", input: { service: "manicure", date: "2026-10-17" } },
],
},
{
stop_reason: "tool_use",
content: [
{
type: "tool_use",
id: "toolu_03",
name: "create_booking",
input: { service: "manicure", date: "2026-10-16", time: "15:00", client_name: "Aigerim", phone: "+77010000000" },
},
],
},
{ stop_reason: "end_turn", content: [{ type: "text", text: "Done: manicure on Friday, October 16 at 15:00." }] },
]);
const salon = new Salon();
salon.book({ service: "manicure", date: "2026-10-16", time: "16:00", clientName: "Test", phone: "+77000000000" });
const client = new Anthropic({ apiKey: "test-key", baseURL: mock.url, maxRetries: 0 });
const messages: Anthropic.MessageParam[] = [
{ role: "user", content: "Book me for a manicure on Friday after 3 pm. Aigerim, +77010000000" },
];
const result = await runTurn(messages, {
client,
ctx: {
salon,
today: "2026-10-12",
allowedTools: new Set(["get_free_slots", "create_booking"]),
confirm: async (summary) => {
console.log(`[confirm button] ${summary} -> yes`);
return true;
},
},
log: (event) => console.log(JSON.stringify(event)),
});
console.log(result);
console.log("bookings:", salon.bookings.map((b) => `${b.id} ${b.date} ${b.time} ${b.clientName}`));
console.log("requests sent:", mock.requests.length);
console.dir(mock.requests[1]!.messages.at(-1), { depth: 3 }); // what we sent back after step 1
await mock.close();$ node src/demo.ts
{"step":1,"stopReason":"tool_use"}
{"tool":"get_free_slots","input":{"service":"manicure","date":"2026-10-16"},"error":false,"ms":1}
{"tool":"get_free_slots","input":{"service":"manicure","date":"2026-10-17"},"error":false,"ms":0}
{"step":2,"stopReason":"tool_use"}
[confirm button] manicure, 2026-10-16 15:00, Aigerim, +77010000000 -> yes
{"tool":"create_booking","input":{"service":"manicure","date":"2026-10-16","time":"15:00","client_name":"Aigerim","phone":"+77010000000"},"error":false,"ms":1}
{"step":3,"stopReason":"end_turn"}
{
status: 'done',
text: 'Done: manicure on Friday, October 16 at 15:00.',
stopReason: 'end_turn',
steps: 3
}
bookings: [ 'B-1 2026-10-16 16:00 Test', 'B-2 2026-10-16 15:00 Aigerim' ]
requests sent: 3
{
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: 'toolu_01',
content: '{"service":"manicure","date":"2026-10-16","slots":["10:00","10:30","11:00","11:30","12:00","12:30","13:00","13:30","14:00","14:30","15:00","17:00","17:30","18:00","18:30","19:00"]}'
},
{
type: 'tool_result',
tool_use_id: 'toolu_02',
content: '{"service":"manicure","date":"2026-10-17","slots":["10:00","10:30","11:00","11:30","12:00","12:30","13:00","13:30","14:00","14:30","15:00","15:30","16:00","16:30","17:00","17:30","18:00","18:30","19:00"]}'
}
]
}The tests have two levels. The first calls runTool directly: invalid arguments, a past date, a taken time, a declining client, a forbidden and an unknown tool. The second runs runTurn against the mock and checks what the API sees: parallel results in one message and in call order, an is_error result followed by the model correcting itself, the step limit, hidden forbidden tools, and exiting without running tools on max_tokens.
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import type Anthropic from "@anthropic-ai/sdk";
import { Salon } from "../src/salon.ts";
import { runTool, type ToolContext } from "../src/tools.ts";
const call = (name: string, input: Record<string, unknown>): Anthropic.ToolUseBlock =>
({ type: "tool_use", id: "toolu_test", name, input }) as Anthropic.ToolUseBlock;
const booking = { service: "manicure", date: "2026-10-16", time: "15:00", client_name: "Aigerim", phone: "+77010000000" };
function context(overrides: Partial<ToolContext> = {}): ToolContext {
return {
salon: new Salon(),
today: "2026-10-12",
allowedTools: new Set(["get_free_slots", "create_booking"]),
confirm: async () => true,
...overrides,
};
}
describe("argument validation", () => {
const invalid: Array<[string, string, Record<string, unknown>, RegExp]> = [
["missing date", "get_free_slots", { service: "manicure" }, /date/],
["date in a wrong format", "get_free_slots", { service: "manicure", date: "16.10.2026" }, /Invalid ISO date/],
["unknown service", "get_free_slots", { service: "massage", date: "2026-10-16" }, /service/],
["extra property", "create_booking", { ...booking, discount: 50 }, /Unrecognized key: "discount"/],
["phone in a wrong format", "create_booking", { ...booking, phone: "8 701 000 00 00" }, /phone/],
["time as a number", "create_booking", { ...booking, time: 15 }, /time/],
["empty name", "create_booking", { ...booking, client_name: " " }, /client_name/],
];
for (const [title, name, input, message] of invalid) {
it(`rejects ${title} with is_error`, async () => {
const ctx = context();
const result = await runTool(call(name, input), ctx);
assert.equal(result.is_error, true);
assert.equal(result.tool_use_id, "toolu_test");
assert.match(String(result.content), message);
assert.equal(ctx.salon.bookings.length, 0);
});
}
it("rejects a date in the past with an instruction for the model", async () => {
const result = await runTool(call("get_free_slots", { service: "manicure", date: "2026-10-01" }), context());
assert.equal(result.is_error, true);
assert.match(String(result.content), /in the past \(today is 2026-10-12\)/);
});
});
describe("business rules and boundaries", () => {
it("returns free slots without the busy time", async () => {
const ctx = context();
ctx.salon.book({ service: "manicure", date: "2026-10-16", time: "16:00", clientName: "T", phone: "+77000000000" });
const result = await runTool(call("get_free_slots", { service: "manicure", date: "2026-10-16" }), ctx);
const { slots } = JSON.parse(String(result.content)) as { slots: string[] };
assert.equal(result.is_error, undefined);
assert.ok(slots.includes("15:00"));
assert.ok(!slots.includes("15:30") && !slots.includes("16:00") && !slots.includes("16:30"));
});
it("refuses a busy time and does not ask the client", async () => {
let asked = false;
const ctx = context({ confirm: async () => (asked = true) });
ctx.salon.book({ service: "manicure", date: "2026-10-16", time: "15:00", clientName: "T", phone: "+77000000000" });
const result = await runTool(call("create_booking", booking), ctx);
assert.equal(result.is_error, true);
assert.match(String(result.content), /not free.*Call get_free_slots/);
assert.equal(asked, false);
});
it("books nothing when the client declines", async () => {
const ctx = context({ confirm: async () => false });
const result = await runTool(call("create_booking", booking), ctx);
assert.equal(result.is_error, true);
assert.match(String(result.content), /did not confirm/);
assert.equal(ctx.salon.bookings.length, 0);
});
it("books after confirmation", async () => {
const ctx = context();
const result = await runTool(call("create_booking", booking), ctx);
assert.equal(result.is_error, undefined);
assert.deepEqual(JSON.parse(String(result.content)), { booking_id: "B-1", service: "manicure", date: "2026-10-16", time: "15:00" });
});
it("does not run a tool that is not allowed in this conversation", async () => {
const ctx = context({ allowedTools: new Set(["get_free_slots"]) });
const result = await runTool(call("create_booking", booking), ctx);
assert.equal(result.is_error, true);
assert.match(String(result.content), /not available/);
assert.equal(ctx.salon.bookings.length, 0);
});
it("answers an unknown tool name with is_error", async () => {
const result = await runTool(call("cancel_booking", { booking_id: "B-1" }), context({ allowedTools: new Set(["cancel_booking"]) }));
assert.equal(result.is_error, true);
assert.match(String(result.content), /Unknown tool cancel_booking/);
});
});import assert from "node:assert/strict";
import { describe, it } from "node:test";
import Anthropic from "@anthropic-ai/sdk";
import { runTurn } from "../src/chat.ts";
import { startMockAnthropic, type MockTurn } from "../src/mock-anthropic.ts";
import { Salon } from "../src/salon.ts";
import type { ToolContext } from "../src/tools.ts";
const slots = (id: string, date: string): MockTurn["content"][number] => ({
type: "tool_use",
id,
name: "get_free_slots",
input: { service: "manicure", date },
});
const book = (id: string, time: string): MockTurn["content"][number] => ({
type: "tool_use",
id,
name: "create_booking",
input: { service: "manicure", date: "2026-10-16", time, client_name: "Aigerim", phone: "+77010000000" },
});
async function run(script: MockTurn[], ctx: Partial<ToolContext> = {}, maxSteps?: number) {
const mock = await startMockAnthropic(script);
const salon = new Salon();
try {
const result = await runTurn([{ role: "user", content: "Manicure on Friday after 3 pm" }], {
client: new Anthropic({ apiKey: "test-key", baseURL: mock.url, maxRetries: 0 }),
ctx: { salon, today: "2026-10-12", allowedTools: new Set(["get_free_slots", "create_booking"]), confirm: async () => true, ...ctx },
maxSteps,
});
return { result, requests: mock.requests, salon };
} finally {
await mock.close();
}
}
type Sent = Anthropic.ToolResultBlockParam[];
const lastUserBlocks = (request: Anthropic.MessageCreateParamsNonStreaming) => request.messages.at(-1)!.content as Sent;
describe("tool-calling loop against the mock Messages API", () => {
it("runs the full cycle: parallel lookups, booking, final answer", async () => {
const { result, requests, salon } = await run([
{ stop_reason: "tool_use", content: [slots("toolu_01", "2026-10-16"), slots("toolu_02", "2026-10-17")] },
{ stop_reason: "tool_use", content: [book("toolu_03", "15:00")] },
{ stop_reason: "end_turn", content: [{ type: "text", text: "Booked for 15:00." }] },
]);
assert.deepEqual(result, { status: "done", text: "Booked for 15:00.", stopReason: "end_turn", steps: 3 });
assert.equal(requests.length, 3);
assert.deepEqual(requests[0]!.tools!.map((t) => (t as Anthropic.Tool).name), ["get_free_slots", "create_booking"]);
// Both parallel results go back in ONE user message, in the order of the calls
const second = lastUserBlocks(requests[1]!);
assert.equal(requests[1]!.messages.at(-1)!.role, "user");
assert.deepEqual(second.map((b) => b.tool_use_id), ["toolu_01", "toolu_02"]);
assert.ok(second.every((b) => b.type === "tool_result" && b.is_error === undefined));
assert.deepEqual(lastUserBlocks(requests[2]!).map((b) => b.tool_use_id), ["toolu_03"]);
assert.equal(salon.bookings.length, 1);
});
it("returns invalid arguments as is_error and lets the model correct itself", async () => {
const { result, requests, salon } = await run([
{ stop_reason: "tool_use", content: [book("toolu_01", "3 pm")] },
{ stop_reason: "tool_use", content: [book("toolu_02", "15:00")] },
{ stop_reason: "end_turn", content: [{ type: "text", text: "Booked." }] },
]);
const first = lastUserBlocks(requests[1]!)[0]!;
assert.equal(first.is_error, true);
assert.match(String(first.content), /Invalid arguments[\s\S]*time/);
assert.equal(result.status, "done");
assert.equal(salon.bookings.length, 1);
});
it("stops at the step limit instead of looping forever", async () => {
const endless = Array.from({ length: 10 }, (_, i): MockTurn => ({
stop_reason: "tool_use",
content: [slots(`toolu_${i}`, "2026-10-16")],
}));
const { result, requests } = await run(endless, {}, 3);
assert.equal(result.status, "step_limit");
assert.equal(requests.length, 3);
});
it("hides and blocks tools that are not allowed", async () => {
const { requests, salon } = await run(
[
{ stop_reason: "tool_use", content: [book("toolu_01", "15:00")] },
{ stop_reason: "end_turn", content: [{ type: "text", text: "I can only show free times." }] },
],
{ allowedTools: new Set(["get_free_slots"]) },
);
assert.deepEqual(requests[0]!.tools!.map((t) => (t as Anthropic.Tool).name), ["get_free_slots"]);
assert.equal(lastUserBlocks(requests[1]!)[0]!.is_error, true);
assert.equal(salon.bookings.length, 0);
});
it("does not run tools when the answer was cut by max_tokens", async () => {
const { result, requests } = await run([{ stop_reason: "max_tokens", content: [{ type: "text", text: "Let me che" }] }]);
assert.deepEqual([result.status, result.stopReason], ["stopped", "max_tokens"]);
assert.equal(requests.length, 1);
});
});> tool-calling@1.0.0 test
> node --test
▶ tool-calling loop against the mock Messages API
✔ runs the full cycle: parallel lookups, booking, final answer (52.1838ms)
✔ returns invalid arguments as is_error and lets the model correct itself (12.8033ms)
✔ stops at the step limit instead of looping forever (9.5571ms)
✔ hides and blocks tools that are not allowed (5.2352ms)
✔ does not run tools when the answer was cut by max_tokens (3.7351ms)
✔ tool-calling loop against the mock Messages API (84.3836ms)
▶ argument validation
✔ rejects missing date with is_error (2.1468ms)
✔ rejects date in a wrong format with is_error (0.2898ms)
✔ rejects unknown service with is_error (0.2232ms)
✔ rejects extra property with is_error (0.6564ms)
✔ rejects phone in a wrong format with is_error (0.1848ms)
✔ rejects time as a number with is_error (0.1194ms)
✔ rejects empty name with is_error (0.1765ms)
✔ rejects a date in the past with an instruction for the model (0.1541ms)
✔ argument validation (4.7439ms)
▶ business rules and boundaries
✔ returns free slots without the busy time (0.4729ms)
✔ refuses a busy time and does not ask the client (0.2533ms)
✔ books nothing when the client declines (0.1299ms)
✔ books after confirmation (0.5623ms)
✔ does not run a tool that is not allowed in this conversation (0.1045ms)
✔ answers an unknown tool name with is_error (0.0917ms)
✔ business rules and boundaries (1.808ms)
ℹ tests 19
ℹ suites 3
ℹ pass 19
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 441.0589$ npm run typecheck
> tool-calling@1.0.0 typecheck
> tscThe tests were checked in reverse: sending each tool_result as its own message makes the mock answer 400 and the full-loop test fail; replacing z.strictObject with z.object fails the test about the extra discount field. For the real API there is the entry point below: the key comes from the ANTHROPIC_API_KEY environment variable, and the confirmation is a question in the terminal.
// Real run: needs ANTHROPIC_API_KEY in the environment (never in code). Usage:
// node src/main.ts "Book me for a manicure on Friday after 3 pm"
import { createInterface } from "node:readline/promises";
import Anthropic from "@anthropic-ai/sdk";
import { runTurn } from "./chat.ts";
import { Salon } from "./salon.ts";
const rl = createInterface({ input: process.stdin, output: process.stdout });
const messages: Anthropic.MessageParam[] = [{ role: "user", content: process.argv[2] ?? "Hi" }];
const result = await runTurn(messages, {
client: new Anthropic(), // reads ANTHROPIC_API_KEY
ctx: {
salon: new Salon(),
today: new Date().toLocaleDateString("sv-SE", { timeZone: "Asia/Almaty" }), // YYYY-MM-DD
allowedTools: new Set(["get_free_slots", "create_booking"]),
confirm: async (summary) => (await rl.question(`Confirm booking: ${summary}? (y/n) `)).trim() === "y",
},
log: (event) => console.error(JSON.stringify(event)),
});
console.log(result.status === "done" ? result.text : `Stopped (${result.status}, ${result.stopReason}). Please call the salon.`);
rl.close();MCP, OpenAI function calling and common mistakes
MCP (Model Context Protocol) is an open standard for connecting AI applications to external systems; its authors compare it to a USB-C port. If your tools must work in several clients — Claude, ChatGPT, IDEs — you move them into an MCP server, and each client connects to it without its own integration. The Claude API has an MCP connector for remote MCP servers. For two tools inside one bot, as in this tutorial, plain tools is enough.
OpenAI has the same concept with different names and data shapes. Its primary interface is the Responses API:
Definition
- Anthropic Messages API
name,description,input_schema- OpenAI Responses API
type: "function",name,description,parameters
Call in the reply
- Anthropic Messages API
- A
tool_useblock withidand aninputobject - OpenAI Responses API
- A
function_callitem withcall_idandargumentsas a JSON string
Result
- Anthropic Messages API
tool_resultwithtool_use_id, failure —is_error: true- OpenAI Responses API
function_call_outputwithcall_idand anoutputstring
Disable parallel calls
- Anthropic Messages API
tool_choice.disable_parallel_tool_use: true- OpenAI Responses API
parallel_tool_calls: false
Strict mode
- Anthropic Messages API
strict: true,additionalProperties: false- OpenAI Responses API
strict: true, every field inrequired, optional ones vianull
- A one-phrase description like "Gets slots" — the model guesses when and how to call it
- Trusting arguments without checks: strict mode doesn't know the date has passed
- A tool failure thrown as an exception that kills the whole answer instead of
is_error - Each
tool_resultin a separate message, or text before the results - Only the answer text saved to history, without the
tool_useblocks - A loop with no step limit
- Confirming a risky action "because the model said so" instead of a button or another human action
- A stack trace and internal data in an error text that ends up in the prompt
Next step — "AI agents: the loop, tools, boundaries and automation" (ai-agenty): the same loop gets a goal, a budget and a step journal.
How this was verified3 · passed1 · not runchecked 12 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.