Build your own Claude Code usage tracker

The data is sitting on your disk in an undocumented but perfectly readable format. Parsing it is an afternoon. Getting the numbers right is the part worth reading about, because there are five specific ways to be wrong and every one of them produces a plausible-looking answer.

By the Continuum team. We build a workbench that runs Claude Code, Codex, and their peers, so the model rates quoted here are the ones our own cost analytics ship with.

The short version

Claude Code writes one JSONL file per session under ~/.claude/projects/. Each assistant message carries a usage object with four token categories. Building a correct tracker means deduplicating on message ID and request ID, pricing at the event timestamp rather than today's rate, picking the right cache-write tier, resolving git worktrees to a canonical repo, and never caching the file that is still being appended to.

What you need to know
  • One JSONL file per session, under an encoded project path, swept after 30 days.
  • Usage lives on assistant messages: input, output, and two cache categories.
  • Deduplicate on message.id plus requestId or you will overcount.
  • Price at the event timestamp. Sonnet 5 reprices on 1 September 2026, which will rewrite your history if you do not.
  • Cache writes have two price tiers, and the file does not tell you which one applied.

Where the files are

~/.claude/projects/
  -Users-you-code-my-project/
    3f2a....jsonl                 one file per session
    3f2a.../subagents/            subagent transcripts
    3f2a.../tool-results/         large tool output spilled to disk

The directory name is the working directory with every non-alphanumeric character replaced by a dash. Anthropic's own documentation shows the shape: a project at /home/user/work/my-repo files under -home-user-work-my-repo.

The record you care about

Each line is a JSON object. Assistant messages carry the usage data:

Trimmed to the fields a tracker needs.
{
  "type": "assistant",
  "timestamp": "2026-08-03T14:22:31.402Z",
  "requestId": "req_01ABC...",
  "sessionId": "3f2a...",
  "cwd": "/Users/you/code/my-project",
  "message": {
    "id": "msg_01XYZ...",
    "model": "claude-sonnet-5",
    "usage": {
      "input_tokens": 4211,
      "output_tokens": 812,
      "cache_creation_input_tokens": 18004,
      "cache_read_input_tokens": 121993
    }
  }
}

The four categories and their published rates for Claude Opus 5, August 2026.

FieldMeaningRate per million
input_tokensNew input after the last cache breakpoint$5
cache_creation_input_tokensWritten to the prompt cache$6.25 at 5 minutes, $10 at 1 hour
cache_read_input_tokensRe-read from the cache$0.50
output_tokensGenerated, thinking included$25

The five traps

01

Duplicates across files

Resuming a session can rewrite earlier messages into a new file, and retries can record twice. Build a key from message.id and requestId and skip anything you have already counted. This is exactly what ccusage does, which is why its numbers are the ones people trust.

const key = `${rec.message?.id}:${rec.requestId}`;
if (seen.has(key)) continue;
seen.add(key);
02

Pricing at the wrong time

Providers reprice, and one is repricing right now: Anthropic lists Claude Sonnet 5 at $2 and $10 per million input and output tokens through 31 August 2026, and $3 and $15 from 1 September 2026. If you multiply historical tokens by today's rate, every past month silently changes value on that date and your trend line becomes fiction. Resolve the rate from the event timestamp and the model name, and keep rate windows rather than single values.

03

Cache writes have two tiers

A 5-minute cache write costs 1.25x the base input rate and a 1-hour cache write costs 2x. The transcript records how many cache-write tokens there were, not which lifetime applied. Anthropic documents the cache lifetime as one hour on a subscription and five minutes on an API key or a cloud provider, so infer the tier from how the session was authenticated rather than assuming the cheaper one.

04

The newest file is still being written

Caching parsed results by file mtime is the obvious optimisation and it is wrong for the active session, which is being appended to while you read it. Always re-parse the newest file per provider and cache only the closed ones.

05

Worktrees fragment your repos

A git worktree has a different cwd, so per-directory grouping reports one project as several. Walk up for .git; if it is a file rather than a directory, read the gitdir: pointer and resolve to the main worktree. Paths that are not in a repository at all should collapse into one bucket rather than producing a row per directory.

