November 16, 2025
Messages as Commits: Claude Code’s Git-Like DAG of Conversations
How Claude Code stores messages in JSONL files.
Claude Code issue #2112 gave us a interesting idea—what if we implemented custom session identification in tweakcc?
If you don't know about tweakcc, it's our open source TUI for customizing Claude Code—use it to personalize thinking verbs, color themes, user messages, etc. We just added support for modifying Claude Code's system prompt and enabled access to built-in LSP support, which Anthropic has only partially implemented at the time of this writing.
In the end, we figured it out and got it added it to tweakcc v3.1.0! But we first had to figure out how Claude Code uses session files to manage conversation and tool call history.
What is "custom session identification?"
In GitHub issue #2112 on the official Claude Code repo, @gwpl writes:
Currently, Claude Code sessions are only identifiable by auto-generated session IDs and timestamps. When using claude --resume to view previous sessions, it's difficult to identify which session contains the work you're looking for, especially after days or weeks. The auto-generated summaries are helpful but often insufficient for distinguishing between similar coding sessions.
This means that Claude Code auto-generates a descriptive name summarizing the content, but sometimes the name doesn't accurately capture the conversation's essence—at least as far as the user is concerned—so that when you try to resume a previous conversation—especially after a few weeks—you might have trouble remembering and identifying that conversation among the list of prior chats. Like, for example, the generated name "Document tracking and request sending refactor" would be better named "Patching minified LSP client code to send textDocument/didOpen." The first summary is generic and not memorable; the second is more detailed and highlights the most important aspect of the conversation.
Session files are your Claude Code history
Claude Code stores user session history in JSONL files under a per-project directory:
~/.claude/projects/<project-slug>/<session-uuid>.jsonl
JSONL ("JSON Lines") is a superset of JSON where each line in the file is a separate, minified JSON object. It's great for logs or timeseries-like data, where you only append new entries to the file.
The "project slug" is the full path to the directory where you're running claude, with slashes and other special characters normalized into dashes. For example:
- Project path:
/code/project-name - Project slug:
-code-project-name
So if you run claude in /code/project-name, Claude Code will use ~/.claude/projects/-code-project-name/ as the folder for that project's session history.
When you start Claude Code, it creates a new session UUID and a new session file, but until you send a message, it's empty. After you send a message, it appends a new entry to the file.
sequenceDiagram autonumber participant U as User participant CC as Claude Code participant F123 as 123.jsonl participant F456 as 456.jsonl U->>CC: Start session (ID = 123) CC->>F123: Append message entries U-->>CC: Exit U->>CC: Start new session (ID = 456) CC->>F123: Read recent entries CC->>CC: Detect unsummarized conversation CC->>F456: Append summary entry<br/>type="summary", leafUuid="..."
Conversations and messages create one JSON object per entry
Claude Code stores each message as a JSON object on its own line. Here's a formatted example of a simple user message:
{ "parentUuid": null, "isSidechain": false, "userType": "external", "cwd": "/path/to/cwd", "sessionId": "caec93dd-2f5d-48b2-b819-75eb5e7e6112", "version": "2.0.41", "gitBranch": "", "type": "user", "message": { "role": "user", "content": "Hi there" }, "uuid": "6d8a96fb-9ebe-4a5d-b4d4-47e19ab429b3", "timestamp": "2025-11-14T23:57:23.004Z", "thinkingMetadata": { "level": "none", "disabled": true, "triggers": [] } }
Some of the interesting fields:
-
sessionId
The UUID of the session. In this example, the file name iscaec93dd-2f5d-48b2-b819-75eb5e7e6112.jsonl. -
uuid
A unique identifier for this message. This is how other entries refer back to it. -
parentUuid
Points to the previous message in the conversation (more on this in the DAG section). The first message in a conversation hasnull. -
type
The type of entry. For user messages it's"user", and for Claude's replies you'll see"assistant"(and there are other types like"summary"). -
message.roleandmessage.content
The actual chat payload Claude sees (uservsassistant, with the text content). -
thinkingMetadata
Configuration for Claude's "thinking" mode. Here I had thinking disabled. -
userType
Hardcoded to"external"for normal users. For Anthropic employees, it shows"ant"(they're internally referred to as "Ants"). -
cwd,gitBranch,version, etc.
Metadata about your environment at the time of the message.
Conversations are DAGs, like Git
The key to Claude Code's conversation model is the parentUuid field—it links messages together. Instead of treating a conversation as a simple array of messages, Claude Code builds a directed acyclic graph (DAG) of messages, where every message, except the first one in a conversation, points back to the previous ("parent") message via parentUuid.
If you know anything about the inner workings of Git, this will sound familiar—this is exactly how Git stores commits and branches. Each commit stores a reference to its parent commit so that all commits point to their previous parent commits, except for the initial commit on the branch which has no parent.
Continuing the Git analogy, a branch is a pointer to a commit. When you make a commit on a branch, Git:
- Creates a new commit with the branch's "tip" (the latest commit on the branch) as the new commit's parent.
- Updates the branch's tip to point to the new commit.
So the branch's tip is constantly changing as you make commits.
Claude Code conversations are functionally identical. Each message object points to the previous message via parentUuid, so you can fork a conversation by creating a new message whose parentUuid points to some earlier message instead of the latest one. Conversations are reconstructed by walking parent pointers back from "leaf" messages.
A leaf message is any message whose UUID is not used as parentUuid by any other message. In other words, a leaf message is the final message in a conversation; nothing continues after it. So each leaf message is the tip of a conversation, and a single session can contain many conversations, each ending in its own leaf.
So, when you run claude --resume, Claude Code:
- Reads all the JSONL entries for the current project.
- Builds the graph of messages using
uuidandparentUuid. - Determines which messages are leaves.
- Handles each leaf message as a separate conversation to display in the resume menu.
This is why you can fork and branch conversations without duplicating the history every time—all shared messages are reused, just like shared commits in Git.

Session summaries are titles attached to leaf messages
{ "type": "summary", "summary": "LSP Tool Demo: Getting Document Symbols", "leafUuid": "f3b3be9e-ff3a-4dfe-a300-1cdc4f1fbd03" }
This means that the conversation whose last message has UUID f3b3be9e-ff3a-4dfe-a300-1cdc4f1fbd03 is being titled LSP Tool Demo: Getting Document Symbols.
Okay, so far so good, but now things get a little weird. Let's say you launch Claude Code and your session ID is 123; you chat, messages are written to 123.jsonl, and then you exit Claude Code. Later, you launch a new Claude Code session, say 456—on startup, Claude Code looks at recent session files and generates a summary for the conversation stored in 123.jsonl; however, because the current active session is now 456, the summary entry gets written to 456.jsonl!
So the summary for a conversation that lives in 123.jsonl is stored in 456.jsonl. This seems unintuitive at first but ultimately works because of how Claude Code loads and merges session data, which merits its own section.
Sessions are fragmented across files
With /resume, Claude Code doesn't treat each *.jsonl file as a self-contained, sealed "session". Instead, it:
-
Reads all the JSONL files in the current project's directory:
~/.claude/projects/<project-slug>/*.jsonl -
Parses every line from every file into a single big flat list of JSON objects.
-
Reconstructs the DAG from all of those objects using
uuidandparentUuid. -
Rebuilds conversations and summaries from that graph.
Because entries are designed to be idempotent and atomic, a few important properties fall out:
- The order of entries within a file doesn't matter.
- The order in which files are read doesn't matter.
- In fact, you could literally shuffle all the lines in all the files and Claude Code would still reconstruct the same set of conversations and summaries.
flowchart LR classDef file fill:#f8fafc,stroke:#64748b,stroke-width:1px; classDef process fill:#eff6ff,stroke:#2563eb,stroke-width:1px; classDef result fill:#ecfdf5,stroke:#16a34a,stroke-width:1px; F1["123.jsonl"]:::file F2["456.jsonl"]:::file F3["789.jsonl"]:::file P1["Parse lines<br/>into JSON objects"]:::process P2["Build global DAG<br/>using uuid + parentUuid"]:::process P3["Find leaf messages<br/>(conversation tips)"]:::process R1["Resume menu<br/>(one item per leaf)"]:::result F1 --> P1 F2 --> P1 F3 --> P1 P1 --> P2 --> P3 --> R1
You might be tempted to think "one session file = one conversation." That's often true in practice, but it's really just an implementation detail.
What actually happens:
-
Claude Code writes entries to "the current session file."
-
When you resume an old session, it:
-
Sets the current session ID to the old session's ID.
-
Switches the current session file to that old file.
-
Subsequent entries (messages, summaries, etc.) are now appended to that older file again, extending it.
You can verify this by:
- Starting a new session and sending a message.
- Running
/resumeto switch back to an older session. - Sending another message.
- Inspecting the files and seeing that the new message has been appended to the old session's JSONL file.
In other words, a "session file" is just whatever file Claude Code is currently appending entries to. Conversations themselves are virtual, reconstructed from the DAG across all entries and files.
Putting this to work in tweakcc—manual session naming
Now that we know how conversations are stored as DAGs, how summaries are linked to leaf UUIDs, and how entries from different JSONL files are merged, we can safely implement manual session naming without fighting Claude Code's design.
So in tweakcc, we attach custom titles to conversations by writing entries that reference specific leaf messages and we dynamically update the leaf message that we're pointing as the conversation moves along
If you'd like to give your Claude Code sessions real names, experiment with your own patches, or just have more control over how Claude Code behaves, check out tweakcc on GitHub:
👉 https://github.com/Piebald-AI/tweakcc
And if you build something cool on top of these JSONL logs, or discover other interesting bits of Claude Code's internals, we'd love to hear about it!