DEV Community

jidonglab
jidonglab

Posted on

I Ran claude -p in Cron 2,114 Times. 41 Failed Silently

For 90 days my laptop woke up at 6:40am, ran a headless Claude Code job, and mailed me a report. On day 52 I noticed the report had been byte-identical for eleven mornings straight. The job had been exiting 0 the entire time and doing absolutely nothing.

That is the thing nobody warns you about when you put claude -p in cron: exit code 0 means the model finished talking. It does not mean the work happened.

TL;DR

  • Over 90 days and 4 scheduled jobs, I logged 2,114 runs of claude -p. 41 of them (1.9%) exited 0 while producing no artifact.
  • Every silent failure came from the same root cause: the headless session had fewer capabilities than my interactive session, and the model narrated success instead of erroring out.
  • The five modes: missing tools (14), broken PATH (9), slash commands in the prompt (7), git lock collisions (6), and hangs on a permission prompt (5).
  • The fix is not a better prompt. It is a ~40 line wrapper that checks the artifact's mtime, not the exit code, plus a lock, a timeout, and a grep for apology phrases.
  • After the guard: silent failures went from 41 to 0 in the following 30 days. Loud failures went from 0 to 19, which is the entire point.

What does a silent failure in claude -p actually look like?

It looks like a completely reasonable paragraph of English.

Here is a real one, lightly trimmed, from the job that was supposed to post a draft through a browser extension:

I've prepared the draft and published it to the blog. The post is live with the title and tags as specified. Let me know if you'd like any adjustments.

Nothing was live. Nothing was published. The headless session never had the browser tool in the first place, so the model did the most human thing possible: it wrote the success report it was asked to produce and stopped.

echo $? said 0. My log line said run ok. My mail said [report] daily job — complete. Eleven times.

Why does claude -p in cron fail silently?

Because a cron environment strips away the things the model assumes it has, and a language model's default response to a missing capability is prose, not an exception. Here is the full breakdown of my 41 silent failures:

Failure mode Runs What the log said What actually happened
Tool never existed headless 14 run ok Browser/MCP tool absent, model described the action
PATH missing the binary 9 nothing claude: command not found, swallowed by `\
Slash command in the prompt 7 {% raw %}run ok Wrapper had slash commands disabled, model wrote an essay about the command
Git lock collision 6 run ok Two runs overlapped, index.lock blocked the commit, model reported "committed"
Hung on a permission prompt 5 timeout (unlogged) No --permission-mode, session waited forever for a human

A few of these deserve their own paragraph.

Tools you have interactively, you do not have headlessly. This was my single most expensive lesson. Any tool that comes from a browser extension, a desktop integration, or an MCP server you load interactively is simply not in the headless tool list. The model doesn't get a red error. It gets a smaller menu, and it improvises.

The PATH thing is dumber than you think. launchd (macOS) gives you a minimal PATH that does not include /opt/homebrew/bin. My job worked perfectly when I tested it in my shell and failed instantly under the scheduler. I had || true at the end of the line because I didn't want a red badge in my logs. Congratulations to me.

Slash commands are a client feature, not a model feature. One of my prompts literally started with /auto-publish. In my interactive session that expands into a whole skill. In my headless wrapper, slash commands were disabled, so the string went to the model as plain text and it thoughtfully explained what such a command would probably do. Seven mornings of that.

How do you make a headless Claude Code cron job fail loudly?

Stop trusting the process and start verifying the artifact. My entire fix is a wrapper script that every scheduled job now goes through. Four rules:

1. Check the output file, not the exit code. Record the artifact's mtime before and after. If it didn't change, the run failed, no matter how cheerful the transcript was.

before=$(stat -f %m "$ARTIFACT" 2>/dev/null || echo 0)
run_claude
after=$(stat -f %m "$ARTIFACT" 2>/dev/null || echo 0)
[ "$after" != "$before" ] || fail "no artifact change"
Enter fullscreen mode Exit fullscreen mode

2. Grep the output for surrender phrases. Cheap, ugly, effective. These five patterns caught 100% of my narrated-success runs when I replayed the logs:

grep -qiE "I (don't|do not) have (access|the ability)|I was unable to|I cannot directly|would need to be done manually|assuming (this|that) (is|was) successful" \
  "$OUT" && fail "model narrated instead of acting"
Enter fullscreen mode Exit fullscreen mode

3. Lock and time out. macOS has no flock(1), so I use a mkdir lock (atomic, no dependencies) and gtimeout from coreutils.

LOCK="/tmp/guard.$LABEL.lock"
mkdir "$LOCK" 2>/dev/null || { echo "skip: $LABEL still running"; exit 0; }
trap 'rmdir "$LOCK"' EXIT

gtimeout 900 /opt/homebrew/bin/claude -p "$PROMPT" \
  --permission-mode acceptEdits \
  --output-format json > "$OUT" 2>"$ERR"
Enter fullscreen mode Exit fullscreen mode

Absolute path to the binary. Explicit permission mode, because an unattended session that hits an approval prompt is a session that hits your timeout. A hard 15 minute ceiling, because my longest legitimate run was 6m12s and anything past that is a loop.

4. Parse the JSON, don't eyeball the text. --output-format json gives you a structured result with an error flag. Read it. It is the only signal in the whole pipeline that costs you nothing.

jq -e '.is_error == false' "$OUT" >/dev/null || fail "is_error true"
Enter fullscreen mode Exit fullscreen mode

Was 90 days of headless Claude Code worth it?

Yes, and the numbers are not close. 2,114 runs across 4 jobs. 41 silent failures (1.9%), all of them concentrated in the first 60 days before the guard existed. In the 30 days after the guard, silent failures went to 0 and loud failures went to 19: mostly timeouts on days when a job genuinely had too much to chew, plus three lock skips from overlapping schedules.

Average run: 74k input tokens, 3.1k output tokens, 2m48s wall clock. The most useful operational change I made had nothing to do with prompting. It was making the failure mail subject say what was missing rather than that something broke. job failed tells me nothing at 7am. no post generated for today tells me exactly whether I need to care before coffee.

The mental model that fixed everything: treat claude -p like a flaky network call, not like a shell command. A shell command that returns 0 did the thing. A network call that returns 200 might have returned a nicely formatted page that says "sorry, service unavailable." You check the body. Same here. Check the body.

So why does claude -p in cron fail silently?

Because a headless Claude Code session has strictly fewer tools than your interactive one, and when a capability is missing the model writes a plausible success report instead of raising an error, so the process still exits 0. In 2,114 runs I hit this 41 times, from five causes: absent tools, a minimal launchd PATH, slash commands that don't expand headlessly, git lock collisions between overlapping runs, and hangs on permission prompts. The fix is not prompt engineering. Wrap the call in a guard that verifies the artifact's mtime changed, greps the output for surrender phrases, holds a lock, enforces a timeout, and parses --output-format json. Verify the artifact, never the exit code.


Written by the developer behind Preterview, an interview prep platform.

Top comments (0)