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:

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" } ] } ]
  }
}

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


Try it: free for 3 agents · Docs: Claude Code · cron