52 Days of Silent Zeros: The Stop Hook Payload Has No usage Field 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. 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 . The 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. Today'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. Claude Code has a hook system. Shell scripts or Node.js scripts configured in ~/.claude/settings.json run automatically when tied to specific events. The Stop hook 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 : "Stop fires per assistant response, not per session" . The 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 and usage.output tokens from that payload, append to a JSONL file, done — and building it with that assumption is exactly the mistake the first version made. The Stop hook payload has no usage field. Here's what the payload actually looks like: { "session id": "...", "transcript path": "/path/to/session.jsonl", "cwd": "/path/to/workdir", "hook event name": "Stop" } session id , transcript path , cwd , hook event name — 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 exists, you read a nonexistent field and get undefined , Number undefined becomes NaN , you keep adding NaN , and 0 gets recorded. No errors. Just silent zeros, piling up every turn. The comment in my cost-tracker.js preserves the evidence verbatim lines 12–13 : The Stop payload does NOT include usage or model directly. The previous version of this hook expected those fields and silently produced zero-filled rows verified: 2,340 rows captured with 0.0% non-zero token rate over 52 days . 52 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 returned "$0.00 / 0 sess". It looked like it was working while recording nothing. This 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 . That switch is the core of this article. Here's the corrected architecture at a glance: ┌─────────────────────────────────────────────────────┐ │ Claude Code セッション │ │ │ │ アシスタントターン完了 │ │ │ │ │ ▼ │ │ Stop フック発火 │ │ │ │ │ ▼ stdin JSON │ │ { session id, transcript path, cwd, ... } │ │ │ │ │ ▼ │ │ cost-tracker.js │ │ ┌──────────────────────────────────────────────┐ │ │ │ 1. transcript path を取得 │ │ │ │ 2. JSONL を読み込み │ │ │ │ 3. type="assistant" の行だけフィルタ │ │ │ │ 4. message.usage を積算 │ │ │ │ 5. モデル名からレートを引いてコスト計算 │ │ │ │ 6. ~/.claude/metrics/costs.jsonl に追記 │ │ │ └──────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────┘ The 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 telling you which session transcript to read. The cost information lives inside the transcript. Claude Code writes every turn of a session into a single JSONL file. Each line corresponds to one message, and the type field distinguishes the kind. What you need for cost calculation are the type: "assistant" lines. Their structure is as follows: { "type": "assistant", "message": { "model": "claude-sonnet-4-6", "usage": { "input tokens": 12483, "output tokens": 847, "cache creation input tokens": 8192, "cache read input tokens": 3200 } } } Summing the four kinds — input tokens , output tokens , cache creation input tokens , cache read input tokens — across all assistant turns gives you the session's total token consumption. Multiply by the billing rates and you have cost. Lines 57–90 of cost-tracker.js implement this JSONL accumulation logic. js function sumUsageFromTranscript transcriptPath { let content; try { content = fs.readFileSync transcriptPath, 'utf8' ; } catch { return null; } let inputTokens = 0; let outputTokens = 0; let cacheWriteTokens = 0; let cacheReadTokens = 0; let model = 'unknown'; for const line of content.split '\n' { if line.trim continue; let entry; try { entry = JSON.parse line ; } catch { continue; } if entry.type == 'assistant' continue; const msg = entry.message; if msg || msg.usage continue; const u = msg.usage; inputTokens += toNumber u.input tokens ; outputTokens += toNumber u.output tokens ; cacheWriteTokens += toNumber u.cache creation input tokens ; cacheReadTokens += toNumber u.cache read input tokens ; if msg.model && msg.model == 'unknown' model = msg.model; } return { inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens, model }; } Three design decisions are worth noting. Swallow parse errors and continue. Even if a line partway through the JSONL is corrupted, try { entry = JSON.parse line ; } catch { continue; } skips 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. Prevent NaN with toNumber . The reason the old implementation kept writing 0 was NaN propagating from trying to convert a nonexistent field into a number. In the new implementation, a toNumber helper checks with Number.isFinite and returns 0 if 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 isn't 'unknown' . The model from the later part of the session becomes the representative value, but the cost error is generally within acceptable range. Per-model billing rates are hardcoded on lines 34–38. js const RATE TABLE = { haiku: { in: 0.80, out: 4.0, cacheWrite: 1.00, cacheRead: 0.08 }, sonnet: { in: 3.00, out: 15.0, cacheWrite: 3.75, cacheRead: 0.30 }, opus: { in: 15.00, out: 75.0, cacheWrite: 18.75, cacheRead: 1.50 } }; Units are US dollars per 1M tokens. The getRates function lines 40–45 determines haiku , opus , or other default sonnet from the model name string. The cost calculation is contained in lines 128–133. js const estimatedCostUsd = Math.round inputTokens / 1e6 rates.in + outputTokens / 1e6 rates.out + cacheWriteTokens / 1e6 rates.cacheWrite + cacheReadTokens / 1e6 rates.cacheRead 1e6 / 1e6; The / 1e6 1e6 round trip is there to round away floating-point error. Rounding at the micro-dollar level keeps errors below 0.000001 out of the result. The line ultimately appended to ~/.claude/metrics/costs.jsonl looks like this: { "timestamp": "2026-08-18T08:45:22.000Z", "session id": "abc123", "transcript path": "~/.claude/transcripts/abc123.jsonl", "model": "claude-sonnet-4-6", "input tokens": 12483, "output tokens": 847, "cache write tokens": 8192, "cache read tokens": 3200, "estimated cost usd": 0.051234 } Everything 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 . In 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 changed before and after the fix. Input to the Stop hook arrives on stdin. Since Node.js stdin is a stream, data may arrive split across multiple chunks. cost-tracker.js handles this on lines 92–98 as follows: js const MAX STDIN = 64 1024; let raw = ''; process.stdin.setEncoding 'utf8' ; process.stdin.on 'data', chunk = { if raw.length < MAX STDIN raw += chunk.substring 0, MAX STDIN - raw.length ; } ; The 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 and chunk.substring 0, MAX STDIN - raw.length ensures the read buffer stops at the cap no matter what input arrives. If the cap is exceeded and the JSON is cut off partway, JSON.parse raw on line 100 throws. But the outer try { ... } catch { } catches it and the hook terminates without incident. What matters is the structure on lines 151–156: } catch { // Non-blocking — never fail the Stop hook. } // Pass stdin through required by ECC hook convention . process.stdout.write raw ; } ; process.stdout.write raw is outside the try . 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. Lines 104–106 hold the logic for obtaining transcript path . js const transcriptPath = typeof input.transcript path === 'string' && input.transcript path ? input.transcript path : process.env.CLAUDE TRANSCRIPT PATH || null; The first candidate is the payload's input.transcript path . It's adopted only after confirming it's a string type and non-empty. The typeof check is there to prevent an exception from touching undefined when the payload falls back to an empty object {} e.g. stdin was empty . The second candidate is the environment variable CLAUDE TRANSCRIPT PATH . 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. CLAUDE TRANSCRIPT PATH=~/.claude/transcripts/abc.jsonl \ echo '{}' | node cost-tracker.js The 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. Session ID retrieval is a three-stage process on lines 108–112. js const sessionId = sanitizeSessionId input.session id || sanitizeSessionId process.env.ECC SESSION ID || sanitizeSessionId process.env.CLAUDE SESSION ID || 'default'; sanitizeSessionId is a utility that validates and sanitizes UUID format. Getting session id from the payload is ideal, but in an ECC session-management environment there may be ECC SESSION ID , and in plain Claude Code there may be CLAUDE SESSION ID as environment variables. If none can be obtained, it falls through to 'default' . Rows with 'default' effectively serve as a signal you can check later that "something is completely broken." Repeating the cost calculation from lines 128–133: js const estimatedCostUsd = Math.round inputTokens / 1e6 rates.in + outputTokens / 1e6 rates.out + cacheWriteTokens / 1e6 rates.cacheWrite + cacheReadTokens / 1e6 rates.cacheRead 1e6 / 1e6; The round trip of 1e6 , then Math.round , then / 1e6 exists to erase floating-point error below the micro-dollar level. It prevents JavaScript's famous 0.1 + 0.2 === 0.30000000000000004 trap 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 . It's a single line of processing, but it shows that the quality of the record is being taken seriously. The 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. The morning after implementing and deploying sumUsageFromTranscript , I ran cost-summary.sh . === cost summary last 7d === sessions: 0 total: $0.00 Thinking "zero again," I first looked directly at ~/.claude/metrics/costs.jsonl . tail -3 ~/.claude/metrics/costs.jsonl The file existed, with 3 rows recorded for that day. The estimated cost usd values were 0.048291 , 0.031457 , 0.072139 . v2 was working correctly. The cause was what cost-summary.sh was reading from. Look at line 10 of the script: LOG="$HOME/.claude/logs/cost-log.jsonl" That's ~/.claude/logs/cost-log.jsonl . Meanwhile, what cost-tracker.js writes to is ~/.claude/metrics/costs.jsonl . Different directories. logs vs metrics. The field names didn't match either. The aggregation logic in cost-summary.sh lines 39–47 looks like this: total += r.get "cost usd", 0 t = datetime.datetime.fromisoformat r "ts" It reads cost usd and ts . But the fields cost-tracker.js writes are estimated cost usd and timestamp . In 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 would 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." What was actually working was the v2 hook. What wasn't working was where the summary script was reading from. The debugging lesson was "look at the terminal record file directly with tail -f ." 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. Let me trace a bit more deeply how the v1 code kept writing 2,340 zero rows. The old implementation looked in pseudocode like this: js // v1 旧実装の想定コード const payload = JSON.parse raw ; const inputTokens = Number payload.usage?.input tokens ; // undefined → NaN const outputTokens = Number payload.usage?.output tokens ; // undefined → NaN const cost = inputTokens / 1e6 rates.in + ...; // NaN Number undefined returns NaN . Every arithmetic operation using NaN returns NaN . That much is predictable. The problem is the next line. js const row = { estimated cost usd: NaN, input tokens: NaN, ... }; JSON.stringify row ; // → '{"estimated cost usd":null,"input tokens":null,...}' JSON.stringify converts NaN to null. That's the JavaScript spec. Not an error — it quietly becomes null . 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 .On the cost-summary.sh side, the Python code lines 40–49 is written as r.get "cost usd", 0 , so when the key exists but the value is null , it returns None . In Python, total += None throws a TypeError , but except Exception: continue on line 49 skips all exceptions, so that session's row is ignored and total doesn't move. No 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 is the JSON spec; skipping null is Python's try-except. Each is correct behavior on its own, but combined they created a 52-day blind spot. The new implementation's toNumber helper lines 47–49 cuts this propagation off at the source. js function toNumber v { const n = Number v ; return Number.isFinite n ? n : 0; } Number.isFinite NaN returns false . NaN , Infinity , null , and undefined all get converted to 0 here. The path by which null could contaminate a record row is sealed at the very top of the conversion chain. model: unknown " rows are handled sumUsageFromTranscript scans the assistant turns of a session JSONL and adopts the last msg.model found as the representative model line 86: if msg.model && msg.model == 'unknown' model = msg.model . But 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. However, in rare cases the assistant message early in a session has no model field streaming interruption, tool-use-only turns, etc. . In that case model stays 'unknown' and gets passed to getRates 'unknown' . js function getRates model { const m = String model || '' .toLowerCase ; if m.includes 'haiku' return RATE TABLE.haiku; if m.includes 'opus' return RATE TABLE.opus; return RATE TABLE.sonnet; // ← fallthrough } 'unknown' matches none of the conditions, so the sonnet rate is applied. Early-session turns from a session that was using a Haiku model get calculated at the sonnet rate, and the cost is recorded inflated by 3.75× in: 0.80 vs in: 3.00 . I noticed this discrepancy when I was looking directly at costs.jsonl and saw multiple rows with model: unknown . {"model":"unknown","input tokens":4821,"estimated cost usd":0.014463} Calculating 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. The 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. Whether 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. In the next part, I'll cover how cost-summary.sh started returning non-zero values after the fix, and the surprising consumption patterns that emerged from the daily cost graph. The 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. Because the Stop hook fires per turn, dozens of rows accumulate under the same session id. If there are 30 assistant turns in one session, 30 rows get written to costs.jsonl . It's stated clearly in comment line 19 of cost-tracker.js "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 = the maximum cumulative value . The current aggregation logic in cost-summary.sh lines 37–49 doesn't account for this cumulative structure and adds every row with for line in open log : . Fixing the hook to v2 isn't enough — the summary script needs fixing too. Passing a transcript path with a literal ~ to fs.readFileSync crashes immediately. fs.readFileSync '~/.claude/...' doesn't do shell expansion. ~ is 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 , if there's no processing to expand it with os.homedir or path.resolve , then depending on the Claude Code execution environment, transcript path arrives with a tilde. I actually hit this. You should run transcriptPath.replace /^~/, os.homedir before using input.transcript path . Writing the hook command in settings.json as just a path means node isn't found. /usr/bin/env node shebang — there are cases where the node that /usr/bin/env finds points to an old system Node at /usr/local/bin/node v16, etc. . The reliable approach is to specify node's absolute path explicitly in settings.json's command. { "hooks": { "Stop": { "command": "/Users/