subagent

Source: src/subagent.ts

A subagent: a live session, a memory, a state.

This is the heart of the library. Everything else - run, chain, fanOut - is built on the three methods below: ask, usage, close.

The rule that governs this file: whoever opens, closes. A Subagent is an explicit object with an explicit owner. There is no global session cache hidden anywhere.

AskOptions

type

export type AskOptions = {
	/**
	 * Cancels this turn. pi's `prompt()` takes no signal, so we bridge it to
	 * `session.abort()`.
	 *
	 * The turn fails with `"aborted"`, unless the signal's reason is a
	 * `TimeoutError`, as `AbortSignal.timeout` gives: then it is a deadline,
	 * and fails with the reason's message, as {@link AskOptions.timeoutMs} does.
	 */
	signal?: AbortSignal;
	/**
	 * Deadline for this turn, in milliseconds. No default: an `ask` waits
	 * forever unless you say otherwise.
	 *
	 * This matters more than it looks. One `ask` is one `session.prompt()`, and
	 * pi's agent loop is a `while (true)` that runs as long as the model keeps
	 * requesting tools - there is no step cap in pi. A model that hallucinates a
	 * tool name, gets "unknown tool" back and asks again will loop until
	 * something stops it. Nothing will, unless it is this.
	 */
	timeoutMs?: number;
};

What governs one turn: how it can be stopped, and when it must be.

CustomToolsFor

type

export type CustomToolsFor = (id: string) => ToolDefinition[] | undefined;

Tools built once the subagent’s id is known.

The one thing a caller cannot decide before spawn: a tool that spawns children has to name their parent, and the parent does not exist yet.

spawn

function

export async function spawn(agent: Agent, options: SpawnOptions = {}): Promise<Subagent> { /* … */ }

Brings an agent to life.

The lifetime is explicit and local: the argument wins over the agent’s frontmatter, which itself wins over the "task" default. Persistence is asked for; it is never obtained by accident.

The caller owns the returned object and must close() it, ideally in a finally.

SpawnOptions

type

export type SpawnOptions = {
	/** Overrides the lifetime declared by the agent. Absent from both, `"task"`. */
	lifetime?: Lifetime;
	/** Working directory the subagent's tools act in. Defaults to the process's own. */
	cwd?: string;
	/** Dedicated session directory, required for the session to be exportable. */
	sessionDir?: string;
	/**
	 * Where this subagent writes its HTML and JSONL when it closes.
	 *
	 * Setting it **implies a session directory** (`<exportDir>/.sessions`),
	 * because pi cannot render an in-memory session to HTML. That is one
	 * decision, not two: asking for an export is asking for the session to be
	 * kept long enough to export it. Pass {@link SpawnOptions.sessionDir}
	 * explicitly to put it somewhere else.
	 *
	 * Still opt-in: with no `exportDir`, a subagent stays in memory and leaves
	 * nothing behind - not in `~/.pi`, not in the working directory.
	 */
	exportDir?: string;
	/** The name its files take in `exportDir`, without the extension. Defaults to its id's, `reviewer-2`. */
	exportName?: string;
	/** Subscribed to the event stream for the subagent's whole life. */
	onEvent?: EventListener;
	/** Shared bus, when several subagents must report to the same place. */
	bus?: EventBus;
	/** Session factory. Injection point for tests - defaults to a real pi session. */
	createSession?: CreateSession;
	/**
	 * Tools combo defines, offered to this subagent.
	 *
	 * Offered, not granted: the agent's `tools:` is an allowlist and covers these
	 * too, so one it does not name is not enabled. See
	 * {@link CreateSessionOptions.customTools}.
	 *
	 * A function receives the id this subagent is about to get, before its
	 * session opens. That is what a tool spawning children needs in order to
	 * name their parent, and it is the only way to have it: the id is minted
	 * here, after the caller has built everything it could.
	 */
	customTools?: ToolDefinition[] | CustomToolsFor;
	/**
	 * The subagent that had this one spawned, when one did.
	 *
	 * Set by `delegateTool`, never guessed: a name is ambiguous the moment two
	 * explorers run at once, so the link is an id or it is nothing. It reaches
	 * the reporters on the `spawn` event and nothing else reads it.
	 */
	parentId?: string;
	/** The flow visit this subagent is spawned for. It reaches the reporters on the `spawn` event. */
	visit?: string;
	/** Where a flow keeps it: its memory scope's path, else its visit's. It reaches the reporters on the `spawn` event. */
	home?: string;
	/**
	 * Model pattern for this subagent, e.g. `"anthropic/claude-sonnet-5"`.
	 *
	 * An override, not a default: the argument wins over the agent's
	 * frontmatter, which wins over pi's own settings - the same rule as
	 * {@link SpawnOptions.lifetime}. It exists so one workflow can run against
	 * different models without editing a single agent file; a frontmatter model
	 * surviving a sweep would make an experiment measure a mixture.
	 *
	 * A pattern that resolves to nothing throws at spawn: better than running
	 * a whole workflow on the wrong model.
	 */
	model?: string;
	/**
	 * Give this subagent its own herdr split, when running inside herdr.
	 *
	 * Opt-in per subagent, like {@link SpawnOptions.lifetime}, and resolved the
	 * same way: this argument wins over the agent's frontmatter, which wins over
	 * `false`. A fan-out of twenty branches must not carpet the screen unless
	 * someone asked for it. Outside herdr it is simply ignored.
	 */
	openInHerdr?: boolean;
};

Everything that can be decided about a subagent before it exists.

Subagent

type

export type Subagent = {
	/** Unique for the process, e.g. `reviewer#2`. Names its transcript files. */
	readonly id: string;
	/** The definition it was spawned from. Inert data - it is not re-read. */
	readonly agent: Agent;
	/** Resolved once, at spawn: the argument, then the frontmatter, then `"task"`. */
	readonly lifetime: Lifetime;
	/** `provider/id` as pi resolved it, which only the session knows. Absent when pi could not say. */
	readonly model?: string;
	/** Cumulative measurements since spawn. Read `Result.usage` for a single turn. */
	readonly usage: Usage;
	/** Runs one turn of work. Never throws on a model failure - returns `ok: false`. */
	ask(task: string, options?: AskOptions): Promise<Result>;
	/**
	 * Stops this subagent, for good: the turn in flight is cut short, and any
	 * later `ask` fails at once with `"stopped"`.
	 *
	 * One-way and idempotent, because that is what a person pressing a key
	 * means. It is **not** `close()`: the session is still there, so the
	 * transcript of what it did before it was stopped is still exportable, and
	 * whoever opened it still owes it a `close()`.
	 */
	stop(): void;
	/**
	 * Writes this subagent's transcript into `dir` - HTML and JSONL, pi's own.
	 *
	 * Callable at any moment while the subagent lives, not only at the end: an
	 * interrupted workflow must still be able to export what it did. After
	 * `close()` the session is gone, so this reports an error instead of
	 * throwing - losing an export must never be worse than losing the run.
	 */
	export(dir?: string): Promise<SessionExport>;
	/** Releases the session. Idempotent. */
	close(): Promise<void>;
};

A living subagent. Its owner is whoever called {@link spawn}.