Tool design: typed contracts an agent can actually use

Pull-quote: “Reject bad input instead of coercing it. A rejection costs one round trip; a coercion costs a wrong write that nobody notices.”
Tool design is API design for a reader that cannot ask you a clarifying question, so every ambiguity in a tool definition turns into silent misuse at run time. A human integrator who hits a confusing parameter sends you a message. A model fills the field with the most plausible value it can produce, the call returns 200, and the wrong thing happens quietly.
This post is for engineers exposing tools to an agent, whether hand-written functions or an MCP server. It uses the vocabulary from the harness architecture post and assumes you already have one tool an agent has misused.
By the end you will be able to write a tool contract a model uses correctly, return errors it can recover from, make writes safe to retry, and stop selection accuracy from collapsing as your catalogue grows.
What you will be able to do
- Write a tool definition with all seven parts a model needs to choose and call it correctly.
- Name tools so the name alone prevents mis-selection between near neighbours.
- Design arguments that reject hallucinated values instead of accepting them.
- Return structured errors that tell the model what to fix and whether to retry.
- Make every write idempotent and give each one a named verification read.
- Keep a growing catalogue usable with namespacing, phase-based exposure, and a router.
What does good tool design put in a tool definition?
A tool definition is a contract with seven parts: a name, a one-line purpose, an argument schema with types and constraints, a clear required-versus-optional split, one worked example, declared side effects, and a declared cost or latency class. Drop any one of them and you convert a decision into a guess.
The model only ever sees text. Whatever it must know has to appear in the name, the description, or a field description, because metadata your harness holds but never renders is invisible at selection time.
{
"name": "shipment_reschedule_pickup",
"description": "Move the pickup window for one shipment. Writes to the dispatch system and notifies the customer. Call shipment_get_pickup first to read the current window.",
"input_schema": {
"type": "object",
"properties": {
"shipment_id": {
"type": "string",
"pattern": "^SHP-[0-9]{8}$",
"description": "Id returned by shipment_search. Not the customer purchase order."
},
"window_start": {
"type": "string",
"format": "date-time",
"description": "ISO 8601 with offset. Must be later than the current server time."
},
"window_hours": { "type": "integer", "minimum": 1, "maximum": 8, "default": 4 },
"reason_code": {
"type": "string",
"enum": ["carrier_delay", "dock_closed", "customer_request", "weather"]
},
"idempotency_key": { "type": "string", "description": "Supplied by the harness, stable across retries." }
},
"required": ["shipment_id", "window_start", "reason_code", "idempotency_key"],
"additionalProperties": false
},
"x_effects": { "class": "write", "reversible": true, "notifies_customer": true },
"x_cost": { "latency_class": "seconds", "price_class": "cheap" },
"x_verify": { "tool": "shipment_get_pickup", "assert": "window_start matches the requested value" }
}
The x_ fields are not part of any provider’s tool schema. They are your catalogue metadata, read by your router, permission layer, and verification step, and the parts the model needs get rendered into the description.

