Every agent system has a seam where reasoning stops and consequences begin. The model decides; a tool acts. Almost all the engineering effort in this field goes into the first half of that sentence, and almost all the production incidents come from the second.
The tool contract is that seam, specified. It is the most under-designed artifact in agent engineering, and it is the one you have the most control over — you cannot retrain the model, but you own every tool it touches.
A tool description is not documentation
Here is the thing that trips up most teams. When you write a tool schema, you are not writing documentation for a colleague who can ask a follow-up question, read the source, or notice that the staging environment behaves differently. You are writing the entire world model that a reasoning system will use to decide whether to fire an irreversible action.
The model cannot read your code. It cannot see your database. It has the name, the description, the parameter schema, and whatever the response tells it afterwards. That is the whole interface. If a fact about the tool is not in there, the model does not have it, and it will fill the gap by inference — which is a polite way of saying it will guess.
Three consequences follow, and they are all worth sitting with:
The description is a selection surface. Between two similarly-named tools, the model picks based on wording. Vague descriptions produce tool misselection, and tool misselection scales badly — the more tools you add, the more often it happens.
The description is an attack surface. If tool descriptions are assembled from anything a user or an external system can influence, an attacker can write instructions into them. This is the tool-description poisoning problem, and it is why generated or third-party tool schemas deserve the same review as third-party code.
The description is a safety boundary. An undeclared side effect is not the model’s mistake. If your “update record” tool also emails the customer, and the schema does not say so, then the agent behaved correctly against the information it was given, and the bug is in the contract.
What a contract has to declare
A complete tool contract answers nine questions. Most schemas in the wild answer two.
1. What job is this for — and what is it not for?
State the job in the terms the caller would use, and state the boundary explicitly. “Adjusts the daily budget on a single active campaign. Not for pausing campaigns, not for changing bids, not for bulk operations.” The negative half does more work than the positive half, because it is what prevents the tool being reached for when something adjacent is needed.
If two tools need a sentence explaining when to prefer one over the other, put that sentence in both descriptions. The model reads them independently.
2. What exactly goes in?
Types, units, ranges, and enumerations — not prose. amount is a bug waiting to happen; amount_cents: integer, minimum 0 is a contract. Currency, timezone, and unit are the three fields most often left implicit and most expensive to get wrong.
Prefer enumerations to free text everywhere you can. Every free-text field is a place where the model’s output becomes your system’s input without a validation layer in between, and it is also the easiest place for injected content to travel.
3. What does it change?
Declare the side effects. Not “updates the record” — every downstream consequence that a reasonable person would want to know about: the webhook that fires, the email that sends, the cache that invalidates, the audit entry that gets written, the downstream job that gets enqueued.
This is tedious to enumerate. It is also the single highest-value field in the whole contract, because it converts an invisible property of your infrastructure into something both the model and the reviewer can see. A useful forcing function: if you cannot enumerate a tool’s side effects, you do not yet know what the tool does, and neither does anyone else.
4. Can it be undone, and by what?
Three honest answers: reversible by a named counter-action, reversible only by a human with elevated access, or not reversible. Name the counter-action where one exists — reverses_with: refund_payment is a machine-readable fact that a policy layer can act on, and “reversible: true” is a comforting sentence that nothing can act on.
Irreversibility is not a reason to forbid a tool. It is a reason to gate it, and to know in advance which gate.
5. What happens if it runs twice?
State the idempotency semantics plainly: naturally idempotent, idempotent given a caller-supplied key, or not idempotent. If it takes a key, say what makes two calls “the same” and how long that window lasts.
And then verify the claim. “Our API is idempotent” is the most common false assumption I run into — very often the key is accepted at the edge, logged, and never actually consulted by the datastore. Write the test that fires the same key twice and asserts one row. Until that test exists, treat the tool as non-idempotent, because that is what it empirically is.
6. How can it fail, and what does each failure mean?
Four outcomes need to be distinguishable, and most APIs collapse them into two:
- Succeeded — the change is applied and visible now.
- Failed cleanly — nothing changed; retrying is safe.
- Failed after partial application — something changed; retrying is not safe, and here is what was applied.
- Accepted, not yet applied — queued or pending validation; the world has not changed yet, and here is how to check later.
The third and fourth are the ones that get dropped, and they are precisely the ones that determine whether the agent’s next step is reasonable. An agent that cannot distinguish “failed cleanly” from “failed halfway” will improvise a recovery, and improvised recovery on partially-applied state is how one bad call becomes an afternoon.
7. What does it cost, and how long does it take?
Rough magnitude is enough — milliseconds or minutes, free or metered. This lets the model plan sensibly instead of calling an expensive aggregation in a loop, and it lets your budget layer set a ceiling that means something.
8. What authority does it need?
Which credential, scoped to what. This belongs in the contract rather than in the deployment config because it makes over-broad access visible at review time — the moment where the question “why does the summarising tool hold a write credential?” is cheap to ask and cheap to fix.
9. Who recovers when it goes wrong?
The field everyone omits. When this tool leaves the system in a bad state at 2am, does the agent retry, does a compensating job run, or does a human get paged? Name the owner. An unowned failure path is not a failure path; it is a future incident with no assignee.
The response is half the contract
Contracts are usually discussed as though they were only about inputs. In practice the return value matters more, because it is the only evidence the agent has that the world changed.
{"status": "ok"} is close to worthless. It cannot distinguish applied from queued, it carries no identifier for the thing that was created, and it gives the next step nothing to verify against. Vague success is worse than clear failure: failure gets handled, and ambiguous success gets built upon.
A useful response returns the outcome as a distinguishable state, an identifier for whatever was created or changed, the resulting values (not just an acknowledgement), and — where it applies — the handle needed to check on it later or reverse it. If the agent will need a fact in order to take the next step correctly, that fact belongs in the response, not in a second lookup call it may or may not think to make.
The same goes for errors. An error that says “invalid request” teaches the model nothing and produces a retry loop. An error that says which field was invalid, why, and whether retrying could ever succeed lets the agent do the right thing — including the right thing of stopping.
Four contract anti-patterns
The god tool. One tool with an action parameter taking twelve values. Every safety property now has to be evaluated per-action inside the implementation, where no policy layer can see it, and the schema can no longer say anything true about reversibility or side effects. Split it. Ten small tools with honest contracts beat one flexible tool with a dishonest one.
The overlapping pair. Two tools that could each plausibly serve the same request. The model will choose inconsistently, and the run-to-run variance will look like model flakiness when it is actually a naming problem you introduced.
The free-text escape hatch. A query, filter, or options field that accepts arbitrary strings passed to a downstream interpreter. This is not a tool; it is a hole with a schema around it. If the agent genuinely needs expressive querying, put a semantic layer or a validated query builder in the path — something that can refuse.
The passthrough. Raw SQL, raw shell, raw HTTP. The contract cannot describe what these do because what they do depends entirely on the argument. Every property you would want to declare — side effects, reversibility, blast radius, cost — becomes undefined at design time. If you expose one, you have chosen to move all your safety enforcement to runtime, and you should do so deliberately rather than by convenience.
Contracts are testable, which is the point
A contract that is only a description is a wish. The value arrives when each clause becomes an assertion:
- Fire the same idempotency key twice; assert one effect.
- Fire the declared side effects and assert that no undeclared ones occurred — that the webhook list, the outbound mail count, and the audit trail match what the schema claims.
- Force a mid-operation failure and assert the response reports partial application, with the applied portion enumerated.
- Execute the declared reversal and assert the system returns to its prior state.
- Call with a credential narrower than declared and assert a clean, informative refusal.
Run these in CI. Their real job is not catching the model — it is catching the day someone adds an email to the update path and the contract silently becomes a lie. Contracts rot the same way documentation rots, and the difference between a contract and documentation is that a contract has tests.
Versioning, when a reasoning system is your caller
Ordinary API versioning assumes callers are updated deliberately. Here, one caller is a model that reads the description fresh on every run, which cuts both ways.
Loosening is usually safe — a clearer description, a new optional parameter, a richer response. Tightening is not: narrowing an enum, adding a required field, or changing what a status means will change agent behaviour immediately and everywhere, with no deploy to correlate against. Treat those as breaking changes with a new tool name, and retire the old one on a schedule.
The subtle one is rewording. Changing a description changes selection behaviour even when the implementation is untouched. That makes tool descriptions part of your evaluation surface: when you edit one, re-run the eval set, because you have just modified a prompt that governs every run.
Where this leaves you
Take the single most consequential action your agent can perform and write its contract out in full — all nine fields, no blanks. Most teams cannot complete it from existing documentation, and the gaps they find were there long before any agent arrived. The agent did not create the ambiguity; it just became the first caller that could not ask a human what “status: ok” meant.
That is the honest summary of this whole discipline. Agents are unusually demanding integration partners, and the contract is where you pay that cost deliberately instead of discovering it in production.
The broader engineering practice around this sits in The Agent Reliability Handbook. The specific ways a weak contract fails are catalogued in Agent Failure Modes — most of the boundary family traces back to a clause that was never written down. For contracts as an adversarial surface rather than a reliability one, see Threat Modeling an AI Agent. And before you promote a tool from specified to trusted, run it in shadow mode.