Pi Subagents · Field Guide
Reference
M08

Chapter 8: The Return Path — Results Become Product Surfaces

1,926 words · 20 code lines · 10 min

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 3f9d35c on Pi 0.84.2 at commit 914cf14. “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
Foreground and background outcome paths across parent context, events, and TUI projections
Foreground and background outcome paths across parent context, events, and TUI projections

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:

OperationRecorded in parent SessionManager?Enters model contextCan start a turn
foreground tool returnyes, as toolResult; disk only with a persistent parent manageryesalready in one
sendMessage completionwhen Pi consumes the custom message; a queued follow-up is not yet historyyes, when consumedyes
appendEntry recordyes, as custom entry; disk only with a persistent parent managernono
pi.events.emitno guaranteenono

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):

  1. With wait: false, a running or queued record returns status and remains unconsumed.
  2. With wait: true, a queued record is polled until it starts, then its promise is awaited.
  3. Aborting this wait aborts only the retrieval tool call. The child keeps running and remains eligible for notification.
  4. Once a terminal result is returned, the tool sets resultConsumed = true and 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 :stop are 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 after session_start and removed at shutdown (lifecycle gating, cleanup). This is an extension-local protocol carried by generic pi.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 notification details into collapsed or expanded Pi TUI Text (renderer). Pi’s interactive transcript looks up the renderer by customType (renderer lookup, custom-message fallback). Rendering changes appearance, not context delivery.
  • AgentWidget reads top-level records from AgentManager, combines them with ephemeral activity, and registers an aboveEditor widget plus status text (projection and registration, update).
  • FleetView is a belowEditor roster built from manager.listAgents() and records that have a child session. Selecting a row opens ctx.ui.custom() with a ConversationViewer (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 supplies setWidget, terminal input, and custom() 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.

  1. A foreground child finishes while the parent is awaiting Agent.
  2. A background child finishes unconsumed while the parent is idle.
  3. get_subagent_result({ wait: true }) successfully returns a background result during the 200 ms hold.
  4. The completion callback only appends subagents:record.
Reveal the routing
  1. 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.
  2. sendMessage adds a custom message and triggerTurn: true starts a turn. It remains in session history and uses the custom renderer in TUI mode.
  3. The retrieval tool’s own result enters the current turn. It marks the record consumed; the held nudge is cancelled or filtered.
  4. The custom entry enters the parent SessionManager but 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

Next: Chapter 9 follows a result beyond one session: persistence, transcripts, and what can be reconstructed after in-memory state is gone.

What to retain

  • Foreground and background execution share a runner but use different delivery contracts.
  • Consuming a terminal result suppresses redundant nudges without clearing the in-memory AgentRecord result.
  • The widget, FleetView, and conversation viewer project manager and AgentSession state through Pi's UI APIs.