bough
Pi All documents

The Pi session file format

Notes from reading Pi's session files, the same way as the Claude Code transcripts and the Codex rollouts.

Pi is open source, so unlike the other two this page was written against the code that writes the files: packages/coding-agent/src/core/session-manager.ts and the message types in packages/ai/src/types.ts, as of Pi 0.87.1 in September 2026. The real corpus behind it is small, a handful of sessions on one machine across three providers, so where the code and a real file could disagree, trust your own files. Pi documents the format itself in docs/session-format.md inside its package, and that is worth reading first.

What will catch you out

A session is a tree, not a list. Every entry has an id and a parentId. Going back to an earlier point with /tree and carrying on adds a second child to that point, in the same file. Pi builds what the model sees by walking back from the newest entry, so the abandoned branch drops out of its context. It does not drop out of the history: those prompts were typed, answered and paid for. Read every entry in file order, and treat an entry whose parent is not the one before it as a jump in the tree. A file can also have more than one root.

Forks copy the whole conversation into a new file. /fork and /clone start a new session file holding the conversation so far, every entry under its original id and timestamp, and the new header's parentSession names the file it came from. /fork copies everything, abandoned branches included; /clone copies only the path to where you are. Add up both files as they stand and everything before the fork is counted twice. Match copied entries by id and timestamp together, since ids are only eight hex characters and unique within one file. A fork can also land in a different project's folder. If the original has been deleted, the copy is the only record left, and then it counts.

A skill prompt is stored with the skill's text pasted in. /skill:review this file is saved as a user message that opens <skill name="review" location="...">, holds the skill's whole body, and ends with the words that followed the command. Show that as the prompt and a page of instructions nobody typed becomes the label on the work.

Sub-agents are an extension, and Pi leaves their cost out of its totals. Pi has no sub-agents of its own. The example subagent extension runs each one as a separate process that saves no session. What it did survives only inside the tool result: details.results[] carries every message the sub-agent exchanged, with each reply's usage. Pi does not add that to the session's usage. Count it yourself or a delegated task looks free.

The answering model is not always the billed one. A reply records the model that was asked for and, when the provider answered as something else, a responseModel. OpenRouter reports its models without the :free suffix, so pricing by responseModel bills free work at the paid rate. Pi prices by the requested model, except when Anthropic falls back to another model, where the fallback is what did the work.

A failed request is still a reply. An error from the provider is saved as an assistant message with stopReason: "error", an errorMessage, empty content and all-zero usage. It opens nothing and costs nothing, but it is there.

Where the files live

~/.pi/agent/sessions/--<cwd>--/<timestamp>_<session id>.jsonl

The folder name is the working directory with its leading separator removed and every /, \ and : turned into -, so D:\pi-test becomes --D--pi-test--. That cannot be turned back into the path, since a - in the name could have been any of the four. Read cwd from the header instead.

PI_CODING_AGENT_DIR moves the whole agent directory, and PI_CODING_AGENT_SESSION_DIR moves just the sessions. A session directory set by hand holds every project's files side by side, with no per-project folders.

The header

The first line of every file:

{"type":"session","version":3,"id":"<uuid>","timestamp":"2026-09-24T18:34:41.984Z","cwd":"D:\\work\\app"}

Version 1 files have no version and no id or parentId on their entries, which then simply run in file order. Pi migrates old files when it opens them. Claude Code records carry a cwd too, so type is what says a file is Pi's.

Entry types

message                a conversation message, with a role (below)
model_change           the model switched: provider, modelId
thinking_level_change  the reasoning level switched
usage                  model work that is not a reply, cache warming for one
compaction             earlier context summarised, with the summary's usage
branch_summary         an abandoned branch summarised, after a /tree jump
context_edit           a later change to what the model sees, not to history
custom                 an extension's own state
custom_message         an extension's message to the model, not a prompt
label                  a bookmark on an entry
session_info           the name given with /name, latest wins

Messages have a role: user, assistant, toolResult, system, bashExecution for a command you ran yourself with !, and custom, branchSummary and compactionSummary for things Pi or an extension put there.

Entry timestamps are ISO strings. The timestamp inside a message is Unix milliseconds.

Tokens

Every assistant message carries usage:

{"input":6108,"output":68,"cacheRead":0,"cacheWrite":0,"reasoning":21,"totalTokens":6176,"cost":{"input":0.0012216,"output":0.0000816,"cacheRead":0,"cacheWrite":0,"total":0.0013032}}

Unlike Codex, input does not include cacheRead, so the four counts can be added as they are. reasoning is already inside output; do not add it again. cacheWrite1h, when present, is the part of cacheWrite held for an hour.

Usage also turns up on usage entries, on compaction and branch_summary entries for the summary they paid for, and on a tool result that did model work of its own. Those last three name no model. The one in use is the honest guess.

Pi works out a cost for every piece of usage from its own price catalog, which is models.dev plus its own corrections. bough prices from LiteLLM's table instead and uses Pi's figure only in its tests, where the two agree to the cent on a real session. Subscription and custom providers record a cost of zero.

Tools

The built-in tools and the arguments that matter:

read    path
write   path, content
edit    path, edits: [{oldText, newText}]  (older files: oldText, newText)
bash    command
grep    pattern, path
find    pattern, path
ls      path

On Windows a powershell tool can stand in for bash, with the same command. A command that exits non-zero comes back as a result with isError: true, which is how a commit that failed is told from one that landed. The git output itself, [main (root-commit) 85fd4d9] ..., is in the result's text.

A path can be relative to cwd, start with @, start with ~, be a file:// URL, or on Windows be a Git Bash path such as /d/work. Pi resolves all of those before touching the disk, so a reader has to as well.

An edit result carries details.patch, a unified diff with --- and +++ headers, which is the reliable way to count the lines that changed.

Local models

A model run on your own machine appears under whatever provider name was given to it in Pi's models.json, for example ollama, with a baseUrl such as http://localhost:11434/v1. The session records only the provider and model, not where the server was, so models.json is the one place that says a model was local. Pi's built-in llama.cpp provider is always local. A local endpoint is not proof on its own: a proxy on localhost that forwards to a hosted model looks exactly the same.