--json run does three things, always: exits non-zero, leaves one
error envelope on stderr — first byte {,
safe to jq directly — and, outside the one feeds watch terminator case
covered on The agent contract, puts nothing
on stdout. Branch on the exit code and on error.code, never on message
text: wording may improve between versions; codes and exits are frozen.
Capturing the envelope
The payload goes to stdout, the envelope to stderr — capture them separately and the failure report is onejq away:
Auth failures: test the FAMILY, not one code
“This run has no usable credential” is answered by four codes —MISSING_API_KEY, NOT_LOGGED_IN, NO_CREDENTIALS and NO_PROFILES —
and which one you get depends on the command, not on anything you did
differently: octolens whoami answers NOT_LOGGED_IN while a data command
answers NO_CREDENTIALS for the identical unauthenticated state. A script
that branches on a single member silently misses the others.
The auth family — every code that answers “this run has no usable credential”. Test membership of the family, never one code alone:
All four exit
3, so the exit is the cheap test; read the member only for
its remedy:
case matters: codes like UNAUTHORIZED and
INVALID_API_KEY also relate to auth (and exit 3), but a credential
exists in each — they are deliberately not in the family, because “log
in again” is the wrong recovery for them. The full partition is on
Auth preflight.
Exit 9: the operation was refused at its target
Exit 9 means your invocation was fine and the CLI hit nothing unexpected —
the command ran, and the work itself was refused where it landed
(octolens notifications test 7 when a destination of notification 7
refuses the delivery, for example). The full report is inside the stderr envelope, so a failing run
still tells you which item failed and why.
Whether a retry can help is in error.status, not in the exit:
Exit 10: NEVER auto-retry
Exit 10 (RESPONSE_LOST) is the one exit that reports an unknown
outcome rather than a known one: the server answered the status line of a
state-changing request — so the write was accepted — and the body never
arrived, so the CLI cannot say whether the change landed. It is raised
precisely so that your generic “retry on failure” policy can exclude it:
retrying blindly is how one feeds create becomes two feeds.
Verify with the matching list/get first, and repeat the write only if it
is genuinely missing:
REQUEST_TIMEOUT (exit 1): the deadline expired before the
server accepted anything, nothing was applied, and a retry is safe. The two
are minted apart precisely so a retry policy can treat them differently —
details on RESPONSE_LOST and
Retries and timeouts.
A safe generic policy, in one rule: retry exit 1 and exit 7 (after its
delay); verify-then-maybe-repeat exit 10; treat everything else as a
deterministic answer.
Exit 7: the envelope schedules the retry for you
A rate-limited run does not make you guess the backoff. RATE_LIMITED’s
envelope carries two additive fields alongside the standard three:
retryAfterSeconds (seconds until the request may be retried) and resetAt
(the ISO-8601 instant the window resets):
Retry-After), so an exit 7 that reaches your script means the
wait was too long to sit through — schedule it instead of spinning.
Exit 8 needs no branch
Exit 8 (a human aborted an interactive prompt) is unreachable in a
--json run: --json never prompts, so there is nothing to cancel. If your
script only ever runs with --json — and it should — you will never see it;
missing input surfaces as exit 2 naming the flags to pass instead
(The agent contract).
Ctrl-C and signals
A plain interrupt outside a prompt exits130 (128 + SIGINT), and an
interrupt during a write warns on stderr — as a JSON object in --json mode
— that the write may already have been applied, naming the command to verify
with. Long-running commands honor SIGINT and SIGTERM, so timeout-wrapped
runs terminate cleanly.