Pi (pi.dev) is an open-source, self-extensible coding agent CLI (the pi command), maintained by earendil-works under the MIT license. Although the repository sits under a GOPATH directory, it is actually a TypeScript / npm workspaces monorepo.

This article is based on the v0.83.0 source code, analyzing Pi’s source to summarize its framework and some key takeaways.

Overall Layering: pi Module Dependencies

Pi’s four modules form a clean onion structure from top to bottom:

The real agent loop lives in packages/agent/src/agent-loop.ts; coding-agent just wraps it into a complete product with session persistence, tools, extensions, and UI.

There’s an easy-to-misread point here: layering does not forbid cross-layer references — it only enforces unidirectional upward dependency. coding-agent‘s package.json directly depends on pi-ai, and this is completely legitimate — basic types like Message, Model, and ImageContent must be uniformly defined at the lowest layer, and every layer needs to reference them. The only real red line is: lower-layer code must not reference any upper-layer module.

The way to verify this is simple: delete the upper layers and see whether the lower layer can still compile and run its tests. If it can’t, the lower layer has leaked a dependency on the upper layer.

Paired with the layering is progressive type extension — the same concept grows into three different shapes across the three layers:

Layer Type What’s added
pi-ai Tool (types.ts:480) name / description / parameters — can only describe itself, cannot execute
pi-agent-core AgentTool (types.ts:380) label / prepareArguments / execute / executionMode
pi-coding-agent ToolDefinition UI renderer, promptSnippet, ExtensionContext

Each layer only extends without modifying the lower layer, relying on tool-definition-wrapper.ts to inject ExtensionContext via closures — the Agent core has no idea the product layer even exists.

runLoop

Starting the runLoop

Process entry pointpackages/coding-agent/src/cli.ts:20
Sets process.title, configures the undici HTTP dispatcher, then calls main(process.argv.slice(2)).

Assembling everythingmain() in packages/coding-agent/src/main.ts:521
This is the longest piece of “glue,” doing the following in sequence:

  • Handle subcommands like pi update / pi config and exit directly (handlePackageCommand, main.ts:540)
  • Parse arguments via parseArgs (main.ts:561)
  • Determine the run mode (interactive / print / json / rpc) — resolveAppMode (main.ts:109)
  • Open or create a session — createSessionManager (main.ts:312)
  • Assemble the runtime (model, settings, extensions, skills, tools, project trust) — the createRuntime factory (main.ts:667), whose core internally is createAgentSessionFromServices, eventually reaching createAgentSession in sdk.ts
  • Dispatch by mode (main.ts:870-903): runRpcMode / new InteractiveMode().run() / runPrintMode

Constructing the Agentpackages/coding-agent/src/core/sdk.ts:294
new Agent({...}), where several of the most critical callbacks are injected:

  • streamFn (sdk.ts:302): the function that actually sends the LLM request, wrapping retries, timeouts, attribution headers, and extension hooks
  • convertToLlm (sdk.ts:256): converts internal AgentMessage[] into the Message[] format the provider understands
  • Restoring historical session messages

It then uses this agent to build an AgentSession (sdk.ts:376), which is responsible for tool registration, event subscription, and writing every message back to the session file.

Key runLoop Concepts: Trace and Turn

Before reading the loop code, these two terms must be clearly distinguished, otherwise neither the logs nor the events will make sense:

  • Trace — one complete execution, from user input to the agent fully stopping. Bounded by the agent_start and agent_end events.
  • Turna single model call, plus all the tool executions triggered by that call. Bounded by turn_start and turn_end.

The key constraint: a Turn has exactly one model call. So a Trace typically contains multiple Turns — the model calls a tool, sees the result, thinks again, calls again, and each round counts as one Turn.

Core runLoop: A Two-Layer Loop Driven by stopReason

UI reads inputpackages/coding-agent/src/modes/interactive/interactive-mode.ts:914
Interactive mode captures the text the user typed and calls this.session.prompt(userInput). (Non-interactive pi -p "..." goes through print-mode.ts:122, also calling session.prompt.)

