AI coding

Teaching a Tool to Return a Hint With Every Error

Antonio Evans10 min read

A clean, flat technical diagram on a light gray background.
Key points

Key points

  1. 1Every error payload needs three fields an agent can branch on: a stable machine code, a short plain message, and an explicit next-step hint.
  2. 2The HTTP status tells the agent which category it is in, but the application code and hint tell it what to do next.
  3. 3The hint should name one of three actions (retry, fix, escalate) plus the specific parameter, command or owner involved, not just "try again".
  4. 4The failure runs in your contract suite must assert all three fields plus the status, the retryability flag and the hint's target, on the real wire response.

Why a status code alone is not a decision

An HTTP status code sets the scene. It says a request failed and roughly which family of failure occurred, and clients can branch on that alone: refresh a token on 403, retry on 500, back off on 429 (Speakeasy's error design guide). That is enough for a hand-written client whose author reads the docs once. It is not enough for an agent that meets your tool for the first time and has to decide, in one step, whether to call again, change the request, or hand the problem to a person.

The difference is that a human reads the surrounding context (the ticket, the logs, the docs tab that is already open). An agent reads only what the tool returns, plus whatever schema it was given. If you return 500 with a stack trace, the agent can see that something broke and nothing about what to do next. If you return 429 with no timing, the agent guesses a sleep interval and either hammers you or gives up too early.

RFC 9457 (Problem Details for HTTP APIs) gives a standard envelope: type, title, status, detail and instance, plus extension members for validation details (Zuplo's API error handling guide). It is a good base, and you should adopt it rather than inventing a fifth ad-hoc shape. But five fields chosen for human troubleshooting do not tell a program which action to take. That is the gap the hint fills.

A concrete measure of the cost: in a 2026 study of 33,596 pull requests opened by coding agents, failing CI or tests was the most common code-level reason for rejection, at 17.6% of rejected PRs analyzed (SpecStory's definition of done guide). Some of that waste is agents retrying things that could not succeed, or escalating things they could have fixed.

The three fields every error needs

A stable machine code. A short string, specific to your application, that never changes meaning once published. trip_not_possible is the canonical example of a code layered on top of a generic 400, precisely so that client code can branch on it (if (error.code === 'trip_not_possible')) while the message stays free to be reworded (Speakeasy). Treat the code as API surface: renaming it is a breaking change, and your contract suite should catch it. Compare that to bare exit codes in a CLI, where the well-known values (1 for general errors, 126 for "cannot execute", 127 for "command not found") collide with user-defined uses until nobody can tell a "command not found" from an application policy, which is exactly the trap the Advanced Bash Scripting Guide warns about (TLDP Appendix E).

A short message. One sentence, plain language, describing what happened to this request. Not a stack trace, not a paragraph of remediation. The message is for the log line and, in some cases, for a human reading the transcript afterwards. Keep it under roughly 120 characters and never put a variable-length array of details in it.

A next-step hint. A structured object, not prose, naming the action and its parameter. The hint is the field that makes the agent's decision cheap. It should carry an action from a closed set (retry, fix, escalate, wait), a target (which field, which header, which command, which team), and, where relevant, a machine-readable timing value.

Here is a payload shape that carries all three, on top of RFC 9457:

{
  "type": "https://api.example.com/errors/validation",
  "title": "Request validation failed",
  "status": 422,
  "instance": "/orders/req_8f21",
  "code": "order_quantity_invalid",
  "message": "quantity must be a positive integer",
  "retryable": false,
  "hint": {
    "action": "fix",
    "target": "body.quantity",
    "detail": "Send an integer of 1 or more."
  }
}

Note what each field does. status and type categorize. code identifies. message explains. retryable is a boolean the agent can check before writing any retry loop. hint.action and hint.target tell it where to apply the fix. An agent that received only the 422 and the message would probably still get there, but it would spend a model call and a guess. An agent that receives the hint does not.

A decision rule for retry, fix and escalate

The three actions map to three questions, and the payload should answer all three without the agent inferring anything.

ActionUse whenRequired hint fieldsTypical status
retryThe same request, unchanged, may succeed on a later attemptafter_ms or Retry-After header, max_attempts429, 500, 502, 503, 504
fixThe request must change before it can succeedtarget pointing at the offending field, header or parameter400, 401, 403, 404, 409, 422
escalateNo change the caller can make will helpowner, ticket_ref, correlation_id401 after credential refresh, 403 on policy, 5xx after retry budget

Two rules make this table work in practice.

First, retryable: false must be honest and obvious. A 400 means the client sent a bad request and must change something before retrying; a 500 means the server failed on a valid request and a retry is usually reasonable (Zuplo). If you mark an error retryable, mean it. If a validation error is marked retryable, agents will loop on it and burn your rate limit.

Second, escalation needs a destination. "Escalate" with no owner is the same as having no hint at all. Name the team, the runbook, or the support queue, and include a correlation id so the human can find the request. The instance field in RFC 9457 exists for exactly this log correlation (Zuplo), so reuse it in the hint rather than inventing a second id.

Worked examples: validation, rate limit, auth, upstream

Validation failure. Status 422, code: "order_quantity_invalid", retryable: false, hint {action: "fix", target: "body.quantity"}. RFC 9457 supports an errors array for reporting several validation problems at once, each with a detail and a pointer (Zuplo). Put the field path in the pointer and let the agent patch each one in a single pass instead of one round trip per field.

Rate limit. Status 429, code: "rate_limit_exceeded", retryable: true, hint {action: "retry", after_ms: 2000, max_attempts: 3}, and always send Retry-After on the 429 (Zuplo). Without a timing value the agent invents one; with it, the agent waits exactly as long as you told it. This is the cheapest hint you will ever add and the one with the largest effect on agent behavior.

Auth failure. Split this into two codes, because the fix differs. token_expired (401) gets {action: "retry", target: "Authorization", detail: "Refresh the token and resend."}. insufficient_scope (403) gets {action: "escalate", owner: "platform-access", detail: "Request the orders.write scope."}. The 401 is a fix the client can automate; the 403 is a policy decision a person has to make. Collapsing both into one auth_failed code guarantees the agent does the wrong thing half the time.

Upstream failure. Status 502 or 504, code: "upstream_timeout", retryable: true, hint {action: "retry", after_ms: 500, max_attempts: 2}, plus a fallback hint for the exhausted case: {action: "escalate", owner: "payments-oncall", ticket_ref: "PAY-114"}. Agents do not know your retry budget unless you state it. State it in max_attempts, and when that budget is spent, the answer to "what now" should already be in the last payload.

One important constraint: never put secrets in a hint. Do not echo the submitted API key in an auth error, and do not name internal hostnames in an upstream error. The Microsoft guidance on error handling is direct about this: generic messages that do not reveal internal details reduce the risk of leaking database structure, keys or credentials (Microsoft Tech Community).

Tuning the payload so agents can parse it without guessing

An error is a machine-readable response, so treat it like one. A CLI design guide that has been through several iterations makes the point bluntly: JSON goes to stdout, everything else to stderr, and stdout is your API contract (Writing CLI Tools That AI Agents Actually Want to Use). The same applies to error bodies. Keep the JSON flat and consistent: if after_ms is a number in one error, do not make it a string like "2 seconds" in another. Inconsistent types are a silent failure mode in agent workflows, and silent failures are worse than loud ones.

That guide also makes the case for documented, specific exit codes over a catch-all 1, because an agent that gets a distinct code can decide to skip a step and move on, while an agent that gets 1 for everything has to parse stderr and will sometimes get it wrong (Dev.to). Your error code field is that exit code for HTTP. Give it the same care.

A CLI that serves both audiences shows how the hint can be layered without duplicating logic. The OpenStatus team rewrote their ConnectRPC error codes into actionable guidance: a 404 tells you to run list, an auth failure tells you to verify with whoami, and a missing flag error lists exactly which flags to provide (OpenStatus engineering blog). That is a hint in prose form. Structured hints are the same idea with the prose replaced by fields the agent can branch on directly.

What the failure runs in your contract suite must assert

This is the part that keeps the design honest. A contract test is not a functional test of the provider: it exists to catch bugs in how the consumer builds requests or handles responses, and misunderstandings about how the provider will respond (Pact consumer tests). Error payloads are exactly such a response shape, so they belong in the contract suite alongside the happy-path cases.

The golden rule for contract assertions: only assert on things that will affect the consumer if they change (Pact). For an agent consumer, that means all five properties below, and nothing about internal error classes, log formats or stack traces.

AssertionWhat it catchesExample failure
Status code is exactWrong categoryValidation returns 400 instead of 422
code is exact and stableRenamed or missing codeorder_quantity_invalid becomes invalid_quantity
message is non-empty and under the length capPlaceholder or stack-trace messagesMessage is "Error" or a Java stack trace
retryable matches the actionA retryable error with a fix hint, or the reverse422 marked true
hint.action is in the enum and hint.target is present for fix/escalateMissing or malformed hint{action: "try again"}

Two further rules matter for agents specifically.

Assert on the wire, not on the object. A unit test on your error-constructor function proves the function works. It says nothing about JSON serialization, the header set, the status mapping or protocol handling. Run the failure scenarios through a real client against a test server, the way ASP.NET Core integration tests submit requests to the app and assert the actual response against an expected one (Microsoft Learn). The layered point is that business logic, contract shape and end-to-end transport are different questions; an error payload can be wrong at any layer.

Assert in both directions. The positive case (a bad request produces this exact code and hint) and the negative case (a good request never produces this code). The negative assertion is what catches an error being returned for the wrong reason. A Go CLI integration testing approach that runs the compiled binary and compares output against golden files is a clean fit: the tests need to change only if the behavior of the program changes (Writing integration tests for a Go CLI application). Store one golden file per failure scenario and diff the whole body.

Finally, keep the hints within the retry budget you actually enforce. If the hint says max_attempts: 3, your rate limiter or backoff should not be counting to 5. A test that simulates three retries and then asserts a 429 with an escalate hint is cheap and catches exactly the mismatch that turns an agent loop into an incident.

Getting the failures into the suite at all

The hardest part of error contract testing is not the assertions. It is ensuring every declared error has a test at all. Coding agents are literal and will implement what you specify, but they will not invent the failure cases you left out. A workflow that hands out gates has the same lesson: spend the time on requirements and acceptance criteria up front, because agents are extremely literal and vague instructions yield code that technically matches what you said but misses what you meant (How I Validate Quality When AI Agents Write My Code).

The practical fix is a definition of done that requires a recorded run: the test command ran after the agent's last edit and exited 0, and the pull request includes the log of each command and its exit code (SpecStory). Apply the same rule to errors. A handler that can return code: "order_quantity_invalid" is not done until a named test asserts the full payload on the wire. If you generate the code list from a single schema constant, the contract suite can iterate over every code and fail when one has no test, which closes the gap at the schema level rather than in a checklist.

For teams running agents against many models and tools, the failure taxonomy becomes infrastructure. A single error shape across your own services helps, but the tools the agent calls may come from anywhere, so a retry policy keyed on retryable and after_ms is reusable across every model and endpoint you connect. That is the role Quanta Inference plays: text, image, video, audio and embedding models through one API key, OpenAI-compatible, so the same error-handling loop works whether the call goes to a chat model or an embedding model.

FAQ

FAQ

What is the difference between the error code and the HTTP status?

The status code is a standard, coarse category that any client understands (400s mean the caller must change something, 500s mean the server failed). The application code is a short string specific to your service that identifies the exact condition, such as order_quantity_invalid, so client code can branch on it while messages stay editable (Speakeasy). Both are needed: choose the most specific status that applies, then add the code (Zuplo).

Should the hint be a sentence or a structured object?

Structured. A sentence such as "check your flags and try again" is fine for a human but forces the agent to parse it. An object with action, target, after_ms, max_attempts and owner lets the agent branch with a single comparison, and lets your contract test assert each field exactly. Keep prose in the message field and keep the hint machine-readable.

How many actions should the hint allow?

Four is plenty: retry, fix, escalate and wait. wait is useful for long-running jobs where the right move is to poll later rather than fail, and it carries after_ms just like retry. Anything beyond that usually means you have two codes where you should have one. Close the enum in your schema and assert membership in the contract suite, because an unknown action is as useless to an agent as no hint at all.

Do I need a separate code for every validation rule?

Yes, if the fix differs. email_invalid and quantity_invalid need separate codes because the agent patches different fields. If two rules share a fix target and the same remediation, one code with an errors array of field-level details is fine and matches the RFC 9457 extension pattern (Zuplo). The test is whether a consumer reading only the code and hint would do the right thing.

What should the contract suite assert about the message?

Only that it is present, non-empty, within your length cap, and free of stack traces, internal hostnames and credentials. Do not assert exact message wording: you will want to rewrite it for clarity without breaking consumers, and the code is the stable identifier. This follows the contract testing principle of asserting only what affects the consumer if it changes (Pact).

If you are putting these error contracts in front of agents and want the retry and escalation logic written by people who do it for a living, a Quanta expert reads your context, does the work and hands back finished deliverables.

Sources

Sources

  1. Errors Best Practices in REST API Design - Speakeasy
  2. Best Practices for API Error Handling
  3. Definition of done for AI coding agents
  4. Appendix E. Exit Codes With Special Meanings
  5. Best Practices for API Error Handling: A Comprehensive ...
  6. Writing CLI Tools That AI Agents Actually Want to Use
  7. Building a CLI That Works for Humans and Machines
  8. Writing Consumer tests
  9. Integration tests in ASP.NET Core
  10. Writing integration tests for a Go CLI application
  11. How I Validate Quality When AI Agents Write My Code
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