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. 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:
{
"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.
| 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 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.
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 },
{ 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
.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
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.