Running claude -p on cron: a production checklist

Headless Claude Code from crontab looks trivial until the first overlapping run, the first silent timeout, or the first 2am page with no log to explain it. This is what to fix before that happens.

On this page

Locking with flock

A cron job that can block — waiting on a permission prompt, a slow API, a long tool call — can still be "running" when its next scheduled tick fires. Without a lock, cron doesn't know that and starts a second instance on top of the first: two agents now sharing a working directory, possibly racing on the same git branch or the same deploy. Wrap every cron'd invocation in flock -n against a fixed lock file:

# crontab -e
0 3 * * * flock -n /tmp/nightly-deploy.lock \
  claude -p "Deploy release to prod" --mcp-config /etc/agent/mcp.json \
  >> /var/log/agent/nightly-deploy.log 2>&1

-n (non-blocking) makes the second invocation exit immediately instead of queuing up behind the first — for a cron job you almost always want "skip this tick" over "stack up and run twice as long once the first one finally finishes." If you want the skip itself logged instead of silently discarded, wrap it:

flock -n /tmp/nightly-deploy.lock -c '
  claude -p "Deploy release to prod" --mcp-config /etc/agent/mcp.json
' >> /var/log/agent/nightly-deploy.log 2>&1 || echo "$(date -u): skipped, lock held" >> /var/log/agent/nightly-deploy.log

Put the lock file somewhere that survives a reboot only if you want a stale lock from a killed process to persist across it — usually you don't, so /tmp or /var/run is the right place, not a path under version control or a network share.

MCP_TOOL_TIMEOUT and other timeouts

MCP_TOOL_TIMEOUT (milliseconds) is the CLI's own per-tool-call timeout — it governs how long Claude Code will wait for any single MCP tool call to return, including one that's deliberately long-lived, like a --permission-prompt-tool adapter holding a call open while it waits for a human. If that adapter's own wait is, say, 9 minutes (540 seconds), MCP_TOOL_TIMEOUT has to be set higher than that in milliseconds or the CLI gives up on the tool call before the tool itself gets a chance to return its answer — surfacing as a generic client-side failure instead of whatever clean deny/timeout response the tool was about to send:

export MCP_TOOL_TIMEOUT=600000   # 600s — must exceed your permission-prompt tool's own wait

Separately, MCP_TIMEOUT (also milliseconds, default 30s) caps how long Claude Code will wait for an MCP server to finish its connection handshake at session start — relevant if --permission-prompt-tool or any other MCP server you configure is slow to come up (cold-starting a container, a slow DNS lookup) on the very first turn.

Add your own outer bound on top of both: cron itself doesn't time out a stuck job, so a hung process (not timing out cleanly, just wedged) will sit there until the next reboot unless something else kills it. timeout 1800 flock -n ... gives you a hard ceiling independent of whatever the CLI's internal timeouts do or don't catch.

Exit codes, including the 75-retry convention

claude -p exits 0 on a normal completion and non-zero on most failures, but "non-zero" alone doesn't tell a cron wrapper whether to retry. A useful convention, borrowed from BSD's sysexits.h, is exit 75 (EX_TEMPFAIL) for "this failed for a reason that might resolve itself — retry the next tick" versus a plain exit 1 for "this failed for a reason that won't change until a human intervenes — don't bother retrying automatically." A permission-prompt tool that distinguishes "no one answered in time" from "someone explicitly said no" is a natural producer of exactly that split — put the convention directly in the prompt and let the model act on it:

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

Be honest with yourself about what this buys you: it depends on the model actually following the instruction and calling the right exit code, not on anything the CLI enforces for you. Test it by forcing both branches (deny once, let it time out once) before trusting it in production. A wrapper script can then branch on $?:

claude -p "..." --permission-prompt-tool mcp__yourservice__permission_prompt
code=$?
case $code in
  0)  echo "ok" ;;
  75) echo "temp failure, will retry next tick" ;;
  *)  echo "hard failure ($code), paging on-call" && /usr/local/bin/page-oncall.sh ;;
esac

Logging

Cron's default behavior — mail the job's stdout/stderr to the crontab owner, if mail is even configured — is not a logging strategy. At minimum, redirect both streams to a file per run, and prefer --output-format json or stream-json over the default text output if anything downstream needs to parse what happened rather than just archive it:

claude -p "..." --output-format json --max-turns 20 \
  >> "/var/log/agent/$(date -u +%F).jsonl" 2>&1

--include-hook-events is worth adding once you have hooks in play — it puts hook firings into the same output stream instead of leaving them as a gap between "tool call requested" and "tool call ran" in your logs. Rotate the log files (a plain logrotate config is fine); a nightly agent that runs for a year without rotation is a surprisingly effective way to fill a disk.

Secrets

Crontab entries and the scripts they call are readable by anyone with the same or higher privilege on the box, and ps can expose command-line arguments to other local users while a process is running. Put secrets in environment variables sourced from a file with tight permissions (chmod 600), not as literal -d flags or inline in the crontab line itself:

# /etc/agent/secrets.env — chmod 600, owned by the cron user
export PENDNT_API_KEY=aio_...
export ANTHROPIC_API_KEY=sk-ant-...
# crontab
0 3 * * * . /etc/agent/secrets.env && flock -n /tmp/job.lock claude -p "..." >> /var/log/agent/job.log 2>&1

If the box has a real secrets manager available (Vault, AWS Secrets Manager, a CI provider's own secret store), pull from it at job start instead of leaving even a permissioned file with plaintext keys sitting on disk indefinitely.

Wake-up patterns

Cron is good at "run this on a schedule," not at "check back on this specific thing in six hours" — a job that finishes and exits has no way to reschedule itself for an arbitrary future time without either a second, differently-timed crontab entry or some external scheduler holding that state. Two patterns cover most of this:

Either way, keep the actual decision logic ("is it time yet, did the thing I'm waiting for happen yet") out of the crontab expression itself — crontab's syntax was never meant to express "whichever comes first, six hours from now or when this webhook fires," and trying to encode that in cron timing alone just pushes the real logic into a harder-to-read schedule string instead of a few lines of code.