DEV Community

Lily
Lily

Posted on Originally published at dev.to

52 Days of Silent Zeros: The Stop Hook Payload Has No usage Field

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.

Why this setup works

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"
}
Enter fullscreen mode Exit fullscreen mode

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).
Enter fullscreen mode Exit fullscreen mode

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 totranscript_path.

That switch is the core of this article.

The overall flow

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 に追記     │   │
│  └──────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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.

The structure of the JSONL that transcript_path points to

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
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

Implementing the sumUsageFromTranscript function

Lines 57–90 of cost-tracker.js implement this JSONL accumulation logic.

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 };
}
Enter fullscreen mode Exit fullscreen mode

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.

The rate table and cost calculation

Per-model billing rates are hardcoded on lines 34–38.

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 }
};
Enter fullscreen mode Exit fullscreen mode

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.

const estimatedCostUsd = Math.round((
  (inputTokens      / 1e6) * rates.in +
  (outputTokens     / 1e6) * rates.out +
  (cacheWriteTokens / 1e6) * rates.cacheWrite +
  (cacheReadTokens  / 1e6) * rates.cacheRead
) * 1e6) / 1e6;
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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.

Implementation details

The stdin design and the 64KB cap

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:

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);
});
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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.

The three-tier fallback for transcript_path

Lines 104–106 hold the logic for obtaining transcript_path.

const transcriptPath = (typeof input.transcript_path === 'string' && input.transcript_path)
  ? input.transcript_path
  : process.env.CLAUDE_TRANSCRIPT_PATH || null;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

The sessionId resolution chain

Session ID retrieval is a three-stage process on lines 108–112.

const sessionId =
  sanitizeSessionId(input.session_id) ||
  sanitizeSessionId(process.env.ECC_SESSION_ID) ||
  sanitizeSessionId(process.env.CLAUDE_SESSION_ID) ||
  'default';
Enter fullscreen mode Exit fullscreen mode

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."

Floating-point countermeasures in cost calculation

Repeating the cost calculation from lines 128–133:

const estimatedCostUsd = Math.round((
  (inputTokens      / 1e6) * rates.in +
  (outputTokens     / 1e6) * rates.out +
  (cacheWriteTokens / 1e6) * rates.cacheWrite +
  (cacheReadTokens  / 1e6) * rates.cacheRead
) * 1e6) / 1e6;
Enter fullscreen mode Exit fullscreen mode

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.


Where I got stuck

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.

Stuck point ①: Fixed to v2, but summary still says "$0.00 / 0 sess"

The morning after implementing and deploying sumUsageFromTranscript, I ran cost-summary.sh.

=== cost summary (last 7d) ===
  sessions: 0
  total:    $0.00
Enter fullscreen mode Exit fullscreen mode

Thinking "zero again," I first looked directly at ~/.claude/metrics/costs.jsonl.

tail -3 ~/.claude/metrics/costs.jsonl
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"])
Enter fullscreen mode Exit fullscreen mode

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.

Stuck point ②: Why NaN propagation doesn't make the rows look broken

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:

// 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
Enter fullscreen mode Exit fullscreen mode

Number(undefined) returns NaN. Every arithmetic operation using NaN returns NaN. That much is predictable. The problem is the next line.

const row = { estimated_cost_usd: NaN, input_tokens: NaN, ... };
JSON.stringify(row);
// → '{"estimated_cost_usd":null,"input_tokens":null,...}'
Enter fullscreen mode Exit fullscreen mode

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.

function toNumber(v) {
  const n = Number(v);
  return Number.isFinite(n) ? n : 0;
}
Enter fullscreen mode Exit fullscreen mode

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.

Stuck point ③: Unexpected cost overstatement from how "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').

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
}
Enter fullscreen mode Exit fullscreen mode

'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}
Enter fullscreen mode Exit fullscreen mode

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.

Pitfalls

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.
    Node.js's 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.
    If you're using nvm-managed Node, the nvm bin directory may not be in PATH by the time the hook is launched through a shell. The same applies to a #!/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/<you>/.nvm/versions/node/v24.13.0/bin/node ~/.claude/scripts/hooks/cost-tracker.js"
      }]
    }
  }
Enter fullscreen mode Exit fullscreen mode