Session-layer preprocessingprompt() in packages/coding-agent/src/core/agent-session.ts:1114
Before actually entering the loop, a bunch of product-level logic runs:

  • Extension commands starting with / are executed directly and returned
  • Skill / prompt templates are expanded
  • If output is currently streaming, the message is pushed into the steer / followUp queue
  • Model and auth are validated, triggering context compaction if necessary (shouldCompact, agent-session.ts:2038)
  • The user message is assembled, and finally _runAgentPrompt is called (agent-session.ts:1264) → agent.prompt()

Agent entry pointprompt() in packages/agent/src/agent.ts:339
Normalizes the input → runPromptMessages (agent.ts:398) → calls runAgentLoop, passing this.processEvents in as the event callback.

Core looprunLoop in packages/agent/src/agent-loop.ts:155

  • Inner loop: streamAssistantResponse (sends the LLM request, agent-loop.ts:281) → checks the response for tool calls → if present, calls executeToolCalls (agent-loop.ts:411) → splices the tool results back into the context → continues to the next round, until the assistant stops calling tools
  • Outer loop: handles steering / follow-up queued messages (getFollowUpMessages, agent-loop.ts:263)
  • At every step, events are thrown out via emit(event)

The entire loop is actually driven by a single field — stopReason:

stopReason Meaning How the loop proceeds
toolUse The model requested a tool Execute the tool, continue to the next Turn
stop / length The model no longer wants a tool Prepare to stop
error / aborted Error or aborted Hard stop, skip tool execution

Worth calling out specifically is this loop’s “kernel + layers” structure. Strip away everything peripheral, and the kernel is just a dozen or so lines of ReAct: call the model → if there’s a tool, execute it → feed the result back in → call the model again. Everything else is an optional layer wrapped around it:

  • Steering queue: urgent messages that jump the queue mid-execution (the user can’t wait, so they add a line while things are running)
  • followUp queue: follow-up tasks after this round finishes, still part of the same Trace
  • prepareNextTurn hook (agent-loop.ts:232): dynamically switches models or modifies context between Turns
  • shouldStopAfterTurn hook (agent-loop.ts:248): an external safety valve that can call a halt at any time

A stable kernel with all capability hung off the outside — this is the most worth-copying structure in this codebase.

Tool Execution: A Five-Stage Pipeline with One Veto

From the model saying it wants to call a tool to the tool actually running, there are five gates in between:

  1. The model returns a batch of tool calls at once
  2. Does any tool declare executionMode = sequential? ──Yes──→ Entire batch runs sequentially
  3. No (parallel by default)
  4. prepareArguments absorbs quirks in each model's output ┐
  5. ② validateToolArguments runtime validation via TypeBox schema ├ Preparation phase · always sequential
  6. ③ beforeToolCall permission interception, block:true aborts ┘
  7. ④ tool.execute onUpdate streams progress ← Execution phase · Promise.all in parallel
  8. ↓ ↘ throws → framework try-catch as a safety net
  9. ⑤ afterToolCall redaction · auditing · error fix-up · early stop ← Event phase · strictly in call order
  10. ToolResultMessage (errors are always encoded as isError: true, never thrown to interrupt the loop)

A few easily overlooked details:

Parallel is the default; sequential execution comes via a single veto. The model can return multiple tool calls in a single response. As long as any one tool in that batch declares executionMode: "sequential", the entire batch degrades to sequential execution. None of the 7 built-in tools (read / bash / edit / write / grep / find / ls) declare this, so by default they’re all parallel. The edit tool instead uses withFileMutationQueue internally to serialize “edits to the same file” — resolving conflicts on its own turf, rather than dragging down the whole batch.

Parallel isn’t a single flat parallelism — it’s three phases. Preparation (①②③) always executes sequentially, to avoid side effects colliding with each other; only the execute step is truly run via Promise.all; and when the final ToolResultMessage is sent, it’s again arranged strictly in call order. From the model’s perspective, the order of tool results is deterministic, even though they may have finished out of order.

