pi-subagents: How the Custom Pi Subagents Are Set Up

A walkthrough of the subagent and workflow tools in the pi-subagnets repo — a pi extension that lets the parent model define and orchestrate subagents on the fly, with no predefined agent roster.

Overview

The project is a single pi extension living at .pi/extensions/pi-subagents/, made of two files:

Extension files
FileLinesResponsibility
index.ts~1350The subagent tool (single / parallel / chain modes), the shared runSingleAgent() core, structured-output plumbing, and TUI rendering.
workflow.ts~1370The workflow tool: a sandboxed JavaScript orchestration runtime with agent(), spawn(), parallel(), race(), waitAny(), journaling, and resume.
The core idea: no predefined agents

Unlike systems with a registry of named agents, there are no roles, prompts, or agent files here. The parent model defines each subagent inline at call time by passing a systemPrompt (the role) plus a task (the job). The extension is pure mechanism; the model supplies all the policy.

Dependencies are the pi SDK packages (@earendil-works/pi-agent-core, pi-coding-agent, pi-tui, all ^0.80.2) plus typebox for tool parameter schemas. The only script is npm run check (tsc --noEmit) — pi loads the .ts extension files directly.

Architecture: in-process sessions

Each subagent is not a separate OS process. runSingleAgent() creates a fresh headless createAgentSession() from the pi SDK, running in the same process as the parent, with:

  • An in-memory session (SessionManager.inMemory(cwd)) — nothing is written to session history.
  • A DefaultResourceLoader with noExtensions: true — the subagent/workflow tools never load inside a child, which prevents recursion, while skills and AGENTS.md context are still discovered normally.
  • The parent's model registry — subagents resolve models and auth exactly like the parent, and inherit the parent session's model unless a spec pins its own model (matched as provider/id or exact id; loose substring matches are deliberately rejected so a typo falls back to the parent model instead of silently picking the wrong one).
  • A live event subscription — the runner listens for message_end / tool_execution_end events to stream progress into the parent TUI, and reads the final transcript and token usage straight off the session.

The subagent's extra instructions are delivered via appendSystemPrompt, so the inline systemPrompt is appended to pi's default system prompt rather than replacing it. The task itself arrives as the first user message: Task: <task>.

Abort is cooperative: an AbortSignal from the parent triggers session.abort(), and an aborted subagent returns a result with stopReason: "aborted" instead of throwing — so parallel and chain runs keep partial results rather than crashing.

The subagent tool

Registered by index.ts, this is the direct delegation tool. Exactly one mode must be supplied per call:

Modes
ModeShapeBehavior
Single { task, systemPrompt? } Runs one subagent and returns its final assistant text.
Parallel { tasks: [{ task, ... }, ...] } Fans out up to 8 tasks with concurrency 4. Returns a combined report (Parallel: N/M succeeded plus a per-task section); each task's text is capped at 50 KB with the full output preserved in tool details.
Chain { chain: [{ task, ... }, ...] } Runs steps sequentially. Each step's task can contain the {previous} placeholder, which is replaced with the prior step's output (using a function replacer so $&/$1 in the output stays literal). A failed step stops the chain and returns partial results with isError.

Every task item (in any mode) accepts the same optional spec fields:

systemPrompt
Role/approach written on the fly; appended to pi's default system prompt.
name
Short display label (e.g. auth-scout). Defaults to subagent, task N, or step N.
tools
Restrict to specific built-in tool names, e.g. ['read','grep','find','ls'] for a read-only scout. Omit for all default tools.
model
Override as provider/id or exact id. Omit to inherit the session model.
cwd
Working directory for the subagent. Defaults to the parent's cwd.

In chain mode, when a step produced structured output (see below), {previous} carries the JSON-serialized structured object rather than the step's prose — so data, not narration, flows between steps.

There's also a small /pi-subagents command that just prints a reminder that the tool is active and how it works.

Structured output

Any spec may include a JSON-schema-ish schema. When present, the runner:

  1. Converts the schema to TypeBox via jsonSchemaToTypebox() — covering objects, arrays, strings, numbers, booleans, enums, required, and descriptions. It tolerates schemas that omit type but are unambiguous (bare properties ⇒ object, bare items ⇒ array); anything unrecognized degrades to Type.Any().
  2. Injects a one-shot structured_output tool into the subagent whose parameters are that schema, so the model's arguments are validated at the tool-call layer (mismatches trigger a retry by the model, not a parse failure).
  3. Appends an instruction telling the subagent to call it exactly once as its final action; the tool sets terminate: true so the run ends there.
  4. Captures the validated object onto the result as result.structured, which chain mode and the workflow runtime both prefer over final text.

If a spec restricts tools and also supplies a schema, structured_output is automatically added to the allowlist.

The workflow tool

Registered by workflow.ts (wired in from index.ts via registerWorkflowTool(pi)), this is the orchestration layer: the model writes a JavaScript script inline, and the extension runs it in a lightweight sandbox built from an AsyncFunction whose parameters are the host primitives. Top-level export keywords are stripped so export const meta = {...} runs as a plain const; the meta object itself is extracted separately (by brace-matching the literal) to feed the progress UI before execution.

