Format guide
Where Claude Code stores sessions — and how to read the JSONL
The format is undocumented and changes without notice. This is what’s actually in the files, including the two traps that produce wrong token numbers.
The paths
# Claude Code — one file per session
~/.claude/projects/<encoded-path>/<session-uuid>.jsonl
~/.claude/projects/<encoded-path>/<session-uuid>/subagents/*.jsonl
# Codex CLI — "rollouts", dated folders
~/.codex/sessions/YYYY/MM/DD/rollout-<timestamp>-<uuid>.jsonl
On Windows the same trees live under %USERPROFILE%. Claude Code encodes the project’s
absolute path into the directory name by replacing separators with dashes
(/Users/you/dev/webapp → -Users-you-dev-webapp). Subagent transcripts
— what the Task tool spawns — are separate files beside the parent session in newer versions.
Long-running histories are not small: individual session files reach tens or hundreds of megabytes.
What a record looks like
Each line is one JSON record. The load-bearing field is type; the common ones are
user, assistant, system, and summary, and new
types appear as Claude Code evolves. A trimmed assistant record:
{"type":"assistant","uuid":"…","parentUuid":"…","timestamp":"2026-07-01T10:00:05.123Z",
"sessionId":"…","cwd":"/Users/you/dev/webapp","version":"1.0.61","isSidechain":false,
"message":{"id":"msg_01…","model":"claude-opus-4-8","role":"assistant",
"content":[{"type":"text","text":"I'll look at the hook first."}],
"usage":{"input_tokens":4,"output_tokens":85,
"cache_read_input_tokens":5496,"cache_creation_input_tokens":0}}}
The parts that matter when you parse:
message.contentis a block array (or a bare string on user records):text,thinking,tool_use(name + input), and on user recordstool_result(withis_error). One assistant response becomes several lines — one per content block.uuid/parentUuidthread the conversation. Order in the file is close to chronological but the chain is the truth — interruptions and retries branch.- Not every
userrecord is you. Injected context (isMeta), hook output, and tool results all arrive as user-typed records; treat “prompt” as a classification, not a given. - Resumes copy history. Resuming writes a new file that begins with the previous conversation in full. Any per-file aggregation counts the shared prefix once per resume.
- Unknown records are normal. The format is versioned by nothing except the
versionstring; a parser that throws on surprises will not survive an update. Keep what you don’t recognize.
Trap one: usage repeats per line
Because one API response is split across one line per content block, every one of those
lines repeats the same message.id and the same usage object. Sum
usage over lines and your token and cost numbers come out roughly 2.5–3×
too high — a mistake with precedent: an early Turnlog release did exactly this, overcounted about
2.7×, and the fix (count once per message.id) is on
its changelog. Dedupe first:
# tokens for one session, counted once per API response
jq -s '[.[] | select(.message.usage) | {id: .message.id, u: .message.usage}]
| unique_by(.id) | map(.u.input_tokens + .u.output_tokens) | add' session.jsonl
Codex rollouts, and trap two
Codex CLI’s files are also JSONL but differently shaped. Two things to know before aggregating:
- Conversation text rides two channels.
event_msgrecords carry what was actually typed and answered;response_itemrecords carry the model-facing copies — assistant-role ones are pure duplicates of text you already have, and user-role ones are injected environment context. Index both channels naively and every reply is doubled. - The cumulative-token trap.
token_countevents carry both running cumulative totals for the session andlast_token_usage(just the response that finished). Sum the cumulative field across rows and a long session’s total explodes quadratically. Sumlast_token_usageonly. Note also that OpenAI’sinput_tokensincludes the cached share — split outcached_input_tokensif you price them differently.
If you’re building on this
Three rules earn their keep: dedupe usage by response id (both traps are versions of this), classify records defensively and keep the unknowns raw, and treat the format as adversarial — version-sniff, don’t assume. The one thing you can rely on is that everything is there: prompts, diffs, shell output, token counts, timestamps. It’s a complete flight recorder with no cockpit.
npx turnlog demo.)The parsed way
Turnlog reads all of this for you
Turnlog is a free, MIT-licensed CLI whose adapters handle both formats — usage deduped by response, resumes stitched into one conversation, Codex’s two channels and cumulative counters handled, unknown records kept raw and counted on a health panel instead of crashing. One command indexes everything into local full-text search and turn-by-turn replay. You can even drop a JSONL file in the browser demo and watch it parse — the page can’t transmit it.
Related: searching your history · tracking costs honestly · one file’s history across sessions