A five stage parsing pipeline from JSONL transcripts to cost by day, with the wrong answer each stage prevents shown beneath it~/.claude/projects/*.jsonlcost by day, model, repo1DEDUPmessage.id +requestIdcounted twice2EVENT TIMEthe rate thatapplied thenlast month moves3CACHE TIER5 minute or1 hour writeoff by 1.6x4FRESHNESSreparse thenewest filetoday is missing5REPOworktree tothe main repoone repo, 5 rowsSkip any one and the number still looks plausible. That is what makes it expensive.

A minimal correct implementation

Enough to be right, short enough to read.
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';

// Rates per token, with windows so history keeps the price it was charged at.
// From the Anthropic pricing page, read August 2026.
const RATES = {
  'claude-opus-5': [
    { from: '2000-01-01', in: 5e-6, cw5: 6.25e-6, cw60: 10e-6, cr: 0.5e-6, out: 25e-6 },
  ],
  'claude-sonnet-5': [
    { from: '2000-01-01', in: 2e-6, cw5: 2.50e-6, cw60: 4e-6, cr: 0.2e-6, out: 10e-6 },
    { from: '2026-09-01', in: 3e-6, cw5: 3.75e-6, cw60: 6e-6, cr: 0.3e-6, out: 15e-6 },
  ],
  'claude-haiku-4-5': [
    { from: '2000-01-01', in: 1e-6, cw5: 1.25e-6, cw60: 2e-6, cr: 0.1e-6, out: 5e-6 },
  ],
};

// Transcripts record how many cache-write tokens, not which TTL they used.
// Claude Code caches for an hour on a subscription and five minutes on an
// API key, so pick the class that matches how you are signed in.
const CACHE_WRITE = 'cw60';

const rateFor = (model, ts) =>
  (RATES[model] || []).filter((w) => w.from <= ts).at(-1) || null;

const walk = (dir) =>
  readdirSync(dir, { withFileTypes: true }).flatMap((e) =>
    e.isDirectory() ? walk(join(dir, e.name))
      : e.name.endsWith('.jsonl') ? [join(dir, e.name)] : []);

const seen = new Set();
const byDay = new Map();

for (const file of walk(join(homedir(), '.claude', 'projects'))) {
  for (const line of readFileSync(file, 'utf8').split('\n')) {
    if (!line.trim()) continue;
    let r; try { r = JSON.parse(line); } catch { continue; }
    const u = r?.message?.usage;
    if (!u) continue;

    const key = `${r.message.id}:${r.requestId}`;   // trap 1: dedup
    if (seen.has(key)) continue;
    seen.add(key);

    const rate = rateFor(r.message.model, r.timestamp);  // trap 2: event-time
    if (!rate) continue;            // unknown model: count tokens, not dollars

    const cost = (u.input_tokens || 0) * rate.in
      + (u.cache_creation_input_tokens || 0) * rate[CACHE_WRITE]  // trap 3
      + (u.cache_read_input_tokens || 0) * rate.cr
      + (u.output_tokens || 0) * rate.out;

    const day = r.timestamp.slice(0, 10);
    byDay.set(day, (byDay.get(day) || 0) + cost);
  }
}

for (const [day, cost] of [...byDay].sort())
  console.log(day, '$' + cost.toFixed(2));

Details that bite later

  • Subagent transcripts are separate files. They live under projects/<project>/<session>/subagents/ and their requests are real spend. Walk the tree, do not glob one level.
  • Unknown models are normal. A new model ships before your rate table knows about it. Count its tokens and report the dollars as unpriced rather than dropping the row or pricing it at zero.
  • Sessions are not the same as projects. Group by resolved repository for anything you plan to act on. Session and file are implementation details.
  • The format is undocumented. Parse defensively, skip records you do not recognise, and never assume a field is present. It changes between releases without notice.
  • Transcripts are plaintext and unencrypted. If a tool read a .env file, that value is in the JSONL. A tracker that ships transcripts anywhere is exfiltrating secrets; parse locally and send only aggregates.

What you cannot build this way

Remaining plan quota is not in these files. It is published to your status line command as rate_limits.five_hour and rate_limits.seven_day, and drawn on the /usage screen, but it is never written to disk. A tracker built purely on the filesystem can tell you what you spent and never how much you have left. Getting that half means reading the status line JSON or running something that does.

Questions people ask

In JSONL files under ~/.claude/projects/, one directory per project and one file per session, with subagent transcripts and spilled tool output in sibling directories. The directory name is the working directory with every non-alphanumeric character replaced by a dash.

Build a key from message.id and requestId and skip anything already counted. Resumes and retries write the same logical request more than once, so summing every usage object overcounts.

Because providers reprice. Anthropic lists Claude Sonnet 5 at $2 and $10 per million tokens through 31 August 2026 and $3 and $15 from 1 September 2026, so a tracker using today's rate will silently restate every past month on that date.

Cache reads cost 0.1x the base input rate. Cache writes cost 1.25x at a five-minute lifetime and 2x at one hour. The transcript does not record which lifetime applied, so infer it from the sign-in: one hour on a subscription, five minutes on an API key or cloud provider.

Walk up from the recorded cwd looking for .git. If it is a file rather than a directory you are in a worktree, so read the gitdir: pointer and resolve to the main worktree, otherwise one repo reports as several.

No. Plan state is published to status line commands as rate_limits and shown on the /usage screen, and is never written to the session files. A filesystem tracker can report consumption but never remaining allowance.

Thirty days by default. Claude Code deletes transcripts older than cleanupPeriodDays on startup. Raise the setting, or have your tracker persist its own aggregates, before you need the history.

No. It is not formally documented and can change between releases. Parse defensively, skip records you do not recognise, and treat every field as optional.

Sources

Every figure above was read from these pages on August 2026. Vendors reprice without notice; if you find a stale number, tell us.

  1. Claude Code: the .claude directory
  2. Anthropic model and feature pricing
  3. ccusage on GitHub
  4. Claude Code: customize your status line
Try it

Or skip the
afternoon.

Continuum already implements the dedup, rate windows, cache tiers, and worktree-safe repo bucketing, across Claude Code and Codex. Free app.

free app · your subscriptions · local-first