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 JSON Schema describing valid inputs, plus the description the model actually reads.
- Validate — a hard boundary that rejects anything malformed before it reaches real logic.
- Dispatch — a lookup table mapping the model's string
nameto a real function. Nevereval.
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:
additionalProperties: falseon every object, nested ones included. Standard JSON Schema defaults it to true, so a schema that validates in your editor still fails here — an open object has no closed grammar to compile.- Every field in
propertiesmust be inrequired. Optional fields are expressed as a union with null, e.g."type": ["string", "null"].
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).
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:
- Refusal. If the model declines, you get
stop_reason: "refusal"with an HTTP 200. You are billed, and the output may not match the schema. - Truncation.
max_tokensis a hard cap. Hit it mid-object and every token emitted was valid — the JSON is simply cut off and your parser rejects it. The fix is a bigger budget, not a retry on the same one.
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.