← All articles

Tool-calling JSON schemas: how to define, validate, and dispatch tools your agent can't misuse

AI Agents · October 12, 2026 · 8 min read · from our Deploy Your First AI Agent guide

Tool calling looks like a model feature. It isn't. It's a contract between a model that guesses and code that executes — and the contract is written in JSON Schema. Get the schema right and the model rarely produces anything your code can't handle. Get it loose and you'll spend your week writing if isinstance(x, str) guards inside every tool.

A working tool definition is three things, and most people only write the first:

Define: the schema is the product surface

On the Anthropic API a user-defined tool is a name (matching ^[a-zA-Z0-9_-]{1,128}$), a plaintext description, and an input_schema. The API builds a system prompt out of those definitions — so the description and the per-property descriptions are your instructions to the model. They are not documentation.

TOOLS = [
  {
    "name": "lookup_order",
    "description": (
      "Look up an order by its ID. Use when the user references an order "
      "ID (format: ORD- followed by 6 digits). Returns status, shipping "
      "info, and customer ID."
    ),
    "input_schema": {
      "type": "object",
      "properties": {
        "order_id": {
          "type": "string",
          "pattern": "^ORD-[0-9]{6}$",
          "description": "The order ID, e.g. ORD-482913",
        },
      },
      "required": ["order_id"],
    },
  },
  {
    "name": "create_ticket",
    "description": (
      "Create a support ticket. Call this after the customer is identified "
      "and the issue is understood. Do not call for general questions you "
      "can answer directly."
    ),
    "input_schema": {
      "type": "object",
      "properties": {
        "customer_id": {"type": "string"},
        "category": {
          "type": "string",
          "enum": ["shipping", "refund", "damaged", "wrong_item", "other"],
        },
        "priority": {
          "type": "string",
          "enum": ["low", "normal", "high", "urgent"],
        },
        "summary": {"type": "string", "maxLength": 280},
      },
      "required": ["customer_id", "category", "priority", "summary"],
    },
  },
]

Three schema choices carry most of the weight. enum turns a free-text field into a closed set — if there are five categories, the model cannot invent a sixth. required declares what your function will crash without. pattern pins a format the model would otherwise approximate. The tighter the schema, the less the model hallucinates shapes you can't handle.

Say when to use the tool and when not to, in the description. "Do not call for general questions you can answer directly" prevents an agent that writes a ticket every time a customer says hello.

Strict mode: what it guarantees, and what it costs

Without strict mode, the schema is guidance. The model can return "2" where you need 2, or omit a required field entirely. Strict mode fixes that by constraining token sampling to schema-valid output — grammar-constrained decoding, the same machinery behind structured outputs. On the Anthropic API you set "strict": true at the top level of the tool definition; OpenAI does the same on its function object.

It buys you a real guarantee: the input block in the tool_use call conforms to the schema you sent, and name is always one of your tools. In exchange, your schema must fit a subset of JSON Schema. The two rules that cause nearly every 400:

Beyond that, strict mode rejects or ignores constraints you may be relying on: numeric bounds (minimum, maximum, multipleOf), string length bounds (minLength, maxLength), recursive schemas, and external $ref. Composition support differs by vendor — anyOf works but can't sit at the root, while oneOf and allOf are commonly rejected. String format is limited to a supported set (date-time, date, time, duration, email, hostname, uri, ipv4, ipv6, uuid).

Two limits worth knowing before you design a 40-tool agent. Strict schemas are capped per request — the Anthropic API allows at most 20 strict tools, 24 cumulative optional parameters across strict schemas, and 16 union-typed parameters. Past that you get a "schema is too complex for compilation" 400. Split the toolset, or drop to non-strict for the exotic ones.

Validate anyway

Strict mode constrains the shape. It does not check your business rules, and it silently drops bounds like maxLength from the wire. Treat the boundary as your own:

from pydantic import BaseModel, Field, ValidationError

class LookupOrder(BaseModel):
    order_id: str = Field(pattern=r"^ORD-[0-9]{6}$")

class CreateTicket(BaseModel):
    customer_id: str
    category: str = Field(pattern=r"^(shipping|refund|damaged|wrong_item|other)$")
    priority: str = Field(pattern=r"^(low|normal|high|urgent)$")
    summary: str = Field(max_length=280)

SCHEMAS = {"lookup_order": LookupOrder, "create_ticket": CreateTicket}

def validate_input(name: str, raw: dict):
    """Return (model, error). Never raise into the agent loop."""
    model = SCHEMAS.get(name)
    if model is None:
        return None, f"unknown tool: {name}"
    try:
        return model.model_validate(raw), None
    except ValidationError as e:
        return None, f"invalid input for {name}: {e.error_count()} error(s)"

This is the pattern that makes agents survive contact with reality. A validation failure becomes a tool_result the model reads and corrects, instead of a traceback that ends the run.

Dispatch: a table, never eval

import json

def execute_tool(name: str, raw_input: dict) -> str:
    # 1. Unknown name -> refuse. No dynamic lookup, no exec, no getattr on user data.
    if name not in HANDLERS:
        return f"error: unknown tool '{name}'"

    # 2. Validate at the boundary.
    args, err = validate_input(name, raw_input)
    if err:
        return f"error: {err}"

    # 3. Call the real function with typed, checked arguments.
    try:
        return json.dumps(HANDLERS[name](args), default=str)
    except Exception as e:
        # 4. Errors go BACK to the model, not up the stack. It can retry differently.
        return f"error: {name} failed: {type(e).__name__}: {e}"

Point 4 is the one people skip. A tool that raises kills the agent loop; a tool that returns an error string lets the model adapt — different arguments, a different tool, or an honest "I couldn't do that" to the user.

What strict mode still won't save you from

Constrained decoding guarantees every token is schema-valid. It does not guarantee a complete result. Two failure modes survive:

So check stop_reason and parse defensively regardless of how strict your schema is.

One more operational detail: compiled strict grammars are cached server-side, roughly 24 hours from last use, and the cache invalidates when the schema structure or the tool set changes — but not when you edit only a name or description. Rewording descriptions mid-experiment is cheap; reshaping schemas is not.

Putting it together

Tight schemas, a validation boundary, a dispatch table, and errors returned as data. That's the whole discipline — and it's the difference between an agent that runs unattended and one you have to watch. Next up: evals, so you know whether your last change made the agent better or just different.

The full playbook · $9

Deploy Your First AI Agent

The complete, step-by-step version — every command, config, and the reasoning behind it. Instant PDF, yours to keep.

Get the guide — $9

Instant PDF · 30-day money-back guarantee · buy once, keep forever