AI coding

Exit Codes and Output Contracts for Agent Tools

Antonio Evans10 min read

A terminal window in close-up, split into two vertical panes.
Key points

Key points

  1. 1Return 0 only when the whole job succeeded, and map every failure to a small fixed set of nonzero codes.
  2. 2Write machine-readable data to stdout and send all logs, progress and diagnostics to stderr.
  3. 3Make each error message name the cause, the failing argument and the exact next action the agent should take.
  4. 4Publish the code table and the output schema as a versioned contract that CI tests can verify.

Why an exit code is an interface, not a detail

A shell reports success the way the operating system has for decades: a process that completes correctly exits with status 0 per the Linux convention described on Stack Overflow. Everything else is a failure signal. That single integer is the cheapest channel you have to an agent, because the agent reads $? and branches on it before it ever parses text.

The problem is that the byte is overloaded. Code 1 is a catchall for general errors, 2 signals misuse of a shell builtin, 126 means the command cannot be executed, 127 means not found, and 128+n reports a fatal signal n per the Advanced Bash Scripting Guide appendix. Those meanings belong to the shell and the kernel, not to your program. If your tool returns 127 because "the record you asked for is gone," the calling agent cannot distinguish your domain error from a missing binary.

The widely available sysexits.h scheme was an attempt at a standard, but practitioners have long argued that its categories are ambiguous enough that callers cannot reliably depend on them as discussed on Hacker News. Read that thread as a warning: a code table only works if you own it, document it, and keep it small enough that an agent can hold the whole map in view.

Note also the arithmetic trap. Exit values wrap modulo 256, so exit 3809 surfaces as 225 per the same appendix. Never compute a code from a variable you have not bounded.

A five-code contract that agents can branch on

The practical rule is to define your own tiny scheme in the range above the shell's reserved values, document it in --help and in the tool's README, and treat it as API. A working set for agent-called tools looks like this.

CodeClassMeaningWhat the agent should do
0SuccessWork completed, output on stdout is completeConsume stdout, proceed
2Input errorA named argument or file is missing, malformed or contradictoryFix the named input, retry once
3Not foundThe referenced resource does not existList candidates, choose again, or escalate
4Permission deniedCredentials missing, expired or insufficient scopeStop retrying, escalate to a human
5ConflictResource already exists or state moved underneath youSkip creation, reconcile, or re-read state
75Transient failureTimeout, rate limit, network blipRetry with backoff, then escalate
78Partial failureSome items succeeded, some failedRead the per-item report, retry only failed items

This is close to the scheme published in a widely read guide on writing CLIs for agents, which uses 0 for success, 1 for general failure, 2 for usage error, 3 for not found, 4 for permission denied and 5 for conflict in Writing CLI Tools That AI Agents Actually Want to Use. The additions here are the transient and partial classes, which matter for batch operations and network calls.

Two decision rules follow from the table. First, reserve 1 for "unclassified bug in the tool" so it never means anything a caller could act on. Second, keep the set closed: if a new failure does not fit an existing class, you almost always want it to become an input error or a permission error rather than a new code, because every new code costs the agent a branch it must learn.

An agent that receives 5 can decide to skip creation and move to the next step; an agent that receives 1 for everything has to parse stderr to guess what happened, and it will sometimes guess wrong per the same guide. That sentence is the whole argument for a closed table.

Separating stdout from stderr

The contract has a second half: what goes where. Stdout is the API. Stderr is the human channel. Progress bars, spinners, warnings, retry notices and deprecation chatter belong on stderr so they never corrupt a pipe.

A production example of this discipline comes from the OpenStatus CLI rebuild, which routes every output decision through a terminal check: spinners show only when stderr is a TTY, color respects NO_COLOR, and JSON mode strips formatting entirely per Building a CLI That Works for Humans and Machines. The same post reports a --debug flag that logs HTTP method and duration to stderr specifically so it never corrupts piped output.

A short check you can run against any tool before you hand it to an agent:

# stdout should parse as JSON with nothing else mixed in
mytool list --json 2>/dev/null | python3 -c 'import json,sys; json.load(sys.stdin)'

# stderr should still carry the diagnostics
mytool list --json 2>&1 >/dev/null | head -5

# exit code should survive the pipe
mytool list --json >/dev/null; echo "exit=$?"

If the first command fails, an agent will fail too, because it parses on the assumption that stdout is one clean document. If the third prints anything other than the documented code, the branch logic downstream is already broken.

Two more output rules earn their keep. Prefer flat keys over deep nesting (pod_status rather than pod.status.phase), and keep types stable: if a duration is a number in one command, do not return a prose string like "3 days" in another per the CLI guide. When output is incremental, emit one JSON object per line so the agent can process it as a stream.

Agents check exit codes and parse output to decide what to do next, so a tool that returns 0 on failure breaks every workflow that depends on it per the same guide. Treat a wrong exit code as a data-integrity bug, not an ergonomics issue.

Writing error messages the agent can act on

An error message for an agent is closer to a repair instruction than a log line. It needs three parts: what failed, which input is implicated, and what to do next. The OpenStatus team rewrote raw RPC codes into exactly that shape: monitor 999 not found. Run 'openstatus monitors list' to see available monitors, and authentication failures tell the caller to check the token environment variable and verify with a whoami command per their engineering post.

The same post shows the pattern for missing arguments in non-interactive mode: the tool lists precisely the flags that are absent (missing required flags: --title, --status, --message, --page-id), which is concrete enough for a model to retry with the right values per the same post. Their design also handles the two-audience problem with a "wizard fallback": if required flags are missing and stdin is a terminal, a human gets an interactive form; otherwise the tool errors with the exact missing flags.

Write your messages to that standard with a checklist.

BadBetterWhy it works for an agent
Error: failedError: --since "yesterday-ish" is not an ISO 8601 date. Use YYYY-MM-DD.Names the argument and the accepted format
404Error: project "atlas" not found. Run 'tool projects list'.Points to a discovery command
UnauthorizedError: token expired. Set TOOL_TOKEN or run 'tool login'. Do not retry.Tells the agent not to loop
Something went wrongError: upstream timeout after 30s. Retry up to 3 times with backoff.Gives a retry budget
stack traceError: value 0 invalid for --concurrency (must be 1-64).Machine-checkable bounds

One more discipline: never put success text on stderr and then exit 0, and never print an error to stdout and then exit 0. The pipe and the code must agree.

Idempotency, partial failure and the retry budget

Agents retry. Networks fail. A command gets interrupted halfway. If your create command fails on the second run because the resource already exists, the agent must write special-case retry logic for your tool alone per the CLI guide. Make creation idempotent and return 0 with the existing object, or return 5 with the existing ID so the caller can decide.

For batch work, the exit code alone cannot carry enough information. Exit 78 plus a per-item report on stdout is the usable combination: the agent reads which items failed and retries only those. Without the report the agent must re-run the whole batch, which is how duplicate side effects get created.

Retries also need a cap. Long-horizon agent runs degrade as tasks stretch: one production analysis found success rates start falling after roughly 35 minutes of human-equivalent work, and doubling task duration roughly quadruples the failure rate rather than doubling it per a survey of long-horizon planning research. A tool that keeps returning "transient" invites the agent into exactly that failure mode. Return the transient class a bounded number of times, then switch to a code that means escalate.

That research also notes that after 25 to 30 tool calls even large context windows show coherence problems, with agents re-executing steps they already finished per the same survey. Idempotent commands and precise "already exists" codes are the cheapest defense against re-execution damage.

Comparing contracts: CLI, MCP server and code sandbox

You are not only choosing exit codes; you are choosing a transport. The three common ways to hand a capability to an agent have different contract surfaces.

SurfaceSuccess signalFailure signalOutput channelBest fit
CLI binaryexit 0nonzero exit codestdout (data) plus stderr (logs)Shell-capable agents, composable pipelines
MCP serverresponse payloadprotocol error envelopestructured responseMany tools behind one server, stateful sessions
Sandboxed executionrun statuserror with stderr and exit codestdout, stderr, exit code, truncation noticeUntrusted generated code with real side effects

The sandbox column is worth understanding because a good sandbox already implements the contract you want. Production sandboxes standardize an execute interface for shell commands that returns stdout, stderr, exit codes and truncation notices, with filesystem access through explicit tools rather than unrestricted access per an overview of agent code execution. If your CLI mirrors that return shape, it behaves the same whether the agent runs it directly or inside a sandbox.

On the CLI versus MCP tradeoff, the reported experience is that tool schemas in MCP sit in the prompt before the tool is used, and JSON-RPC framing adds per-call overhead; one author measured roughly 40 percent lower token use after replacing a simple MCP server with a CLI per the CLI guide. The same guide says MCP still wins when you expose dozens of tools behind one server, need stateful sessions, or want protocol-level composition. Either way, the exit code and output rules carry over.

Skill contract and testing the contract

An agent-facing document is also a contract problem. A spec-driven workflow that has the agent write requirements, then a plan, then an ordered task list before any code is written gives both the human and the agent checkpoints to review per JetBrains' guide to spec-driven coding. Apply the same sequence to your tool: write the interface spec (codes, channels, message shape) as a file, review it, then implement against it.

The evidence standard matters too. A published audit of agent "done" claims found no passing test run behind 65 to 69 percent of them, depending on how strictly they were counted per a definition-of-done guide for coding agents. The guide's remedy is a checklist enforced by a hook or CI job that runs after the agent stops and records each command with its exit code, with the passing run required to happen after the agent's last edit per the same guide. Notice what that checklist asks for: exit codes. Your tool's contract and the agent's evidence trail are the same artifact.

Minimum test suite for a tool you expose to agents:

  1. One test per documented exit code, asserting the exact code and that stdout stays empty or schema-valid.
  2. A pipe test proving --json stdout parses with logging enabled.
  3. An idempotency test running create twice and asserting the documented second-run behavior.
  4. A partial-failure test on a batch with one bad item, asserting exit 78 and a per-item report.
  5. A message test asserting every error names an argument or a next command.

If you use coding agents to build these tools, the contract file doubles as instructions they can follow. A skill library lets you install that discipline into the agent rather than restating it each session; Quanta Skills is one place to start, and the same skills underpin every Quanta expert that reads your context and returns finished deliverables.

Worked example: an export command with numbers

Suppose an agent calls report export --since 2026-01-01 --format csv. Here is one concrete run and how the numbers decide the contract.

  • The job needs 4,200 rows from an API that pages at 200 rows per call, so it makes 21 upstream requests.
  • Request 17 returns HTTP 429 after 12 seconds. The tool retries twice with backoff and succeeds; the run stays on the success path and exits 0.
  • Rows 3,880 to 3,900 fail schema validation (a changed currency field). The tool does not exit 0. It writes the good rows to stdout, lists the 21 rejected row IDs in a stderr report and on stdout in --json, and exits 78.
  • The agent reads 78, sees that 99.5 percent of rows are valid, writes the good rows to disk, and retries only the rejected IDs rather than re-running 21 upstream requests.
  • Had the same run failed the first three retries on request 17, the tool would have exited 75 with upstream timeout after 3 attempts (36s). Retry later or lower --page-size. The agent backs off instead of looping.

The arithmetic is the point. A catchall exit 1 would have forced the agent to re-run the whole export, repeat 21 network calls, and possibly duplicate the 4,179 good rows on disk. One extra documented code saved the batch.

FAQ

FAQ

Should I use sysexits.h constants in a new tool?

Generally no, unless you are writing in C or C++ and your callers expect them. The categories are ambiguous enough that callers cannot reliably depend on them per practitioner discussion of sysexits, and shell-reserved values above 126 collide with commonsense meanings per the Advanced Bash Scripting Guide. Define your own closed table in a safe range and document it.

Is exit code 2 safe to use for input errors?

It collides with shell and Bash conventions, where 2 can mean misuse of a builtin per the Advanced Bash Scripting Guide appendix. Many agent-facing CLIs still use 2 for usage errors because the meaning is close enough and callers branch on it explicitly per the CLI guide. Pick one meaning, write it in --help, and never change it.

What does the agent lose if I only ever return 0 or 1?

Recovery choice. With a single failure code, the agent must parse stderr to guess whether to retry, fix an argument, or escalate, and it will sometimes get that wrong per the same guide. On long runs that guesswork compounds, because success rates fall as tasks stretch and agents start re-executing finished steps per the long-horizon survey.

How do I test that stdout stays clean?

Load it as a document. Run the command with --json, discard stderr, and parse stdout in a one-liner; if it fails, something leaked into the data channel. The OpenStatus build applies the same principle by making JSON a parallel code path and keeping --debug and progress output on stderr per their post.

How do I keep coding agents from claiming the tool works when it does not?

Require a recorded passing run after the last edit, captured by a hook or CI job that logs each command with its exit code per the definition-of-done guide. That guide reports that 65 to 69 percent of audited "done" claims had no passing test run behind them, so an exit-code record is the cheapest evidence you can demand per the same guide.

Sources

Sources

  1. Are there any standard exit status codes in Linux? - Stack Overflow
  2. Appendix E. Exit Codes With Special Meanings
  3. Sysexits, preferable exit codes for programs | Hacker News
  4. Writing CLI Tools That AI Agents Actually Want to Use
  5. Building a CLI That Works for Humans and Machines
  6. Long-Horizon Planning and Goal Decomposition in AI Agents
  7. How Do AI Agents Execute Code?
  8. How to Use a Spec-Driven Approach for Coding with AI
  9. Definition of done for AI coding agents
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