How do you name tools so the model picks the right one?
Name tools so the name alone disambiguates them, because selection happens before the model reads any argument schema. By the time it is filling in fields, it has already decided.
Four rules cover most of it. Use one pattern everywhere, such as object_verb, and never mix it with verb_object. Never ship two tools whose names differ only by a synonym, such as get_invoice and fetch_invoice. Put the scope in the name, so invoice_search and invoice_search_archived are distinguishable without reading descriptions. And put the dangerous verb in the name, because subscription_cancel cannot be mistaken for a read.
| Name to avoid | Why it mis-selects | Better |
|---|---|---|
run_query |
Says nothing about what it touches or whether it writes | orders_search |
manage_user |
Behaviour depends on a mode argument, so one name hides four operations |
user_create, user_disable |
api_call |
Delegates the real choice to a free-text path argument | One named tool per endpoint |
A tool whose behaviour changes with a mode or action argument is several tools sharing one name, and selection accuracy pays for the convenience. Split it.
How do you design arguments the model cannot get wrong?
Constrain every argument to the smallest legal set the schema can express, because a free-text field is an invitation for the model to produce a plausible value that does not exist. Enums do not just validate, they tell the model what the world contains.
- Enumerate closed sets. A
reason_codewith four allowed values eliminates the whole class of invented reason strings. - Make identifiers traceable. Require an id produced by another tool and name that tool in the field description. This is the most effective fix for hallucinated identifiers.
- Use constraints as documentation.
pattern,minimum,maximum, andformattell the model the shape and let you reject before executing. - Set
additionalProperties: false. Then reject unknown fields loudly, because silently dropping them teaches the model nothing. - State defaults in the description. An optional argument with an unstated default gets filled with a guess.
- Split semantics-changing flags. A
forceorcascadeboolean belongs in its own tool with its own permission tier.
Reject, do not coerce. Trimming an out-of-range value to the nearest legal one converts a model mistake into a wrong action nobody reviews. A rejection costs one extra round trip. A coercion costs a wrong write and a support ticket three days later.
What does an error the model can recover from look like?
A recoverable error names what was wrong, where it was wrong, and what a valid call looks like, in machine-readable fields plus one plain sentence. A bare 400 Bad Request gives the model three options, and two of them are bad: retry the identical call, invent a different wrong call, or report success it cannot see.
{
"ok": false,
"error": {
"code": "invalid_argument",
"field": "window_start",
"reason": "Value 2026-08-02T09:00:00-04:00 is in the past. Server time is 2026-08-13T14:05:11-04:00.",
"class": "fix_and_retry",
"fix": "Send a window_start later than the current server time.",
"example": { "window_start": "2026-08-14T09:00:00-04:00" }
}
}
The class field matters more than the prose. Three classes are enough: fix_and_retry for an argument the model can correct, retry_after for a transient condition where the same call works later, and terminal for permission denied or a resource that is gone. Collapsing all three into one shape is why agents retry forever, listed as cascading retries in the failure taxonomy for production agents.
Two more rules. Never return a stack trace, because it leaks internals and hands the model text it will pattern-match into a fictional diagnosis. And report partial success explicitly: if three of five records were written, name the three and return a resume handle, since a bare error on a half-completed batch invites a full rerun.

How do you make tool calls safe to retry?
Every write tool takes an idempotency key, and a repeated call with the same key returns the first result instead of performing the action twice. Agents retry more than humans do: loops retry, timeouts retry, and a user who does not see a confirmation asks again.
The harness generates the key and binds it to the plan step, not to the attempt. A model asked to invent a key produces a fresh one on the retry, which defeats the mechanism precisely when it matters. Store the key with its result, and return replayed: true so the loop can tell a duplicate from a fresh success.
Keep reads genuinely free of side effects. A read tool that writes an audit row is fine; one that mutates state means the agent can no longer explore safely, and every parallel call becomes a risk.
How do you limit blast radius with permission tiers?
Classify every tool as read, write, or destructive, and let the tier decide who approves the call and how the effect gets verified. Read tools need no approval and are safe to run in parallel. Write tools need an idempotency key and a verification read. Destructive tools, meaning anything irreversible or externally visible such as delete, cancel, refund, or send, need an approval gate and a dry-run variant where one can exist.
Blast radius is a second axis, and it is the one people forget. A destructive tool scoped to one record is safer than a write tool scoped to a whole tenant. Score each tool on both axes and set approval on the combination.
Enforce tiers in the harness, not in the prompt. Expose only the tools the current role may call, so a forbidden tool is absent from the catalogue instead of merely discouraged. A prompt asking the model not to call something is a request; an allowlist is a control.
Verification belongs in the contract. Name the read tool that confirms each write, as x_verify does above, and have the loop call it before reporting success. A 200 means your request was accepted, not that the state you wanted exists, and a 202 means the system will decide later. An agent that reports success from a status code is guessing.

