pi-flows communicates with extensions through a typed event bus exposed on pi.events. Every interaction — registering content, observing a running flow, and querying state — is mediated by named flow:* events.
// Listen (subscribe)
const unsub = pi.events.on("flow:complete", (data: FlowResult) => { ... });
unsub(); // unsubscribe
// Emit (fire-and-forget)
pi.events.emit("flow:notify", { message: "Done!", level: "info" });
// Query pattern (synchronous mutation of the data object)
const query = { agents: undefined as any };
pi.events.emit("flow:get-agents", query);
const agents: Map<string, AgentConfig> = query.agents;The query pattern (mutation of the data object) is used when the caller needs an immediate synchronous answer from pi-flows. The listener mutates the payload object in-place and returns.
Emit these events during your extension's activate() to register content with pi-flows.
Register a directory of agent .md files. Triggers an immediate re-discovery scan.
pi.events.emit("flow:register-agents-dir", { dir: "/abs/path/to/agents" });Payload:
{ dir: string } // Absolute path to directory containing *.md agent filesRe-discovery overwrites agents with the same name field — later registrations win.
Unregister a previously registered agents directory.
pi.events.emit("flow:unregister-agents-dir", { dir: "/abs/path/to/agents" });Payload: { dir: string }
Register a directory of flow .yaml files. Triggers re-discovery and registers any new flows as slash-commands.
pi.events.emit("flow:register-flows-dir", { dir: "/abs/path/to/flows" });Payload: { dir: string }
Flow names are derived from the filesystem path relative to the registered root:
flows/research.yaml→researchflows/judo/research.yaml→judo:research
Maximum nesting depth: one subfolder. Files nested two or more levels deep are skipped with a warning.
Unregister a previously registered flows directory.
pi.events.emit("flow:unregister-flows-dir", { dir: "/abs/path/to/flows" });Payload: { dir: string }
Register a directory of skill bundles. Each subdirectory must contain a SKILL.md index file.
pi.events.emit("flow:register-skills-dir", { dir: "/abs/path/to/skills" });Payload: { dir: string }
Skills registered here are resolvable by name when an agent declares them in skills:; they are advertised in the agent's prompt and read on demand with read. See flow-authoring.md for skill directory format.
Inject an extension into every spawned agent subprocess. Use this to add guards, provider middleware, or custom tools to all subagent sessions.
// Option A: factory function (in-process, preferred)
pi.events.emit("flow:register-agent-extension", {
factory: async (piApi: ExtensionAPI) => {
piApi.on("tool_call", (event: any) => {
if (event.toolName === "bash" && event.params?.command?.startsWith("rm -rf")) {
return { block: true, reason: "rm -rf not allowed" };
}
});
},
});
// Option B: path to a TypeScript/JS file (loaded via dynamic import)
pi.events.emit("flow:register-agent-extension", {
path: join(pkgRoot, "extensions", "my-guard.ts"),
});Payload:
{
factory?: (pi: ExtensionAPI) => void | Promise<void>; // in-process factory
path?: string; // path to an extension module file
}Exactly one of factory or path must be set. Factories are preferred for performance (no module load overhead). Extensions registered here run in every spawned agent session — keep them lightweight.
Note:
flow:register-guard-extensionis a deprecated alias for this event; preferflow:register-agent-extension.
Register a precondition gate that blocks named flows from running unless a condition is satisfied.
pi.events.emit("flow:register-gate", {
name: "auth-required",
check: () => isAuthenticated(),
flows: ["judo:*"], // supports trailing wildcard
message: "You must be logged in. Run /login first.",
});Payload:
{
name: string; // unique gate identifier
check: () => boolean; // returns true if the gate PASSES (flow may run)
flows: string[]; // flow names or patterns (trailing * wildcard)
message: string; // shown to user when gate blocks the flow
}Gates are checked synchronously before any flow step runs.
Make a custom tool available inside spawned agent sessions (not the main session). Extension-provided tools appear alongside built-in tools in subagent sessions.
import { Type } from "@sinclair/typebox";
pi.events.emit("flow:register-tool", {
tool: {
name: "my_domain_query",
description: "Query domain-specific data.",
parameters: Type.Object({
entityId: Type.String({ description: "Entity to query" }),
}),
execute: async (_id, params, _signal, _onUpdate, _ctx) => {
const result = await queryDomain(params.entityId);
return { content: [{ type: "text", text: JSON.stringify(result) }], details: {} };
},
},
});Payload:
{ tool: ToolDefinition } // Full tool object including .execute()ToolDefinition follows the same shape as pi.registerTool() — see the pi-coding-agent SDK docs.
Register a custom metric renderer for the agent dashboard. Called with a name that maps to the card.metric field in agent frontmatter.
pi.events.emit("flow:register-card", {
name: "my-metric",
factory: () => new MyMetricRenderer(),
});Payload:
{
name: string; // matches agent frontmatter: card.metric: "my-metric"
factory: () => AgentCardRenderer;
}AgentCardRenderer interface:
interface AgentCardRenderer {
onToolCall(toolName: string, input: any): void;
onToolResult(toolName: string, output: any): void;
onComplete(result: AgentResult): void;
renderMetric(width: number): string; // single metric line shown in the card
}Register a workflow pipeline definition used by the dashboard breadcrumb to show progress stages.
pi.events.emit("flow:register-workflow", {
id: "my-pipeline",
stages: [
{ name: "Research", flows: ["my-pipeline:research"] },
{ name: "Implement", flows: ["my-pipeline:implement"] },
{ name: "Verify", flows: ["my-pipeline:verify"] },
],
});Payload:
{
id: string; // unique workflow identifier
stages: WorkflowStage[];
}
interface WorkflowStage {
name: string; // display name in breadcrumb
flows: string[]; // flow/command names that map to this stage
detailFn?: (ctx: any) => string; // optional dynamic detail text
}Add a custom segment to the TUI footer bar.
pi.events.emit("flow:register-footer-segment", {
id: "my-segment",
priority: 10, // higher = further right
render: () => "◉ connected",
});Payload:
{
id: string;
priority: number;
render: () => string; // returns styled text for the footer
}Listen to these events to observe a running flow.
Every core lifecycle payload below carries a runId: string — an engine-minted identifier that is stable for the duration of one run and distinct across runs. It is minted once by FlowManager.start() and stamped onto every live payload, so it is present in headless/RPC sessions too, not only under the TUI.
The core set carrying runId: flow:flow-started, flow:agent-started, flow:agent-complete, flow:assistant-text, flow:thinking-text, flow:subagent-tool-call, flow:subagent-tool-result, flow:auto-decision, flow:loop-iteration, flow:agent-error, flow:complete. On flow:complete the payload is a FlowResult, which carries it as FlowResult.runId.
Use runId to correlate interleaved events when a host multiplexes several sessions, and to discard stale events from a previous run.
Emitted when a flow begins execution.
pi.events.on("flow:flow-started", (data: {
runId: string; // stable for this run
flowName: string;
flow: FlowConfig;
task: string;
}) => { ... });Emitted when an individual flow node begins.
pi.events.on("flow:agent-started", (data: {
runId: string;
agentName: string;
stepId: string;
resolvedModel: string; // actual model ID after role resolution
nodeKind?: NodeKind; // the node's TYPE (all node types emit this)
target?: string; // resolved handler path, code / code-decision only
}) => { ... });nodeKind is now emitted for every node type (see NodeKind taxonomy), not just code nodes. target is the resolved handler path, present only on code/code-decision started events.
Emitted when a flow node finishes (success or error).
pi.events.on("flow:agent-complete", (data: {
runId: string;
agentName: string;
stepId: string;
result: AgentResult;
nodeKind?: NodeKind; // the node's TYPE (all node types emit this)
}) => { ... });Streaming assistant text chunk from a running agent.
pi.events.on("flow:assistant-text", (data: {
runId: string;
agentName: string;
stepId: string;
text: string;
}) => { ... });Streaming thinking/reasoning text chunk from a running agent.
pi.events.on("flow:thinking-text", (data: {
runId: string;
agentName: string;
stepId: string;
text: string;
}) => { ... });An agent called a tool.
pi.events.on("flow:subagent-tool-call", (data: {
runId: string;
agentName: string;
stepId: string;
toolName: string;
input: any;
}) => { ... });A tool call returned a result.
pi.events.on("flow:subagent-tool-result", (data: {
runId: string;
agentName: string;
stepId: string;
toolName: string;
output: any;
isError: boolean;
}) => { ... });An agent made an autonomous decision at a fork step (autonomous mode).
pi.events.on("flow:auto-decision", (data: {
runId: string;
forkId: string;
agentName: string;
chosenBranch: string;
targetStepId: string;
}) => { ... });A loop decision step started a new iteration.
pi.events.on("flow:loop-iteration", (data: {
runId: string;
stepId: string;
iteration: number;
maxIterations: number;
loopTarget?: string; // step jumped back to
}) => { ... });Emitted when the flow finishes (success, error, or abort).
pi.events.on("flow:complete", (data: FlowResult) => {
console.log(`Flow "${data.flowName}" completed in ${data.totalDuration}ms`);
console.log(`Steps: ${data.stepCount}`);
});FlowResult shape — see public-api.md. For a real run the payload carries runId; for a dispatch rejection (see flow:run) it does not, because no run existed.
flow:complete is therefore the single terminal channel: whether a run finished, failed, aborted, or was never started at all, exactly one flow:complete arrives.
General notification from pi-flows to be displayed to the user.
pi.events.on("flow:notify", (data: {
message: string;
level: "info" | "warning" | "error";
}) => { ... });Autonomous mode was toggled.
pi.events.on("flow:autonomous-mode-changed", (data: {
enabled: boolean;
}) => { ... });Flow summary is ready to display in the TUI.
pi.events.on("flow:summary-ready", (data: {
flowResult: FlowResult;
agentNames: string[];
}) => { ... });The user dismissed the post-flow summary overlay.
pi.events.on("flow:summary-dismissed", () => { ... });NodeKind is a first-class discriminator carried end-to-end on flow node lifecycle events. It is an exported type in extensions/flow-engine/types.ts:
type NodeKind =
| "agent"
| "fork"
| "agent-decision"
| "code"
| "code-decision";NodeKind is the node's TYPE. It is distinct from the dashboard timeline-entry kind (text | thinking | tool | error), which describes individual entries inside a card.
Every node executor emits its own nodeKind on the node's lifecycle started/complete callbacks. Previously only code/code-decision carried a tag, and it was silently dropped at the FlowManager fan-out. Now FlowManager forwards the kind to every observer, and the EventEmitObserver puts it on the flow:agent-started / flow:agent-complete payloads.
The FlowObserver interface (in extensions/flow-engine/flow-io.ts) gained an optional extra parameter on two callbacks:
interface FlowObserver {
// 5th param added
onAgentStarted(
agentName: string,
stepId: string,
resolvedModel: string,
/* ... */,
extra?: { nodeKind?: NodeKind; target?: string },
): void;
// 4th param added
onAgentComplete(
agentName: string,
stepId: string,
result: AgentResult,
extra?: { nodeKind?: NodeKind; target?: string },
): void;
}FlowManager now forwards this extra argument to every observer (it used to discard it — flow-manager.ts). The persisted FlowEventRecord.data is the exact emitted payload, so nodeKind lands in persisted records automatically with no FlowEventRecord interface change.
The Architect is the AI-powered flow designer. These events track its lifecycle.
| Event | Direction | Payload |
|---|---|---|
flow:architect-started |
emit | { flowName, mode: "new"|"edit" } |
flow:architect-tool-call |
emit | { toolName, input } |
flow:architect-tool-result |
emit | { toolName, output, isError } |
flow:architect-text |
emit | { kind: "assistant"|"thinking", text } |
flow:architect-context-generating |
emit | { mode: "new"|"edit" } |
flow:architect-context-ready |
emit | { hasContext: boolean } |
flow:architect-preview |
emit | { flows, parsedFlows, flowPath, createdFiles } |
flow:architect-replan |
emit | { iteration: number, notes: string } |
flow:architect-saved |
emit | { flowName, flowPath } |
flow:architect-complete |
emit | { choice: "save"|"cancel"|"error", flowName?, flowPath? } |
flow:architect-cancelled |
emit | { phase: string } |
flow:architect-error |
emit | { error?|summary } |
flow:architect-init-error |
emit | { reason: "no-flows"|"agent-not-found" } |
flow:architect-run-handoff |
emit | { flowName } — auto-run after save |
flow:architect-abort |
listen | {} — send to cancel an in-progress architect run |
Two-way synchronous query events and programmatic control signals.
Programmatically trigger a flow by name. This is the single programmatic entry point a host uses to start a flow.
pi.events.emit("flow:run", { flowName: "my-flow" });Payload: { flowName: string }
A flow:run that does not start a flow is never dropped silently. The engine emits a terminal flow:complete — the same channel a real run finalizes on — carrying a rejection:
pi.events.on("flow:complete", (data: FlowResult) => {
if (data.status === "rejected") {
// dispatch declined — no run ever started
console.error(`${data.flowName}: ${data.reason}`);
return;
}
// ...normal terminal handling
});The rejection payload guarantees these stable top-level paths:
| Path | Value |
|---|---|
status |
"rejected" — machine-readable, distinct from a run that started and failed ("error") |
reason |
human-readable cause, byte-identical to the message the slash-command path sends on flow:notify |
flowName |
the requested flow name |
lastResult.result.summary |
the same reason string, mirrored |
It omits results — so a host's post-flow summary is not generated for a run that never started — and carries no runId, because no run was ever minted.
The three decline reasons:
| Cause | reason |
|---|---|
| Unknown flow | Flow "<name>" no longer exists — it may have been deleted |
| A flow is already running | A flow is already running (<activeFlowName>) |
| Blocked by a gate | the gate's own message |
A consumer that never observed flow_started can render the outcome from status === "rejected" plus reason alone — no correlation with an earlier event is required.
Single-run guard is atomic. Two flow:run events racing into one session cannot start two concurrent runs: the check and the claim happen together, and the loser is declined with the "already running" reason (so it, too, gets a terminal flow:complete).
Abort the currently running flow.
pi.events.emit("flow:abort", {});Toggle autonomous mode on or off. Fires flow:autonomous-mode-changed after toggling.
pi.events.emit("flow:toggle-autonomous", {});Toggle flow/agent authoring edit-mode. Inbound: dashboard → pi-flows. Converges on the same handler as the /flows:edit-mode <on|off> command.
pi.events.emit("flow:set-edit-mode", { enabled: true });Payload: { enabled: boolean }
The handler: writes flows.editFlow to the project .pi/settings.json (read-merge-write, preserves other keys, never the global file); syncs the project-local skill copy at .pi/skills/manage-flows/SKILL.md with frontmatter disable-model-invocation set to !enabled; and reconciles the flow_agents/flow_write tools to match. Reload nuance: the event path runs on the base ExtensionContext and has no reload — tools update immediately, but the skill-visibility change applies on the next session start. (The /flows:edit-mode command path additionally calls ctx.reload(), so the change is fully live in the current session.)
Force a full re-discovery of agents and flows from all registered directories.
pi.events.emit("flow:rediscover", {});Called automatically by flow_write and flow_agents (op write) after writing files.
Retrieve the current agent map synchronously.
const q = {} as { agents: Map<string, AgentConfig> };
pi.events.emit("flow:get-agents", q);
const agents = q.agents;List all discovered flows as a plain array.
const q = {} as { flows: FlowListEntry[] };
pi.events.emit("flow:list-flows", q);
interface FlowListEntry {
name: string;
description: string;
source: string;
taskRequired: boolean;
}Get the current session's conversation entries.
const q = {} as { entries: any[] };
pi.events.emit("flow:get-session-entries", q);Get context needed to spawn agent sessions programmatically.
const q = {} as {
authStorage: any;
modelRegistry: any;
extraAgentExtensions: any[];
extensionTools: any[];
};
pi.events.emit("flow:get-spawn-context", q);Request deletion of a project-local flow file.
// Request
pi.events.emit("flow:delete-request", { flowName: "my-flow" });
// Listen for result
pi.events.on("flow:delete-result", (data: {
success: boolean;
flowName: string;
error?: string;
}) => { ... });Low-level prompt bus used internally. Prefer ctx.ui.* methods or the ask_user tool.
// Emit a prompt
pi.events.emit("flow:prompt-request", {
pipeline: "my-pipeline",
type: "input", // "input" | "select" | "confirm"
question: "Enter value:",
});
// Listen for the response
pi.events.on("flow:prompt-response", (data: {
answer: string;
cancelled?: boolean;
}) => { ... });Events for configuring model roles (@planning, @coding, etc.).
| Event | Direction | Payload |
|---|---|---|
flow:role-get-all |
query | { roles: Map<string, string> } — mutated with current roles |
flow:role-set |
emit | { role: string, modelId: string } |
flow:role-preset-load |
emit | { name: string } |
flow:role-preset-save |
emit | { name: string } |
flow:role-preset-delete |
emit | { name: string } |
flow:roles-manage-request |
emit | {} — opens the roles management UI |
flow:get-available-models |
query | { models: string[] } — mutated with authenticated models |