Как делать12 октября 2026 г.10 мин чтения
AI-интеграцииШаг 4 из 8

Tool calling: модель вызывает ваши функции

Коротко

Tool calling — механизм, при котором вы описываете функции (имя, назначение, JSON Schema аргументов), модель отвечает структурированным запросом на вызов (tool_use), ваш код выполняет функцию и возвращает результат (tool_result), а модель продолжает ответ. Сама модель ничего не выполняет: аргументы нужно проверять в коде, ошибки возвращать с is_error: true, а действия с последствиями подтверждать вне модели.

Пошаговый урокУровень: СреднийВызов инструментовПрактика: ~1,5–3 ч

После материала вы сможете

  • описать инструмент именем, подробным описанием и 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 г.

Содержание
  1. 01Как работает tool calling
  2. 02Описание инструмента: имя, описание и JSON Schema
  3. 03Цикл: tool_use → выполнение → tool_result
  4. 04Валидация аргументов и ошибки инструмента
  5. 05Опасные действия: подтверждение и права
  6. 06Проверка: мок Messages API, полный цикл и тесты
  7. 07MCP, OpenAI function calling и частые ошибки

Как работает tool calling

Tool calling (в документации Anthropic — tool use, у OpenAI — function calling) даёт модели доступ к вашим данным и действиям. Вы описываете функции: имя, назначение и JSON Schema аргументов. Модель решает, нужна ли функция, и вместо текста возвращает структурированный запрос: «вызови get_free_slots с { "service": "manicure", "date": "2026-10-16" }». Выполняет его ваш код, а результат уходит обратно в разговор.

Модель никогда ничего не выполняет сама. Она выдаёт запрос, ваш код выполняет операцию, результат возвращается в разговор — это контракт, а не магия.
Запрос с toolsОтвет tool_useВаш код выполняетtool_resultОтвет модели
Модель выбираетВаше приложениеМодель
Один круг tool calling: модель просит вызов, приложение выполняет и возвращает результат

Документация 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 нужен только для проверки типов.

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
Проект: SDK Anthropic, zod, TypeScript для проверки типов
tsconfig.json
{
  "compilerOptions": {
    "target": "es2024",
    "module": "nodenext",
    "strict": true,
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "types": ["node"],
    "skipLibCheck": true
  },
  "include": ["src", "test"]
}
Только стираемый синтаксис TypeScript и импорт с расширением .ts — так файлы запускаются в Node.js 24 без сборки

Расписание салона держим в памяти: один мастер, слоты каждые 30 минут с 10:00 до 20:00. В реальном проекте здесь была бы база данных или API CRM — инструменту это безразлично.

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;
  }
}
Предметная область без LLM: свободные слоты и запись с повторной проверкой

Определение инструмента для Claude — это name (латиница, цифры, _ и -, до 128 символов), description и input_schema в формате JSON Schema. Документация Anthropic называет подробное описание самым важным фактором качества: что делает инструмент, когда его вызывать и когда нет, что значит каждый параметр, каких данных инструмент не возвращает. Рекомендованный минимум — 3–4 предложения на инструмент.

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,
    },
  },
];
Фрагмент src/tools.ts: описания инструментов, которые увидит модель (продолжение файла — в разделе о валидации)
  • В описании 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:

  1. Запрос

    Отправляем tools, системный промпт и историю сообщений.

  2. Ответ с tool_use

    Модель возвращает stop_reason: "tool_use" и один или несколько блоков tool_use с id, name и input.

  3. Выполнение

    Код запускает функции и оформляет результаты в блоки tool_result с тем же tool_use_id.

  4. Новый запрос

    Отправляем историю, ответ модели целиком и одно сообщение user со всеми результатами.

  5. Повтор или выход

    Пока stop_reason равен tool_use, круг повторяется; любой другой — выход: end_turn, max_tokens, refusal и т. д.

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 };
}
Цикл с параллельными вызовами, лимитом шагов и обработкой stop_reason
  • В историю кладём весь 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 дней», «слот действительно свободен».

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}.`);
  }
}
Фрагмент src/tools.ts (продолжение): zod-схемы, бизнес-правила и выполнение вызова

Аргументы не прошли 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.».

Опасные действия: подтверждение и права

Модель может ошибиться, а текст пользователя или результат инструмента — подтолкнуть её к неверному действию. Поэтому границы действий задаёт код, а не промпт. В примере их пять.

  1. Модель видит только разрешённое

    runTurn фильтрует TOOLS по ctx.allowedTools. Анонимному посетителю можно дать только get_free_slots.

  2. Код проверяет ещё раз

    runTool отклоняет инструмент не из списка, даже если модель его назвала: защита на случай ошибки в фильтре или подмены истории.

  3. Подтверждает человек

    create_booking вызывает ctx.confirm — в боте это кнопка «Подтвердить». Фраза модели «клиент согласен» подтверждением не считается.

  4. Проверка перед записью

    Salon.book перепроверяет слот: между ответом get_free_slots и подтверждением его мог занять другой клиент.

  5. Нет инструмента — нет действия

    Отмены и переноса в наборе нет. Чего бот не должен делать, того в списке инструментов просто нет.

Решает модель

  • Какой инструмент вызвать и с какими аргументами
  • Что спросить у клиента, если данных не хватает
  • Как сформулировать ответ

Решает код

  • Какие инструменты доступны в разговоре
  • Допустимы ли аргументы и бизнес-правила
  • Подтвердил ли действие человек
  • Сколько шагов можно сделать

Про 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.

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())),
  };
}
Локальный мок Messages API: сценарий ответов и проверка правила истории
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();
Демонстрация полного цикла: два параллельных запроса слотов, запись с подтверждением, финальный ответ
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"]}'
    }
  ]
}
Реальный вывод на Node.js 24.18: в слотах пятницы нет 15:30–16:30 — 16:00 занято заранее; оба результата ушли одним сообщением

Тесты разделены на два уровня. Первый вызывает runTool напрямую: неверные аргументы, прошедшая дата, занятое время, отказ клиента, запрещённый и неизвестный инструмент. Второй гоняет runTurn против мока и проверяет то, что видит API: результаты параллельных вызовов в одном сообщении и в порядке вызовов, ошибка с is_error и самоисправление модели, лимит шагов, скрытие запрещённых инструментов, выход без выполнения при 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 тестов инструментов: валидация zod, бизнес-правила, подтверждение и права
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 тестов цикла против мока 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
Реальный вывод npm test на Node.js 24.18: 19 тестов
terminal
$ npm run typecheck
> tool-calling@1.0.0 typecheck
> tsc
Проверка типов TypeScript 7.0.2 прошла без ошибок

Тесты проверены «от обратного»: если отправлять каждый tool_result отдельным сообщением, мок отвечает 400 и падает тест полного цикла; если заменить z.strictObject на z.object, падает тест про лишнее поле discount. Для работы с настоящим API остаётся точка входа ниже: ключ берётся из переменной окружения ANTHROPIC_API_KEY, подтверждение — вопрос в терминале.

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();
Запуск против настоящего API: проверен tsc, но в рамках урока не запускался — нужен ключ

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.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.