How to buildOctober 12, 202611 min read
AI integrationsStep 4 of 8

Tool calling: the model calls your functions

In short

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.

TutorialLevel: IntermediateTool CallingPractice: ~1.5–3 h

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_result loop with parallel calls, a step limit and stop_reason handling
  • validate arguments with zod and return errors with is_error: true so the model can fix its call
  • limit risky actions: a per-conversation tool set, human confirmation and a re-check right before writing

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
  1. 01How tool calling works
  2. 02Defining a tool: name, description and JSON Schema
  3. 03The loop: tool_use → execution → tool_result
  4. 04Argument validation and tool errors
  5. 05Risky actions: confirmation and permissions
  6. 06Verification: a mock Messages API, the full loop and tests
  7. 07MCP, 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.
Request with toolstool_use replyYour code runs ittool_resultModel answer
Model choosesYour applicationModel
One tool-calling round: the model asks for a call, the application runs it and returns the result

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.

bash
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
Project: Anthropic SDK, zod, TypeScript for type checking
tsconfig.json
{
  "compilerOptions": {
    "target": "es2024",
    "module": "nodenext",
    "strict": true,
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "types": ["node"],
    "skipLibCheck": true
  },
  "include": ["src", "test"]
}
Erasable TypeScript syntax only and .ts import extensions, so Node.js 24 runs the files without a build

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.

src/salon.ts
// 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;
  }
}
Domain logic without any LLM: free slots and booking with a re-check

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.

src/tools.ts
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,
    },
  },
];
Fragment of src/tools.ts: the tool definitions the model sees (the rest of the file is in the validation section)
  • The get_free_slots description says what the tool does not do ("does not book anything") and which time format it uses — that removes guesswork.
  • The create_booking description 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 enum for the service instead of a free string: the model can't invent a "manicure plus design" that isn't on the price list.
  • strict: true turns on strict mode: Anthropic guarantees that input matches the schema and name is one of the tools you passed. It requires additionalProperties: false and a required list on every object.

Types, required fields, no extra fields

Strict mode
Supported
Where we check it
Schema + zod in 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
zod only

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:

  1. Request

    Send tools, the system prompt and the message history.

  2. tool_use reply

    The model returns stop_reason: "tool_use" and one or more tool_use blocks with id, name and input.

  3. Execution

    Your code runs the functions and wraps the results in tool_result blocks with the same tool_use_id.

  4. Next request

    Send the history, the model's whole reply and one user message with all results.

  5. Repeat or exit

    While stop_reason is tool_use, go round again; any other value exits: end_turn, max_tokens, refusal and so on.

src/chat.ts
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 };
}
The loop with parallel calls, a step limit and stop_reason handling
  • Push the whole response.content into history, not just the text: it holds the tool_use blocks and, on models with thinking on, thinking blocks too. Claude Opus 5.5 always runs adaptive thinking.
  • All results go back in one user message, with the tool_result blocks 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_slots through Promise.all, create_booking one at a time.
  • If you decide not to run a call, it still needs a tool_result — with is_error: true and a short explanation.
  • maxSteps is 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".

src/tools.ts
// 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}.`);
  }
}
Fragment of src/tools.ts (continued): zod schemas, business rules and running a call

Arguments failed zod

What the tool returns
is_error: true and the z.prettifyError text 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: true with 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: true and a hint to call get_free_slots again
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: true with 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.

  1. The model sees only what's allowed

    runTurn filters TOOLS by ctx.allowedTools. An anonymous visitor can get just get_free_slots.

  2. Code checks again

    runTool rejects a tool outside the list even if the model named it: protection against a bug in the filter or a tampered history.

  3. A human confirms

    create_booking calls ctx.confirm — in a chat bot, a "Confirm" button. The model saying "the client agreed" is not a confirmation.

  4. Re-check before writing

    Salon.book re-checks the slot: another client could have taken it between the get_free_slots answer and the confirmation.

  5. 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.

src/mock-anthropic.ts
// 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())),
  };
}
A local mock of the Messages API: scripted replies and a check of the history rule
src/demo.ts
// 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();
The full loop: two parallel slot lookups, a confirmed booking, the final answer
terminal
$ 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"]}'
    }
  ]
}
Real output on Node.js 24.18: Friday's slots lack 15:30–16:30 because 16:00 was booked in advance; both results went back in one message

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.

test/tools.test.ts
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/);
  });
});
14 tool tests: zod validation, business rules, confirmation and permissions
test/chat.test.ts
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);
  });
});
5 loop tests against the mock Messages API
terminal
> 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
Real npm test output on Node.js 24.18: 19 tests
terminal
$ npm run typecheck
> tool-calling@1.0.0 typecheck
> tsc
TypeScript 7.0.2 type check passed with no errors

The 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.

src/main.ts
// 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();
Running against the real API: type-checked with tsc but not run for this tutorial — it needs a key

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_use block with id and an input object
OpenAI Responses API
A function_call item with call_id and arguments as a JSON string

Result

Anthropic Messages API
tool_result with tool_use_id, failure — is_error: true
OpenAI Responses API
function_call_output with call_id and an output string

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 in required, optional ones via null
  • 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_result in a separate message, or text before the results
  • Only the answer text saved to history, without the tool_use blocks
  • 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.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.