5. Errors and exit codes
Source 05-errors.md
Goal: every CLI fails in the same shape, so an agent can branch on fields and a human can read the text.
One error shape, and it is the JSON-RPC 2.0 error object. JSON-RPC defines an error as {code, message, data}, where code is an integer, message is a short string, and data is anything. The CLI’s error is that object, so anything that already speaks JSON-RPC can pass it on unchanged, and the word code has one meaning. Our own fields live in data.
{
"error": {
"code": -32006,
"message": "Task T-12 changed since you read it.",
"data": {
"kind": "conflict",
"exit": 6,
"parameter": "-if-version",
"retryable": false,
"remediation": "Run `tracker task get -id T-12`, check the change, then repeat with the new version.",
"next": ["tracker task get -id T-12"],
"details": { "expected": "v7", "actual": "v9" },
"cli": "tracker",
"command": "task set-status",
"requestId": "01J..."
}
}
}
- A command prints
{"error": {...}}on stderr, as above. - Success mirrors it (section 4). A command prints
{"result": ...}on stdout. code(JSON-RPC): an integer from the table below. It follows JSON-RPC’s reserved ranges, so any JSON-RPC client understands it. It is not the exit code.message(JSON-RPC): one plain sentence for a human. No stack trace, no colour.data.kind: a short, stable word. This is the contract. Agents branch on it.data.exit: the process exit code for that kind. One kind, one exit code.data.parameter: the parameter at fault, if there is one, in its canonical spelling (3a).data.retryable:trueonly if repeating the same call could succeed (lock held, network blip). Nevertrueforinvalid_input,unsupported,forbidden, orconflict.data.retryAfter: optional, whole seconds. How long to wait before repeating, when the CLI knows (a locked file, a rate limit). Only present withretryable: true. Whole seconds, like-lock-timeout(section 7).data.remediation: what to do next, in plain words. Optional. A CLI includes it when it knows a fix.data.next: zero or more exact commands that follow from the remediation, ready to run. Optional.data.details: free-form object for facts about this kind. Keys are documented per kind and per command indescribe. Optional.data.cli,data.command,data.requestId: where it came from.- Required:
code,message,data.kind,data.exit. Everything else is optional, includingremediation. New fields may be added later. Readers ignore fields they do not know. - Unknown
data.kind: treat it bydata.exit, then bycode. New kinds are added only with a new exit code.
Where and how it is printed
- On failure, stdout stays empty (only
confirmation_requiredwrites to stdout, see section 6) and the error goes to stderr. - JSON mode (
-output json, or non-TTY): stderr holds exactly one{"error": {...}}object on one line, and nothing else. (With-verbosethe diagnostic lines of section 12 come first, and the object is the last line.) YAML mode: the same object as YAML.textandmduse the human form below. - Human form. The error is written as markdown, then shown by the output rules in section 4:
-output md: the markdown as is. Marks included, no colour, even on a terminal.text, styling on: the same content with the marks dropped. Bold spans are written in bold (for exampleError:and the labels). Code spans are shown in colour (for example-if-versionand the commands).- Source for the human form:
**Error:** Task T-12 changed since you read it. - **kind:** `conflict` - **parameter:** `-if-version` - **fix:** Run `tracker task get -id T-12`, check the change, then repeat with the new version. - Layout: a first line
**Error:** <message>, a blank line, then one bullet per present field in this order:kind,parameter,fix(fromremediation). Aretryable: trueerror adds- **retryable:** yes.nextcommands, if any, follow as- **next:**and a code span each.detailsand the otherdatafields are not shown in the human form. They are injsonandyaml.
- Several problems at once (for example three bad parameters): one error, kind
invalid_input, withdata.details.problemslisting each as{parameter, message}. Never print several error objects.
Error formats at a glance. One error, shown in every format. All go to stderr, stdout is empty, and the exit code is 6.
-output json (one line on the wire, wrapped here):
{"error": {
"code": -32006,
"message": "Task T-12 changed since you read it.",
"data": {
"kind": "conflict", "exit": 6, "parameter": "-if-version", "retryable": false,
"remediation": "Run `tracker task get -id T-12`, check the change, then repeat with the new version.",
"next": ["tracker task get -id T-12"],
"details": {"expected": "v7", "actual": "v9"},
"cli": "tracker", "command": "task set-status"
}}}
-output yaml:
error:
code: -32006
message: Task T-12 changed since you read it.
data:
kind: conflict
exit: 6
parameter: -if-version
retryable: false
remediation: Run `tracker task get -id T-12`, check the change, then repeat with the new version.
next:
- tracker task get -id T-12
details:
expected: v7
actual: v9
cli: tracker
command: task set-status
-output md:
**Error:** Task T-12 changed since you read it.
- **kind:** `conflict`
- **parameter:** `-if-version`
- **fix:** Run `tracker task get -id T-12`, check the change, then repeat with the new version.
- **next:** `tracker task get -id T-12`
text, styling on: the same lines with no ** and no backticks. Error: and the labels (kind:, parameter:, fix:, next:) are in bold. conflict, -if-version, and the commands are in colour.
How to think about error output. This describes the model a conforming CLI must behave as if it followed. It says nothing about how a CLI is written or in which language. There are two families, and they differ.
jsonandyamlare two serializations of one object. (yamlis optional, see section 4. Where a CLI supports it, these rules apply.)- There is one error object, the JSON-RPC error object from this section. The JSON output is that object written as JSON. The YAML output is the same object written as YAML.
- The two outputs carry exactly the same keys, values, types, and key order. Parse both and compare them, and the results are equal.
- Nothing is added or removed for one format. No YAML-only comments, anchors, or extra documents. No JSON-only fields. If a field is worth having, it goes in the object and appears in both.
- The object is the source of truth, and each format is only how it is written down. Neither format is derived from the other’s text.
- The same holds for
{"result": ...}on success.
textandmdare two renderings of one markdown error.- The markdown human form (above) is made from the object. It is a short summary, not the full object. It shows the message,
kind,parameter, fix, andnext, and leaves outdetailsand other fields. mdis the markdown left unrendered, with marks and no colour.textis the same markdown rendered for the terminal: marks replaced by styling (bold for bold spans, colour for the other spans) when styling is on.- Never write a separate
textlayout. Iftextandmdcan differ in anything but marks and styling, something is wrong.
- The markdown human form (above) is made from the object. It is a short summary, not the full object. It shows the message,
- The markdown is not a serialization of the object. It is lossy and cannot be turned back into the error. Anything a program needs to read comes from
jsonoryaml. Anything a person needs to read comes fromtextormd. - The model.
error object, then eitherserialize as json or yaml, orwrite as markdown, thenshow as text (rendered)ormd (raw). How a CLI achieves this is up to its author. - Conformance checks. A black-box check (section 10) runs the CLI, parses the
jsonandyamloutputs (for CLIs that supportyaml) and checks they are equal. It strips the styling (bold and colour) fromtextand checks the result equalsmdwith the marks removed.
Kinds, exit codes, and JSON-RPC codes. Exit codes and kind words are never renumbered or reused once published. This is the recommended table (exit 0 to 4 follow Arcjet). An adopter may choose other numbers, and then keeps them fixed:
| Exit | kind |
JSON-RPC code |
Meaning |
|---|---|---|---|
| 0 | success | ||
| 1 | internal |
-32603 | unexpected failure, a bug, or an unclassified error |
| 2 | auth |
-32002 | not logged in, token expired, credential refused |
| 3 | invalid_input |
-32602 | bad or missing parameter, failed local validation |
| 3 | invalid_input |
-32601 | unknown command (JSON-RPC “method not found”) |
| 3 | invalid_input |
-32600, -32700 | malformed request, unparseable JSON |
| 4 | confirmation_required |
-32004 | mutation waiting for -confirm (a normal result with status, not an error, see section 6; the code is reserved) |
| 5 | not_found |
-32005 | the named resource does not exist |
| 6 | conflict |
-32006 | precondition failed (6a), or the thing already exists |
| 7 | locked |
-32007 | another process holds the lock (section 7) |
| 8 | unavailable |
-32008 | remote or dependency down or timed out, usually retryable |
| 9 | forbidden |
-32009 | the caller is not permitted to run the command, or the remote refused the action |
| 10 | cancelled |
-32010 | the command was interrupted (Ctrl-C or termination). It releases its lock, leaves no partial write, and reports anything already applied in data.details.applied |
| 11 | rate_limited |
-32011 | a remote asked the caller to slow down. retryable: true, with retryAfter when known |
| 12 | unsupported |
-32012 | a format or feature this CLI does not offer, for example -output yaml on a CLI without YAML. parameter names the parameter, and the fix lists what is supported |
- JSON-RPC reserves -32768 to -32000. The standard ones (-32700, -32600, -32601, -32602, -32603) keep their meaning. Our own codes use the implementation range -32000 to -32099, as
-32000 - exitfor exit 2 and up (exit 10 is -32010). - One
kindhas one exit code. Akindmay have more than one JSON-RPCcodewhere JSON-RPC already defines several protocol faults that all mean “bad input”.
Rules
- Validate input locally, before any side effect, and fail with
invalid_input. - A mutation that fails after part of it was applied reports what was applied in
data.details.applied, and says so inmessage. It never claims “nothing changed” unless that is true (6a: atomic). - Never put secrets in an error, including values of sensitive parameters. Print the parameter name, not the value.
- Debug detail (stack, raw remote response) is not printed by default. With
-verboseit is printed as diagnostic lines (section 12). internalerrors say how to report a bug: repeat the command with-verboseand include therequestId(section 12).- Error text follows the same readability goals as parameters: concrete, uses canonical parameter names, and shows the exact fix.
- Warnings that do not fail the command go in a
warningslist besideresult(section 4). - Messages are English only.
kindandcodecarry the meaning.