AI Engineering · 9 min read

Strict Tool Calling for AI Agents: Schemas, Validation and Recovery

Strict schemas constrain the shape, validation enforces the rules, and a bounded recovery loop decides what happens when either fails.

By Praful Patel

Short answer

LLM tool calling is not deterministic, but you can make what reaches your code predictable: strict schemas constrain the shape, your validator enforces the business rules, and a bounded recovery loop decides what happens when either one fails. OpenAI and Anthropic both offer strict: true on tool definitions, which uses constrained decoding so arguments always match your JSON Schema. Neither guarantees that the model picked the right tool or sensible values. That part is still your job.

“Deterministic tool calling” is a slightly misleading phrase. The model still samples. It can choose a different tool, or a different order ID, on two identical requests. What you can make deterministic is everything around the model: which inputs are even expressible, which are accepted, and what your system does with each kind of failure. This article covers those three layers in practical detail. For the wider agent architecture, see building autonomous AI agents with Next.js and vector databases.

What strict mode guarantees, and what it does not

Layer Guarantees Does not guarantee
Strict schema (provider) Arguments parse as JSON and match your schema’s types, enums, required fields and patterns. Tool names are valid. The right tool was chosen. Values are correct (a well-formed but wrong order ID). Unsupported constraints such as numeric ranges.
Your validator Business rules: ranges, cross-field rules, existence checks, permissions That the user actually wanted this action
Recovery loop Every failure ends in a known state: retried, escalated or stopped That the retry succeeds

The schema guarantee also has documented gaps. If the model refuses, or generation hits the token limit, the output may not match the schema. Both providers say so in their structured output docs. Your loop has to handle those stop reasons explicitly.

Strict tool schemas in OpenAI and Claude

Both APIs use the same switch, but the rules differ in ways that matter if you share schemas between providers.

OpenAI (function calling) Anthropic (strict tool use)
Enable "strict": true on the function tool "strict": true alongside name and input_schema
Objects additionalProperties: false on every object additionalProperties: false on every object
Optional fields Every property must be listed in required; express “optional” as ["string", "null"] Properties may be left out of required
Default The Responses API tries to normalise schemas to strict when strict is omitted; Chat Completions is non-strict by default Off unless set
Notable limits A subset of JSON Schema; see the supported-schemas list No minimum/maximum, minLength/maxLength or recursive schemas; limited regex

Sources: OpenAI function calling guide, Anthropic strict tool use and the JSON Schema limitations it links to. Both providers compile the schema on first use, so expect extra latency on the first request with a new schema.

The portable subset is simple: every property in required, nullable types for optional values, additionalProperties: false everywhere, and no numeric or length constraints in the schema. Enforce those in code instead.

Design schemas that cannot express bad states

Strict mode can only enforce what the schema says. Most of the reliability comes from schema design:

  • Enums instead of free text. reason: "damaged" | "not_received" instead of reason: string.
  • IDs instead of names. Ask for order_id with a pattern, not “the customer’s order”. Provide a lookup tool so the model can find the ID first.
  • Integers in minor units. amount_minor: 1250 rather than "12.50" or a float.
  • One action per tool. issue_refund and cancel_order rather than manage_order with a mode flag and five optional fields.
  • No open-ended options objects. They defeat additionalProperties: false in spirit, even when they satisfy it in form.
  • Constraints in descriptions, too. “Must not exceed the order total” in the description steers the model; your validator enforces it.

Validate after the schema

Treat tool arguments as untrusted input, just like a form submission. A schema check alone is not enough. The example below is TypeScript with the Anthropic SDK and Zod. Zod catches the rules that Claude’s strict mode cannot express (a positive amount), and the handler checks rules that no schema can (the order exists, and the amount does not exceed its total).

import Anthropic from "@anthropic-ai/sdk";
import { z } from "zod";
import { orders, payments } from "./services"; // your own modules

const client = new Anthropic();

const RefundInput = z.object({
  order_id: z.string().regex(/^ord_[A-Za-z0-9]{12}$/),
  amount_minor: z.number().int().positive(),
  reason: z.enum(["damaged", "not_received", "duplicate_charge"]),
});

const tools: Anthropic.Tool[] = [
  {
    name: "issue_refund",
    description:
      "Refund part or all of an order. amount_minor is in the smallest currency unit " +
      "(pence, cents), must be positive and must not exceed the order total.",
    strict: true,
    input_schema: {
      type: "object",
      properties: {
        order_id: { type: "string", pattern: "^ord_[A-Za-z0-9]{12}$" },
        amount_minor: { type: "integer" },
        reason: { type: "string", enum: ["damaged", "not_received", "duplicate_charge"] },
      },
      required: ["order_id", "amount_minor", "reason"],
      additionalProperties: false,
    },
  },
];

type Outcome =
  | { ok: true; data: unknown }
  | { ok: false; error: string; retryable: boolean };

