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:
- A tighter polling tick. Run every 5–15 minutes, and have the job itself decide almost every run is a no-op ("nothing to do yet, exit 0 immediately") — simple, but means every tick pays the process-startup cost even when there's nothing to check.
- An external wake-up primitive. Something durable outside the cron job's own
process stores "check back at time T (or when event E happens)" and the job's real trigger becomes
"poll that thing," not the crontab schedule itself. A scheduled-wakeup API (pendnt's
schedule_wakeup/wait_for_eventis one implementation of this) or a message queue with delayed delivery both work; the schedule still needs a cron tick to actually poll it, but that tick can be cheap and frequent since the real "did anything happen" check lives elsewhere.
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.