2026-10-24
Heartbeat and cost alerts for Claude Code and other unattended agent runs
Ping at the end of a Claude Code run with tokens and cost_usd. A missing ping means the run never finished. The same numbers are what a spend alert watches.
An unattended agent has the same two failures as a cron job, plus a bill. The process can fail to start, because the runner was asleep, the workflow was disabled, or the API key was missing, and nothing writes an error where you are looking. Or it can start, succeed, and spend far more than the last run. A log line from the previous success does not distinguish those. A ping at the end does, if you send it only when the run finished and you include the usage from that run.
Ping from the wrapper, not from a guess inside the model
Claude Code's non-interactive mode is claude -p. It exits 0 when the run succeeds and non-zero when it fails. With --output-format json, the payload includes total_cost_usd, a per-model breakdown, and usage metadata. The docs are explicit that total_cost_usd is a client-side estimate and can differ from the bill, and that --continue or --resume reports the conversation's whole total, earlier runs included. Use the figure as a signal. Do not invoice anyone from it, and do not treat it as Anthropic's usage page. That page is the bill.
The usage object follows Anthropic's fields. input_tokens is the uncached input on that result. Cache reads and cache writes are separate. If you add only input_tokens and output_tokens, a run that was mostly a cache read looks cheap. Sum the four fields you actually have. If a CLI build omits one, treat the missing field as zero so the script still posts a number.
--bare is the mode the docs recommend for scripts. It skips hooks, skills, plugins, MCP servers, and CLAUDE.md, so a teammate's local hook cannot change what the cron run does. Bare mode does not use subscription login. Set ANTHROPIC_API_KEY, or pass an apiKeyHelper. Without --bare, claude -p loads the project the way an interactive session would, which is what you want only when that project configuration is part of the job.
#!/bin/sh
# Usage: agent-run.sh "your prompt"
set -eu
out=$(mktemp)
trap 'rm -f "$out"' EXIT
if claude --bare -p "$1" --output-format json >"$out"; then
body=$(jq -c '{
tokens: ([.usage.input_tokens // 0, .usage.output_tokens // 0, .usage.cache_creation_input_tokens // 0, .usage.cache_read_input_tokens // 0] | add),
cost_usd: (.total_cost_usd // 0),
duration_ms: (.duration_ms // 0)
}' "$out")
curl -fsS -m 10 -X POST "$NIGHTSHIFT_PING_URL" \
-H "content-type: application/json" \
-d "$body"
else
curl -fsS -m 10 -X POST "$NIGHTSHIFT_PING_URL/fail" || true
exit 1
fiduration_ms is on current JSON results. The // 0 keeps the body valid if a build omits it. The ping API stores tokens, cost_usd, and duration_ms, all optional. A non-zero exit from claude skips the success ping and POSTs /fail. If curl cannot reach the monitor, the missing success ping is the backup.
Why a Stop hook is a weak place to read the bill
Claude Code hooks can run a command when a turn ends. Stop fires when Claude finishes responding. It does not fire on a user interrupt. An API error fires StopFailure instead. Stop also fires on ordinary turns, not only when a long task is done, so a Stop hook on an interactive session pings once per turn. That is a noisy heartbeat if the monitor expects one run a day.
The Stop payload includes session_id, transcript_path, and last_assistant_message. It does not include usage or total_cost_usd. The transcript can lag the turn that just finished, and some billed background calls never appear in it. Scraping the file under-counts. The wrapper already has the JSON result. Use that.
A Stop hook is still a reasonable 'session ended' ping when you do not control the wrapper, for example a developer running Claude Code at a desk. Keep it boring: POST the URL, and POST /fail if curl fails. Do not block the turn. Exit 0. A Stop hook that exits 2 tells Claude to keep working, and after several of those Claude Code stops honoring the block. You do not want the heartbeat to become another agent loop.
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"timeout": 15,
"command": "curl -fsS -m 10 -X POST \"$NIGHTSHIFT_PING_URL\" >/dev/null || curl -fsS -m 10 -X POST \"$NIGHTSHIFT_PING_URL/fail\" >/dev/null || true"
}
]
}
]
}
}SessionEnd hooks share a short time budget, 1.5 seconds unless you raise the hook timeout, and even then the cap is 60 seconds. A network call at the end of a cloud session is the wrong thing to squeeze into that window. The cron wrapper is outside that budget.
What the two alerts mean
Create the monitor with the cadence of the runner. A nightly agent gets a daily cron, in UTC if you can, plus grace for a long run. The schedules page is how the deadline is computed. If the machine never launches claude, the wrapper never posts, the deadline passes, and you get a down alert. That is the missing run. /start is optional and does not move the deadline. If you only ping /start, a hung agent looks alive until the original deadline, which is the opposite of what you wanted from a start hook.
The spend alert exists only when the ping carries numbers. Free workspaces store tokens and cost and draw the chart. They do not send runaway alerts. Pro and Team can set a hard max on runs per hour, tokens per hour, and dollars per rolling 24 hours, plus a baseline multiplier that defaults to 3×. The runaway limits page is the exact rule: a hard limit fires when the window is over the number you set, and a baseline needs three samples, a median above zero, and a window above both the multiplier and a floor (10 runs, 10,000 tokens, or $1). A new monitor will not baseline-alert on its first expensive day. If you already know a bad number, set the hard dollar cap.
Because cost_usd is the CLI's estimate, the alert tracks the estimate. If the installed Claude Code prices a model wrong, the monitor is wrong by the same amount. That is still useful for a loop that goes from a few cents to many dollars. It is not a reconciliation against the invoice. The same cooldown rules apply after an alert: an hour for runs and tokens, a day for cost, so a stuck loop does not email you every minute.
Claude Code also has --max-budget-usd, which stops the run's own spend. That cap is inside the process. It does not tell you the process never started, and it does not tell you tomorrow's run cost more than today's. Leave it on if you want a local ceiling. Keep the ping either way. If the process exits non-zero because it hit the cap, the wrapper posts /fail.
The same POST for any other runner
Nothing in the monitor is Claude-specific. The body is three numbers. A Python agent, a Node loop, a Codex CLI run, or a CI job that shells out to a model can post the same shape at the end, using whatever token count and dollar figure that runner reports. Omit cost_usd if the runner does not know it. You still get the silence alert. You do not get a spend alert, because there is no spend in the ping. Do not invent a cost to fill the field.
curl -fsS -m 10 -X POST "$NIGHTSHIFT_PING_URL" \
-H "content-type: application/json" \
-d "{\"tokens\":${TOKENS:-0},\"cost_usd\":${COST_USD:-0}}"Send those variables only when they are real. A default of zero will not trip a dollar cap, and it will reset the deadline. If zero would be a lie, because the run spent money and you failed to read it, fail the script instead of posting a zero.
One monitor per schedule. A nightly reviewer and an hourly inbox triage are two promises, and one URL cannot describe both. The free plan covers 3 monitors and email for silence and recovery. Pro, at $12 a month, is where Slack, Discord, Telegram, and the spend limits live. Team, at $39, raises the cap to 100 monitors. The docs have copy-paste snippets for the curl, the GitHub Actions step, and the Claude hook if you want them next to the monitor URL.