The Claude Code transcript format
Notes from reading a real corpus, since Anthropic does not document this and the details matter if you want to read the files correctly.
Everything below was measured on one machine in August 2026, across 6 project directories and 28 transcripts: 275 MB, 76,801 lines, nothing unparseable. Claude Code version 2.1.238 and a little either side. Your corpus will differ, and the format moves, so treat the shapes as reliable and the counts as illustrative.
What will catch you out
Four things in these files will give you wrong numbers, and none of them fail loudly. Each is covered in full below; this is the short version, and it is the part worth reading before you write any code.
The files replay. Claude Code appends to a transcript when a session is
resumed or rewound, rewriting records it has already written, so the same
uuid turns up several times. Count lines instead of distinct records and the
largest session here comes out at 36,676 rather than 10,964: more than three
times too high. The overstatement is uneven, so there is no constant to
correct it by. Deduplicate on uuid first, keeping the last copy.
Two fields arrive in more than one shape. toolUseResult is an object
17,319 times and a bare string 490 times. Type it as an object only and every
string-valued line fails to decode, and if a decode failure drops the record you
lose it without a word: 85 of the 340 lines in this repository's own test
fixture went missing that way.
A quiet commit leaves no hash. Claude Code fills in gitOperation by
reading what git printed, so git commit -q prints nothing and the field never
appears. Of 88 quiet commits measured here, none carried one, against 53 of
57 ordinary ones. Reading that field alone found 4 of one repository's 31
commits.
A hash was only true when it was written. Rebase or amend afterwards and the transcript still names objects the repository can no longer reach. 12 of one project's 19 recorded hashes are unreachable for that reason.
One more, which is not about the format at all. If you go looking for commits in
the shell commands instead, a command that writes a file can hold the words
git commit inside the text it writes. Seven of one project's apparent commits
were heredocs doing exactly that.
Where the files live
~/.claude/projects/<mangled-cwd>/<sessionId>.jsonl
The directory name is the working directory with the separators replaced by
dashes, so d:\my-project becomes d--my-project. That is lossy and you cannot
reverse it, because a dash in the original path is indistinguishable from a
separator. Every record carries a cwd field, so read the real path from there.
The same directory holds things that are not transcripts:
memory/andMEMORY.md, saved notes- a directory named after a session id, holding that session's own working
files,
subagents/among them
The layout inside those has changed at least once, so do not depend on it.
Match *.jsonl at the top level only. A recursive walk will hand you files that
are not sessions.
The files are append-only, with replay
Claude Code appends to a transcript when a session is resumed or rewound, and it
rewrites records it has already written. The same uuid shows up several times.
In the largest session measured:
| lines | 36,676 |
| distinct records | 10,964 |
| uuids appearing more than once | 7,919 |
| highest repeat count for one uuid | 6 |
Counting lines instead of records overstates that session by more than three to one, and the overstatement is uneven, so you cannot correct for it with a constant.
Repeat copies are not identical. Later ones tend to carry fields the earlier ones left empty. Across the duplicates, the fields that changed were:
| field | times it differed |
|---|---|
cwd |
20,413 |
promptId |
5,592 |
toolUseResult |
4,318 |
slug |
15 |
parentUuid |
10 |
Most of the cwd differences are a drive letter changing case, d:\ against
D:\, which is worth normalising if you group by path.
So the rule is: key on uuid, merge later copies over earlier ones taking any
non-empty value, and keep the position of the first appearance. A field missing
from a later copy was not repeated, not cleared.
Tool results are filed as user records
A record with "type": "user" is not necessarily something a person typed. Tool
output is recorded the same way. Breaking down the user records that carry a
promptId in one session:
| content | count |
|---|---|
tool_result blocks |
2,700 |
| plain string | 114 |
text blocks |
89 |
| mixed with an image or document | 2 |
Tool results outnumbered real prompts by more than thirteen to one. Check that the content actually holds text before treating a record as a prompt.
The schema is loosely typed
Two fields arrive in more than one shape:
message.contentis a list of blocks 52,641 times and a bare string 400 timestoolUseResultis an object 17,319 times and a string 490 times
Content blocks come in five types: text, tool_use, tool_result,
document, image.
Decode defensively, and ignore fields you do not recognise. The format gains and loses keys between releases, and an unknown key should never be a parse error.
The toolUseResult shape is worth taking seriously rather than noting. Typing
it as an object only makes every string-valued line fail to decode, and if you
drop a record on a decode failure you lose it silently: 85 of the 340 lines in
this repository's own test fixture went missing that way.
Commits
A commit the agent makes is recorded on the result of the tool call that made it:
"toolUseResult": {
"gitOperation": { "commit": { "sha": "d0a65cc", "kind": "committed", "branch": "master" } }
}
kind is committed or amended. Pushes appear the same way under a
different key.
These arrive on user-type records, in document order, so the commit belongs to
whichever prompt was open when it landed.
Do not trust this field to tell you a commit happened. Claude Code fills it in by reading what git printed, so silencing git silences the field:
| commit command | calls | carried gitOperation |
|---|---|---|
without -q |
57 | 53 (93%) |
with -q |
88 | 0 |
Relying on it alone found 4 of one repository's 31 commits and 25 of another's
47. The dependable signal is the shell command itself: a git commit that came
back without an error. Treat gitOperation as where a hash comes from when
there is one, not as whether a commit occurred.
Three more things before counting. Records replay, so this corpus holds 102 commit records covering 53 distinct ones and counting lines doubles the total. A commit the person typed themselves in a terminal never appears at all. And a hash is only true at the moment it was written: rebasing or amending afterwards leaves the transcript pointing at objects the repository no longer reaches, and 12 of one project's 19 recorded hashes are unreachable for exactly that reason.
Record types
15 types appear in the corpus. The ones worth knowing:
| type | count | what it is |
|---|---|---|
assistant |
33,079 | model turns |
user |
19,875 | prompts and tool results, see above |
attachment |
13,424 | injected context, reminders, file contents |
ai-title |
2,434 | Claude Code's own label for the session |
system |
120 | includes compact_boundary |
ai-title is worth pulling out. Claude Code already names each session, and the
names are good: "Product architecture and design", "Test an MCP server
for arbitrary file read". Free labels, generated locally.
The system records with subtype compact_boundary mark where context was
compacted, 35 of them in this corpus. They are honest signals that the thread
moved on.
Structure in the record
parentUuid links each record to the one before it. In this corpus there were
66,434 links, none crossing a session boundary and none dangling. That gives you
clean ordering and a way to check integrity.
It does not give you a tree. Every session was a single unbroken chain with no branch points. Rendering it as a hierarchy draws a straight line.
Sub-agent runs are the one place real branching exists, but not where you might
look for it. Counting isSidechain: true over the raw lines gives 1,078 records
in this corpus. Every one of them is a replay duplicate: after collapsing on
uuid, the count is zero. Anyone reading these files line by line will find
sub-agent structure that is not there.
What does survive is the call that started the work. Sub-agents are launched
through a Task or Agent tool call whose input carries a subagent_type and
a description, 19 of them here:
Explore Research PDF redaction stack
Plan Design the architecture and milestones
Explore Diagnose PDF text layer accuracy
Those descriptions were written at the time, by the agent, about the work it was about to do. They are better labels than anything reconstructed afterwards.
Fields that carry less than you would hope
gitBranch is on every record, but across four months of work it held three
values: HEAD (48,189), master (14,702) and main (3,611). If the developer
does not use feature branches, and many do not, it tells you almost nothing.
cwd is effectively constant within a session, so it identifies the project and
nothing finer.
Retention
Nothing in the corpus suggested transcripts are pruned. The oldest was four months old and still complete. Do not rely on that.