async function runTool(runId: string, name: string, input: unknown): Promise<Outcome> {
  if (name !== "issue_refund") {
    return { ok: false, retryable: false, error: `Unknown tool: ${name}` };
  }
  const parsed = RefundInput.safeParse(input);
  if (!parsed.success) {
    const issues = parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`);
    return { ok: false, retryable: true, error: `Invalid arguments. ${issues.join("; ")}` };
  }
  const { order_id, amount_minor, reason } = parsed.data;
  const order = await orders.get(order_id);
  if (!order) {
    return { ok: false, retryable: true,
      error: `No order ${order_id}. Look it up with find_order or ask the user to confirm.` };
  }
  if (amount_minor > order.total_minor) {
    return { ok: false, retryable: true,
      error: `amount_minor ${amount_minor} exceeds order total ${order.total_minor}.` };
  }
  // Same run + same arguments = same key, so a retried call cannot refund twice.
  const idempotencyKey = `${runId}:${order_id}:${amount_minor}:${reason}`;
  return { ok: true, data: await payments.refund({ order_id, amount_minor, reason, idempotencyKey }) };
}

The error messages are written for the model, not for a log file. Anthropic’s tool-handling guide recommends this explicitly: say what went wrong and what to try next, rather than returning “failed”.

The error recovery loop

Recovery means sorting every failure into one of three paths:

Failure Path
Validation or business-rule failure the model can fix (wrong ID, amount too large) Return it to the model as an error result; count it against a per-tool budget
Transient infrastructure failure (timeout, 429, 5xx from your service) Retry in code with backoff, without involving the model, using an idempotency key for writes
Non-recoverable (unknown tool, permission denied, budget exhausted) Stop and escalate to a human or a deterministic fallback

The loop itself needs hard limits on steps and on repeated failures, so that a confused model cannot retry the same bad call until a timeout fires.

const MAX_STEPS = 8;
const MAX_FAILURES_PER_TOOL = 2;

export async function runAgent(runId: string, userMessage: string) {
  const messages: Anthropic.MessageParam[] = [{ role: "user", content: userMessage }];
  const failures = new Map<string, number>();

  for (let step = 0; step < MAX_STEPS; step++) {
    const response = await client.messages.create({
      model: "claude-sonnet-5-5",
      max_tokens: 2048,
      tools,
      messages,
    });

    // Strict mode does not cover truncated or refused output.
    if (response.stop_reason === "max_tokens") return { status: "truncated" as const };
    if (response.stop_reason === "refusal") return { status: "refused" as const };
    if (response.stop_reason !== "tool_use") return { status: "done" as const, response };

    messages.push({ role: "assistant", content: response.content });

    const results: Anthropic.ToolResultBlockParam[] = [];
    for (const block of response.content) {
      if (block.type !== "tool_use") continue;
      const outcome = await runTool(runId, block.name, block.input);

      if (!outcome.ok) {
        const count = (failures.get(block.name) ?? 0) + 1;
        failures.set(block.name, count);
        if (!outcome.retryable || count > MAX_FAILURES_PER_TOOL) {
          return { status: "escalated" as const, tool: block.name, error: outcome.error };
        }
      }
      results.push({
        type: "tool_result",
        tool_use_id: block.id,
        content: outcome.ok ? JSON.stringify(outcome.data) : outcome.error,
        is_error: !outcome.ok,
      });
    }
    messages.push({ role: "user", content: results });
  }
  return { status: "step_limit" as const };
}

The same structure works with OpenAI’s Responses API. Read function_call items from output, run the function, and append a function_call_output item with the matching call_id. There is no is_error flag, so put a clear error message in output. Keep the model name in configuration rather than in code. If you would rather not hand-write the loop for Claude, the SDK’s Tool Runner manages it, but you still need your own validation and limits.

Make it testable

  • Record and replay. Log every step (model, prompt version, tool name, arguments, outcome). Replay recorded tool calls through runTool in unit tests, so validation and recovery are tested without calling the model.
  • Test the failure paths on purpose. Feed the handler a non-existent order ID, an oversized amount and an unknown tool name, and assert the loop ends in the right state.
  • Evaluate tool choice separately. Strict mode cannot tell you the model picked the right tool. Keep a set of prompts with the expected tool and arguments, and track accuracy across model and prompt changes. The approach mirrors evaluating RAG pipelines against a golden set.

Provider-level concerns, such as rate limits, fallback routing and pinned model versions, are covered in integrating OpenAI and Claude APIs into enterprise web apps.

Checklist

  • strict: true on every tool; additionalProperties: false on every object.
  • Enums, IDs and integer minor units instead of free text, names and floats.
  • Runtime validation for every rule the schema cannot express.
  • Errors returned to the model as instructive tool results, flagged as errors.
  • Transient failures retried in code, with idempotency keys on writes.
  • Hard caps on steps and on failures per tool, with an escalation path.
  • Explicit handling of max_tokens and refusal stop reasons.

Questions people actually ask

If strict mode guarantees the schema, do I still need validation?
Yes. Strict mode guarantees types and structure, not meaning. It will happily produce a valid-looking order ID that does not exist, or (on Claude, where numeric bounds are not supported) a negative amount. Validate business rules in code.

Does strict mode work with parallel tool calls?
On Claude, strict applies per tool definition, and your loop should handle several tool_use blocks in one response, as above. On OpenAI, check the current function calling guide for your model: it documents interactions between parallel calls and strict mode, including that fine-tuned models disable strict mode for multi-call turns.

How many times should an agent retry a failed tool call?
Few. One or two model-visible retries per tool is usually enough to fix a wrong argument. If the same call fails repeatedly, the model is missing information, so escalate or ask the user rather than looping.

Agents taking actions you cannot fully predict?

I help teams design tool contracts, validation layers and recovery loops, so agent actions are bounded, auditable and safe to retry.

Review your agent’s tool layer →
← Back to all insights