Guide: Designing Errors for Autonomous Recovery
The principle: Every error must answer four questions in machine-readable fields: what failed (
code), is the identical call worth repeating (retryable), what must change first (fix_required), and what exact command makes that change (fix_command). Design for the least capable caller — an agent that can copy a string and compare a boolean, nothing more.
A human reads an error message, infers the cause, and improvises a fix. The weakest agent that will ever call your tool can do none of that. It can branch on a constant, compare a boolean, sleep for a number of milliseconds, and paste a string back into a shell. An error designed for autonomous recovery is one where those four operations are sufficient to reach the right next action every time.
This is not a degraded experience for capable models. A frontier model burns hundreds of tokens reasoning about Connection failed.; it spends near zero acting on retryable: true, retry_after_ms: 5000. The recovery ladder makes weak models correct and strong models cheap.
The recovery ladder
Four fields, ordered by the question they answer. Each rung requires strictly less caller capability than prose:
| Question | Field | What the caller does with it |
|---|---|---|
| What failed? | error.code |
Branch on a constant; never parse message |
| Worth repeating unchanged? | error.retryable (+ retry_after_ms) |
Compare a boolean; sleep; reissue |
| What must change first? | error.fix_required |
Know the failure is correctable, not terminal |
| What command changes it? | error.fix_command |
Paste and run; reissue the original once |
The ladder degrades gracefully. A tool that only provides code and retryable already prevents retry loops (the §19 failure). Adding fix_required prevents premature abandonment of correctable failures (the §53 failure). Adding fix_command closes the loop: recovery without synthesis.
The corresponding contracts: REQ-C-013 (code + message), REQ-C-014 (retryable + backoff), REQ-C-030 (executable remediation).
The three recovery classes
Every failure your tool can emit belongs to exactly one class, and the field combination encodes which:
| Class | Encoding | Agent behavior |
|---|---|---|
| Transient | retryable: true (+ retry_after_ms) |
Wait, reissue unchanged, bounded attempts |
| Caller-correctable | retryable: false + fix_required (+ fix_command) |
Apply the fix, reissue once |
| Terminal | retryable: false, neither fix field |
Stop; escalate with the error verbatim |
The complete decision procedure an agent needs — and the test of your error design is that this is all it needs:
if error.retryable: wait retry_after_ms, reissue (≤3 attempts)
elif error.fix_command: run it; if exit 0, reissue once
elif error.fix_required: apply the stated fix if achievable, reissue once;
else escalate with the condition
else: stop, escalate verbatim
If any error your tool emits requires logic outside these four lines, the error is misclassified or underspecified. The classification must hold under the spec's retryable semantics: true means the identical unchanged invocation may succeed and no side effects occurred (see exit-code.md); "the user could fix this and retry" is never retryable: true — it is caller-correctable.
Why prose suggestions are not remediation
suggestion: "Run tool login to refresh your credentials" looks actionable. It is actionable for a human and for a strong model. A weak model must: extract the command from the sentence, decide whether the surrounding words are part of it, decide whether it is safe, and construct a new tool call. Each step is a failure opportunity, and the extraction step is a prompt-injection reflex waiting to be exploited by any error text that quotes untrusted input.
fix_command: "tool login" removes every step except copying. Keep suggestion for humans; it is good UX. But never make prose the only path to recovery when a command exists:
{
"code": "CREDENTIALS_EXPIRED",
"message": "Access token expired at 2026-07-01T00:00:00Z",
"retryable": false,
"fix_required": "Refresh credentials, then reissue",
"fix_command": "tool auth refresh",
"suggestion": "Your token lifetime is 1h; consider a service account for long sessions"
}
Each field serves a different reader: fix_required tells any agent the failure is correctable, fix_command tells the weakest agent exactly how, suggestion tells the human something worth knowing that is not a recovery step.
Designing fix_command safely
The caller will execute this string blindly; that is its purpose. Treat authoring a fix_command as injecting code into every consumer of your tool, because it is. The normative constraint list lives in REQ-C-030; this section carries the rationale:
- Verbatim-executable: no placeholders.
tool auth login --token <your-token>forces synthesis back on the caller and teaches it to paste literal<your-token>. If remediation needs caller input, omitfix_command; state the condition infix_required - Idempotent or read-only: the caller may run it more than once (crashed mid-recovery, retried the whole procedure).
tool auth refreshis safe;tool migrate apply-nextis not - Never destructive: no command declared destructive (REQ-C-002) may ever appear as remediation. The framework enforces this at registration — an agent will not stop to ask whether
tool reset --hardis proportionate - Static, never interpolated: a
fix_commandtemplate that splices in file names, server responses, or user input is an injection channel (§25). The value emitted at runtime must come from the registration-time registry, not from the failure context - Self-contained: one invocation of the same tool or a declared companion; never a shell pipeline, which would smuggle arbitrary execution past the registration-time safety checks
- Closed-loop: after
fix_commandexits0, the original invocation must not fail with the sameerror.code. If it can, the remediation is wrong or the failure is misclassified
Specialized variants follow the same contract: refresh_command (REQ-F-063), reauth_command (§53), dependency fix_command in tool doctor (REQ-O-031). Emit the generic error.fix_command alongside any specialized field so consumers need only one code path.
A worked example
The same failure at each maturity level. Score 0, the agent can only guess:
Error: Something went wrong
exit 1
Score 1, the agent can branch but not decide:
{ "code": "AUTH_ERROR", "message": "Authentication failed" }
Retry? Re-login? Give up? Undecidable: expired credentials, wrong credentials, and missing permissions all collapse into one code.
Full ladder — every agent from the weakest to the strongest takes the same correct action:
{
"ok": false,
"data": null,
"error": {
"code": "CREDENTIALS_EXPIRED",
"message": "Access token expired at 2026-07-01T00:00:00Z",
"retryable": false,
"fix_required": "Refresh credentials, then reissue",
"fix_command": "tool auth refresh"
},
"warnings": [],
"meta": { "duration_ms": 120 }
}
The exit code carries the same classification out-of-band: AUTH_REQUIRED (8) here, PERMISSION_DENIED (7) for the terminal sibling, so even a caller that never parses stdout can distinguish correctable from hopeless.
Related
| Reference | Relationship |
|---|---|
| §18 Error Message Quality | Sources: the failure mode this guide's ladder eliminates |
| §19 Retry Hints in Error Responses | Sources: retry-loop and premature-abandonment failures addressed by rungs 2 and 3 |
| §53 Credential Expiry Mid-Session | Sources: the canonical caller-correctable failure |
| §25 Prompt Injection via Output | Sources: the injection surface fix_command must not widen |
| REQ-C-013 | Enforces: code + message (rung 1) |
| REQ-C-014 | Enforces: retryable + retry_after_ms + fix_required (rungs 2–3) |
| REQ-C-030 | Enforces: executable fix_command (rung 4) with registration-time safety checks |
| REQ-F-063 | Specializes: auth-specific remediation fields |
response-envelope.md |
Provides: the ErrorDetail shape carrying all four rungs |
exit-code.md |
Provides: the out-of-band classification (retryable semantics, after fix codes) |