Tool calling — механизм, при котором вы описываете функции (имя, назначение, JSON Schema аргументов), модель отвечает структурированным запросом на вызов (tool_use), ваш код выполняет функцию и возвращает результат (tool_result), а модель продолжает ответ. Сама модель ничего не выполняет: аргументы нужно проверять в коде, ошибки возвращать с is_error: true, а действия с последствиями подтверждать вне модели.
После материала вы сможете
- описать инструмент именем, подробным описанием и JSON Schema со
strict: trueи перечислить ограничения, которые схема не выражает и которые нужно проверять в коде - написать цикл
tool_use→ выполнение →tool_resultс параллельными вызовами, лимитом шагов и обработкойstop_reason - валидировать аргументы через
zodи возвращать модели ошибки сis_error: trueтак, чтобы она могла исправить вызов - ограничить опасные действия: набор инструментов на разговор, подтверждение человеком и повторная проверка перед записью
Что знать заранее
Проверено на
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
Сверено с документацией: 12 октября 2026 г.
Содержание
Как работает tool calling
Tool calling (в документации Anthropic — tool use, у OpenAI — function calling) даёт модели доступ к вашим данным и действиям. Вы описываете функции: имя, назначение и JSON Schema аргументов. Модель решает, нужна ли функция, и вместо текста возвращает структурированный запрос: «вызови get_free_slots с { "service": "manicure", "date": "2026-10-16" }». Выполняет его ваш код, а результат уходит обратно в разговор.
Модель никогда ничего не выполняет сама. Она выдаёт запрос, ваш код выполняет операцию, результат возвращается в разговор — это контракт, а не магия.
Документация Anthropic перечисляет, когда инструменты оправданы: действия с последствиями (записать клиента, отправить письмо), свежие или внутренние данные (свободные слоты, остатки, статус заказа), ответ строго заданной формы и вызов существующих систем — баз, внутренних API. Не нужны они, когда модель отвечает из общих знаний (перевод, пересказ) и когда лишний круг запроса дороже самой работы.
В уроке соберём ассистента записи в салон красоты в Алматы с двумя инструментами: get_free_slots (свободное время на дату) и create_booking (создать запись после подтверждения клиентом). Материал для backend-разработчиков, которые уже отправляли запрос к LLM API (llm-api-pervyj-zapros) и знают основы промптов. Окружение: Node.js 24, TypeScript 7.0, @anthropic-ai/sdk 0.133.0, zod 4.6.6. В примере модель claude-opus-5-5: на 12 октября 2026 года документация Anthropic советует начинать с Claude Opus 5.5. Описания инструментов тоже стоят денег: имена, описания и схемы входят во входные токены каждого запроса, а для Opus 5.5 API добавляет ещё 286 токенов служебного системного промпта.
Описание инструмента: имя, описание и JSON Schema
Проект — обычное Node.js-приложение на ES-модулях. Node.js 24 запускает файлы .ts напрямую, отбрасывая типы, поэтому tsc нужен только для проверки типов.
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"]
}Расписание салона держим в памяти: один мастер, слоты каждые 30 минут с 10:00 до 20:00. В реальном проекте здесь была бы база данных или API CRM — инструменту это безразлично.
// 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;
}
}Определение инструмента для Claude — это name (латиница, цифры, _ и -, до 128 символов), description и input_schema в формате JSON Schema. Документация Anthropic называет подробное описание самым важным фактором качества: что делает инструмент, когда его вызывать и когда нет, что значит каждый параметр, каких данных инструмент не возвращает. Рекомендованный минимум — 3–4 предложения на инструмент.
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,
},
},
];- В описании
get_free_slotsсказано, чего инструмент не делает («does not book anything»), и в каком формате время — это снимает догадки модели. - Описание
create_bookingзапрещает выдумывать имя и телефон. Документация отмечает, что при нехватке обязательных данных модель может подставить правдоподобное значение, особенно на неоднозначных запросах. enumдля услуги вместо свободной строки: модель не придумает «маникюр+дизайн», которого нет в прайсе.strict: trueвключает строгий режим: Anthropic гарантирует, чтоinputсоответствует схеме, аname— одному из переданных инструментов. Для этого у каждого объекта нужныadditionalProperties: falseи списокrequired.
Тип, обязательные поля, нет лишних полей
- Строгий режим (strict)
- Поддерживается
- Где проверяем
- Схема +
zodна случай другого провайдера или выключенного strict
enum, format: date, простой pattern
- Строгий режим (strict)
- Поддерживается
- Где проверяем
- Схема +
zod
minLength, maxLength, minimum, maximum
- Строгий режим (strict)
- Не поддерживается — запрос получит 400
- Где проверяем
- Только
zod
Дата не в прошлом, слот свободен
- Строгий режим (strict)
- JSON Schema этого не выражает
- Где проверяем
- Код инструмента
Цикл: tool_use → выполнение → tool_result
Инструменты, которые выполняет ваше приложение, требуют цикла. Документация Anthropic описывает его как while по полю stop_reason:
Запрос
Отправляем
tools, системный промпт и историю сообщений.Ответ с tool_use
Модель возвращает
stop_reason: "tool_use"и один или несколько блоковtool_useсid,nameиinput.Выполнение
Код запускает функции и оформляет результаты в блоки
tool_resultс тем жеtool_use_id.Новый запрос
Отправляем историю, ответ модели целиком и одно сообщение
userсо всеми результатами.Повтор или выход
Пока
stop_reasonравенtool_use, круг повторяется; любой другой — выход:end_turn,max_tokens,refusalи т. д.
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 };
}- В историю кладём весь
response.content, а не только текст: там блокиtool_use, а у моделей с включённым мышлением — ещё и блокиthinking. У Claude Opus 5.5 адаптивное мышление включено всегда. - Результаты всех вызовов уходят одним сообщением
user, блокиtool_resultидут первыми. Если поставить текст перед ними, API вернёт 400; если пропустить результат, будет ошибка «tool_use ids were found without tool_result blocks immediately after». - Порядок выполнения параллельных вызовов API не диктует. Документация советует запускать независимые операции только для чтения параллельно, а операции с побочными эффектами — последовательно. Так и сделано:
get_free_slotsчерезPromise.all,create_bookingпо одному. - Если вызов решили не выполнять,
tool_resultдля него всё равно нужен — сis_error: trueи коротким объяснением. maxSteps— предохранитель от бесконечного цикла: шесть кругов модель–инструмент на одну реплику клиента для записи в салон с запасом.
Валидация аргументов и ошибки инструмента
Аргументы из tool_use — это входные данные от внешней системы, как тело HTTP-запроса. Строгий режим гарантирует форму, но не смысл: дата 2026-10-01 пройдёт схему, хотя уже прошла. Поэтому вторая половина src/tools.ts повторяет форму в zod и добавляет то, чего схема не выражает: длину имени, «не в прошлом и не дальше 30 дней», «слот действительно свободен».
// 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}.`);
}
}Аргументы не прошли zod
- Что вернёт инструмент
is_error: trueи текстz.prettifyErrorс полем и причиной- Что сделает модель
- Исправит вызов, если данные есть, или спросит клиента
Дата в прошлом или дальше 30 дней
- Что вернёт инструмент
is_error: trueс сегодняшней датой и правилом- Что сделает модель
- Уточнит дату у клиента
Время занято
- Что вернёт инструмент
is_error: trueи подсказка снова вызватьget_free_slots- Что сделает модель
- Запросит слоты и предложит другое время
Клиент не подтвердил
- Что вернёт инструмент
is_error: true: ничего не записано- Что сделает модель
- Спросит, что изменить
Неожиданное исключение
- Что вернёт инструмент
is_error: trueс общим текстом; подробности — в наших логах- Что сделает модель
- Извинится и предложит позвонить в салон
По документации Anthropic, на некорректный вызов с ошибкой в tool_result Claude обычно делает 2–3 попытки исправиться, прежде чем извиниться перед пользователем. Поэтому текст ошибки — это инструкция для модели. Совет из документации: вместо общего «failed» писать, что случилось и что попробовать дальше, например «Rate limit exceeded. Retry after 60 seconds.».
Опасные действия: подтверждение и права
Модель может ошибиться, а текст пользователя или результат инструмента — подтолкнуть её к неверному действию. Поэтому границы действий задаёт код, а не промпт. В примере их пять.
Модель видит только разрешённое
runTurnфильтруетTOOLSпоctx.allowedTools. Анонимному посетителю можно дать толькоget_free_slots.Код проверяет ещё раз
runToolотклоняет инструмент не из списка, даже если модель его назвала: защита на случай ошибки в фильтре или подмены истории.Подтверждает человек
create_bookingвызываетctx.confirm— в боте это кнопка «Подтвердить». Фраза модели «клиент согласен» подтверждением не считается.Проверка перед записью
Salon.bookперепроверяет слот: между ответомget_free_slotsи подтверждением его мог занять другой клиент.Нет инструмента — нет действия
Отмены и переноса в наборе нет. Чего бот не должен делать, того в списке инструментов просто нет.
Решает модель
- Какой инструмент вызвать и с какими аргументами
- Что спросить у клиента, если данных не хватает
- Как сформулировать ответ
Решает код
- Какие инструменты доступны в разговоре
- Допустимы ли аргументы и бизнес-правила
- Подтвердил ли действие человек
- Сколько шагов можно сделать
Про tool_choice. По умолчанию auto: модель сама решает, вызывать ли инструмент. Варианты any и tool заставляют вызвать инструмент, но на Claude Opus 5.5, Sonnet 5.5 и Fable 5.1 они возвращают ошибку 400 — документация советует auto со строгим режимом и явной инструкцией в промпте. Параметр disable_parallel_tool_use: true внутри tool_choice ограничивает ответ одним вызовом, если параллельность мешает вашей логике.
Проверка: мок Messages API, полный цикл и тесты
Настоящий запрос к API в этой проверке не выполнялся: для этого нужен ключ, а ответ модели каждый раз разный. Вместо этого поднимаем локальный мок POST /v1/messages. Он отдаёт заранее записанные ходы модели с блоками tool_use, сохраняет каждый запрос и, как настоящий API, отвечает 400, если после tool_use нет tool_result или результаты стоят не первыми. SDK подключается к нему через 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"]}'
}
]
}Тесты разделены на два уровня. Первый вызывает runTool напрямую: неверные аргументы, прошедшая дата, занятое время, отказ клиента, запрещённый и неизвестный инструмент. Второй гоняет runTurn против мока и проверяет то, что видит API: результаты параллельных вызовов в одном сообщении и в порядке вызовов, ошибка с is_error и самоисправление модели, лимит шагов, скрытие запрещённых инструментов, выход без выполнения при 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
> tscТесты проверены «от обратного»: если отправлять каждый tool_result отдельным сообщением, мок отвечает 400 и падает тест полного цикла; если заменить z.strictObject на z.object, падает тест про лишнее поле discount. Для работы с настоящим API остаётся точка входа ниже: ключ берётся из переменной окружения ANTHROPIC_API_KEY, подтверждение — вопрос в терминале.
// 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 и частые ошибки
MCP (Model Context Protocol) — открытый стандарт подключения AI-приложений к внешним системам; авторы протокола сравнивают его с портом USB-C. Если ваши инструменты должны работать в нескольких клиентах — Claude, ChatGPT, IDE, — их выносят в MCP-сервер, и каждый клиент подключает его без своей интеграции. В Claude API для удалённых MCP-серверов есть MCP connector. Для двух инструментов внутри одного бота, как в уроке, обычного tools достаточно.
Концепция у OpenAI та же, отличаются названия и форма данных. Основной интерфейс там — Responses API:
Описание
- Anthropic Messages API
name,description,input_schema- OpenAI Responses API
type: "function",name,description,parameters
Вызов в ответе
- Anthropic Messages API
- Блок
tool_useсidиinput-объектом - OpenAI Responses API
- Элемент
function_callсcall_idиarguments— JSON-строкой
Результат
- Anthropic Messages API
tool_resultсtool_use_id, ошибка —is_error: true- OpenAI Responses API
function_call_outputсcall_idи строкойoutput
Отключить параллельность
- Anthropic Messages API
tool_choice.disable_parallel_tool_use: true- OpenAI Responses API
parallel_tool_calls: false
Строгий режим
- Anthropic Messages API
strict: true,additionalProperties: false- OpenAI Responses API
strict: true, все поля вrequired, необязательное — черезnull
- Описание из одной фразы вроде «Gets slots» — модель гадает, когда и как вызывать
- Доверие аргументам без проверки: строгий режим не знает, что дата прошла
- Ошибка инструмента пробрасывается исключением и роняет весь ответ вместо
is_error - Каждый
tool_result— отдельным сообщением или с текстом перед результатами - В историю сохранён только текст ответа, без блоков
tool_use - Цикл без лимита шагов
- Подтверждение опасного действия «по словам модели», а не кнопкой или другим действием человека
- Стектрейс и внутренние данные в тексте ошибки, который попадает в промпт
Следующий шаг — материал «AI-агенты: цикл, инструменты, границы и автоматизация» (ai-agenty): там тот же цикл получает цель, бюджет и журнал шагов.
Как проверено3 · пройдено1 · не запускалосьсверено 12 октября 2026 г.
- запущено, работает
src/salon.ts, src/tools.ts, src/chat.ts, src/mock-anthropic.ts, src/demo.ts, src/main.ts, test/*.ts
tsc 7.0.2 без ошибок (strict, nodenext); Node.js 24.18 с @anthropic-ai/sdk 0.133.0 и zod 4.6.6 (версии из npm view на 2026-10-12)
- запущено, работает
Полный цикл tool_use → tool_result против локального мока POST /v1/messages (src/demo.ts)
node src/demo.ts: настоящий клиент SDK через baseURL на мок, 3 запроса, 2 параллельных get_free_slots одним сообщением, create_booking с подтверждением; вывод в статье — реальный
- запущено, работает
test/tools.test.ts (14) + test/chat.test.ts (5)
npm test (node --test): 19/19; проверка «от обратного»: результаты отдельными сообщениями → мок 400 и падение теста цикла; z.object вместо z.strictObject → падение теста лишнего поля
- не запускалось
src/main.ts — запрос к настоящему Claude API
только tsc; живой запрос не выполнялся — нет ключа (по заданию); поведение реальной модели не проверялось
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.