How Pi Web Stores Conversations: Understanding the Session File Format (.jsonl)
Pi Web persists every chat session as an append-only JSON Lines (.jsonl) file where each line represents a discrete event—such as messages, model changes, or compaction markers—linked by parent-child relationships to form an immutable history.
The agegr/pi-web repository implements a robust logging system that stores conversation state in plain-text JSONL files located under the user's Pi data directory (~/.pi/agent/sessions/). This format enables features like session forking, tree navigation, and historical compaction while maintaining a complete audit trail of every interaction. Understanding the schema defined in lib/types.ts is essential for developers building tools or integrations that read or manipulate Pi session data.
What Is the JSONL Session File Format?
Pi Web treats each session as a linear, immutable log where new entries are always appended to the end of the file and earlier lines never change. This append-only design guarantees a reproducible audit trail and simplifies conflict resolution during concurrent access.
Each line in the file is a valid JSON object representing a specific session event. The entryIds[] array built by lib/session-reader.ts mirrors the line order, enabling the UI to map displayed messages back to their underlying entry IDs for operations like fork or navigate_tree.
Schema and Entry Types Defined in lib/types.ts
The TypeScript definitions in lib/types.ts establish the contract for every possible entry type. Each object includes a type discriminator field and maintains hierarchical links via parentId.
Session Header
The first line of every .jsonl file contains the session metadata:
type:"session"version: Schema version (e.g.,3)id: UUID for the sessiontimestamp: ISO 8601 creation timecwd: Current working directory where the session originatedparentSession(optional): Absolute path to the parent file when this session was created via a fork
Model Changes
When the user switches LLM providers or models, Pi records a model_change entry:
type:"model_change"provider: The AI provider (e.g.,"zenmux")modelId: Specific model identifier (e.g.,"claude-sonnet-4-6")parentId: References the previous entry in the chain
Messages
Conversation content uses the message type with a nested message object containing a role field:
- User messages:
role: "user"with plain textcontent - Assistant messages:
role: "assistant"withcontentas an array of blocks (text, image, thinking, or toolCall) - Tool results:
role: "toolResult"includingtoolCallIdand result content
Compaction Markers
To prevent unbounded file growth, Pi can replace a range of historical entries with a single compaction record:
type:"compaction"summary: Textual summary of the compacted rangefirstKeptEntryId: The entry ID where the retained history beginstokensBefore: Token count prior to compaction
Session Info
Optional human-readable metadata for UI display:
type:"session_info"name: Display name for the session
How Parent-Child Relationships Work
Every entry except the root carries a parentId linking it to the immediately preceding entry, forming a singly-linked list that represents the conversation timeline. When you fork a session, Pi creates a new .jsonl file with a fresh header where the parentSession field points to the absolute path of the original file, while the new file's internal parentId chain starts fresh.
This design allows the UI to reconstruct conversation trees and navigate between branches without modifying historical data.
Working with Session Files
Reading Session Headers and Contexts
Use the utilities in lib/session-reader.ts to access session data without manual file parsing:
import { readSessionHeader, resolveSessionPath } from '@/lib/session-reader';
import { SessionHeader, SessionEntry } from '@/lib/types';
// Resolve a session ID to its file path (cached for performance)
const filePath = await resolveSessionPath('a1b2c3-uuid');
// Read the header (first line) without loading the whole file
const header: SessionHeader | null = readSessionHeader(filePath);
console.log('Session cwd:', header?.cwd);
// Load the full session (using Pi's SessionManager under the hood)
const { entries, context } = await piBuildSessionContext(filePath);
Creating Forked Sessions
Forking creates a new independent file while preserving the lineage reference:
import { startRpcSession } from '@/lib/rpc-manager';
// Fork the current session – Pi Web will write a new .jsonl file
const forkResult = await startRpcSession({
sessionId: currentId,
command: 'fork',
// optional: initial tool list, model, etc.
});
console.log('New forked session ID:', forkResult.newSessionId);
Normalizing Tool Call Data
Pi stores tool calls internally as {type: "toolCall", id, name, arguments}, but the UI expects {toolCallId, toolName, input}. The normalizeToolCalls() function in lib/normalize.ts bridges this gap:
import { normalizeToolCalls } from '@/lib/normalize';
// Convert UI-side tool call data to Pi's on-disk schema
const rawCall = {
toolCallId: 't123',
toolName: 'search',
input: { query: 'JSONL format' },
};
const normalized = normalizeToolCalls([rawCall])[0];
// Push as a new entry (handled by AgentSession.prompt internally)
await session.send({ type: 'toolCall', ...normalized });
Summary
- Append-only architecture: New entries are appended to
.jsonlfiles in~/.pi/agent/sessions/; existing lines remain immutable to ensure data integrity. - Type-discriminated schema:
lib/types.tsdefines entry types includingsession,model_change,message,compaction, andsession_info. - Linked history: The
parentIdfield creates a singly-linked chain of events, whileparentSessionenables session forking without data duplication. - Compaction support: Long conversations are managed via
compactionentries that summarize historical ranges to control file size. - Normalization layer:
lib/normalize.tshandles conversion between the UI model and the JSONL storage format, particularly for tool call representations.
Frequently Asked Questions
What is the difference between parentId and parentSession in the JSONL format?
The parentId field links an individual entry to the immediately preceding entry within the same file, creating a linear history chain. The parentSession field appears only in the session header of forked sessions and contains the absolute file path to the original session that was forked, establishing a cross-file lineage relationship.
How does Pi Web handle tool calls in the session file format?
Tool calls are stored in assistant messages as content blocks with type: "toolCall", containing id, name, and arguments fields. When tool execution completes, a separate message entry with role: "toolResult" records the output, referencing the original call via toolCallId. The normalizeToolCalls() function in lib/normalize.ts converts between this storage format and the UI's expected structure.
Can I manually edit a .jsonl session file?
While the files are plain text JSON, manual editing is discouraged because Pi Web relies on the append-only guarantee and specific ID relationships. Editing existing lines breaks the immutability contract and may cause the lib/session-reader.ts parser or SessionContext builder to fail when reconstructing the conversation tree.
Where does Pi Web store session metadata like display names?
Display names are stored as session_info entry types within the .jsonl file itself, written as discrete lines with type: "session_info". All other configuration settings (model preferences, provider settings) live in the Pi configuration directory outside the session files, keeping the JSONL logs focused solely on conversation history and state transitions.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →