AI coding

Designing Error Payloads an Agent Can Recover From

Antonio Evans8 min read

A close-up of a JSON error payload on a dark terminal, with three labeled branches drawn from it as simple arrows: a green loop labeled retry with a backoff clock.
Key points

Key points

  1. 1An error payload for an autonomous agent is a contract with a fixed schema, not a log line a human reads later.
  2. 2Seven fields carry the whole decision: error code, category, retryable boolean, retry_after_ms, scope, offending resource id, and correlation id.
  3. 3Stack traces, secrets, internal hostnames, and prose that drifts between releases should never leave the service boundary.
  4. 4The agent turns retryable, the backoff hint, and scope into one of three actions: retry, escalate with a correlation id, or stop and report upstream.

Why an agent reads errors differently than a developer does

A human developer who hits a 400 response can open the docs, reproduce the call, and read a stack trace. An agent inside an execution loop has none of those moves. It gets the response body, interprets it, and must pick its next action without leaving the loop. A body that says only "Invalid input" leaves the agent with exactly one fact (something failed) and no path forward, which in practice means it either halts or retries the same broken payload indefinitely per this analysis of agent error handling.

The industry measure of this gap is blunt. Postman's State of the API Report found that 89% of developers use AI in daily work but only 24% design APIs with AI agents in mind, which is why vague error bodies remain the norm according to Zuplo's write-up of the readiness gap. The fix is not better prose. It is a schema the agent can branch on.

There is a second reason to treat errors as structured data: the runtime channel is a trust boundary. Tool responses flow into the model's context and are treated as trusted input, and the MCP specification explicitly says tool annotations and descriptions should be treated as untrusted unless they come from a trusted server per the MCP specification. An error object built from a fixed schema and validated on arrival is one of the few things the agent can safely act on without a human reading it.

Worth noting: MCP already provides a transport-level error channel in the JSON-RPC 2.0 error response object, and the protocol reserves error codes for protocol failures as described in the schema reference. Your application-level error payload sits inside result.isError or your own response envelope, not in the transport error. Keep those two layers separate or you will confuse protocol bugs with business failures.

The seven fields and what each one has to do

Design the object so that an agent can act on it with zero additional lookups. Every field earns its place by removing a decision the agent would otherwise have to guess.

FieldTypeRequired whenWhat it prevents
codestable string enumalwaysAgent grepping free text for meaning
categoryenum (auth, validation, rate_limit, upstream, conflict, internal)alwaysMixing retryable and terminal classes
retryablebooleanalwaysBlind retry loops
retry_after_msintegerretryable: trueThundering herd, self-inflicted DoS
scopeenum (request, resource, service)alwaysRetrying a whole workflow over one bad record
resource_idstringfault is tied to one objectAgent retrying the wrong item
correlation_idstringalwaysSupport tickets that cannot be traced

The stable code is the anchor. It must be an enum your team controls, not a string built with interpolation, because an agent that matches on a code it has seen before can reuse its known recovery path. The category groups codes into classes so a general policy can be written once. The retryable boolean is the single most valuable bit in the object: it turns the agent's most dangerous instinct (try again) into a decision that was made by the service that knows.

retry_after_ms should be a number of milliseconds, not a human string. It carries the backoff the service actually wants. Idempotency-aware APIs already return a Retry-After header on concurrent duplicates, and treating that header as authoritative is what stops an agent from hammering an endpoint that is still processing its first call as documented in monday.com's idempotency guide. If your error body carries the milliseconds, the agent does not need to parse headers from five different vendors.

Scope is the field people forget and later regret. A single malformed record in a batch should not make the agent stop the whole job, but a service-wide outage should. Marking scope as resource with a resource_id tells the agent "skip this one, keep going"; marking scope as service tells it "stop, report upstream." Without scope, both failures look identical and you get either silent data loss or a full stop over one bad row.

Here is the shape as a JSON Schema fragment an agent runtime can validate against on arrival:

{
  "type": "object",
  "required": ["code", "category", "retryable", "scope", "correlation_id"],
  "additionalProperties": false,
  "properties": {
    "code": { "type": "string", "enum": ["RATE_LIMITED", "RESOURCE_NOT_FOUND", "AUTH_EXPIRED", "UPSTREAM_TIMEOUT", "VALIDATION_FAILED", "CONFLICT"] },
    "category": { "type": "string", "enum": ["rate_limit", "validation", "auth", "upstream", "conflict", "internal"] },
    "retryable": { "type": "boolean" },
    "retry_after_ms": { "type": "integer", "minimum": 0 },
    "scope": { "type": "string", "enum": ["request", "resource", "service"] },
    "resource_id": { "type": "string" },
    "correlation_id": { "type": "string" }
  }
}

