Skip to content
advanced

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…

Published 2026-09-11Updated 2026-09-1216 min read
Detailed view of a sewing machine needle stitching fabric, highlighting sewing process and textile technology.
Detailed view of a sewing machine needle stitching fabric, highlighting sewing process and textile technology. Photo by Alex Andrews on Pexels.

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:

PropertyFailure it preventsWhere it lives
SelectableWrong tool chosen among siblingsName + description
ConstructibleInvalid argumentsSchema
AuthorizableUnbounded side effectsRisk metadata + harness policy
RecoverableRetry loops, silent partial successError contract
RepeatableDuplicate writesIdempotency 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.

You are designing a deterministic tool that creates support tickets and accepts a priority from a fixed set. Which interface choice best follows the article's schema guidance?
Scenario Interpretation

Focus: Apply schema-design principles to decide which constraints should be encoded for a deterministic write tool.

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.

Two sibling tools can both appear relevant to a request. Which description change most directly improves the model's tool selection?
Comparison Reasoning

Focus: Distinguish descriptions that improve sibling-tool selection from descriptions that merely document an operation.

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.

Which statement correctly reflects the article's distinction between tool metadata and authorization?
Misconception Check

Focus: Explain why risk annotations and natural-language permission text do not replace harness-enforced authorization.

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.

A tool returns {"error":"denied"} for both malformed arguments and insufficient permission. What is the most important repair?
Debugging

Focus: Diagnose why an error contract causes pointless retries and choose the information needed for corrective behavior.

The model keeps retrying the same call after both kinds of failure.

Idempotency: Making Retries Safe by Construction

Flowchart showing a call with an idempotency key entering an atomic reserve step. A new key becomes in-flight and proceeds to the side effect, then commits a stored result. An existing in-flight key returns pending for a later retry, an existing committed key returns the stored result, and a reused key with different arguments returns a conflict.
Atomic reservation separates in-flight, committed, and conflicting retries so a timeout does not become a duplicate side effect.

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:

TestInjected conditionExpected tool-visible outcomePass condition
Sibling selectionPrompt implies an updateModel calls update_ticket, not create_ticketCorrect tool chosen
Schema rejectionpriority = "ASAP"VALIDATION_FAILED, field: priority, recoverableModel corrects and resends
Permission denialCaller lacks write scopePERMISSION_DENIED, not recoverableModel stops retrying
Transient failureUpstream returns 503UPSTREAM_UNAVAILABLE, recoverableModel retries with same key
Partial successBatch of 5, 2 failPer-item status arrayModel retries only failed items
Duplicate keySame key, same argsOriginal result returnedNo second ticket created
Conflicting keySame key, different argsIDEMPOTENCY_CONFLICTModel does not treat as success
Timeout after commitCrash between send and commitpending_error, recoverableRetry 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.

With the article's atomic reservation state machine, what should a retry do when the same key and fingerprint are found in the `in-flight` state?
Question 1 of 2Output Prediction

Focus: Predict the behavior of an atomic idempotency reservation when a retry arrives while the original operation is still unresolved.

The first request may have timed out after the downstream send committed but before the wrapper stored the result.
A wrapper uses atomic key reservation, but the downstream email service does not honor the key. What guarantee can the wrapper honestly claim?
Question 2 of 2Scenario Interpretation

Focus: Apply the article's end-to-end idempotency limitation to decide what guarantee a wrapper can and cannot provide.

The process can crash after the downstream service commits the email but before the wrapper records its committed state.

References

  1. Effective context engineering for AI agentswww.anthropic.com
  2. Context Engineeringwww.langchain.com
  3. AI Agent Tool Design: What Works and What Doesn'tmachinelearningmastery.com
8sources checked
8source domains
10searches run

Research updated Sep 11, 2026

Related sites

Build the foundations behind advanced AI systems

Use LearnLLMFast for practical LLM application foundations and LearnPyFast for the Python mechanisms that support implementation work.

LLM tutorialstutorial

LearnLLMFast

Practical LLM tutorials for builders who want to understand prompting, workflows, agents, and AI applications.

LLMAIBuilders
Visit LearnLLMFast
Python tutorialstutorial

LearnPyFast

Beginner-friendly Python tutorials, examples, and learning paths for practical programming foundations.

PythonProgrammingBeginners
Visit LearnPyFast

Keep exploring

Related AI engineering tutorials

Continue with adjacent system layers, implementation patterns, and current AI engineering ideas.