Quick overview of the Pi-Mono Framework
A project I’ve been working on lately runs on Pi under the hood. After using it for a while, I found the way it decomposes the agent loop pretty clean, so I want to take some time to systematically write it down. I’ll start with how the Pi-Mono agent loop works, then build on top of that to cover how Sub-Agent and MCP support are implemented.
Glossary
A few Pi-specific terms up front, so we don’t have to flip back later:
- Session: one full conversation, from
Pi Session Startsall the way through torun_end - Run / Turn / Message / Tool Call: Pi’s four-layer event model, from coarse to fine
- Steering: a “redirect” message the user injects mid-session, allowing the current turn to be swayed in a different direction
- Followup: a supplemental message queued after a turn ends, picked up in the next turn
- Subagent: a child Pi Session launched as a separate process; the parent session waits for it to finish before continuing
- MCP Adaptor: MCP (Model Context Protocol) collapsed into a single built-in tool
mcp(...)that handles mounting and dispatch
1. The Session Main Loop

The first sketch is the main loop. The first thing that caught my eye reading Pi’s code was the event granularity — run / turn / message, tool_call layered from coarse to fine. A run wraps several turns, each turn runs one llm streaming pass (broken into message_start / update / end), and when there are tool calls it enters a tool_execution_cycle. The immediate benefit of this layering is that external hooks can attach at whichever level they need.
Walking through the flow: after Pi Session Starts, a batch of pre-session events runs first — directory check, session-start UI notify, resource discover (this is where skills / prompts get injected). By the time you reach User Prompts, tools are attached as well.
Then come the pre-prompt events: input can rewrite the user input, before_agent_start appends context, and agent-start signals the actual kick-off. The core chain is run_start → turn_start → llm_streaming.
When llm streaming finishes, there are a few branches:
- If the steering queue has a message → go straight to
start new turn, carrying the redirect message in - No steering but more tool calls remain → enter
tool_execution_cycle(tool_execution_start → tool_call → tool_execution_update → tool_result → tool_execution_end), then back tollm evaluationso the model can decide what’s next - Neither → check the followup queue; if there’s anything, jump into the next turn; otherwise
run_end
The bit I find neat is the steering / followup queues. They turn “user interrupts mid-flight” from a special case into two explicit consumption points inside the main loop, so async input timing no longer has to be scattered all over the place. Session events themselves are also mount points for cross-cutting concerns (permission control, memory injection, UI notifications), which makes the whole thing read as “one main loop + a pile of pluggable callbacks” — not that complicated.
2. Subagent: Compressing Concurrency Into One Tool Call

Pi’s take on subagents is clever: it’s just a special tool call. When the parent session hits tool_execution: subagent_call, it blocks and waits for every subagent to stream results back over cross-process jsonl before moving on.
Two flags corral the child process behavior:
--mode json: the entire child session streams as jsonl; the parent only needs to match the core events (message_start / message_end / tool_execution_xx) to track progress--no-session: the child terminates as soon as the current turn finishes with no further tool calls — naturally one-shot, no persistent session left behind
Structurally the child session is nearly isomorphic to the main session; it just skips session persistence. Because each one runs in its own process, concurrent sub-tasks don’t share state and can’t contaminate the main loop’s context. From the parent’s perspective, all it sees is one ordinary tool_execution — “parallelism” gets no special syntax in the event model. I quite like this “don’t invent new concepts” approach.
3. MCP Adaptor: One Tool Swallows the Whole Protocol

Going into the MCP part, I expected to see a bunch of tool schemas fanned out — mount one server, get a dozen tools kind of thing. Pi doesn’t do that. Instead, it exposes exactly one mcp(...) tool and dispatches actions via parameters:
mcp({
action?: string, // e.g. oauth
server?: string, // list all the tools in given server
search?: string, // search the item in all mcp server
describe?: string, // read the describe info of tool and schema
connect?: string, // connect to a mcp server
tool?: string,
args?: string,
regex?: boolean,
includeSchemas?: boolean
})
Discovery, connection, auth, invocation — all folded into this single entry. From the main loop’s point of view, it’s just “turn start → llm streaming → tool_execution: mcp → tool_execution_result → llm_evaluation → keep looping until there are no more tool calls”.
The right half of the sketch gives a concrete example that I find the most illustrative — document read + OAuth authorization. Three phases:
- First call to
read_documentshits an unauthorized state → returnsauth_required - Launch auth:
action: "auth", parameters pulled from themcp.authconfig (grantType / clientId / scope / redirectUri, standard OAuth 2.0) - Once you have the redirectUrl, send another call with
action: "auth-complete"carryingcodeandstate; the auth info gets persisted locally; then the model naturally “reads again” to finish the original request
OAuth — normally a multi-turn external interaction — gets no special treatment here. It’s just the llm calling the same mcp(...) tool multiple times with different action values. The whole path still obeys the turn / tool_call event model. Same idea as the subagent section: don’t invent new syntax for special cases; push everything back onto the main loop.
Wrap
The three sketches line up with Pi’s three design layers: the main loop slices events into four levels (run / turn / message, tool_call) plus the two async queues (steering / followup); subagents are one special tool call + a child process + jsonl to isolate concurrency; and MCP fits an entire protocol ecosystem — plus multi-turn auth — into a single mcp(...) tool.
The takeaway I found most valuable: push whatever you can back onto the main loop. Async input, concurrent sub-tasks, external protocols — capabilities that would normally each demand a separate channel — all get folded back into the same event stream in Pi. That’s what keeps the project simple, and much easier to understand and maintain down the road.