Why does adding more tools make the agent worse?
Selection accuracy degrades as the catalogue grows, because every tool you add contributes another near neighbour the model can confuse with the right one, and every description is paid for in tokens on every single request. The symptom is a wrong-tool call on a task the agent handled last month.
Five remedies, cheapest first. Namespace by domain, so the model narrows before it chooses. Disclose progressively by task phase, exposing research tools during research and write tools only after a plan is approved. Put a router in front, where a cheap call picks the toolset and the expensive call picks the tool. Merge near-duplicates behind one tool with an enum. And delete tools your own trajectories show nobody calls.
The catalogue is context, so tool definitions compete with memory, history, and evidence in the same window, which is the accounting in treating the context window as a budget. You know the problem is real in your system when you log the selected tool per turn alongside whether its arguments validated, and the invalid rate rises after a release that added tools.
What does a well-scoped MCP server definition look like?
MCP, the Model Context Protocol, is a standard shape for exposing tools, resources, and prompts to any MCP-capable client over STDIO or HTTP, so you write the contract once and any client can consume it. What MCP does not do is design the surface for you: a server exposing forty unscoped tools is a badly designed tool surface with a standard wire format.
Scoping is the work. This is a Hermes Agent configuration, where dispatch stands in for your own server and filesystem is the documented example server rooted to one directory.
mcp_servers:
dispatch:
command: "uvx"
args: ["dispatch-mcp"]
tools:
include: [shipment_search, shipment_get_pickup, shipment_reschedule_pickup]
resources: false
prompts: false
enabled: true
filesystem:
command: "npx"
args: ["-y", "filesystem-mcp@latest", "--root", "/srv/dispatch-runbooks"]
tools:
include: [read_file, list_directory]
resources: false
prompts: false
enabled: true
Three choices carry the safety here: an include allowlist rather than everything the server offers, resources and prompts turned off because nothing uses them, and a filesystem root pointing at one project directory rather than $HOME. You know the scoping worked when hermes mcp test dispatch reports exactly the three tools you allowlisted and nothing else.
Where this goes wrong
- Two tools, one job. The agent picks
fetch_invoicewhen it neededget_invoice_lines, then mixes arguments between them. Synonym names are the cause. Rename with scope, or merge them behind one enum. - Free-text identifiers. The agent passes a well-formed id that does not exist and you get a 404 loop. Require ids from a named search tool, add a
pattern, and reject rather than matching something close. - Retry storms. A bare 400 arrives with no class, so the loop retries a call that can never succeed until the budget runs out. Return
fix_and_retry,retry_after, orterminalon every error. - Coercion. A write lands on the wrong day because your API rounded an out-of-range value instead of refusing it. Validate at the boundary and return the reason.
- Permissions in the prompt. The agent calls a destructive tool the system prompt told it to avoid. Remove the tool from that role’s catalogue, since instructions are not access control.
- Success from a status code. The agent reports a rescheduled pickup that never took effect, because the endpoint returned 202 and queued the change. Call the verification read first.
Common questions
Should one tool do a lot, or should tools be small?
Small enough that the name fully describes the behaviour. Combine operations only when they are always used together and share a permission tier, because a tool with several modes moves the selection decision inside the arguments, where you have less control over it.
How many tools is too many?
The number at which your own trajectories start showing wrong-tool calls, which is why you instrument selection before tuning the catalogue. As a working rule, keep the set exposed for a single task phase small enough to read in a few seconds, and use phase-based exposure rather than one flat list.
How long should a tool description be?
One or two sentences plus a description per field. State the purpose, the prerequisite call if there is one, and the gotcha most likely to cause misuse. A paragraph costs tokens on every request and buries the sentence that mattered.
Is MCP required to give an agent tools?
No. MCP is a wire format and a discovery mechanism, and a plain function registry inside your harness works. MCP earns its place when the same tools serve several clients, or when another team owns the server and you want a stable contract between you.
Who should generate the idempotency key?
The harness, keyed to the plan step. A model asked to produce the key generates a new one on each attempt, which is exactly the case the key exists to prevent, so the harness mints it once and reuses it for every retry of that step.
Next steps
- Audit your catalogue against two rules: every write tool has an idempotency key, and every write tool names the read that verifies it. Fix the ones that fail before adding anything new.
- Continue the course with structured output from LLMs. Tool calling and structured extraction run on the same machinery, and the schema discipline here is what makes both reliable.
- For a concrete configuration, including allowlists, STDIO versus HTTP, and testing a server before you trust it, read connecting MCP servers to Hermes Agent safely.
- This is the tool discipline behind FreightCortex in freight operations, where a write tool that dispatches a real vehicle carries an idempotency key and a verification read, and the destructive tier does not execute without a person on the approval.
