{"slug": "52-days-of-silent-zeros-the-stop-hook-payload-has-no-usage-field", "title": "52 Days of Silent Zeros: The Stop Hook Payload Has No usage Field", "summary": "A developer building an autonomous Claude Code environment discovered that the Stop hook payload lacks a usage field, causing a cost-tracking script to silently record zero token counts for 52 days and 2,340 rows. The fix required reading the transcript file referenced by the payload instead of relying on nonexistent fields.", "body_md": "I used to be a college student scraping by on ¥100k a month. Then I was laid off. Six months later, after building an autonomous Claude Code environment, I'm clearing ¥1.2M a month in revenue. What closed that gap wasn't talent or capital — it was **continuously growing an environment that thinks and works in my place**.\n\nThe first thing I noticed when trying to grow income through solo development: raising the *quality of the environment* has far better ROI than raising the *amount of work*. Spending a week building a system that runs autonomously and stacks up deliverables while I'm away beats hammering Claude Code for 8 hours a day — at least when you measure revenue three months out. This series is a record of mass-producing that kind of environment.\n\nToday's topic is **cost visibility**. When you live on Claude Code, token consumption happens as naturally as breathing. The problem is that you can't optimize a cost you can't see. \"How much did I spend this month?\" \"Which session was heavy?\" \"How many dollars a month would I save by shifting a bit more Sonnet work to Haiku?\" — without answers to these, gross margin doesn't improve even as revenue grows.\n\nClaude Code has a hook system. Shell scripts or Node.js scripts configured in `~/.claude/settings.json`\n\nrun automatically when tied to specific events. The `Stop`\n\nhook is the most important of them, and it **fires every time the assistant completes a turn**. Not just at session end — once per completed turn (see the comment on line 19 of `cost-tracker.js`\n\n: *\"Stop fires per assistant response, not per session\"*).\n\nThe natural idea here is: \"If I record token counts in the Stop hook, I get automatic cost tracking.\" The implementation looks simple. The hook receives a JSON payload on stdin. Read `usage.input_tokens`\n\nand `usage.output_tokens`\n\nfrom that payload, append to a JSONL file, done — and building it with that assumption is exactly the mistake the first version made.\n\n**The Stop hook payload has no usage field.**\n\nHere's what the payload actually looks like:\n\n```\n{\n  \"session_id\": \"...\",\n  \"transcript_path\": \"/path/to/session.jsonl\",\n  \"cwd\": \"/path/to/workdir\",\n  \"hook_event_name\": \"Stop\"\n}\n```\n\n`session_id`\n\n, `transcript_path`\n\n, `cwd`\n\n, `hook_event_name`\n\n— that's it. No model name, no token counts, no cost. Because this isn't spelled out in the docs, if you write code assuming `usage`\n\nexists, you read a nonexistent field and get `undefined`\n\n, `Number(undefined)`\n\nbecomes `NaN`\n\n, you keep adding `NaN`\n\n, and `0`\n\ngets recorded. No errors. Just silent zeros, piling up every turn.\n\nThe comment in my `cost-tracker.js`\n\npreserves the evidence verbatim (lines 12–13):\n\n```\n* The Stop payload does NOT include `usage` or `model` directly. The previous\n* version of this hook expected those fields and silently produced zero-filled\n* rows (verified: 2,340 rows captured with 0.0% non-zero token rate over 52\n* days).\n```\n\n52 days, 2,340 rows, 0.0% non-zero rate. As a tracker, a total failure. And the whole time, the script kept running without complaint, the log file grew steadily, and running `cost-summary.sh`\n\nreturned \"$0.00 / 0 sess\". **It looked like it was working while recording nothing.**\n\nThis isn't merely an implementation bug — it's an architectural mistake stemming from a misunderstanding of what Claude Code's Stop hook is. To fix it, you have to give up on a field that doesn't exist in the payload and go read the place the payload *points to* — `transcript_path`\n\n.\n\nThat switch is the core of this article.\n\nHere's the corrected architecture at a glance:\n\n```\n┌─────────────────────────────────────────────────────┐\n│  Claude Code セッション                              │\n│                                                     │\n│  アシスタントターン完了                              │\n│       │                                             │\n│       ▼                                             │\n│  Stop フック発火                                    │\n│       │                                             │\n│       ▼ stdin (JSON)                                │\n│  { session_id, transcript_path, cwd, ... }          │\n│       │                                             │\n│       ▼                                             │\n│  cost-tracker.js                                    │\n│  ┌──────────────────────────────────────────────┐   │\n│  │  1. transcript_path を取得                   │   │\n│  │  2. JSONL を読み込み                         │   │\n│  │  3. type=\"assistant\" の行だけフィルタ        │   │\n│  │  4. message.usage を積算                     │   │\n│  │  5. モデル名からレートを引いてコスト計算     │   │\n│  │  6. ~/.claude/metrics/costs.jsonl に追記     │   │\n│  └──────────────────────────────────────────────┘   │\n└─────────────────────────────────────────────────────┘\n```\n\nThe key is the shift in understanding: \"the Stop hook is not a courier delivering cost information.\" The hook is strictly an **event notifier**, and all the notification contains is an address (`transcript_path`\n\n) telling you which session transcript to read. The cost information lives inside the transcript.\n\nClaude Code writes every turn of a session into a single JSONL file. Each line corresponds to one message, and the `type`\n\nfield distinguishes the kind.\n\nWhat you need for cost calculation are the `type: \"assistant\"`\n\nlines. Their structure is as follows:\n\n```\n{\n  \"type\": \"assistant\",\n  \"message\": {\n    \"model\": \"claude-sonnet-4-6\",\n    \"usage\": {\n      \"input_tokens\": 12483,\n      \"output_tokens\": 847,\n      \"cache_creation_input_tokens\": 8192,\n      \"cache_read_input_tokens\": 3200\n    }\n  }\n}\n```\n\nSumming the four kinds — `input_tokens`\n\n, `output_tokens`\n\n, `cache_creation_input_tokens`\n\n, `cache_read_input_tokens`\n\n— across all assistant turns gives you the session's total token consumption. Multiply by the billing rates and you have cost.\n\nLines 57–90 of `cost-tracker.js`\n\nimplement this JSONL accumulation logic.\n\n``` js\nfunction sumUsageFromTranscript(transcriptPath) {\n  let content;\n  try {\n    content = fs.readFileSync(transcriptPath, 'utf8');\n  } catch {\n    return null;\n  }\n\n  let inputTokens = 0;\n  let outputTokens = 0;\n  let cacheWriteTokens = 0;\n  let cacheReadTokens = 0;\n  let model = 'unknown';\n\n  for (const line of content.split('\\n')) {\n    if (!line.trim()) continue;\n    let entry;\n    try { entry = JSON.parse(line); } catch { continue; }\n\n    if (entry.type !== 'assistant') continue;\n    const msg = entry.message;\n    if (!msg || !msg.usage) continue;\n\n    const u = msg.usage;\n    inputTokens      += toNumber(u.input_tokens);\n    outputTokens     += toNumber(u.output_tokens);\n    cacheWriteTokens += toNumber(u.cache_creation_input_tokens);\n    cacheReadTokens  += toNumber(u.cache_read_input_tokens);\n\n    if (msg.model && msg.model !== 'unknown') model = msg.model;\n  }\n\n  return { inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens, model };\n}\n```\n\nThree design decisions are worth noting.\n\n**Swallow parse errors and continue.** Even if a line partway through the JSONL is corrupted, `try { entry = JSON.parse(line); } catch { continue; }`\n\nskips it. The Stop hook must be non-blocking. Having Claude Code's session termination fail because the cost log couldn't be captured would be completely backwards.\n\n**Prevent NaN with toNumber().** The reason the old implementation kept writing\n\n`0`\n\nwas NaN propagating from trying to convert a nonexistent field into a number. In the new implementation, a `toNumber()`\n\nhelper checks with `Number.isFinite()`\n\nand returns `0`\n\nif the value isn't a finite number (lines 47–49).**Use the last model name found.** Assuming the model can switch mid-session, it keeps overwriting as long as `msg.model`\n\nisn't `'unknown'`\n\n. The model from the later part of the session becomes the representative value, but the cost error is generally within acceptable range.\n\nPer-model billing rates are hardcoded on lines 34–38.\n\n``` js\nconst RATE_TABLE = {\n  haiku:  { in: 0.80,  out: 4.0,  cacheWrite: 1.00,  cacheRead: 0.08 },\n  sonnet: { in: 3.00,  out: 15.0, cacheWrite: 3.75,  cacheRead: 0.30 },\n  opus:   { in: 15.00, out: 75.0, cacheWrite: 18.75, cacheRead: 1.50 }\n};\n```\n\nUnits are US dollars per 1M tokens. The `getRates()`\n\nfunction (lines 40–45) determines `haiku`\n\n, `opus`\n\n, or other (default `sonnet`\n\n) from the model name string.\n\nThe cost calculation is contained in lines 128–133.\n\n``` js\nconst estimatedCostUsd = Math.round((\n  (inputTokens      / 1e6) * rates.in +\n  (outputTokens     / 1e6) * rates.out +\n  (cacheWriteTokens / 1e6) * rates.cacheWrite +\n  (cacheReadTokens  / 1e6) * rates.cacheRead\n) * 1e6) / 1e6;\n```\n\nThe `/ 1e6 * 1e6`\n\nround trip is there to round away floating-point error. Rounding at the micro-dollar level keeps errors below `0.000001`\n\nout of the result.\n\nThe line ultimately appended to `~/.claude/metrics/costs.jsonl`\n\nlooks like this:\n\n```\n{\n  \"timestamp\": \"2026-08-18T08:45:22.000Z\",\n  \"session_id\": \"abc123\",\n  \"transcript_path\": \"~/.claude/transcripts/abc123.jsonl\",\n  \"model\": \"claude-sonnet-4-6\",\n  \"input_tokens\": 12483,\n  \"output_tokens\": 847,\n  \"cache_write_tokens\": 8192,\n  \"cache_read_tokens\": 3200,\n  \"estimated_cost_usd\": 0.051234\n}\n```\n\nEverything up to here is recorded automatically on each completed turn. Every time the Stop hook fires, the session's \"cumulative cost up to that point\" is appended. If you want the final cost for one session, you take the last line with the same `session_id`\n\n.\n\nIn the next part, I'll verify the specific failure patterns of the old implementation that spat out zeros for 52 days, and how the output of `cost-summary.sh`\n\nchanged before and after the fix.\n\nInput to the Stop hook arrives on stdin. Since Node.js stdin is a stream, data may arrive split across multiple chunks. `cost-tracker.js`\n\nhandles this on lines 92–98 as follows:\n\n``` js\nconst MAX_STDIN = 64 * 1024;\nlet raw = '';\n\nprocess.stdin.setEncoding('utf8');\nprocess.stdin.on('data', chunk => {\n  if (raw.length < MAX_STDIN) raw += chunk.substring(0, MAX_STDIN - raw.length);\n});\n```\n\nThe 64KB (65,536 byte) cap is a defensive design: the Stop hook payload is a 4-field JSON of a few hundred bytes in practice, but the cap prevents the process from hanging on a future Claude Code spec change or an unexpectedly large payload. The double guard of `raw.length < MAX_STDIN`\n\nand `chunk.substring(0, MAX_STDIN - raw.length)`\n\nensures the read buffer stops at the cap no matter what input arrives.\n\nIf the cap is exceeded and the JSON is cut off partway, `JSON.parse(raw)`\n\non line 100 throws. But the outer `try { ... } catch { }`\n\ncatches it and the hook terminates without incident. What matters is the structure on lines 151–156:\n\n```\n  } catch {\n    // Non-blocking — never fail the Stop hook.\n  }\n\n  // Pass stdin through (required by ECC hook convention).\n  process.stdout.write(raw);\n});\n```\n\n`process.stdout.write(raw)`\n\nis *outside* the `try`\n\n. Even if a parse failure blows away the cost record, the stdin contents always flow to stdout. This is a requirement of the ECC hook convention, and an expression of the design philosophy that **delivering input to downstream links in the hook chain takes top priority**. Losing one line of cost log versus crashing the entire Stop hook — the magnitudes of impact are literally incomparable.\n\nLines 104–106 hold the logic for obtaining `transcript_path`\n\n.\n\n``` js\nconst transcriptPath = (typeof input.transcript_path === 'string' && input.transcript_path)\n  ? input.transcript_path\n  : process.env.CLAUDE_TRANSCRIPT_PATH || null;\n```\n\nThe first candidate is the payload's `input.transcript_path`\n\n. It's adopted only after confirming it's a string type and non-empty. The `typeof`\n\ncheck is there to prevent an exception from touching `undefined`\n\nwhen the payload falls back to an empty object `{}`\n\n(e.g. stdin was empty).\n\nThe second candidate is the environment variable `CLAUDE_TRANSCRIPT_PATH`\n\n. This is for testing and debugging. It's unnecessary via the actual Stop hook, but when you want to run the script standalone by hand and check the log, you can set the environment variable manually and run it.\n\n```\nCLAUDE_TRANSCRIPT_PATH=~/.claude/transcripts/abc.jsonl \\\n  echo '{}' | node cost-tracker.js\n```\n\nThe old implementation, which tried to read directly from the payload, had none of this design — it didn't even have a variable called transcript_path.\n\nSession ID retrieval is a three-stage process on lines 108–112.\n\n``` js\nconst sessionId =\n  sanitizeSessionId(input.session_id) ||\n  sanitizeSessionId(process.env.ECC_SESSION_ID) ||\n  sanitizeSessionId(process.env.CLAUDE_SESSION_ID) ||\n  'default';\n```\n\n`sanitizeSessionId`\n\nis a utility that validates and sanitizes UUID format. Getting `session_id`\n\nfrom the payload is ideal, but in an ECC session-management environment there may be `ECC_SESSION_ID`\n\n, and in plain Claude Code there may be `CLAUDE_SESSION_ID`\n\nas environment variables. If none can be obtained, it falls through to `'default'`\n\n. Rows with `'default'`\n\neffectively serve as a signal you can check later that \"something is completely broken.\"\n\nRepeating the cost calculation from lines 128–133:\n\n``` js\nconst estimatedCostUsd = Math.round((\n  (inputTokens      / 1e6) * rates.in +\n  (outputTokens     / 1e6) * rates.out +\n  (cacheWriteTokens / 1e6) * rates.cacheWrite +\n  (cacheReadTokens  / 1e6) * rates.cacheRead\n) * 1e6) / 1e6;\n```\n\nThe round trip of `* 1e6`\n\n, then `Math.round`\n\n, then `/ 1e6`\n\nexists to erase floating-point error below the micro-dollar level. It prevents JavaScript's famous `0.1 + 0.2 === 0.30000000000000004`\n\ntrap from leaving damage in the decimals of cost aggregation. You could argue there's no real harm if $0.051234 gets recorded in the JSONL as $0.051234000000000003, but the numbers look ugly when you later aggregate with `cost-summary.sh`\n\n. It's a single line of processing, but it shows that the quality of the record is being taken seriously.\n\nThe fix wasn't finished by simply rewriting the old zero-writing implementation to read the transcript. Even after rewriting to v2, two separate problems stacked up and turned verification into a maze.\n\nThe morning after implementing and deploying `sumUsageFromTranscript`\n\n, I ran `cost-summary.sh`\n\n.\n\n```\n=== cost summary (last 7d) ===\n  sessions: 0\n  total:    $0.00\n```\n\nThinking \"zero again,\" I first looked directly at `~/.claude/metrics/costs.jsonl`\n\n.\n\n```\ntail -3 ~/.claude/metrics/costs.jsonl\n```\n\nThe file existed, with 3 rows recorded for that day. The `estimated_cost_usd`\n\nvalues were `0.048291`\n\n, `0.031457`\n\n, `0.072139`\n\n. v2 was working correctly.\n\nThe cause was what `cost-summary.sh`\n\nwas reading from. Look at line 10 of the script:\n\n```\nLOG=\"$HOME/.claude/logs/cost-log.jsonl\"\n```\n\nThat's `~/.claude/logs/cost-log.jsonl`\n\n. Meanwhile, what `cost-tracker.js`\n\nwrites to is `~/.claude/metrics/costs.jsonl`\n\n. **Different directories. logs vs metrics.**\n\nThe field names didn't match either. The aggregation logic in `cost-summary.sh`\n\n(lines 39–47) looks like this:\n\n```\ntotal += r.get(\"cost_usd\", 0)\nt = datetime.datetime.fromisoformat(r[\"ts\"])\n```\n\nIt reads `cost_usd`\n\nand `ts`\n\n. But the fields `cost-tracker.js`\n\nwrites are `estimated_cost_usd`\n\nand `timestamp`\n\n.\n\nIn other words, at the point I fixed things to v2, **two mismatches existed simultaneously: a file path mismatch and a field name mismatch**. If it were only one of them, `cost-summary.sh`\n\nwould read an empty file and return \"0 sess\". With both at once the symptom is unchanged, so I nearly misdiagnosed it as \"v2 still isn't working either.\"\n\nWhat was actually working was the v2 hook. What wasn't working was where the summary script was reading from.\n\nThe debugging lesson was \"look at the terminal record file directly with `tail -f`\n\n.\" Rather than trusting the summary script's output and concluding \"it's not working,\" check the raw JSONL with your own eyes. Without the habit of isolating which stage of the pipeline is broken one step at a time, this double mismatch would have stayed unsolved.\n\nLet me trace a bit more deeply how the v1 code kept writing 2,340 zero rows.\n\nThe old implementation looked (in pseudocode) like this:\n\n``` js\n// v1 (旧実装の想定コード)\nconst payload = JSON.parse(raw);\nconst inputTokens  = Number(payload.usage?.input_tokens);   // undefined → NaN\nconst outputTokens = Number(payload.usage?.output_tokens);  // undefined → NaN\nconst cost = (inputTokens / 1e6) * rates.in + ...;          // NaN\n```\n\n`Number(undefined)`\n\nreturns `NaN`\n\n. Every arithmetic operation using `NaN`\n\nreturns `NaN`\n\n. That much is predictable. The problem is the next line.\n\n``` js\nconst row = { estimated_cost_usd: NaN, input_tokens: NaN, ... };\nJSON.stringify(row);\n// → '{\"estimated_cost_usd\":null,\"input_tokens\":null,...}'\n```\n\n** JSON.stringify converts NaN to null.** That's the JavaScript spec. Not an error — it quietly becomes\n\n`null`\n\n. The lines written to the JSONL aren't malformed JSON; they're perfectly well-formed lines. It's just that all the values are `null`\n\n.On the `cost-summary.sh`\n\nside, the Python code (lines 40–49) is written as `r.get(\"cost_usd\", 0)`\n\n, so when the key exists but the value is `null`\n\n, it returns `None`\n\n. In Python, `total += None`\n\nthrows a `TypeError`\n\n, but `except Exception: continue`\n\non line 49 skips all exceptions, so that session's row is ignored and `total`\n\ndoesn't move.\n\nNo errors. No exceptions. Just silently skipped from aggregation, over and over. The structure was: **it wasn't that zeros were being recorded — the recorded rows were being continuously excluded from aggregation.** Writing `null`\n\nis the JSON spec; skipping `null`\n\nis Python's try-except. Each is correct behavior on its own, but combined they created a 52-day blind spot.\n\nThe new implementation's `toNumber()`\n\nhelper (lines 47–49) cuts this propagation off at the source.\n\n``` js\nfunction toNumber(v) {\n  const n = Number(v);\n  return Number.isFinite(n) ? n : 0;\n}\n```\n\n`Number.isFinite(NaN)`\n\nreturns `false`\n\n. `NaN`\n\n, `Infinity`\n\n, `null`\n\n, and `undefined`\n\nall get converted to `0`\n\nhere. The path by which `null`\n\ncould contaminate a record row is sealed at the very top of the conversion chain.\n\n`model: unknown`\n\n\" rows are handled\n`sumUsageFromTranscript`\n\nscans the assistant turns of a session JSONL and adopts the last `msg.model`\n\nfound as the representative model (line 86: `if (msg.model && msg.model !== 'unknown') model = msg.model`\n\n).\n\nBut the Stop hook fires \"on each completed assistant turn\" (comment line 19: *\"Stop fires per assistant response, not per session\"*). When the hook runs right after the session's first turn finishes, the transcript has only one assistant line, and there's no problem as long as the model name was recorded correctly on that turn.\n\nHowever, in rare cases the assistant message early in a session has no `model`\n\nfield (streaming interruption, tool-use-only turns, etc.). In that case `model`\n\nstays `'unknown'`\n\nand gets passed to `getRates('unknown')`\n\n.\n\n``` js\nfunction getRates(model) {\n  const m = String(model || '').toLowerCase();\n  if (m.includes('haiku')) return RATE_TABLE.haiku;\n  if (m.includes('opus'))  return RATE_TABLE.opus;\n  return RATE_TABLE.sonnet;   // ← fallthrough\n}\n```\n\n`'unknown'`\n\nmatches none of the conditions, so the `sonnet`\n\nrate is applied. Early-session turns from a session that was using a Haiku model get calculated at the `sonnet`\n\nrate, and the cost is recorded inflated by 3.75× (`in: 0.80`\n\nvs `in: 3.00`\n\n).\n\nI noticed this discrepancy when I was looking directly at `costs.jsonl`\n\nand saw multiple rows with `model: unknown`\n\n.\n\n```\n{\"model\":\"unknown\",\"input_tokens\":4821,\"estimated_cost_usd\":0.014463}\n```\n\nCalculating 4,821 input tokens at the sonnet rate gives about $0.0145. At the haiku rate it would be about $0.0039. Nearly a 4× difference.\n\nThe root fix would be either \"defer cost calculation for rows where the model couldn't be obtained\" or \"retroactively correct once the model becomes known in a later turn,\" but implementation complexity jumps sharply. In the current implementation, the policy is to **approximate unknown rows at the sonnet rate and judge the monthly error to be within acceptable range**. What I want from a cost tracker is precision at a granularity usable for business decisions, not matching the Anthropic console down to the yen.\n\nWhether you make that trade-off consciously is, I think, the difference in attitude toward a tool's reliability. Rather than settling for \"that's just how it is,\" it's only once you've traced \"the conditions under which this row becomes unknown,\" \"the upper bound of the cost error at that point,\" and \"the impact that has on monthly aggregation\" that the numbers become something you can use with confidence.\n\nIn the next part, I'll cover how `cost-summary.sh`\n\nstarted returning non-zero values after the fix, and the surprising consumption patterns that emerged from the daily cost graph.\n\nThe previous two parts dug into four points: \"the Stop hook payload has no usage,\" \"the NaN→null JSON.stringify trap,\" \"path/field name mismatches,\" and \"model:unknown cost overstatement.\" Here I'll cover the actual sticking points that fell outside those. Every one of them was a wall I thought was impossible.\n\n**Because the Stop hook fires per turn, dozens of rows accumulate under the same session_id.**\n\nIf there are 30 assistant turns in one session, 30 rows get written to `costs.jsonl`\n\n. It's stated clearly in comment line 19 of `cost-tracker.js`\n\n(*\"Stop fires per assistant response, not per session\"*), but if the aggregation script doesn't know this, it sums all rows and produces a value dozens of times the real cost. I also made the error in the opposite direction: I thought \"I spent $90 this month\" when it was actually $4. To get per-session cost, you must aggregate only the last row for each `session_id`\n\n(= the maximum cumulative value). The current aggregation logic in `cost-summary.sh`\n\n(lines 37–49) doesn't account for this cumulative structure and adds every row with `for line in open(log):`\n\n. Fixing the hook to v2 isn't enough — the summary script needs fixing too.\n\n**Passing a transcript_path with a literal ~ to fs.readFileSync crashes immediately.**\n\n`fs.readFileSync('~/.claude/...')`\n\ndoesn't do shell expansion. `~`\n\nis treated as a plain string and it throws because the file doesn't exist. After receiving the path on lines 104–106 of `cost-tracker.js`\n\n, if there's no processing to expand it with `os.homedir()`\n\nor `path.resolve()`\n\n, then depending on the Claude Code execution environment, `transcript_path`\n\narrives with a tilde. I actually hit this. You should run `transcriptPath.replace(/^~/, os.homedir())`\n\nbefore using `input.transcript_path`\n\n.**Writing the hook command in settings.json as just a path means node isn't found.**\n\n`#!/usr/bin/env node`\n\nshebang — there are cases where the `node`\n\nthat `/usr/bin/env`\n\nfinds points to an old system Node at `/usr/local/bin/node`\n\n(v16, etc.). The reliable approach is to specify node's absolute path explicitly in settings.json's command.\n\n```\n  {\n    \"hooks\": {\n      \"Stop\": [{\n        \"command\": \"/Users/<you>/.nvm/versions/node/v24.13.0/bin/node ~/.claude/scripts/hooks/cost-tracker.js\"\n      }]\n    }\n  }\n```\n\nAdjust the path to your own setup. When a hook \"doesn't feel like it's firing,\" this is the first thing I suspect.\n\n**Debugging with console.log breaks the hook chain.**\n\n`cost-tracker.js`\n\nline 156: `process.stdout.write(raw);`\n\n— downstream scripts in the hook chain receive their input from this stdout. Insert a single `console.log('debug:', something)`\n\nand an arbitrary string contaminates stdout, breaking the downstream hook when it tries to parse the JSON. Always write debug output to `process.stderr.write(...)`\n\nor a dedicated log file.**Not counting cache_creation_input_tokens makes costs come out low.**\n\n`cache_creation_input_tokens`\n\naccounts for a non-negligible share. Sonnet's cache write rate is $3.75/1M tokens (1.25× normal input) and the read rate is $0.30/1M tokens (0.1×) (see the RATE table on lines 34–38 of cost-tracker.js). If a session's input is 50,000 tokens with 30,000 of those being cache writes, the write portion alone is about $0.11 — 1.5× the cost of normal input. Leaving `cache_creation_input_tokens`\n\nout of aggregation makes 20–40% of your real cost invisible.**Calling process.exit() inside the Stop hook cuts stdin short and loses data.**\n\n`process.stdin.on('data', ...)`\n\ncallback and call `process.exit(1)`\n\n, the process ends before the remaining chunks arrive, and the payload gets cut off. Design hooks so all processing completes inside the `process.stdin.on('end', () => { ... })`\n\ncallback, with no early exit.**Even when transcript_path arrives, the file may not exist.**\n\n`cost-tracker.js`\n\n— `if (transcriptPath && fs.existsSync(transcriptPath))`\n\n— is for. Omit it and call `readFileSync`\n\ndirectly, and occasionally you'll throw on \"file doesn't exist\" rather than \"the path exists but can't be read.\" The outer try-catch will catch the error, but that session's cost is lost entirely.**Trying to match the Anthropic console's numbers is a swamp.**\n\nThe cost `cost-tracker.js`\n\ncalculates is strictly an estimate. Actual billing is based on the token counts Anthropic measures, plus various factors like batch discounts, promotions, and taxes. Start chasing \"why is it $2 off from the console\" and it will consume infinite time. All I want from this tracker is \"monthly trends and identification of high-cost sessions.\" I decided within ±15% of the Anthropic console is my acceptable range and stopped pursuing precision beyond that.\n\n**Verifying behavior using only the summary script's output.**\n\nI touched on this in p2, but I'll restate it as a universal anti-pattern. `cost-summary.sh`\n\nreturns correct numbers only when the read path, field names, and aggregation logic are all correct. If even one of them doesn't match, you get `$0.00 / 0 sess`\n\n. Whether the hook is working correctly can only be determined by checking `~/.claude/metrics/costs.jsonl`\n\ndirectly with `tail -5`\n\n, not by the summary output. When you think \"it's not working,\" your first command should be checking the raw file, not re-running the summary script.\n\nHere are 10+ design principles for tracking cost with the Stop hook, solidified through implementation.\n\n**① Wrap the entire hook in try-catch and make it absolutely non-blocking.**\n\n`cost-tracker.js`\n\nare the core of this design.\n\n```\n} catch {\n  // Non-blocking — never fail the Stop hook.\n}\n```\n\nLosing the cost log is far more acceptable than crashing the hook.\n\n**② Put the stdin pass-through outside the try block.**\n\n`process.stdout.write(raw)`\n\ninside the `try`\n\n, input stops reaching downstream scripts in the hook chain when parsing fails. The placement on line 156 (after the try-catch) is deliberate design. The cost log can be lost, but the hook chain stays alive — that's the priority order.**③ Kill NaN at the source with a toNumber() helper.**\n\n`Number(undefined) === NaN`\n\n, and `JSON.stringify({v: NaN})`\n\nbecomes `{\"v\":null}`\n\n. Cutting off this propagation with `toNumber()`\n\non lines 47–49 seals the path by which `null`\n\ncontaminates a record row, at the source.\n\n``` js\nfunction toNumber(v) {\n  const n = Number(v);\n  return Number.isFinite(n) ? n : 0;\n}\n```\n\n**④ Take the write path and the read path from the same constant.**\n\nThe `logs/cost-log.jsonl`\n\nvs `metrics/costs.jsonl`\n\nmismatch I wrote about in p2 was born from two scripts writing the path separately. Ideally you place a constant like `COSTS_PATH`\n\nin a shared module and import it from both the hook script and the summary script. Between two JavaScript files you can share it with `require('../lib/paths')`\n\n.\n\n**⑤ Manage field names with schema constants too.**\n\nName mismatches like `ts`\n\nvs `timestamp`\n\nand `cost_usd`\n\nvs `estimated_cost_usd`\n\nalso don't happen if the schema is defined in one place. It structurally guarantees that JSONL writing and reading reference the same keys.\n\n**⑥ Verify the terminal file directly with tail.**\n\n```\ntail -5 ~/.claude/metrics/costs.jsonl | python3 -m json.tool\n```\n\nIf `estimated_cost_usd`\n\nhas a non-zero value, the hook is working.\n\n**⑦ Prevent hangs with a stdin cap (64KB).**\n\n`cost-tracker.js`\n\nline 92: `const MAX_STDIN = 64 * 1024;`\n\n. The current Stop hook payload is a few hundred bytes, but this is a defensive design preventing the process from hanging on a future spec change or unexpectedly large input.\n\n**⑧ Check that transcript_path exists before reading.**\n\n`fs.existsSync(transcriptPath)`\n\ncheck (line 115) and a read will run during the race window when the file doesn't exist, losing that session's cost row entirely.**⑨ Always count cache_creation_input_tokens and cache_read_input_tokens.**\n\n**⑩ Use only the last row of each session_id as the per-session cost.**\n\n**⑪ Make model: unknown rows identifiable to make the location of cost error explicit.**\n\n`model`\n\nfield is `'unknown'`\n\nare approximated at the sonnet rate (the getRates function's fallthrough). When there are many such rows, the divergence from real cost widens. Displaying the count and computed cost of `unknown`\n\nrows separately during aggregation makes the scale of the error visible.**⑫ Send debug output to stderr or a log file. Don't touch stdout.**\n\nA hook's stdout is exclusively for pass-through. `console.log`\n\nis forbidden; use only `console.error`\n\nor appending to a dedicated log file.\n\n**⑬ Specify node's absolute path in the hook command.**\n\nSpecifying `/path/to/.nvm/versions/node/vX.Y.Z/bin/node`\n\ndirectly ensures the hook launches regardless of whether PATH is expanded. It's mandatory in execution environments where `nvm use`\n\ndoesn't take effect.\n\n**⑭ State your acceptable error range and stop chasing precision.**\n\nWhat you want from a cost tracker is precision sufficient to \"identify high-cost sessions and grasp monthly trends,\" not matching the Anthropic console to the yen. Deciding on an error you can accept (e.g. ±15%) saves you from spending time on pointless precision improvements.\n\n52 days, 2,340 rows, 0.0% non-zero rate — the fact that this confession still lives in the code shows how quietly, and for how long, a misunderstanding of the Stop hook can keep doing real damage.\n\nThe root cause was simple: **the assumption that \"the Stop hook payload has a usage field.\"** Trying to read a field that doesn't exist in the payload produced `NaN`\n\n, which `JSON.stringify`\n\nconverted to `null`\n\n, which was then silently skipped during aggregation. No errors, no warnings. Just zeros quietly stacking up.\n\nThe essence of the fix comes down to one point. **The cost information isn't in the payload — it's in the JSONL that transcript_path points to.** The Stop hook is \"a notifier that tells you which JSONL to read,\" not \"a courier that delivers cost information.\" That shift in understanding is the entirety of the v1-to-v2 rewrite.\n\nEven if v2 is written correctly, the output stays zero unless `cost-summary.sh`\n\n's read target and field names match. Until you look at the terminal file directly with `tail -5`\n\n, you can't tell which of the two is broken. The principle of pipeline debugging is not \"trust the summary output\" but \"work backwards from the most terminal record.\"\n\nOnce cost becomes visible as numbers, the resolution of your business decisions changes. \"How much am I spending on Sonnet per month?\" \"Which work can be replaced by shifting to Haiku?\" \"Is the overnight autonomous loop more cost-effective than a morning manual session?\" — you can judge these with numbers instead of intuition. Being able to keep optimizing the breakdown of ¥1.2M in monthly revenue is possible because there's a system making gross margin visible.\n\nI've written up the full picture of the system, the breakdown of the ¥1.2M/month, and the 30-day procedure in a paid note.\n\n📕 [Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート](https://note.com/bokuwalily/n/n849b3a07784a)\n\n*Written by **Lily** — I ship iOS apps and automate my content stack with Claude Code.\n\nFollow along: [Portfolio](https://bokuwalily.com) · [X](https://x.com/bokuwalily) · [GitHub](https://github.com/bokuwalily)*", "url": "https://wpnews.pro/news/52-days-of-silent-zeros-the-stop-hook-payload-has-no-usage-field", "canonical_source": "https://dev.to/bokuwalily/52-days-of-silent-zeros-the-stop-hook-payload-has-no-usage-field-1kg8", "published_at": "2026-08-28 05:00:06+00:00", "updated_at": "2026-08-28 05:18:30.880770+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools", "mlops"], "entities": ["Claude Code", "Anthropic"], "alternates": {"html": "https://wpnews.pro/news/52-days-of-silent-zeros-the-stop-hook-payload-has-no-usage-field", "markdown": "https://wpnews.pro/news/52-days-of-silent-zeros-the-stop-hook-payload-has-no-usage-field.md", "text": "https://wpnews.pro/news/52-days-of-silent-zeros-the-stop-hook-payload-has-no-usage-field.txt", "jsonld": "https://wpnews.pro/news/52-days-of-silent-zeros-the-stop-hook-payload-has-no-usage-field.jsonld"}}