Launching a subagent is only half an orchestration system. The other half is deciding where its result goes. Should the parent model receive it now? Should it wake up later? Should an extension observe it without adding tokens to the prompt? Should the user see a compact row, a notification, or the entire child conversation?
Pi-subagents answers those questions without inventing another agent protocol. The child runner returns a structured outcome; AgentManager settles one AgentRecord; routing code then projects that state through Pi’s existing tool, message, session, event, and TUI contracts.
Source lock. This chapter describes pi-subagents 0.18.0 at commit
3f9d35con Pi 0.84.2 at commit914cf14. “Pi” below means that pinned host, not an unspecified current release. All implementation links below are pinned to these commits.
Recall: one settled record, not two result engines
From the previous chapters, recall the ownership chain:
child AgentSession
│ produces responseText / failure / usage / session
▼
AgentManager settles one AgentRecord
│
├── foreground route ──► current Agent tool result
├── background route ──► later custom message
├── history route ─────► custom session entry
├── observer route ────► lifecycle event bus
└── human routes ──────► renderer, widget, FleetView, viewer
The manager’s settle path writes status, result, error, session, and completedAt before calling its completion callback. Foreground and background runs therefore share terminal classification, but mode matters before and after settlement: foreground work bypasses background admission and follows the tool-call cancellation signal, while background work uses detached queue policy; their outcomes then take different delivery paths (manager settle path). The chapter’s central design lesson remains: execution creates an outcome; product policy chooses its surfaces.
1. Foreground: the result returns inside the current tool call
In foreground mode, the Agent tool calls spawnAndWait(), which starts a normal managed record with isBackground: false and awaits its promise (spawnAndWait). When the run settles, the manager sets resultConsumed = true before invoking the shared completion callback. Lifecycle events and persistence can still happen, but the callback knows not to send a second completion notification.
The tool handler then formats record.result and returns an ordinary AgentToolResult (foreground handler). Pi’s agent loop awaits the extension tool, converts its content and details into a ToolResultMessage, and emits that message into the current turn (Pi tool execution). Thus the parent model receives the child result through the same tool-result channel it already understands.
There is no extra wake-up. The parent is already inside a turn, waiting for the tool. Structured details serve UI and logs; the model-facing payload is content. A useful consequence is that a foreground result needs no separate retrieval protocol. A subtle consequence is that the foreground agent ID lives in renderer details rather than model-visible text; the parent should use the inline answer, not invent an ID and call get_subagent_result (regression test).
2. Background: return a receipt now, deliver the outcome later
Background mode must release the current tool call before the child finishes. Its first Agent tool result is therefore only a receipt: started or queued, agent ID, type, description, and retrieval instructions (background handler). That receipt enters the current parent turn as a normal tool result, just like the foreground answer, but it does not pretend the work is done.
For an unconsumed record on the individual asynchronous-delivery path, settlement schedules a short 200 ms hold, rechecks resultConsumed, then sends a custom message. Consumed records skip the nudge, while grouped records follow the separate group-join barrier:
pi.sendMessage(
{
customType: "subagent-notification",
content: notification,
display: true,
details,
},
{ deliverAs: "followUp", triggerTurn: true },
);
The implementation is at the background nudge boundary. The two options have different jobs. If Pi is streaming, deliverAs: "followUp" queues the custom message after the current work rather than steering the active response. If Pi is idle, triggerTurn: true starts a new agent turn with that message. Pi’s host implementation makes both cases explicit (sendCustomMessage).
That is why a background completion can cause the parent to reason again: it is not merely a desktop toast. It is a model-visible custom message with a deliberate continuation policy.
3. Persistence is not delivery
The same completion callback also calls:
pi.appendEntry("subagents:record", { id, type, status, result, error, ... });
This stores history metadata intended for cross-extension reconstruction (completion callback). It does not notify the model, start a turn, or render a chat message. Pi’s API contract says that custom entries are for state persistence and are “not sent to LLM” (extension types); the host appends the custom entry and emits an entry_appended observation (host binding). These operations update the parent SessionManager, but disk durability depends on whether that parent manager is persistent or in memory. At this commit, pi-subagents has no reader that rebuilds live AgentRecords from these entries, so the entry must not be described as durable fleet restoration.
Keep this distinction sharp:
| Operation | Recorded in parent SessionManager? | Enters model context | Can start a turn |
|---|---|---|---|
| foreground tool return | yes, as toolResult; disk only with a persistent parent manager | yes | already in one |
sendMessage completion | when Pi consumes the custom message; a queued follow-up is not yet history | yes, when consumed | yes |
appendEntry record | yes, as custom entry; disk only with a persistent parent manager | no | no |
pi.events.emit | no guarantee | no | no |
Calling all four “messages” would erase the most important boundary in the design.
None of these rows alone guarantees disk durability. A persistent parent writes entries according to SessionManager flush rules; an in-memory parent preserves none across process exit.
4. Retrieval is an alternative consumer
get_subagent_result lets the parent pull the full result rather than wait for the preview notification. Its state machine is careful (tool implementation):
- With
wait: false, a running or queued record returns status and remains unconsumed. - With
wait: true, a queued record is polled until it starts, then its promise is awaited. - Aborting this wait aborts only the retrieval tool call. The child keeps running and remains eligible for notification.
- Once a terminal result is returned, the tool sets
resultConsumed = trueand cancels a pending individual nudge.
Why the 200 ms hold on the individual path? Completion and a waiting retrieval resume on neighboring microtasks. Without a grace window, completion could call fire-and-forget sendMessage just before retrieval marks the record consumed. The hold lets successful retrieval win that race, and the callback rechecks the flag. Grouped delivery uses its own timers and consumption filtering instead. Tests drive the real extension wiring with a mocked runner and cover queued waiting, notification suppression, and abort-without-child-abort (wait tests).
Group join adds only a delivery barrier. A 100 ms spawn debounce forms a group; completions are held until all arrive or a 30-second timeout produces a partial batch, with 15-second rebatching for stragglers (batch wiring, join state machine). It does not change runner outcomes or ownership; it only reduces how many follow-up messages wake the parent.
5. Lifecycle events: an observation plane
Lifecycle events are an observation plane, not an ordered creation state machine. An immediately started Agent-tool spawn can emit started synchronously before its caller emits created; a queued background resume emits created before the later started. Consumers must correlate by id and tolerate either order. Settlement emits completed or failed; compaction emits subagents:compacted; steer_subagent and @handle steering emit subagents:steered, while UI calls that invoke manager.steer() directly do not. Foreground, RPC, and scheduler paths do not all emit the same family (event emission). Extensions can react without scraping terminal text, but they cannot treat the event stream as a durable ordered log.
Pi supplies only a generic in-process event bus: string channel, unknown payload, on, emit, and unsubscribe, backed by Node’s EventEmitter (Pi event bus). Pi-subagents defines the channel names and payload meanings. These events are observations, not durable entries and not LLM messages.
RPC boundary.
subagents:rpc:ping,:spawn, and:stopare also pi-subagents-defined channels. The extension adds request IDs, per-request reply channels, success/error envelopes, and protocol version 2 (local RPC implementation). Handlers are registered only aftersession_startand removed at shutdown (lifecycle gating, cleanup). This is an extension-local protocol carried by genericpi.events; it is not Pi core subagent support, transport RPC, or a persistence guarantee.
6. Human surfaces are projections, not owners
Pi provides rendering and TUI seams; pi-subagents supplies the product views.
- The custom message renderer is registered for
subagent-notification. It turns notificationdetailsinto collapsed or expanded Pi TUIText(renderer). Pi’s interactive transcript looks up the renderer bycustomType(renderer lookup, custom-message fallback). Rendering changes appearance, not context delivery. AgentWidgetreads top-level records fromAgentManager, combines them with ephemeral activity, and registers anaboveEditorwidget plus status text (projection and registration, update).FleetViewis abelowEditorroster built frommanager.listAgents()and records that have a child session. Selecting a row opensctx.ui.custom()with aConversationViewer(roster, overlay).- The viewer reads
AgentSession.messages, subscribes to session events to request re-rendering, and exposes steer/stop controls through manager callbacks (subscription, message projection). Pi suppliessetWidget, terminal input, andcustom()component APIs (Pi UI contract).
If a widget is cleared, the agent does not stop. If the viewer closes, its subscription is removed, not its record. State ownership remains with AgentManager and AgentSession; UI components are disposable projections.
Predict: route four deliveries
Before opening the answer, predict which cases enter model context, start a new turn, and suppress a later completion nudge.
- A foreground child finishes while the parent is awaiting
Agent. - A background child finishes unconsumed while the parent is idle.
get_subagent_result({ wait: true })successfully returns a background result during the 200 ms hold.- The completion callback only appends
subagents:record.
Reveal the routing
- The answer becomes the current call’s
toolResult; it enters context but does not start a separate turn. Foreground settlement pre-consumes the record, so no nudge follows. sendMessageadds a custom message andtriggerTurn: truestarts a turn. It remains in session history and uses the custom renderer in TUI mode.- The retrieval tool’s own result enters the current turn. It marks the record consumed; the held nudge is cancelled or filtered.
- The custom entry enters the parent
SessionManagerbut stays outside model context. It reaches disk only when that manager is persistent; it neither renders as a custom chat message nor starts a turn.
Practice: add an audit consumer without duplicate delivery
Design an extension that records completion duration and status for analytics, but must never wake the model or suppress the user’s normal notification. Which surface should it consume, and what must it avoid mutating?
One sound design
Subscribe to subagents:completed and subagents:failed, copy only the required fields into the analytics sink, and unsubscribe on the extension’s shutdown boundary. Do not call get_subagent_result, set resultConsumed, or send another custom message: those actions participate in delivery policy. Treat the event as best-effort observation; if durable audit history is required, persist it explicitly with an idempotency key such as the agent ID rather than assuming pi.events is durable.
Evidence and proof gaps
The pinned source proves the host contracts for tool-result conversion, custom-message queuing, non-LLM custom entries, generic events, renderer lookup, widgets, and session subscriptions. Pi-subagents tests cover manager routing, wait cancellation, group timers, RPC envelopes, renderer output, and UI wiring. However, many of those tests use a mocked child runner, an in-memory event bus, or mocked TUI methods. On 2026-08-24, the pinned checkout’s full offline suite passed 77 test files and 1,388 tests, with four tests skipped. The opt-in live print-mode smoke suite was not run; it exercises real models, remains nondeterministic, and does not prove every interactive renderer or terminal behavior. The strongest claim is therefore architectural and source-backed, not a claim that every terminal/provider combination was executed here.
Source index
- Outcome settlement and foreground consumption
- Completion routing, persistence, lifecycle events, and renderer
- Background receipt and foreground inline return
get_subagent_result- RPC protocol over
pi.events - Widget, FleetView, and conversation viewer
- Pi extension actions and TUI APIs
- Pi message delivery and persistence bindings
- Pi generic event bus
Next: Chapter 9 follows a result beyond one session: persistence, transcripts, and what can be reconstructed after in-memory state is gone.