The additionalProperties: false matters more than it looks. It means the payload cannot quietly grow a stack trace or a secret later without a schema change, and schema changes are reviewed. Structured outputs work the same way on the model side: constraining the response to a schema eliminates the class of parse and missing-field failures that free-form text produces according to OpenAI's structured outputs guide. Apply that same discipline to what your service sends back.

What to leave out, and why leaving it in is a security decision

The instinct to attach a stack trace to an error is a developer convenience that becomes an attack surface the moment the consumer is an agent with tool access. Four things should never cross the boundary.

Stack traces leak internal structure: file paths, dependency versions, database table names. An agent will faithfully copy a stack trace into its own reasoning and, if it has a file-writing or web-fetching tool, may send it somewhere. Secrets leak the same way, whether in a connection string embedded in an exception message or an API key echoed in a validation error.

Internal hostnames are the subtle one. An agent that sees db-primary.internal:5432 in an error may, when improvising a retry, try to reach it. Removing the hostname removes the temptation and the capability.

Prose that changes between releases is the fourth and the least obvious. If your error message text changes on every deploy (a new sentence, a reworded clause), any agent logic that matched on that text breaks silently. Stable codes and enums exist precisely so that the human-facing message can evolve freely while the machine contract holds. Treat the message field as display-only, never as a branch condition.

The MCP tool poisoning writeup makes the stakes concrete: an attacker running a malicious tool server can embed instructions in a tool response that look like a compliance directive, and if the agent has file access and HTTP access, those injected instructions can read /etc/shadow and exfiltrate it per OWASP's description of the attack. The mitigation the OWASP page recommends is exactly the discipline this article argues for: require structured output with a fixed schema, reject responses that do not match, and enforce restrictions server-side rather than relying on the model to obey. If your error payload is a validated schema object with no free-text channel, there is far less room for injected prose to arrive looking like a directive.

Turning the fields into a decision rule

The agent needs one readable rule that maps the payload to an action. Here is a rule that holds up in production and does not depend on the model's mood:

if not valid_schema(payload):      escalate(correlation_id or request_id)
elif payload.scope == "service":   stop_and_report(payload.code, payload.correlation_id)
elif payload.retryable is False:   escalate(payload.code, payload.correlation_id)
else:                              sleep(payload.retry_after_ms or default_backoff)
                                   retry(attempt + 1)
                                   if attempt >= max_attempts: escalate(correlation_id)

Three properties make this work. The schema check comes first, so a malformed payload is never trusted. Scope is checked before retryable, because a service-wide failure should not be retried even if the service helpfully says the request is retryable. And every escalation path carries the correlation id, so a human can pick up the trace without asking the agent what it did.

Worked example. An agent is enriching 10,000 contacts through a vendor API. Row 4,127 returns { "code": "VALIDATION_FAILED", "category": "validation", "retryable": false, "scope": "resource", "resource_id": "contact_4127", "correlation_id": "c-88a1" }. The agent does not retry. It records the failed id, continues to row 4,128, and at the end hands back a deliverable that lists contact_4127 with the correlation id and skips it. If instead the vendor had returned { "code": "RATE_LIMITED", "category": "rate_limit", "retryable": true, "retry_after_ms": 2000, "scope": "service", "correlation_id": "c-88b2" }, the agent would pause two seconds, wait, and resume. Same schema, two completely different recovery paths, no human reading logs.

The second half of that example is where naive retries become a self-inflicted outage. An agent in a tight retry loop can hammer a downstream API hard enough to get your outbound IP blocked, which is why the resilience guidance is exponential backoff, parsing rate-limit responses, and circuit breakers as Composio's reliability guide puts it. retry_after_ms is how your error payload feeds that backoff directly instead of leaving the agent to invent one.

One more layer worth naming: if the failing operation is a mutation, the agent also needs a deterministic idempotency key so a retry after a network timeout does not create a duplicate. The monday.com guide lays out the rule clearly: generate the key once at the first attempt and reuse the same key on every retry, never a fresh one, or deduplication silently fails per the idempotency documentation. Your error payload can include a hint that the failed mutation is safe to replay, but the deduplication itself belongs in the request.

Schema design at the tool boundary

The error schema does not stand alone. It is part of a tool interface, and tool interfaces fail when the schema is loose. A tool that takes an action parameter and branches in code ("create, get, update, delete") forces the model to figure out which mode to invoke before it can solve the task, and the fix is one tool per responsibility with tight parameter schemas where invalid states cannot be expressed as argued in this tool design breakdown. The same principle applies to errors: one code per failure class, not a status field that means three things depending on context.

