cd /news/developer-tools/52-days-of-silent-zeros-the-stop-hooโ€ฆ ยท home โ€บ topics โ€บ developer-tools โ€บ article
[ARTICLE ยท art-113852] src=dev.to โ†— pub= topic=developer-tools verified=true sentiment=ยท neutral

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.

read24 min views2 publishedAug 28, 2026

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.

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.

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.

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:

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

.

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.

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:

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:

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

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.

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')

.

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/<you>/.nvm/versions/node/v24.13.0/bin/node ~/.claude/scripts/hooks/cost-tracker.js"
      }]
    }
  }

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.

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.

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.

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.

cost-tracker.js

โ€” if (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.

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.

cost-tracker.js

are the core of this design.

} catch {
  // Non-blocking โ€” never fail the Stop hook.
}

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

โ‘ก Put the stdin pass-through outside the try block.

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;
}

โ‘ฃ 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.

tail -5 ~/.claude/metrics/costs.jsonl | python3 -m json.tool

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.

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.

โ‘ฉ Use only the last row of each session_id as the per-session cost.

โ‘ช Make model: unknown rows identifiable to make the location of cost error explicit.

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.

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*

โ”€โ”€ more in #developer-tools 4 stories ยท sorted by recency
โ”€โ”€ more on @claude code 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain โ€” perfect for shipping the agent you just read about.

$git push zahid main
โ†’ Live at https://your-agent.zahid.host โœ“
Get free account โ†’ Pricing
from โ‚ฌ0/mo ยท no card required
LIVE [news/52-days-of-silent-zeโ€ฆ] indexed:0 read:24min 2026-08-28 ยท โ€”