Errors are never thrown out. All tool errors are ultimately encoded as a ToolResultMessage with isError: true and handed to the model, without interrupting the loop. This is split into two layers of responsibility: the tool’s inner layer actively identifies known error types (the bash tool separately handles abort, timeout, and nonzero exit codes, each attaching “what was already output” and “specifically why it failed”); the framework’s try-catch only serves as a fallback, passing error.message through as-is. Letting the model see the error and retry on its own is far more useful than letting the program crash.

There’s also a test-friendly design worth noting: tools don’t call system APIs directly, but instead go through their own minimal defined interfaces — the read tool needs ReadOperations { readFile, access }, the bash tool needs BashOperations { exec }. To run remotely or mock, you just swap the implementation, without touching a single line of tool code.

LLM Call Boundary: 12 Kinds of Events and One Contract

8. Call boundarypackages/agent/src/agent-loop.ts:281
Inside streamAssistantResponse, convertToLlm (agent-loop.ts:295) first converts messages into the provider format, then calls config.streamFn (the one injected by sdk.ts) → which internally routes to streamSimple in the ai package (packages/ai/src/compat.ts:275) → dispatches to the specific provider based on model.api → each provider’s protocol implementation lives under packages/ai/src/api/.

Unifying 30-plus providers relies on three layers:

  1. stream() —— looks up a table and dispatches, doing only resolveApiProvider + forwarding [compat.ts:250]
  2. 12 unified event types —— start / text_* / thinking_* / toolcall_* / done / error
  3. Provider-specific translators —— anthropic-messages.ts / openai-completions.ts / ...

BUILTIN_APIS (compat.ts:178) is exactly that address book, mapping api strings to their corresponding translators. Every translator must emit the same set of 12 unified events, and each event also carries a complete snapshot of the current message (partial: AssistantMessage), so the upper layer is free to do incremental rendering or full replacement, whichever it wants.

There’s no inheritance here — it’s a single function signature: StreamFunction. Its contract is written directly into the comments at packages/ai/src/types.ts:312-324, with three rules:

  1. Must return an AssistantMessageEventStream
  2. Once invoked, all failures — request / model / runtime — must be encoded into the stream, not thrown
  3. Error termination must produce an AssistantMessage carrying stopReason: "error" | "aborted" and errorMessage

Protocol trumps implementation — plugging in a new provider only requires writing a function, adding one line to the address book, and configuring the api field. Not a single line of the Agent Loop needs to change.

The way each provider’s dialect is unified is also worth copying: thinking levels use completely different parameters across providers (Anthropic uses thinking.budget_tokens, OpenAI uses reasoning_effort, Google uses thinkingConfig.thinkingLevel). Pi unifies these into a five-level ThinkingLevel enum, with each model carrying its own mapping table and a clamp fallback (search upward first, then downward if not found). The same approach applies to cache checkpointing: the four providers’ strategies all differ from each other, unified into a three-value CacheRetention enum — and this one is real money, since cache hits are billed at roughly 1/10 the input price.

Event Flow-Back: emit Isn’t a Notification, It’s a Sync Barrier

Events emitted via emit in the loop are dispatched to all subscribers via Agent.processEvents (agent.ts:529) (subscribe, agent.ts:243). There are two kinds of subscribers:

  1. Inside AgentSession, _handleAgentEvent (agent-session.ts:595) — responsible for persisting every message by writing it into the session’s .jsonl file.
  2. The UI layer — interactive / print mode picks up events via session.subscribe (agent-session.ts:800) and renders them into text, tool call blocks, diffs, etc., streaming into the terminal.

Putting together steps 4–8 above with the event flow-back, the timing of one complete request looks like this:

pi Compaction and firstKeptEntryId

This event system has a counterintuitive but crucial design: emit returns a Promise, and every event dispatched inside the Agent Loop must be awaited. In other words, it’s not “I shout once and whoever hears it, hears it” — it’s a sync barrier: only once all consumers have caught up is the Agent allowed to proceed to the next step. This is how it guarantees that persistence and on-screen rendering never lag behind the loop’s own state.

A second, paired decision: no try-catch inside the listener loop — if a subscriber errors out, it bubbles up directly and fails the entire run. Better a loud crash than a silently-failing renderer that lies to you that everything is fine.

