Tool Engineering for AI Agents: Schemas, Descriptions, Contracts, Errors, and Idempotency
A failed agent run rarely looks like an interface problem. It looks like a model problem: wrong tool, malformed arguments, an error swallowed and retried…

Key topics
A failed agent run rarely looks like an interface problem. It looks like a model problem: wrong tool, malformed arguments, an error swallowed and retried into a loop. But the model reasons from exactly four things per tool — name, description, parameter schema, parameter docs — plus whatever risk annotations the runtime exposes. Nothing else. It cannot read your source, cannot ask a clarifying question mid-call under the tool-call protocols most runtimes implement, and will retry after a timeout without knowing whether the first attempt landed.
Grant the narrow case: a genuinely weak model on a genuinely hard task will fail regardless of interface quality. That case is real and it is not the one worth engineering against. Most tool-boundary failures are predictable from the interface itself, which means they are fixable before an agent ever touches the tool.
The invariant this article builds toward: every tool call must be selectable, constructible, authorizable, recoverable, and repeatable. Five properties, five failure classes. If you already know what a harness is and how tool calls get dispatched, this is the layer underneath that dispatch — the contract itself, not the runtime around it.
The Tool Interface Is the Only Contract the Model Sees
Treat the tool definition as an API contract with a client that has no documentation, no debugger, and no memory of your last conversation. That client is non-deterministic, may be interrupted mid-call, and may re-issue a call it already made.
Five properties fall out of that framing:
| Property | Failure it prevents | Where it lives |
|---|---|---|
| Selectable | Wrong tool chosen among siblings | Name + description |
| Constructible | Invalid arguments | Schema |
| Authorizable | Unbounded side effects | Risk metadata + harness policy |
| Recoverable | Retry loops, silent partial success | Error contract |
| Repeatable | Duplicate writes | Idempotency mechanism |
Two constraints make this harder than ordinary API design. First, tool definitions consume context tokens and compete for attention with every other tool and instruction in the window. A bloated tool set is a design defect, not a feature — it degrades selection for every tool in the catalog, not just the new one. Second, the model's only feedback loop is the result you return. If the result does not tell it what to do next, it guesses, and its guess is usually to repeat the call.
The tool interface is the entire observable surface. Everything the model gets wrong, it got wrong from what you handed it.
Start From the Smallest Useful Tool, Not the API Surface
Pick one operation with a real side effect and a real failure mode. A write, not a read. Reads are forgiving — they are naturally idempotent, their errors are usually terminal, and a wrong read rarely costs money. Writes force you to solve idempotency and error design on day one, which is exactly when those decisions are cheap.
Write the tool definition by hand before generating anything from an OpenAPI spec. Auto-generated schemas inherit API-shaped parameters: nested resource identifiers, optional fields that exist for backward compatibility, enums that mirror database columns. Those are hostile to model construction. The wrapper is a translation layer — it accepts agent-facing arguments, validates them, maps them to the underlying call, and normalizes the response into something token-efficient. The generated spec is an input to that translation, not the output.
The failure mode to name explicitly: exposing a general-purpose endpoint and calling it a tool. A raw query executor, a shell, an unfiltered database client. The action space becomes the model's problem, and the model has no way to know which of ten thousand valid statements you actually wanted. There is a legitimate version of this — filesystem, shell, browser, calendar tools — where the action space is the underlying abstraction and constraining it would remove the model's ability to express intent. That is a deliberate choice, not an accident of exposure. Know which one you are making.
Smallest useful implementation: one tool, one schema, one error taxonomy, one idempotency key, exercised against a scripted set of malformed calls. Ship that before you ship the second tool.
Schemas That Make Invalid Calls Hard to Construct
Every free-form field is a place the model must guess. Schema design is constraint encoding, and the constraint you encode is the guess you eliminate.
Tight versus loose is the first axis. Enums instead of free strings. Typed fields instead of untyped dicts. Bounded ranges where a range exists. Required versus optional made explicit rather than implied. Formats declared in the schema rather than described in prose — if a field is an ISO 8601 date, say so in the type, not in a sentence the model may skim.
Parameter naming carries selection signal. Verb-first, resource-specific, consistent across a resource family:
{
"name": "create_ticket",
"description": "Creates a support ticket. Use when the user reports a new issue. Do not use to update an existing ticket — use update_ticket.",
"parameters": {
"type": "object",
"properties": {
"subject": { "type": "string", "maxLength": 200 },
"priority": { "type": "string", "enum": ["low", "normal", "high", "urgent"] },
"requester_email": { "type": "string", "format": "email" },
"idempotency_key": {
"type": "string",
"description": "Stable key derived from subject + requester_email. Same key on retry returns the original ticket without creating a second one."
}
},
"required": ["subject", "requester_email", "idempotency_key"]
}
}
Compare create_ticket, fetch_support_issue, change_ticket_status, show_all_tickets against create_ticket, get_ticket, update_ticket, list_tickets. The second family lets the model infer relationships from naming alone. The first forces it to infer them from descriptions, which is a worse use of the same tokens.
Parameter interaction is the hard part. When two parameters are mutually exclusive, or one only applies under a condition, encode it in the schema where the runtime supports it — oneOf, dependentRequired, strict schema mode — and in the description where it does not. Nested and array parameters are where construction errors concentrate. Flatten when you can. When you cannot, give a worked example in the description showing a valid call.
One honest limit: what the schema enforces depends on the model, the runtime, and whether strict schema mode is available. A schema is a strong suggestion at the decoding layer and a hard constraint at the validation layer. Know which layer you are relying on, and validate server-side regardless.
Deliberate loosening has a place. Exploratory or search-shaped tools lose expressiveness when over-constrained — a search query squeezed into an enum cannot express intent the enum did not anticipate. Loosen there, tighten everywhere the operation is deterministic.
Knowledge check
Check your understanding
Answer this question before you continue.
Descriptions Are Selection Logic, Not Documentation
The description is the highest-leverage field in the definition, and most teams write it like a docstring. It answers three questions: what it does, when to use it, and — the one almost everyone omits — when not to use it and which sibling to use instead.
Overlapping descriptions are the most common cause of wrong-tool selection. If a competent engineer cannot state which of two tools applies to a given situation, the model will not either. That is not a model limitation; it is an interface defect you can measure and fix.
Include boundary cases and one short worked example for tools with non-obvious parameter interaction. Something like: to find wireless headphones under $100, set query='wireless headphones' and use the price filter — do not set query='headphones under $100', the filter handles pricing. That single sentence converts a class of malformed calls into correct ones.
Keep descriptions token-lean. Every sentence is paid for on every turn of every conversation that has the tool loaded. Tool count is a design variable: a large catalog degrades selection, and dynamic or retrieved tool loading is the mitigation when the catalog cannot shrink. Selection accuracy depends on the model version and the surrounding context, so treat description quality as a measurable variable rather than a solved problem. If you are not measuring wrong-tool selection, you are guessing about the thing that matters most.
Knowledge check
Check your understanding
Answer this question before you continue.
Permissions, Risk Metadata, and the Combination Problem
Annotation hints — read-only, destructive, idempotent, open-world — are vocabulary for the harness's permission layer. They are advisory unless the runtime enforces them. Do not confuse declaring risk with controlling it.
The risk that matters often emerges from tool combinations rather than any single tool. Private data access plus untrusted content exposure plus an outbound channel is the pattern to design against: an agent that can read your internal documents, ingest a web page, and send email can be talked into exfiltrating the first through the third. No individual tool in that chain looks dangerous.
Authorization belongs in the harness, not in the tool description. Natural-language permission text is not a control — it is a suggestion to a non-deterministic caller. Design implications for the tool author: declare scope narrowly, separate read and write paths into distinct tools so policy can distinguish them, and make the blast radius of a write visible in the arguments. If a delete takes a filter, the filter should be a required, explicit parameter, not a default that silently matches everything.
The tool author declares risk. The harness enforces policy. Pretending the first does the second produces false confidence.
Knowledge check
Check your understanding
Answer this question before you continue.
The Call Path: Where Each Property Is Evaluated
The five properties are not evaluated in one place. They are checked at different points in a single call lifecycle, and the model only ever sees the payload that survives to the end of that path.
model emits tool call
→ schema validation (Constructible)
→ authorization decision (Authorizable)
→ idempotency check (Repeatable)
→ side effect executes
→ result normalized (Recoverable)
→ result returned to model
The distinction that matters: a validation denial and a permission denial both reach the model as tool results, but they carry different information. A validation denial tells the model its arguments were wrong and it can fix them. A permission denial tells the model the operation is not allowed — retrying with the same arguments is pointless, and the error payload should say so. If both arrive as {"error": "denied"}, the model cannot tell a fixable mistake from a hard stop, and it will retry the unfixable one.
This is why the error contract and the permission layer have to be designed together. The harness decides; the tool's error shape communicates that decision back to the model in a form it can act on.
Errors the Model Can Act On
The failure mode is an unhandled exception or a raw stack trace returned as tool output. The model cannot distinguish "your arguments were wrong" from "the upstream service is down," so it guesses — usually by retrying the identical call.
Minimum viable error shape: a stable error code, a recoverability flag, a human-readable message, and a suggested corrective action or the specific field that failed validation.
{
"error_code": "VALIDATION_FAILED",
"recoverable": true,
"message": "Field 'priority' must be one of: low, normal, high, urgent.",
"field": "priority",
"suggested_action": "Resend with priority set to one of the allowed values."
}
Validation errors should name the offending parameter and the constraint violated. That single detail converts a retry loop into a corrected call. Compare it to {"error": "invalid request"}, which tells the model nothing it did not already know.
Partial success is the silent killer. A batch operation that succeeds for some items and fails for others must report per-item outcomes, or the model will assume the whole call failed and duplicate the successful work. Return a result with a per-item status array, not a single boolean.
Distinguish error semantics you define from error semantics the protocol or runtime imposes. The two layers can disagree — a runtime may treat any non-2xx as terminal while your tool considers it retryable — and the disagreement is worth testing explicitly. Sometimes a soft failure belongs in a normal result rather than an error: a search that returns zero matches is a successful search, not a failure, and returning it as an error changes how the model plans its next step.
Knowledge check
Check your understanding
Answer this question before you continue.
Idempotency: Making Retries Safe by Construction
Retries are inevitable. Timeouts, truncated responses, and interrupted runs mean the model may not know whether a call took effect. Without a mechanism, at-least-once delivery becomes duplicate effects.
The naive version looks correct and is not:
def send_email(to, subject, body, idempotency_key):
existing = idempotency_store.get(idempotency_key)
if existing:
return existing
result = email_service.send(to=to, subject=subject, body=body)
idempotency_store.set(idempotency_key, result, ttl=86400)
return result
Two windows break it. First, two concurrent calls with the same key both pass the get before either reaches set, and both send. Second, the process crashes after send commits but before set records the result — the retry finds no record and sends again. The get-then-set pattern is a check-then-act race wearing an idempotency key.
The fix is to make the key a reservation with explicit states, not a result cache:
key state machine:
absent → no record
in-flight → reserved, side effect not yet confirmed
committed → side effect done, result stored
def send_email(to, subject, body, idempotency_key):
fingerprint = hash_args(to, subject, body)
# atomic reserve: succeeds only if key is absent
reserved = idempotency_store.reserve(idempotency_key, fingerprint)
if not reserved:
record = idempotency_store.get(idempotency_key)
if record.fingerprint != fingerprint:
return conflict_error(idempotency_key)
if record.state == "in-flight":
return pending_error(idempotency_key) # caller should retry later
return record.result # committed: dedupe
try:
result = email_service.send(to=to, subject=subject, body=body)
idempotency_store.commit(idempotency_key, result)
return result
except Exception:
idempotency_store.release(idempotency_key) # allow a clean retry
raise
The reserve must be atomic — a conditional write, a unique constraint, or a lock. get-then-set is not. The fingerprint binds the key to the operation's arguments, so a key reused with different arguments returns a conflict instead of silently returning the wrong result.
Walk the hard case. A call times out after email_service.send commits but before commit records the result. The model retries with the same key. reserve fails because the key is in-flight. The tool returns pending_error, which is recoverable — the model waits and retries. On the next attempt, either commit has landed and the tool returns the stored result, or the process recovered and released the key, and the retry executes cleanly. The duplicate is suppressed in every branch.
Key generation is the design decision. The model must produce a stable key for the same logical operation across retries, which usually means deriving it from the operation's arguments rather than from a timestamp. A timestamp-based key defeats the entire mechanism: every retry looks like a new operation. Document the derivation in the parameter description so the model can reproduce it.
Storage and lifetime matter. Where the mapping lives, how long it survives, and what happens when the store is unavailable. Fail closed — reject the call — or fail open — execute anyway — and say which. Failing open silently reintroduces the duplicate you were preventing.
Read-only tools are naturally idempotent, with one caveat: a read that triggers audit logging, rate-limit consumption, or an external observation is not free to repeat, even if it returns the same data.
The honest limit: this pattern guarantees duplicate suppression at the wrapper. It does not guarantee exactly-once effects end to end. If the downstream service does not participate in the same idempotency protocol, a crash between the external commit and your commit can still produce a duplicate on the next attempt — the wrapper cannot see a side effect it never recorded. Exactly-once requires the side effect's owner to honor the same key.
Testing the Tool Boundary Before the Agent Finds It
Drive the tool with scripted malformed calls before any agent touches it. Then read the returned error as the model would. Is the next action obvious from the payload alone? If you have to consult the source to know what to do next, so does the model.
A compact pass/fail matrix tied to the running create_ticket tool:
| Test | Injected condition | Expected tool-visible outcome | Pass condition |
|---|---|---|---|
| Sibling selection | Prompt implies an update | Model calls update_ticket, not create_ticket | Correct tool chosen |
| Schema rejection | priority = "ASAP" | VALIDATION_FAILED, field: priority, recoverable | Model corrects and resends |
| Permission denial | Caller lacks write scope | PERMISSION_DENIED, not recoverable | Model stops retrying |
| Transient failure | Upstream returns 503 | UPSTREAM_UNAVAILABLE, recoverable | Model retries with same key |
| Partial success | Batch of 5, 2 fail | Per-item status array | Model retries only failed items |
| Duplicate key | Same key, same args | Original result returned | No second ticket created |
| Conflicting key | Same key, different args | IDEMPOTENCY_CONFLICT | Model does not treat as success |
| Timeout after commit | Crash between send and commit | pending_error, recoverable | Retry dedupes, no duplicate |
Measure selection separately from execution. A tool that executes correctly but is chosen at the wrong time is a description problem, not a schema problem, and the fix lives in a different file. Log the full tool call and result at the boundary — without that trace, every failure becomes archaeology.
Treat the tool definition as versioned interface surface. A description change is a behavior change for every agent that has the tool loaded, and it deserves the same review as a schema change.
The Decision Rule
Before shipping any tool to a model-driven caller, verify five things: the name and description make selection unambiguous among siblings; the schema makes invalid arguments hard to construct; the risk is legible to the harness's permission layer; every error tells the caller what to do next; and every write is safe to repeat.
Then do the concrete thing. Take one existing endpoint — a write, with a real failure mode — and wrap it by hand. Write the schema, the description, the error taxonomy, and the idempotency key. Run the malformed-call matrix against it, including the timeout-after-commit case. Read every error as the model would. Fix what you find before an agent ever sees it.
The adjacent concern, and the next thing worth understanding, is how the harness enforces the permissions the tool merely declares — because the tool can describe risk, but only the runtime can refuse it.
Knowledge check
Final check
Finish the article by checking the ideas you just learned.
References
Research updated Sep 11, 2026


