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.
- 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.idplusrequestIdor you will overcount. - Price at the event timestamp, from dated rate windows. A single current rate rewrites your history the next time one moves.
- 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:
{
"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
}
}
}
| Field | Meaning | Rate per million |
|---|---|---|
input_tokens | New input after the last cache breakpoint | $5 |
cache_creation_input_tokens | Written to the prompt cache | $6.25 at 5 minutes, $10 at 1 hour |
cache_read_input_tokens | Re-read from the cache | $0.50 |
output_tokens | Generated, thinking included | $25 |
The five traps
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);
Pricing at the wrong time
Providers reprice, and they also announce repricings that never land. Claude Sonnet 5 is a worked example of both: Anthropic launched it at $2 and $10 per million input and output tokens, said that was introductory and that $3 and $15 would take over on 1 September 2026, then cancelled the increase and made $2 and $10 the standard rate (platform pricing page, read 19 August 2026). Each half breaks a different naive tracker. Multiply historical tokens by whatever is current and every past month silently changes value the next time a rate really does move. Hard-code an announced future rate and you are wrong the day the announcement is withdrawn. Resolve the rate from the event timestamp and the model name, and keep rate windows rather than single values.
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.
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.
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 minimal correct implementation
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 },
],
'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
.envfile, 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
Where does Claude Code store session data?
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.
How do I avoid double counting Claude Code tokens?
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.
Why price at the event timestamp rather than current rates?
Because providers reprice, and sometimes they cancel a repricing they already announced. Anthropic said Claude Sonnet 5 would rise from $2 and $10 per million tokens to $3 and $15 on 1 September 2026, then withdrew that and kept $2 and $10 as the standard rate. A tracker holding one current rate restates every past month whenever a rate does move, and one holding an announced future rate is wrong the moment that announcement changes.
How do I price cache tokens correctly?
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.
How do I group usage by repository?
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.
Can I read remaining quota from these files?
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.
How far back does the data go?
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.
Is the JSONL format documented or stable?
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.