The one exception is the high-frequency tool_execution_update: instead of awaiting each one individually, the Promises are first collected and then waited on together via Promise.all at the end — a deliberate carve-out between correctness and performance.

Events are split into two layers overall: the kernel has 10 kinds (Agent / Turn / Message / Tool Execution, four nested layers, each paired as “start → update → end,” see packages/agent/src/types.ts:422-437), and the Session layer adds 7 more product-level events (compaction start / end, auto-retry, thinking level change, etc.).

Dual-Layer Messages: Rich Internally, Strict at the Boundary

There’s an inherent contradiction here: the model only recognizes three kinds of messages (UserMessage / AssistantMessage / ToolResultMessage), but the product layer wants to store far more than that — for example, when you type !ls -la in pi to run bash directly, that record needs to both display as a command block in the UI and be able to enter the model’s context.

The solution is a “rich internally, strict at the boundary” dual-layer structure:

  1. AgentMessage = Message (3 standard types) | CustomAgentMessages (application-layer custom types)

CustomAgentMessages (packages/agent/src/types.ts:310) is an empty interface in the kernel; coding-agent uses TypeScript’s declaration merging to inject 4 of its own types into it (packages/coding-agent/src/core/messages.ts:70-77):

  1. declare module "@earendil-works/pi-agent-core" {
  2. interface CustomAgentMessages {
  3. bashExecution: BashExecutionMessage;
  4. custom: CustomMessage;
  5. branchSummary: BranchSummaryMessage;
  6. compactionSummary: CompactionSummaryMessage;
  7. }
  8. }

As a result, the core package has zero dependency on these 4 types, while the application layer still gets full type safety.

Before being sent to the model, messages pass through a two-stage pipeline with strictly separated responsibilities:

  • transformContext: AgentMessage → AgentMessage, trimming and injection within the same layer
  • convertToLlm: AgentMessage → Message, a cross-layer lossy translation, where custom types are all downgraded to UserMessage

Add one more excludeFromContext switch, and you get three visibility tiers: visible to both the model and the UI / visible only to the UI / persisted to disk but visible to neither.

In one sentence: structured storage, with lossy translation only at the boundary.

Session and Context Management

Context Management

The window is fixed, but the conversation is infinite. Pi doesn’t try to solve this with one big trick — instead it splits the problem into four parts, each handled by a technique suited specifically to that part:

Source of bloat Protection layer Specific technique
A single tool output is too large (bash outputs tens of KB, read on a large file 80KB) Tool output truncation 2000-line + 50KB dual limit, whichever triggers first wins; truncateHead for read, truncateTail for bash; accumulated byte-by-byte per whole line, never returning a half-line; after truncation, the model is always given a way to continue fetching more data
Project conventions and Skills fully stuffed into the system prompt System prompt assembly AGENTS.md / CLAUDE.md are recursively gathered from the current directory up to the root — global → ancestors → this project, with the most specific one placed last, wrapped in XML tags; Skills are lazy-loaded, with only a several-hundred-token manifest included
Linear growth of the conversation (50 rounds × 3000 tokens) Compaction Triggered once window − reserveTokens is exceeded; accumulates from the newest backward to find the cut point at keepRecentTokens; compressed into a 6-section structured summary, with the file manifest accumulating across compactions
Experience from old branches forgotten after a fork Branch summary An LCA algorithm locates the fork point of the abandoned branch; a 5-section template, maxTokens fixed at 2048, positioned as supplementary context that doesn’t overshadow the main content

These key constants can all be found in the code:

  • The dual truncation limits are in packages/coding-agent/src/core/tools/truncate.ts:11-12 (DEFAULT_MAX_LINES = 2000, DEFAULT_MAX_BYTES = 50 * 1024);
  • The compaction thresholds are in packages/coding-agent/src/core/compaction/compaction.ts:134-135 (reserveTokens: 16384, keepRecentTokens: 20000).

