A script of mine launches Claude Code from one directory but points it at a different repo. Every session that script kicked off got filed under the launch directory, not the repo where the actual work happened, not the file reads, not the edits, not the git commands. When I went looking for those sessions later, they were misfiled under the wrong project entirely.

Claude Code writes every session to a JSONL file on your disk. Nobody publishes the schema. If you want to turn that pile into something useful (a searchable dev journal, a daily digest, raw material for a blog) you have to read the format yourself first. I did, and I hit three traps that would have silently corrupted everything downstream, starting with the misfiled sessions above. Read the format before you build on it.

Where the files live

The transcripts sit under ~/.claude/projects/, one directory per project, one .jsonl file per session:

~/.claude/projects/<encoded-cwd>/<session-uuid>.jsonl

The <encoded-cwd> part is the absolute path Claude Code was launched from, with the path separators flattened to dashes. A session started in /home/you/projects/app becomes a directory named -home-you-projects-app. Subagent transcripts nest one level deeper, at <uuid>/subagents/agent-*.jsonl, so if you walk the tree you will find both top-level sessions and their spawned subagents.

That encoded directory name is the first trap, and I will come back to it.

The record-type zoo

Each line is one JSON object with a type field. On the version I built against, I saw these types:

user, assistant, attachment, system, permission-mode, file-history-snapshot, last-prompt, queue-operation, ai-title, summary.

Most of those are plumbing. The session lifecycle, permission-mode flips, queued operations, and file-history snapshots are interesting if you are debugging the harness, but they are noise if what you want is the actual conversation. For mining the human-and-model dialogue, you keep user and assistant, and you cherry-pick ai-title and summary for metadata. The rest you can drop on the floor.

But “keep the user records” is where the second trap lives.

Trap 1: the directory name is the launch cwd, not the session cwd

The obvious read is that the encoded directory name tells you which project a session belongs to. It does not. It tells you the working directory Claude Code was launched from. A session’s own records also carry a cwd field, and the two can disagree.

This bit me with SDK-driven agents. A script launches Claude Code from one directory but points it at a different repo, so the session gets filed under the launch path while the work, the file reads, the edits, the git commands, all happened somewhere else entirely. If you trust the directory name to attribute a session to a project, every one of those gets misfiled.

The fix is to record both. Read the encoded directory name for the launch context, and read the per-record cwd for where the work actually ran. When they differ, the record-level value is the truth. If you only have room for one, take the record-level cwd.

Trap 2: tool-result records masquerade as user turns

This is the one that quietly poisons output. When Claude Code runs a tool, the result comes back as a record with type: "user". No human typed it. It is the tool output being fed back into the conversation, wearing a user label because that is the role the API expects tool results to arrive under.

So a naive parser that says “every user record is something the human typed” is wrong on a large fraction of records. If you count user messages to gauge how much a human engaged, tool results inflate the number. If you grab “the first user message” as a slug or title, you might grab a 4KB blob of file contents instead of the question the person asked. If you render the transcript to markdown, you get walls of tool output labeled as if the person said them.

The discriminator is content shape. A genuine human turn carries actual typed text. A tool-result record carries a tool_result payload, not free text. I ended up with a predicate, call it is_real_user_text(), that returns false for any user record whose content is a tool result rather than typed text. Every place that asks “did a human say something here” has to route through that check.

One sharp edge from building this: that predicate is the right gate for counting and slug selection, but it is the wrong gate for rendering. I wanted the markdown view to show tool output as its own block, so when I reused the same “is this real user text” check to decide whether to build a row at all, I dropped tool output from the rendered file entirely. One file shrank by a third and I almost shipped it. The lesson: one function deciding two different questions (“should this count as human input” and “should this appear in the output”) is a conflation waiting to bite. Split the gate.

Trap 3: aiTitle is rare, so build a fallback chain

Claude Code sometimes writes an ai-title record, a short generated label for the session. It is the nicest thing to use as a filename or heading. It is also rare. Across the corpus I ran, only about 4 in 621 files had one. If your slug strategy is “use the aiTitle,” virtually every session gets no slug.

So you build a fallback chain. Mine was:

  1. aiTitle if present.
  2. summary if present (also not guaranteed).
  3. The first genuine human message, slugified, truncated to about 60 characters.

That third fallback is where Trap 2 comes back around: “first genuine human message” has to use the real-user-text predicate, or your fallback slug ends up being the first 60 characters of a tool result. Date is easier, take the first record’s timestamp, fall back to file mtime if the file is somehow timestamp-free.

The triviality threshold does the real work

Here is the part that turns a pile of JSONL into something you actually want to read. Most sessions are noise. You open Claude Code, ask one quick thing, get an answer, close it. That is not journal material. A small minority of sessions contain real thinking, a debugging arc, an architecture decision, a war story, and those are the ones worth keeping.

You need an explicit cutoff to separate them, and you need to write the numbers down. The thresholds I settled on:

MIN_USER_MESSAGES = 2 # at least a back-and-forth, not a one-shot
MIN_ASSISTANT_TEXT_CHARS = 200 # at least a paragraph of real output

A transcript that does not clear both bars gets dropped as trivial. On a corpus of 621 transcripts, this filtered roughly 70% as noise, leaving around 200 with genuine substance. That is exactly the signal-to-noise ratio you want from a mining pass: it throws away the “what’s the syntax for X” sessions and keeps the ones where something actually happened.

The temptation is to leave these as magic numbers buried in a function, or worse, to make them implicit in a tangle of conditionals. Do not. Hoist them to named constants at the top of the module. Heuristics drift, you will tune MIN_ASSISTANT_TEXT_CHARS from 200 to 150 next month when you notice it is eating real sessions, and when you do, you want one obvious place to change it, not a hunt through the code. An explicit, named threshold is also self-documenting: anyone reading the script knows precisely what “trivial” means here, instead of reverse-engineering it from behavior.

A small parser footgun on top

One more thing that wasted ten minutes. If you build a CLI around this and pass an encoded project name as an argument, watch out for the leading dash. The encoded directory names start with a dash (because absolute paths start with a separator), and argparse sees --project -home-you-projects-app and tries to interpret the dash-leading value as another flag. Use the equals form instead:

--project=-home-you-projects-app

The equals binds the value to the flag and argparse stops trying to be clever about it. Same fix applies to any flag whose value can start with a dash.