Script primitives
PrimitiveSemantics
agent(prompt, opts)Run one subagent and await it. Returns the parsed structured object when opts.schema is given, otherwise final text. Never rejects — a failed subagent still resolves with whatever it produced.
spawn(prompt, opts)Starts a subagent and returns a thenable handle immediately. await handle rejects with SubagentTaskError (carrying .label/.result) on failure — this is what lets race/waitAny distinguish success from failure.
parallel([thunks])Run zero-arg thunks concurrently (limit 6), wait for all, return results in order.
race([participants])Like Promise.race: settles with the first participant to finish, win or lose. Losers keep running — hold their handles and await them later.
waitAny([participants])Like Promise.any: first to succeed wins; failures are ignored unless all fail (then an AggregateError). Use spawn() handles here so failures are visible.
phase(title)Advance the live progress phase; agents started afterward group under it (or set opts.phase explicitly).
log(...args)Annotate the run; lines appear in the progress UI and journal.

agent()/spawn() options are { label?, phase?, schema?, model?, systemPrompt?, tools? } — the same knobs as a subagent spec. Everything else in the script is plain JavaScript (map/filter/if/await/template strings), so fan-out can be dynamic and later prompts can interpolate earlier results. A final return value becomes the workflow's reported result.

Concurrency is enforced by a single fair FIFO Semaphore (6 slots) shared by every entry point — so ad-hoc fan-out like a bare Promise.all([spawn(...), spawn(...), ...]) is bounded the same way parallel() is. The semaphore hands released slots directly to the next queued waiter, so a fresh acquire can't jump the queue. Every spawn()/race participant is also tracked in an inFlight list and joined with Promise.allSettled before the run finalizes, so a race loser nobody awaited is still fully journaled and counted in the pass/fail status.

Determinism, journaling, and resume

Workflows are designed to be resumable, which requires the script to be deterministic:

  • Guards: Date and Math.random are shadowed with proxies that throw with an explanatory message. Timestamps must be passed in via prompts or stamped after the workflow returns.
  • Journal: each run gets a directory <agentDir>/workflows/<runId>/ containing script.js (the exact script), journal.jsonl (start/phase/log/started/result/race_result entries appended as they happen), and a final workflow.json summary.
  • Call keys: every agent()/spawn() call is keyed by a SHA-256 of { callIndex, prompt, schema, label, model, systemPrompt, tools }. The ordinal callIndex is included so two identical calls in a loop don't collide in the cache.
  • Resume: re-invoking with { scriptPath, resumeFromRunId } replays completed calls from the journal instead of re-running them. Failed calls are replayed as the same rejection (shown as a cached failure, still counted toward pass/fail) rather than silently retried, so any branch that depended on the failure stays reproducible.
  • Race decisions: which participant won a race()/waitAny() is non-deterministic in real time, so the decision itself is journaled (keyed by call order). On resume the same winner is re-awaited — its underlying call is separately cached, so this resolves near-instantly — and the script takes the same branch it took originally. An all-failed waitAny() replays its AggregateError too.

Execution model & UI

Backgrounding. In interactive (TUI/RPC) sessions, workflows run in the background by default: the tool returns a run id, transcript directory, script path, and resume instructions immediately; live progress shows in the status footer; and completion is delivered back to the parent model as a follow-up user message (pi.sendUserMessage(..., { deliverAs: "followUp" })). In non-interactive (print/JSON) sessions there is nothing to background against, so runs are always synchronous — and if background: true was explicitly requested, a note says it was ignored rather than silently dropping it. On session_shutdown all active background runs are aborted.

Watching runs. The /workflows command lists runs (active ones marked with , read live from an in-memory registry; finished ones read from workflow.json), and /workflows <runId> shows one run's status and result.

Rendering. Both tools ship custom renderCall/renderResult implementations for the pi TUI: compact collapsed views (task previews, per-step ✓/✗ icons, running ⏳ and cached ⟲ states, Ctrl+O to expand hints) and expanded views with the full task, tool-call trails (bash/read/write/edit/grep calls formatted with home-relative paths and line ranges), markdown-rendered final output, and per-agent plus total usage lines (turns, ↑input/↓output tokens, cache read/write, cost, context size, model).

Limits & defaults

Max parallel tasks
8
subagent parallel mode
Subagent concurrency
4
parallel mode worker pool
Workflow concurrency
6
shared semaphore, all entry points
Per-task output cap
50 KB
parallel summaries; full output kept in details
Result text cap
6,000
chars, workflow result rendering
Inheritance defaults
SettingDefault
ModelParent session's model (override per spec with model; exact match only)
ToolsAll default built-in tools (restrict per spec with tools)
Working directoryParent's cwd (override per spec with cwd)
ExtensionsDisabled in children (prevents subagent recursion)
Skills / AGENTS.mdDiscovered normally in children
Session persistenceIn-memory only; workflow runs journal to <agentDir>/workflows/<runId>/
Workflow backgroundOn in interactive sessions, off (forced synchronous) otherwise