“Giving the model a way to continue fetching data after truncation” looks different across the two tools: bash writes the full output to tmpdir()/pi-bash-<id>.log, appending a line in the footer like Full output: <path> so the model can read it itself; read doesn’t write to a file — instead it hints Use offset=N to continue, and in the extreme case where a single line already exceeds 50KB, it instead suggests the model use sed -n 'Np' <path> | head -c 51200. Multibyte safety isn’t handled by accumulating character-by-character either: the main loop computes Buffer.byteLength per whole line, which naturally never cuts a character in half; only truncateTail, when it hits the edge case where “the last line alone already exceeds the limit,” needs to cut inside a line — and only there does it use (byte & 0xc0) === 0x80 to search backward for a UTF-8 character boundary.

Context Compaction

Context compaction essentially compresses a span of context into a CompactionEntry, which contains the following elements:

  • Goal: what the user originally wanted to do
  • Constraints & Preferences: what constraints exist
  • Progress: what’s been done (Done / In Progress / Blocked)
  • Key Decisions: key decisions made
  • Next Steps: what to do next
  • Critical Context: critical information that must not be forgotten

Cut Point Algorithm

The cut point algorithm (findCutPoint at compaction.ts:403) has two hard rules worth remembering: accumulate from the newest message backward (because the most recent context is the most important and needs to be protected), and the cut point must never land on a toolResult — cutting there would discard the assistant message before it that carries the toolCall, leaving behind an orphaned toolResult with a result but no corresponding call, which would confuse the model. Conversely, cutting at an assistant message that carries a toolCall is safe: its tool result comes after it and will be kept together with it.

Compaction Node Management

When compaction lands, it doesn’t modify any existing entry — instead, it appends a CompactionEntry onto the session tree. The firstKeptEntryId it carries is exactly the watershed between “history swallowed by the summary” and “recent conversation kept verbatim”:

pi Compaction and firstKeptEntryId

There’s a detail here that’s easy to miss: the CompactionEntry is physically placed last on the tree (hung after the leaf), but comes first in the flattened message list — buildContextEntries() first pushes the compaction node, then pushes the retained region starting from firstKeptEntryId. And the next compaction will use this same pointer as its starting line: prepareCompaction() sets boundaryStart to the index it sits at, and findCutPoint() only looks for a new cut point after it — old messages that have already been summarized will never be re-summarized. Once a new compaction node lands, replay only recognizes the last one on the path; the old one automatically drops out of context, so summaries never pile up into two at once.

The summary uses a fixed 6-section template (Goal / Constraints & Preferences / Progress / Key Decisions / Next Steps / Critical Context), and on repeated compactions it passes in previousSummary for an incremental update rather than a rewrite. There’s also a small design specific to coding agents: the two tags <read-files> and <modified-files> accumulate across compactions — the conversation can be forgotten, but “which files I’ve touched” cannot.

Session Management

Session Forking

What Is Session Forking

To understand what session forking is, recall from above that an agent’s trace is a linear structure. This kind of linear conversation has one fatal property: one failed exploration permanently pollutes the window. You ask the agent to look into a bug, it reads the wrong file, tries 5 rounds of wrong hypotheses, and stuffs in 80KB of tool output along the way. This now costs twice over — every subsequent round has to pay the token cost again, and the model gets anchored by it, with its subsequent reasoning carrying the bias of “we were just investigating in that direction.”

With linear history you only have two choices: put up with it, or delete it. Deleting permanently loses the record.

A tree is the only structure that can move content out of the context without destroying the record. This is the fundamental reason forking exists; everything else is derivative.

The Purpose of Session Forking

In an ordinary chat scenario, if you go down the wrong path, you just went down the wrong path — delete it. But in a coding agent, a failed branch often accumulates real knowledge: that file wasn’t the entry point, that hypothesis was ruled out by the logs, that dependency version didn’t match.

If forking simply meant “discard the old path,” these lessons would have to be re-learned every time. So pi has BranchSummaryEntry, which is attached at the new landing point rather than on the old branch — paying 2048 tokens to buy back the old branch’s conclusions, instead of carrying that entire 30K-token process along.

