Field notes · 2026-08-25 · RunVouch
Monitoring a headless claude -p job: hooks, exit codes, cost and alerts
A full tutorial for monitoring headless claude -p cron jobs with SessionStart, PostToolUse and Stop hooks: transcript cost, evidence checks and alerts.
For two years I have run things unattended: nightly research jobs, a crypto trading bot, and a growing pile of claude -p commands fired by cron. The failures that cost me money were never loud crashes. They were jobs that exited 0, quietly cost ten times the usual amount, or produced nothing at all while cron reported green. This is a full tutorial for monitoring a headless Claude Code job: when it started, what tools it called, what it cost, and whether it left evidence behind.
I use RunVouch for this, so a definition up front: RunVouch is a dead man's switch, cost cap and outcome check for unattended AI agents — it alerts within minutes when a scheduled run is missing, failed, looping, over budget, or green without proof. Free for 3 agents, MIT-licensed if you want to self-host, EU-hosted if you don't.
What cron and exit codes actually tell you about claude -p
The headless mode docs are clear about the contract: claude -p exits 0 on success and non-zero when the run fails, and with --output-format json the payload includes total_cost_usd so scripts can track spend per invocation. That is genuinely useful, and you should use both. But it leaves three gaps that bit me in production:
- Nobody is watching for absence. If the machine reboots, the API key expires, or someone comments out the crontab line, there is no exit code. Silence looks identical to success.
- Exit 0 is not an outcome. Claude can finish politely — "I was unable to access the site, here's a summary of what I would have done" — and exit 0. The report file is empty. Cron is green.
- Cost is per run, but damage is cumulative. A retry loop that costs $0.40 per invocation looks fine in any single JSON payload and is a problem by morning.
Step 1: register the agent and wrap the cron line
Tell the watchdog what "normal" looks like, then wrap the command:
rv agent nightly-report --cadence 24h --cap-run-cost 2 --evidence # crontab 15 2 * * * rv run nightly-report --evidence-file out/report.html -- \ claude -p "Build tonight's report into out/report.html" --allowedTools "Read,Write,Bash"
This alone covers the basics. No run inside the 24h cadence fires a MISSED alert — the dead man's switch. A non-zero exit fires FAILED. And --evidence-file checks that out/report.html is non-empty and was modified during this run; a polite exit-0 failure with a stale or missing file fires NO_EVIDENCE. That last one catches the failure mode exit codes structurally cannot.
Monitoring Claude Code with SessionStart, PostToolUse and Stop hooks
The wrapper sees the process from outside. Hooks see inside the session. Per the Claude Code hooks docs, every hook receives JSON on stdin with session_id, transcript_path, and for tool events tool_name and tool_input. Three events are enough for monitoring:
{
"hooks": {
"SessionStart": [ { "hooks": [ { "type": "command", "command": "rv-hook.sh start" } ] } ],
"PostToolUse": [ { "matcher": "", "hooks": [ { "type": "command", "command": "rv-hook.sh tool" } ] } ],
"Stop": [ { "hooks": [ { "type": "command", "command": "rv-hook.sh end" } ] } ]
}
}- SessionStart fires when the session begins — open a run record.
- PostToolUse fires after each successful tool call — forward
tool_nameandtool_input. This stream is what makes loop detection possible: RunVouch fires RETRY_STORM when the same tool is called with identical input 8 or more times, and STALLED when a run goes quiet without finishing. - Stop fires when Claude finishes responding — close the run, check evidence, and compute cost from the transcript.
You can write these scripts yourself, or install the plugin that is exactly this: /plugin marketplace add runvouch/claude-plugin, then /plugin install runvouch. It activates only when RUNVOUCH_KEY and RUNVOUCH_AGENT are set in the environment of the headless run — your interactive sessions are a no-op. The same wiring is available as an MCP server (official registry: com.runvouch/runvouch) and as Python and Node clients if hooks don't fit your setup.
Reading tokens and cost from the transcript
The Stop hook's transcript_path points at the session's JSONL transcript. Each assistant line carries a usage block. The summing has two traps I learned the hard way: streamed messages appear multiple times under the same message id, so deduplicate by id before summing, and cache tokens (cache_creation_input_tokens, cache_read_input_tokens) are priced differently from plain input tokens, so price all four buckets per model. Treat the result as an estimate — the docs say the same about total_cost_usd: it is a client-side estimate that can differ from your bill. For a kill-switch threshold, an estimate is plenty.
With cost per run flowing in, the caps do the watching: --cap-run-cost 2 fires BUDGET_RUN when a single run blows past $2, a daily cap fires BUDGET_DAY, and DRIFT flags a run whose cost or duration deviates from the median absolute deviation of the last 7 runs — the "nothing failed, but tonight cost 6x Tuesday" signal that no threshold you set in advance will catch.
Evidence via an environment variable
In hook mode there is no wrapper to pass --evidence-file to, so the check moves into the environment of the cron line or Routine:
export RUNVOUCH_KEY=rv_... export RUNVOUCH_AGENT=nightly-report export RUNVOUCH_EVIDENCE=out/report.html # or https://... (expects 200), or cmd:test -s out/report.html claude -p "Build tonight's report into out/report.html"
At Stop, the hook verifies the file (non-empty, modified since session start), the URL, or the shell assertion, and attaches the result to the run. Green plus failed evidence equals NO_EVIDENCE, delivered to Telegram, Slack or a webhook.
Fail open, alert, pause — never kill
A watchdog that can take down the thing it watches is worse than no watchdog. So everything here fails open: the hook script swallows every error and exits 0 unconditionally, API calls time out after 5 seconds, and if RunVouch is unreachable your job runs exactly as it would have without it. On the response side the same restraint applies: RunVouch alerts, and it can pause an agent via webhook so the next scheduled runs are skipped — but it never kills a running process. My trading bot taught me that a monitor which SIGKILLs mid-write creates the incidents it was meant to prevent. Paid tiers are $9 Solo and $29 Team; the free tier's 3 agents cover most cron setups.
FAQ
Does this work with Claude Code Routines, not just cron? Yes. Hooks fire in any headless session, so it is the same setup: set RUNVOUCH_KEY, RUNVOUCH_AGENT and optionally RUNVOUCH_EVIDENCE in the Routine's environment. The cadence detector doesn't care what scheduler you use — it only cares that a run arrives on time.
Will my job fail if RunVouch is down? No. The CLI wrapper and the hook script both fail open: short timeouts, errors swallowed, exit 0. The worst case is a run that goes unrecorded, which the MISSED detector then surfaces — the failure mode is a false alert, not a broken job.
Can RunVouch stop a runaway claude -p job? It alerts (Telegram, Slack, webhook) and can pause the agent via webhook so nothing further is scheduled. It deliberately never kills a running process — deciding whether to interrupt a half-finished run stays with you.
Related field notes
- My Claude Code cron ran up $1,800 in two nights. The watchdog that stops it at $2
- Claude Code Routine failed silently? How to know your scheduled agent actually ran
- The dead man's switch for AI agents (and why a ping isn't enough)
Try it: free for 3 agents · Docs: Claude Code · cron