MCP formalizes this at the protocol level. A tools/call response carries a content array and an isError boolean, which tells the client whether the tool execution itself failed per the tools specification. The isError flag is coarse on purpose: it distinguishes "the tool ran and reported a problem" from "the transport blew up." Your rich error object should live inside the content when isError is true, and the transport error should stay in the JSON-RPC error channel. Mixing them means the client cannot tell whether it should fix its request or its network.

If you are wiring multiple providers, keep the error schema yours. Function calling definitions differ across providers (the same weather tool has a different wrapper in the Anthropic and OpenAI APIs, and the tool logic lives in your code in both cases) as the Prefect comparison shows. Your error contract is your contract; do not let a provider's wrapping structure dictate its shape.

Govern the codes, or they drift

A schema without governance decays. Six months in, someone adds a code called BAD_THING in one service and another team adds bad_thing in another, and the agent's recovery table silently stops matching. Three rules prevent that.

First, version the enum and treat additions as a minor change, removals and renames as breaking. Second, put the enum in a shared package that the service, the client SDK, and the agent's recovery table all import. Third, add a validation test that fails the build if a service emits a code not in the enum. That is a five-line test and it is the difference between a contract and a suggestion.

The wider point is that this discipline pays across consumers. A payload designed for an agent today is a better payload for a human developer tomorrow, and vice versa; the API readiness literature keeps landing on the same list (structured errors, recovery hints, machine-readable metadata) because it is the list that works for autonomous consumers regardless of who built the API per Zuplo's five principles. If your team is running agents against internal and external tools routinely, standardizing the error contract once is where the leverage is; Quanta Inference (https://quantaintelligence.ai/inference) lets you point those agents at any model behind one OpenAI-compatible key, so the error contract is what stays stable as the models under it change.

FAQ

FAQ

Should every error return a retryable boolean, even for obviously terminal failures?

Yes. An explicit false is more valuable than a missing field, because it removes the agent's choice. The agent should never have to infer "probably not retryable" from a category name. Make the boolean required in the schema so the service author has to make a deliberate call.

What is the difference between scope and category?

Category describes what kind of failure it is (rate limit, validation, auth, upstream, conflict, internal). Scope describes how far the damage reaches (request, resource, service). A conflict is a category; whether it affects one resource or the whole service is scope. The agent branches on scope to decide whether to continue, and on category to decide whether to retry or escalate.

How does retry_after_ms relate to an idempotency key?

They solve different problems. retry_after_ms tells the agent when to retry; the idempotency key tells the server how to deduplicate the retry so a mutation is not applied twice. If a retry has no key, a successful-but-timed-out first request plus a retry can create a duplicate, so send the same key on every attempt per monday.com's guidance.

Where should the error payload sit in an MCP tool response?

Inside the content when isError is true, so the client knows the tool executed and reported a problem. The transport-level JSON-RPC error object is for protocol failures and carries reserved error codes per the MCP schema reference. Keep application errors out of the transport channel.

Does a strict error schema actually reduce prompt injection risk?

It reduces one channel. OWASP recommends constraining tool response format to structured output with a fixed schema and rejecting responses that do not match, because free-text responses can carry embedded instructions that the model treats as trusted input per the MCP tool poisoning writeup. A schema-validated error object has no free-text field for an injected directive to hide in, though you should still enforce access controls server-side where the payload lands.

At a glance, the decision rule the agent should implement:

Condition in payloadAgent actionWhy
Payload fails validationEscalate with request idUntrusted shape, cannot act
scope: serviceStop and report upstreamBlast radius too wide to retry
retryable: falseEscalate with code and correlation idService says do not retry
retryable: true, within attempt budgetSleep retry_after_ms, retryBackoff is service-directed
retryable: true, attempts exhaustedEscalate with correlation idGive a human the trace

Building that recovery table once, against a fixed schema, is the difference between an agent that recovers on its own and one that stalls on the first imperfect call.

Sources

Sources

  1. Error Handling for AI Agents: A Different Standard Than ...
  2. How to Design APIs That AI Agents Can Actually Use
  3. Specification
  4. Schema Reference
  5. Idempotency
  6. Structured model outputs | OpenAI API
  7. MCP Tool Poisoning | OWASP Foundation
  8. AI Agent Security: Why Reliability is the Missing Defense ...
  9. AI Agent Tool Design: What Works and What Doesn't
  10. Tools
  11. MCP vs Function Calling: When to Use Which - Prefect
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles