Exit codes for unattended agents: retry vs stay-dead

An unattended agent that just exits non-zero on failure is giving whatever runs it next — cron, systemd, a CI retry policy — nothing to decide with. The fix isn't a bigger error message; it's picking a small, consistent set of exit codes and having every script in the fleet agree on what they mean. This is the general version of that convention — see running claude -p on cron for the same ideas applied specifically to Claude Code's own headless mode, including MCP_TOOL_TIMEOUT and wake-up patterns this guide doesn't cover.

On this page

Where 75 comes from: sysexits.h

BSD's sysexits.h defines a small table of exit codes for command-line programs, meant to be more informative than a bare 0/1. Most of it predates agents entirely, but two codes map cleanly onto what an unattended run needs to say about its own failure:

CodeNameMeaning
0Success.
75EX_TEMPFAILTemporary failure — the caller should retry later.
69EX_UNAVAILABLEA required service was unavailable.
78EX_CONFIGSomething in the configuration is wrong.
1Generic failure (no standardized meaning beyond "not success").

Nothing in the OS or the shell enforces these meanings — sysexits.h is a convention, not a contract the kernel checks. That's exactly why it's worth adopting deliberately for your own scripts rather than reinventing a different scheme per project: your on-call rotation only has to learn it once.

The 75/1 convention for agent scripts

For an agent invoked non-interactively, the distinction that actually matters is narrower than the full sysexits.h table: exit 75 for "this might resolve itself, retry the next tick," and exit 1 for "this needs a human, don't retry automatically." A permission-prompt tool or approval flow is a natural producer of exactly that split — "nobody answered in time" is temporary (retry), "someone explicitly said no" is not (stop):

claude -p "Deploy to prod. If a tool permission is denied and the reason is \"timeout\", \
exit with code 75 (EX_TEMPFAIL — retry next tick). If the reason is \"operator_denied\", exit with \
code 1 (a human said no; don't retry)." \
  --permission-prompt-tool mcp__yourservice__permission_prompt

Be honest about what this buys you when the exit code is coming from a model following a prompt instruction rather than code you wrote: it depends on the model actually calling the right exit code, not on anything the runtime enforces. Test both branches deliberately — force a deny, force a timeout — before trusting the split in production, and prefer wrapping the model's own process in a script that derives the code from a structured result (JSON output, a file it wrote) wherever you can, rather than trusting an LLM's raw exit call as the only signal.

A wrapper that acts on it

The exit code is only useful if something downstream branches on it. A thin wrapper is enough:

#!/usr/bin/env bash
set -uo pipefail
./run-agent.sh "$@"
code=$?
case $code in
  0)  echo "ok" ;;
  75) echo "temp failure — will retry next tick, no page" ;;
  *)  echo "hard failure ($code) — paging on-call"
      /usr/local/bin/page-oncall.sh "agent failed with exit $code" ;;
esac
exit "$code"

Note set -uo pipefail without -e: this wrapper deliberately keeps running after run-agent.sh fails so it can inspect $? and act on it — set -e would abort the script at the first non-zero exit, before the case statement ever runs.

flock: locking against overlapping runs

A job that can block — a slow API, a permission prompt, a deferred tool call waiting on a human — can still be "running" when its next scheduled tick fires, and without a lock, the scheduler has no way to know that. flock -n against a fixed lock file makes the second invocation exit immediately instead of stacking up behind the first:

flock -n /var/run/agent-job.lock ./run-agent.sh || echo "skipped: lock held"

Two details worth getting right:

Cron hygiene checklist

Most cron incidents trace back to one of a handful of repeat offenders, not to the exit-code logic above:

A systemd timer alternative

On a host that already runs systemd, a timer unit gets you overlap protection, timeouts, and structured logging without flock or manual log rotation, at the cost of more boilerplate up front:

# /etc/systemd/system/agent-job.service
[Unit]
Description=Unattended agent job

[Service]
Type=oneshot
ExecStart=/opt/agent/run-agent.sh
TimeoutStartSec=1800
Restart=no
# /etc/systemd/system/agent-job.timer
[Unit]
Description=Run agent-job nightly

[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true

[Install]
WantedBy=timers.target

A service unit's own instance can't run twice concurrently by default — systemd refuses to start a second instance of an already-running unit — which is the overlap protection flock exists to bolt onto cron. journalctl -u agent-job.service replaces the log-file-and-rotation setup entirely, and the exit-code convention from earlier still applies unchanged: have the systemd unit's ExecStart be the same wrapper script that branches on 75 vs. everything else, and point OnFailure= at a second unit that pages on-call only for the non-75 case.