Understanding the Session File Format Structure in pi-web: JSONL Entries and Entry-ID Mapping

pi-web stores conversations as JSON Lines files (.jsonl) under ~/.pi/agent/sessions/, where each line represents a typed entry (session header, model changes, or messages), and the frontend maps UI elements to these entries via parallel entryIds[] and messages[] arrays in SessionContext.

The session file format structure in agegr/pi-web defines how conversational state persists across sessions. According to the source code in lib/session-reader.ts and lib/rpc-manager.ts, pi-web uses a line-delimited JSON format that supports branching, forking, and tree navigation through unique entry identifiers.

File Location and Naming Convention

Session files reside in the Pi data directory using a hierarchical structure that encodes the project context:

~/.pi/agent/sessions/<encoded-cwd>/<timestamp>_<uuid>.jsonl
  • <encoded-cwd>: Base64-encoded path of the working directory
  • <timestamp>_<uuid>: Chronological prefix with unique identifier

This organization allows pi-web to group conversations by project while maintaining unique session identification.

JSONL Entry Types

Every line in a session file is a self-contained JSON object where the type field determines its semantic role. The file always begins with a mandatory header, followed by chronological event entries.

Session Header

The first line must contain a session entry that defines conversation metadata:

{"type":"session","version":3,"id":"c2e9b4d5-1a2b-4c3d-9e8f-0123456789ab","timestamp":"2026-08-17T12:00:00Z","cwd":"/home/user/project","parentSession":null}

Key fields include:

  • version: Format version (currently 3)
  • id: Session UUID
  • cwd: Absolute path of the working directory
  • parentSession: Optional path to a parent session file when forked

Model Change Events

When the underlying LLM switches during a conversation, pi-web appends a model_change entry:

{"type":"model_change","id":"a1b2c3d4","parentId":null,"provider":"zenmux","modelId":"claude-sonnet-4-6","timestamp":"2026-08-17T12:00:01Z"}

This entry tracks the provider and modelId, with parentId linking to the previous entry in the sequence.

Message Entries

The conversation content consists of message entries with three distinct roles:

User Messages capture text input:

{"type":"message","id":"msg001","parentId":"a1b2c3d4","message":{"role":"user","content":"Explain JSON-Lines format."}}

Assistant Messages contain model responses, which may include text arrays or tool calls:

{"type":"message","id":"msg002","parentId":"msg001","message":{"role":"assistant","content":["JSON-Lines is a text format where each line is a valid JSON object..."]}}

Tool Result Messages store execution outputs:

{"type":"message","id":"msg003","parentId":"msg002","message":{"role":"toolResult","toolCallId":"tc-123","content":["Tool execution completed successfully"]}}

Each message entry carries a unique id (typically 8-character hex) and references its parent via parentId, forming a linked chain that preserves conversation flow.

Compaction and Metadata Entries

Compaction entries optimize storage by replacing large message blocks with summaries:

{"type":"compaction","id":"cmp001","parentId":"msg100","summary":"Previous discussion about API design","firstKeptEntryId":"msg050","tokensBefore":15000}

Optional session_info entries allow user-defined metadata such as custom session names or tags.

Entry-ID to Message Mapping

The frontend relies on a one-to-one correspondence between JSON Lines entries and displayed UI messages. In lib/session-reader.ts, the SessionContext interface exposes two parallel arrays:

  • messages[]: Rendered chat messages (user, assistant, and tool results)
  • entryIds[]: The originating entry id from the .jsonl file for each message

This mapping enables two critical operations:

  1. Session Forking: The UI passes the entryId corresponding to a selected message to POST /api/agent/[id] with the fork command, creating a new session file rooted at that specific entry.

  2. Tree Navigation: The navigate_tree command uses the entryId (referenced as leafId in API calls) to jump to specific conversation leaves within the same session file.

As documented in AGENTS.md, this parallel array structure ensures every rendered message traces back to its exact source line in the JSONL file.

Reading and Writing Session Files

Creation and Updates

The lib/rpc-manager.ts module handles file initialization and incremental writes. When a new session starts, it writes the session header line. As the conversation progresses, it atomically appends model_change and message entries to the .jsonl file.

Parsing and Normalization

When loading a session, lib/session-reader.ts streams the file line-by-line and constructs the SessionContext. The reader normalizes tool call formats via lib/normalize.ts before populating the messages[] and entryIds[] arrays, ensuring consistent UI rendering regardless of provider-specific message formats.

Practical Implementation Examples

Minimal Session File Structure

The following illustrates a complete session with header, model change, and message sequence:

{"type":"session","version":3,"id":"c2e9b4d5-1a2b-4c3d-9e8f-0123456789ab","timestamp":"2026-08-17T12:00:00Z","cwd":"/home/user/project","parentSession":null}
{"type":"model_change","id":"a1b2c3d4","parentId":null,"provider":"openai","modelId":"gpt-4o","timestamp":"2026-08-17T12:00:01Z"}
{"type":"message","id":"msg001","parentId":"a1b2c3d4","message":{"role":"user","content":"Explain JSON-Lines."}}
{"type":"message","id":"msg002","parentId":"msg001","message":{"role":"assistant","content":["JSON-Lines (JSONL) is a text format for structured data..."]}}
{"type":"message","id":"msg003","parentId":"msg002","message":{"role":"toolResult","toolCallId":"tc-123","content":["Tool execution completed"]}}

Consuming Entry IDs in TypeScript

To fork a session or navigate the conversation tree, reference the entryIds[] array from SessionContext:

import { SessionContext } from '@/lib/session-reader';

function forkFromMessage(ctx: SessionContext, messageIndex: number) {
  const entryId = ctx.entryIds[messageIndex];
  
  fetch(`/api/agent/${ctx.sessionId}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ cmd: 'fork', entryId })
  });
}

function navigateToLeaf(ctx: SessionContext, entryId: string) {
  fetch(`/api/agent/${ctx.sessionId}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ cmd: 'navigate_tree', leafId: entryId })
  });
}

Both functions depend on the entry-id to message mapping generated by session-reader.ts to identify the correct branching point in the .jsonl file.

Summary

  • pi-web persists sessions as JSON Lines files (.jsonl) in ~/.pi/agent/sessions/<encoded-cwd>/ using timestamp-UUID filenames
  • Each line contains a typed entry with a type field: session (header), model_change, message (user/assistant/toolResult), compaction, or session_info
  • The SessionContext in lib/session-reader.ts maintains parallel messages[] and entryIds[] arrays, mapping UI elements to their source JSONL entry IDs
  • Entry IDs enable session forking and tree navigation by referencing specific lines in the conversation history
  • Files are written incrementally by lib/rpc-manager.ts and parsed by lib/session-reader.ts with normalization handled in lib/normalize.ts

Frequently Asked Questions

What is the file extension for pi-web session files?

pi-web uses the .jsonl extension (JSON Lines format) for session storage. Each file contains one JSON object per line, making it human-readable and append-friendly for incremental updates as conversations progress.

How does pi-web handle branching or forking conversations?

When a user forks a conversation, the UI identifies the entry ID corresponding to the selected message from the entryIds[] array. The frontend sends this ID to the API endpoint (POST /api/agent/[id]) with the fork command, which creates a new .jsonl file starting from that specific entry point while preserving the conversation history up to that branch.

What is the purpose of the parentId field in session entries?

The parentId field creates a linked list structure within the session file, where each entry references the ID of its immediate predecessor. This chaining allows pi-web to reconstruct the conversation timeline and maintain proper ordering even when entries are compacted or when navigating to specific points in the conversation tree using the navigate_tree functionality.

Where does pi-web store the mapping between UI messages and file entries?

The mapping occurs in lib/session-reader.ts, which constructs a SessionContext object containing two parallel arrays: messages[] (for the UI) and entryIds[] (the source IDs from the .jsonl file). This one-to-one correspondence allows the frontend to perform operations like forking and navigation by referencing the exact line in the session file that generated a specific message.

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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →