Tool Schemas Are Prompts: Designing Tools AI Agents Can't Misuse
Learn how to design tool schemas for AI agents that the model can actually follow: verb-first names, instruction-grade descriptions, enum-typed parameters, preview and apply paths, actionable errors, and output budgets.
Here are two tool definitions an agent can be given. Same backend, same underlying capability.
{
"name": "search_orders",
"description": "Searches orders.",
"parameters": {
"type": "object",
"properties": {
"query": { "type": "string" },
"all": { "type": "boolean" },
"full": { "type": "boolean" }
}
}
}
Ship this to an agent and watch the traffic. You will see all: true paired with an empty query. You will see full: true on every call, because the model wants to be thorough. You will see complete order histories pulled into context until the window is gone. Nothing in this schema is broken — every one of those calls is allowed, and the model is doing exactly what the documentation invites.
Now the same capability, designed differently:
{
"name": "list_orders",
"description": "Lists orders, newest first. Use for browsing and lookup; use get_order for a single known id. Returns at most `limit` order summaries — never line items. Pass `status` instead of guessing date ranges.",
"parameters": {
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": ["open", "shipped", "delivered", "refunded", "cancelled"],
"description": "Filter by fulfilment state. Omit for all statuses."
},
"customer_email": {
"type": "string",
"description": "Exact email address, lowercased. Not a substring search."
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 50,
"default": 20,
"description": "Page size. Raise only if the first page was not enough."
}
}
}
}
Agents call this version correctly far more often — and when they drift, the drift is small and correctable. The difference is not model quality. The second schema is written as instructions, and the model reads it as instructions.
The schema is the only documentation the model reads
When a model chooses a tool, it sees four things: the tool name, the description, the parameter names and types, and whatever the conversation contains. It never sees your internal wiki, your ADRs, or the intent behind the code. Whatever you put in the schema is the entire documentation set, and the model consumes it the way a new hire consumes a runbook — literally, and without the shared context that lets humans read past imprecision.
That makes every vagueness a prompt. A description like "Searches orders" does not say what matches, what order results come back in, how big a result can be, or when the tool is the wrong choice. The model resolves all of it from priors, and priors are how you get all: true. The fix is not a smarter model. The fix is writing the description as if it were an instruction to a capable person on their first shift: what the tool does, when not to use it, what the units and timezones are, and what comes back.
Name the capability, not the implementation
Tool names carry semantics into every prompt. Verb-first names — list_, get_, create_, cancel_ — tell the model what kind of action it is considering before it reads anything else. orders_handler_2 tells it nothing, so it guesses, and the guess shows up as a wrong call at the worst time.
One tool should be one capability. A manage_orders tool that creates, cancels, and refunds based on an action enum makes every invocation a composition problem: pick the verb, then pick the parameters that verb actually accepts. Splitting it into create_order, cancel_order, and refund_order turns one judgment call into a selection, and gives each tool a description that can be precise instead of a taxonomy of caveats.
The same logic applies at the read/write boundary. Keeping read-only and mutating tools on separate names — list_orders versus cancel_order — is what lets a policy layer say "this agent may call anything starting with list_ and get_, nothing else" without parsing arguments. That separation is the seam sandboxing and approval gates attach to; a naming convention is load-bearing infrastructure, not style.
Constrain with types, not prose
Every enum is an instruction the runtime enforces. status with five allowed values eliminates the whole family of malformed filters a free-form query string invites, and a model that wants "closed a while ago" must map its intent onto delivered or refunded — a much smaller step than inventing date-range syntax.
Booleans are where misuse breeds. all, full, force, include_everything — models set them true to be safe, and true is the one direction you cannot take back. Replace them with typed alternatives: an enum status instead of include_closed, a fields selection instead of full, explicit ranges with minimum and maximum instead of force.
The rest of JSON Schema is cheap insurance. pattern, minLength, maximum fail a bad call at the client boundary with a message the model can correct from, instead of failing mid-query inside your database where the only recovery is a timeout.
Split preview from apply
One tool should not both show what would happen and make it happen. preview_refund computes and returns; apply_refund commits, and takes the token the preview returned. This mirrors the two-step patterns humans already trust, and it produces two concrete benefits:
- The destructive path becomes structurally narrow.
apply_refundcannot be called sensibly without a preview token, so the accidental invocation has nothing to stand on. - Policy gets a natural seam. Autonomy can allow previews freely while applies route through an approval gate — the gate sees a concrete, already-computed intent rather than a hypothetical.
For destructive tools, make the dangerous input structurally required. delete_expenses takes ids — an explicit array, capped — and has no "all" mode at all. A confirm: true boolean is weaker than it feels, because models pass true; a required explicit id list cannot be satisfied by enthusiasm.
Return errors the agent can act on
An error message is a prompt too. The model will read it and decide what to do next, so an error that says only Error: 500 invites a retry loop, and a raw stack trace invites the model to paste fragments of itself into the next attempt.
Design the error for the reader:
{
"error": {
"code": "order_not_refundable",
"message": "Order 8412 was delivered 92 days ago, past the 60-day refund window.",
"recovery": "Offer store credit via grant_store_credit, or escalate to the support queue."
}
}
The stable code lets harnesses assert on behavior — the agent saw order_not_refundable and did not retry the same call. The recovery field tells the model what to do instead, which is the difference between a dead end and a decision.
Make idempotency a parameter, not documentation
If a tool creates or mutates something that matters — payments, messages, state transitions — take an idempotency_key parameter and honor it server side. Prose like "safe to retry" is addressed to the exact agent that just watched a timeout fire and must now decide whether calling again doubles the charge. A required key removes the decision entirely: the retry replays the same key and the backend folds it into the original operation. Command ledgers and workflow engines can then reason about side effects from the key alone.
Budget the output
Context is the scarce resource in an agent run, and a tool that dumps 40 MB of JSON in response to "recent orders" has harmed the run even though every byte was accurate. Output design is interface design:
- Default page sizes, with
limitcapped. - Summaries by default; line items only through a dedicated
get_order. - A truncation contract the model can work with — a
next_cursor, not a silent cutoff. - Field selection where reads are wide.
The general pattern is the same one that keeps runaway agents affordable: every tool should have a defensible answer to "what is the most context this call can consume?"
Version like an API, because it is one
Additive changes only. Adding an optional parameter with a documented default is safe; repurposing what an existing parameter means is not, because prompts and recorded traces bake in the old semantics and will keep arriving long after the server has moved on. When a tool must change shape, deprecate in the description and nudge in errors: a call using a deprecated parameter succeeds, but the response carries a notice the model can read and adapt to. That is slower than a breaking change and it is supposed to be.
Test the contract like one
Two layers, and they catch different things:
- Schema tests. Plain unit tests against your validation layer:
limit: 10000is rejected, emptyidsis rejected, unknownstatusvalues are rejected. These are fast, deterministic, and fail before any model is involved. - Behavioral assertions on replays. Recorded agent traces replayed against the tool layer with assertions attached: given this ticket, the agent called
preview_refundbeforeapply_refund; it never passedlimitabove 50; afterorder_not_refundableit did not retry. Replayable harnesses make these assertions regression tests rather than vibes.
The schema tests catch the contract you wrote. The replay assertions catch the contract the model understood. They are not the same document until both are green.
The checklist
- Verb-first name, one capability per tool.
- Description states what it does, when not to use it, units, and defaults.
- Enums over booleans; JSON Schema bounds on everything free-form.
- Preview and apply split; destructive inputs structurally required.
- Errors carry a stable code, a specific message, and a recovery hint.
- Mutating tools take an idempotency key.
- Outputs paginated, capped, and summarizable.
- Additive versioning; deprecation nudged in description and errors.
- Schema tests and replay assertions both cover the contract.
The model is trying to obey you. The schema is where you actually say how.
About this story
This article was written by Gen-AI using GPT 5.5 or Opus 4.7. Verify technical guidance before using it in production systems.