“Take the conclusions of the exploration with you, throw away the process of the exploration” — this is something linear history simply cannot do; it can only keep everything or discard everything.

Reclaiming Session Forks

When switching branches, the abandoned branch can be summarized along the way. collectEntriesForBranchSummary() first uses LCA to find the common ancestor — it pours the old path’s ids into a Set, then scans backward from the tail of the target path; the first hit is the deepest common ancestor — then it walks back from the old leaf to the LCA collecting entries.

The key point is that the summary is attached at the navigation target position, not on the old branch:

  1. // Summary is attached at the navigation target position (newLeafId), not the old branch
  2. const summaryId = this.sessionManager.branchWithSummary(newLeafId, summaryText, ...);

Attaching it on the old branch would be meaningless — the old branch is no longer on your path, so attaching it there is equivalent to writing it and then throwing it away. Attaching it at the new landing point means the BranchSummaryEntry actually appears on the new path, enters the LLM context, and on the new branch you actually “remember” what was already tried on that other path. This is the implementation of the protection layer mentioned earlier in the article: “experience from old branches forgotten after a fork.”

The summary itself uses a 5-section template with maxTokens fixed at 2048, positioned as supplementary context that doesn’t overshadow the main content. Also, collectEntriesForBranchSummary doesn’t stop when it hits a compaction node — the compaction node gets collected along with everything else, and its summary is used directly as material.

Session Storage: An Append-Only Tree

The last layer is persistence. There are two orthogonal decisions here that many people conflate:

  • Where it’s stored — local JSONL files
  • How it’s organized — a Session Tree

Either can be swapped independently: switching to a database wouldn’t affect the tree structure, and switching to a linear array wouldn’t affect the storage medium.

A session that goes “ask → answer → switch model and continue → regret and roll back → switch approach and re-ask” grows into a tree like this:

  1. e1 MessageEntry User: what's going on with this bug
  2. └── e2 MessageEntry Assistant: let me check → calls the read tool
  3. ├── e3 ModelChangeEntry switch to a stronger model to keep investigating (this branch was later abandoned)
  4. └── e5 BranchSummaryEntry summary of the abandoned branch e3
  5. └── e4 MessageEntry back to e2, re-asking with a different approach ★ leafId currently points here

The essence of the tree is knowing only the parent, never the children: every node only stores parentId, not a list of child nodes. This yields a very comfortable property — undo, retry, and forking never delete any node, they just move the leafId pointer to a different position. History stays forever complete, and you can jump back to any point at any time and grow a new branch from there.

buildSessionContext() (packages/coding-agent/src/core/session-manager.ts:461) is responsible for flattening the tree into a linear sequence: it walks back from the current leaf to the root, collecting only the entries on that branch, dispatching by type along the way — out of 9 entry types, 4 go into the LLM context, 2 only change model parameters, and 3 are pure metadata that get skipped entirely.

Hidden here is a third transferable lesson: materialize state changes as nodes too. Switching models or changing the thinking level isn’t recorded in some global variable — it’s attached to the tree as a ModelChangeEntry / ThinkingLevelChangeEntry. So when you roll back to some point in history, “which model was being used at the time” is automatically correct — no extra version-consistency logic is needed.

Summary

From the source code analysis above, we find that Pi is really just repeatedly applying four design principles:

  1. Protocol over implementation — the 12-event protocol, the StreamFunction signature, the AgentTool interface — all achieve polymorphism through convention rather than inheritance. Adding a new provider or a new tool never requires touching the kernel.
  2. Errors as messages — from the ai layer to the tool layer to the loop layer, errors are always encoded into the data stream and handed to the model; the loop never breaks.
  3. Unidirectional dependency + progressive typing — lower layers have no idea upper layers exist; upper layers extend outward using extends, union types, and declaration merging. So each layer can be pulled out and used independently.
  4. Load on demand rather than pre-loading everything — the Skills manifest, recursive CLAUDE.md lookup, and giving a file path after truncation are all instances of “here’s the directory, go fetch it yourself.”

Whichever layer you want to change, you can generally focus on just that layer — this is probably the biggest value of this codebase.