Adjust the path to your own setup. When a hook "doesn't feel like it's firing," this is the first thing I suspect.

  • Debugging with console.log breaks the hook chain.
    The Stop hook's stdout is exclusively for passing the payload through. cost-tracker.js line 156: process.stdout.write(raw); — downstream scripts in the hook chain receive their input from this stdout. Insert a single console.log('debug:', something) and an arbitrary string contaminates stdout, breaking the downstream hook when it tries to parse the JSON. Always write debug output to process.stderr.write(...) or a dedicated log file.

  • Not counting cache_creation_input_tokens makes costs come out low.
    Because Claude Code makes heavy use of prompt caching, billing for cache_creation_input_tokens accounts 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 out of aggregation makes 20–40% of your real cost invisible.

  • Calling process.exit() inside the Stop hook cuts stdin short and loses data.
    Node.js stdin is a stream. If you detect an error inside the process.stdin.on('data', ...) callback and call process.exit(1), 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', () => { ... }) callback, with no early exit.

  • Even when transcript_path arrives, the file may not exist.
    The Stop hook fires simultaneously with session completion, but there's a very short race window between Claude Code writing the transcript and the hook launching. That's what the existence check on line 115 of cost-tracker.jsif (transcriptPath && fs.existsSync(transcriptPath)) — is for. Omit it and call readFileSync directly, 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.
    The cost cost-tracker.js calculates 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.

  • Verifying behavior using only the summary script's output.
    I touched on this in p2, but I'll restate it as a universal anti-pattern. cost-summary.sh returns 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. Whether the hook is working correctly can only be determined by checking ~/.claude/metrics/costs.jsonl directly with tail -5, 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.


Best practices

Here are 10+ design principles for tracking cost with the Stop hook, solidified through implementation.

① Wrap the entire hook in try-catch and make it absolutely non-blocking.
Even if the cost log can't be captured, it must not obstruct Claude Code's session termination. Lines 151–153 of cost-tracker.js are the core of this design.

} catch {
  // Non-blocking — never fail the Stop hook.
}
Enter fullscreen mode Exit fullscreen mode

Losing the cost log is far more acceptable than crashing the hook.

② Put the stdin pass-through outside the try block.
If you put process.stdout.write(raw) inside the try, 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.
Number(undefined) === NaN, and JSON.stringify({v: NaN}) becomes {"v":null}. Cutting off this propagation with toNumber() on lines 47–49 seals the path by which null contaminates a record row, at the source.

function toNumber(v) {
  const n = Number(v);
  return Number.isFinite(n) ? n : 0;
}
Enter fullscreen mode Exit fullscreen mode

④ Take the write path and the read path from the same constant.
The logs/cost-log.jsonl vs metrics/costs.jsonl mismatch I wrote about in p2 was born from two scripts writing the path separately. Ideally you place a constant like COSTS_PATH in 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').

⑤ Manage field names with schema constants too.
Name mismatches like ts vs timestamp and cost_usd vs estimated_cost_usd also don't happen if the schema is defined in one place. It structurally guarantees that JSONL writing and reading reference the same keys.

⑥ Verify the terminal file directly with tail.
The summary script's output isn't the truth — it's "the world as the summary script sees it." The most reliable way to confirm the hook is working correctly is this one command:

tail -5 ~/.claude/metrics/costs.jsonl | python3 -m json.tool
Enter fullscreen mode Exit fullscreen mode

If estimated_cost_usd has a non-zero value, the hook is working.

⑦ Prevent hangs with a stdin cap (64KB).
cost-tracker.js line 92: const MAX_STDIN = 64 * 1024;. 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.

⑧ Check that transcript_path exists before reading.
Forget the fs.existsSync(transcriptPath) check (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.
Omit these two fields and 20–40% of your real cost becomes invisible on cache-heavy sessions. Because Claude Code automatically caches long prompts, the cache ratio grows the more you use it.

⑩ Use only the last row of each session_id as the per-session cost.
Sum all rows without knowing the cumulative design and you get a value dozens of times the real cost. Always shape the aggregation logic as "group by session_id and take the row with the latest timestamp."

⑪ Make model: unknown rows identifiable to make the location of cost error explicit.
Rows whose model field is 'unknown' are 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 rows separately during aggregation makes the scale of the error visible.

⑫ Send debug output to stderr or a log file. Don't touch stdout.
A hook's stdout is exclusively for pass-through. console.log is forbidden; use only console.error or appending to a dedicated log file.

⑬ Specify node's absolute path in the hook command.
Specifying /path/to/.nvm/versions/node/vX.Y.Z/bin/node directly ensures the hook launches regardless of whether PATH is expanded. It's mandatory in execution environments where nvm use doesn't take effect.

⑭ State your acceptable error range and stop chasing precision.
What 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.


Summary

52 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.

The 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, which JSON.stringify converted to null, which was then silently skipped during aggregation. No errors, no warnings. Just zeros quietly stacking up.

The 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.

Even if v2 is written correctly, the output stays zero unless cost-summary.sh's read target and field names match. Until you look at the terminal file directly with tail -5, 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."

Once 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.


I'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.

📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート


Written by **Lily* — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*

Top comments (0)