Extension paths
Use and build trusted workflow extensions
Install the shipped Subagents and Herdr integrations, or author reusable workflow functions, dynamic model aliases, setup hooks, attempt actions, and packaged roles.
Shipped companion extensions
| Package | Use | Install |
|---|---|---|
@piewf/subagents | Standalone single-shot agents with five durable lifecycle tools and the same roles and options as workflows. | pi install npm:@piewf/subagents |
@piewf/herdr | Live handoff, completed-session inspection, and fully inspectable workflow agents in Herdr. | pi install npm:@piewf/herdr |
Subagents works as a standalone Pi extension. Herdr complements the core workflow extension and activates only in a Herdr-managed pane. Each package has a dedicated guide linked above and a detailed package README in packages/extensions/.
Start here
A workflow extension is trusted TypeScript or JavaScript loaded by Pi. Use one when a workflow capability should be named, discoverable, schema-validated, and reusable across launches. Put the file in a Pi extension location such as ~/.pi/agent/extensions/review-loop.ts, then load it through Pi. The package import is pi-extensible-workflows.
Extension code runs in the host with the same filesystem and process access as Pi. Workflow scripts are a separate sandbox: they have only the documented orchestration primitives and registered globals, cannot import modules, and cannot access the host process directly. Install and load only extension code you trust.
For a complete copy-paste starting point, use the workflow extension template shipped in the package. It includes a registered function, a packaged role resolved from import.meta.url, a focused node:test, and clearly marked optional advanced alias and setup-hook examples.
Minimal extension
This complete extension registers one function. Export a default factory and register inside it, not at module top level.
import { registerWorkflowExtension } from "pi-extensible-workflows";
export default function extension() {
registerWorkflowExtension({
version: "1.0.0",
headline: "Greeting workflow",
functions: {
greet: {
description: "Return a greeting for one person.",
input: { type: "object", properties: { name: { type: "string" } }, required: ["name"], additionalProperties: false },
output: { type: "string" },
run(input) { return `Hello, ${String(input.name)}!`; }
}
}
});
}
Registration contract
WorkflowExtension is the only top-level registration object. Its own keys are strict, and at least one capability such as functions, modelAliases, agentSetupHooks, agentAttemptActions, or roleDirectories must be registered. The accepted fields are:
| Field | Type | Contract |
|---|---|---|
version | string | Required strict semantic version, such as 1.0.0. |
headline | string | Required non-empty short label shown in trusted catalog details. |
functions | record of WorkflowFunction | Optional named reusable functions. Names must be identifier-shaped and globally unique. |
modelAliases | record of WorkflowModelAlias | Optional named dynamic model resolvers. Alias names are case-sensitive and must match [A-Za-z][A-Za-z0-9_-]*; names are unique across extensions. |
agentSetupHooks | record of AgentSetupHook | Optional trusted hooks. Hook names must be identifiers and unique across extensions. |
agentAttemptActions | record of AgentAttemptAction | Optional collision-checked actions for selected agents in /workflow and standalone runs in /subagents. Every action requires label, visible, and run; standalone participation additionally requires both visibleStandalone and runStandalone. |
roleDirectories | array of absolute paths or file: URLs | Optional packaged role directories. They are scanned as extension defaults; standard global and trusted project roles override matching names, while duplicate extension role names are rejected. |
Unknown top-level keys are rejected. The removed workflows and variables formats are not accepted. Function names cannot be reserved globals such as agent, args, JSON, extensions, or workflow_catalog, and names beginning with __pi_extensible_workflows_ are rejected. Duplicate function names across extensions are GLOBAL_COLLISION; duplicate hook or model-alias names are DUPLICATE_NAME errors.
Register from the default Pi extension factory. The package begins a fresh loading registry for a new session-loading cycle, freezes registration during session_start, and resets it during shutdown. Calling registerWorkflowExtension() after the freeze fails with REGISTRY_FROZEN. This lifecycle is why module-top-level registration is not the supported pattern.
Registered workflow functions
A WorkflowFunction is a named host-side reusable workflow. It has exactly four fields: description: string, input: JsonSchema, output: JsonSchema, and run(input, context). The input schema must describe one JSON object. Both schemas and every input and output are validated at the JSON boundary.
Field reference
| Field | Contract |
|---|---|
description | Non-empty author and catalog description. Do not put secrets or operational data here. |
input | Plain JSON-compatible schema with type: "object". Unknown keys can be rejected with additionalProperties: false. |
output | Plain JSON-compatible schema for the final JSON value returned by run. |
run | Receives a cloned, frozen input object and a read-only WorkflowFunctionContext. It may return a JSON value or a promise for one. |
WorkflowFunctionContext
The context object exposes the registered-function authoring surface. It is not the sandbox global object and it never exposes implementations or resolver values to the catalog.
| Member | Behavior |
|---|---|
run | Read-only WorkflowRunContext for this launch. |
agent(prompt, options?) | Launches an agent and returns its bare JSON value. Options include label, model, thinking, tools, skills, extensions, role, outputSchema, retries, timeoutMs, and JSON-compatible extension options. role may be a name string or an object with a required name plus frontmatter overrides. Omitted fields inherit from the role file, null unsets them, and explicit selector values are appended after role selectors. Top-level model and thinking remain incompatible with any role form. |
shell(command, options?) | Runs a host command and returns { exitCode, stdout, stderr }. Options are timeoutMs and string-valued env. |
prompt(template, values) | Interpolates a plain object of JSON values into a prompt. Await agent results before passing them here. |
parallel(name, tasks) | Runs stable keyed tasks concurrently and returns an object of bare values. |
pipeline(name, items, stages) | Runs ordered named stages for each keyed item and returns an object of final values. |
withWorktree(name, callback) | Creates or reuses one deterministic named worktree scope. The callback receives a frozen { path, branch } reference. |
checkpoint(input) | Waits for a decision described by { name, prompt, context } and returns a boolean. The prompt and context have the same size limits as the workflow primitive. |
phase(name) | Records a progress phase. It returns no workflow value. In a sequential loop, use phase(slice.name) for semantic labels such as backend-safety. |
log(message) | Renders attached foreground logs in the live workflow item and appends background or detached logs as bounded TUI-only transcript entries, capped at 4 KB. |
invoke(name, input) | Calls another registered function by unqualified name, with input and output schema validation. Use this for composition instead of importing an implementation. |
WorkflowRunContext
| Member | Type and meaning |
|---|---|
cwd | Resolved workflow launch directory. |
sessionId | Owning Pi session ID. |
runId | Durable workflow run ID. |
workflow | Read-only WorkflowMetadata with name and optional description. |
args | Read-only JSON launch arguments passed to the script; scripts can pass them to registered globals. |
signal | AbortSignal for run cancellation. Check it in bounded work and stop promptly when aborted. |
Completed function calls are journaled by structural path. Repeated sequential calls to the same function in one structural scope use distinct journal paths and occurrence-aware presentation breadcrumbs: the first function name is unsuffixed and later calls are labeled #2, #3, and so on; nested breadcrumbs compose each function occurrence. The journal is durable across cold resume and retry, while phase and log side effects are applied around the call. Use phase(slice.name) when a sequential loop needs semantic labels such as backend-safety. Design function side effects to be bounded and replay-aware: a host crash after an external side effect but before its result is journaled can cause that side effect to run again.
If a function throws, its call is not completed and the workflow fails. workflow_retry is for persisted failed runs: completed function calls replay from the journal and incomplete calls run again. Per-agent retries apply to calls made through agent(); they do not silently rerun the containing function.
Each registered function is available to sandboxed workflow scripts as a global that takes one object argument and returns a promise, so scripts must await it. Launch a script with a required non-empty name and exactly one of script or scriptPath; pass JSON values through args.
Complete function and script composition
import { registerWorkflowExtension, type WorkflowExtension } from "pi-extensible-workflows";
const reviewExtension: WorkflowExtension = {
version: "1.0.0",
headline: "Task review",
functions: {
reviewTask: {
description: "Ask an agent to review a task and return its report.",
input: { type: "object", properties: { task: { type: "string" } }, required: ["task"], additionalProperties: false },
output: { type: "object", properties: { task: { type: "string" }, report: { type: "string" } }, required: ["task", "report"], additionalProperties: false },
async run(input, context) {
const task = String(input.task);
const report = await context.agent(context.run.workflow.name + ":\n\n" + task);
return { task, report };
}
}
}
};
export default function extension() { registerWorkflowExtension(reviewExtension); }
{
"name": "review-task",
"script": "return await reviewTask(args);",
"args": { "task": "Check the release notes for omissions" },
"foreground": true
}
Composition with context.invoke()
Composition stays inside the registered-function boundary. The following complete extension defines both functions, validates both schemas, and invokes the first function from the second without importing its implementation.
import { registerWorkflowExtension, type WorkflowExtension } from "pi-extensible-workflows";
const compositionExtension: WorkflowExtension = {
version: "1.0.0",
headline: "Composed review",
functions: {
securityReview: {
description: "Return a review label for one path.",
input: { type: "object", properties: { path: { type: "string" } }, required: ["path"], additionalProperties: false },
output: { type: "string" },
run(input) { return "reviewed " + String(input.path); }
},
releaseReport: {
description: "Compose the security review into a report.",
input: { type: "object", additionalProperties: false },
output: { type: "object", properties: { report: { type: "string" } }, required: ["report"], additionalProperties: false },
async run(_input, context) {
const report = await context.invoke("securityReview", { path: "src" });
return { report: String(report) };
}
}
}
};
export default function extension() { registerWorkflowExtension(compositionExtension); }
Dynamic model aliases
modelAliases registers trusted host resolvers for model names. A WorkflowModelAlias has one resolve(context) function, which may be synchronous or asynchronous and returns a target string in the normal provider/model[:thinking] syntax or another alias. The read-only context contains cwd, projectTrusted, the selected rootModel, knownModels, availableModels, and a launch or resume AbortSignal.
For a complete advanced example, see the shipped dynamic model router, which selects aliases from the live available-model inventory.
import { registerWorkflowExtension } from "pi-extensible-workflows";
export default function extension() {
registerWorkflowExtension({
version: "1.0.0",
headline: "Model policy",
modelAliases: {
reviewer: {
async resolve({ availableModels }) {
const preferred = "anthropic/opus";
if (availableModels.has(preferred)) return preferred + ":high";
const fallback = availableModels.values().next().value;
if (!fallback) throw new Error("No available models");
return fallback;
},
},
},
});
}
Resolvers run once per launch, before static workflow preflight, availability checks, role validation, and run creation. The resolved concrete map is captured in the launch snapshot and applies everywhere a workflow model is accepted, including role files, direct agent options, thinking suffixes, and chained aliases. An explicit call-level thinking option overrides an alias or target suffix.
Dynamic aliases are package defaults. Static aliases from global settings override them, and trusted project settings override the global map; a same-named shadowed resolver is not called. Duplicate registrations fail with DUPLICATE_NAME. Invalid resolver results, cycles, and unavailable targets fail before a run starts with an existing typed error and include the alias and registering extension headline. Resolver cancellation remains CANCELLED.
Launch, resume, and retry
Resume resolves dynamic aliases again against the current inventory and snapshots the new map. Completed journal operations retain their results; pending, retried, and new calls use the newly resolved map. A changed target produces one persisted alias-drift warning. workflow_retry creates a child run with the same rules and snapshot behavior. If an alias present in the prior snapshot is removed, pending or retried references are blocked as UNKNOWN_MODEL instead of falling through to a native model with the same bare ID.
Use settings files for static aliases and extension registration for dynamic policy. workflow_catalog, workflowCatalog(), and workflowCatalogIndex() expose alias names, static/dynamic kind, and provenance without executing resolvers or exposing dynamic targets to agents; trusted settings, doctor, snapshots, and the inspector retain concrete static or resolved targets.
Agent setup hooks
An AgentSetupHook is trusted code that runs after normal model, tool, cwd, and role resolution but before the selected agent transport creates a session. It has priority?: number and setup(agent, context). The priority must be finite; it defaults to 10. Hooks run in ascending priority order, with the stable hook name breaking ties. Hooks may replace or wrap agent.transport; the final prepared policy remains the capability ceiling. Resource-policy changes are narrowing-only: a hook that widens skills, extensions, or tools fails setup with INVALID_METADATA. Final selectors are applied in global settings, trusted project settings, role, call, then hook-narrowing order. Hook negations retain their original order, including when interleaved with positive selectors, while being applied as the final overlay.
AgentSetup and AgentSetupContext
| Contract | Fields |
|---|---|
AgentSetup | prompt: string, mutable; options: AgentOptions, mutable; mutable sessionInput: SessionInput; immutable prepared: PreparedAgentSession; and the current transport: AgentTransport. |
SessionInput | cwd: string, model: ModelSpec, tools: SessionTools, and sessionLabel: string; optional agentDir: string, customTools: ToolDefinition[], resultTool: ToolDefinition, systemPromptAppend: string, extensionFactories: InlineExtension[], resourcePolicy: AgentResourcePolicy, and options: AgentOptions. |
AgentSetupContext | Read-only run: WorkflowRunContext, read-only identity: AgentIdentity, attempt: number starting at 1, and cancellation signal: AbortSignal. |
AgentIdentity | Read-only structuralPath: string[], callSite: string, and occurrence: number; optional parentBreadcrumb: string and worktreeOwner: string. |
Hook execution is outside the agent timeoutMs clock. Every retry starts from a fresh setup baseline and runs every hook again. Cancellation is checked before and after hooks. A hook exception stops the hook chain, prevents session creation, and fails the setup; it is not retried as a native-session failure. Keep hooks short and cancellation-aware.
The setup surfaces are intentionally powerful. A hook can change the prompt, JSON options, model, thinking, tools, cwd, system prompt, custom tools, extension factories, resource policy, or transport. The immutable prepared launch cannot widen inherited tools, trust, skills, or extensions. Unknown JSON-compatible options are preserved for the current setup but are not inherited by child agents. A transport owns session materialization; core persists only its reference and never reconnects it during cold resume.
One scoped customization
import { registerWorkflowExtension, type WorkflowExtension } from "pi-extensible-workflows";
const advisorExtension: WorkflowExtension = {
version: "1.0.0",
headline: "Scoped advisor",
agentSetupHooks: {
advisor: {
priority: 10,
setup(agent, context) {
if (context.signal.aborted || agent.options.advisor !== true) return;
const suffix = "\n\nAdvisor: call out one concrete risk and one next check.";
const existing = agent.sessionInput.systemPromptAppend;
agent.sessionInput.systemPromptAppend = existing ? existing + suffix : suffix;
}
}
}
};
export default function extension() { registerWorkflowExtension(advisorExtension); }
return await agent("Review the package", { advisor: true });
Latest-attempt actions
agentAttemptActions registers trusted actions for the selected agent's latest attempt in /workflow, and optionally for standalone runs in /subagents. Action IDs are unique across extensions. Every action has a display label, synchronous visible(context), and run(context); standalone actions must also provide visibleStandalone(context) and runStandalone(context). Workflow contexts contain immutable run, agent, and attempt snapshots. Standalone contexts contain an immutable standalone agent snapshot instead of a fabricated workflow record. Both contexts include the persisted session reference when present, the exact live session only while active, an AbortSignal, and a UI facade with notify, confirm, select, input, and optional setWorkingMessage (calling it without an argument restores the default). Visibility must not perform connectivity checks; action failures are reported and the navigator refreshes after execution.
agentAttemptActions: {
inspect: {
label: "Inspect latest attempt",
visible(context) { return Boolean(context.attempt.session); },
async run(context) { context.ui.notify(`Transport: ${context.attempt.transport}`); }
}
}
Other extension-provided resources
The shipped WorkflowExtension has five capability fields: functions, dynamic model aliases, agent setup hooks, latest-attempt actions, and packaged role directories. Role directories use absolute filesystem paths or file: URLs and provide default role files; standard global and trusted project roles override matching names.
Do not document proposed or lower-level resources as extension capabilities. A role may select workflow-agent skills and extension sources through direct skills and extensions frontmatter fields, but that is role policy, not a WorkflowExtension field.
Catalog and host integration
There are two boundaries. workflow_catalog is an agent-facing Pi tool. Trusted host code can use the package APIs workflowCatalog(), workflowCatalogIndex(), workflowCatalogDetail(name), and registeredWorkflowFunctions().
WorkflowRegistry, loadingRegistry(), resetWorkflowRegistry(), beginWorkflowExtensionLoading(), and WorkflowRegistryApi are lower-level trusted host lifecycle controls exported for package integration. They are not the extension authoring surface. Use registerWorkflowExtension() and the documented interfaces instead; do not pass registry implementations or bridge internals to agents.
| API | Result |
|---|---|
workflowCatalog() | Full trusted catalog of function and alias metadata, including extension provenance and schemas. Trusted callers can inspect effective static targets; dynamic resolver targets are not executed by catalog discovery. |
workflowCatalogIndex() | Compact metadata: function names, descriptions, input schemas, and static or dynamic alias names with kind and provenance. |
workflowCatalogDetail(name) | One full function or alias definition. Missing names return a NOT_FOUND result object instead of throwing a workflow error. |
registeredWorkflowFunctions() | Trusted host access to registered function implementations keyed by name. This is not agent-facing metadata and must not be exposed to untrusted code. |
The workflow_catalog tool returns the compact index by default. Pass { "name": "reviewTask" } to request one full detail entry. Agents receive names, descriptions, schemas, and alias provenance, never implementations, source, resolver values, or opaque setup data. The tool is registered only when the active registry has functions, aliases, or effective settings.
Trusted host discovery
import { workflowCatalog, workflowCatalogIndex, workflowCatalogDetail, registeredWorkflowFunctions } from "pi-extensible-workflows";
const index = workflowCatalogIndex();
const fullCatalog = workflowCatalog();
const detail = workflowCatalogDetail("reviewTask");
const implementations = registeredWorkflowFunctions();
if ("error" in detail) console.warn(detail.error.message);
else console.log(index.functions, implementations);
Validation, errors, and troubleshooting
| Code or result | Common authoring cause and fix |
|---|---|
INVALID_METADATA | Missing or empty extension metadata, unknown keys, malformed capability records, invalid function fields, or an obsolete workflows field. Keep contracts exact. |
INVALID_SCHEMA | A schema is not a plain JSON-compatible schema object. Use JSON values only and provide a recognized schema shape. |
GLOBAL_COLLISION | A function name is reserved or already registered. Rename it. |
DUPLICATE_NAME | An agent setup hook or model alias name is already registered. Choose a globally unique name. |
UNKNOWN_MODEL, CONFIG_ERROR | A model alias target is invalid, cyclic, unavailable, or its resolver failed. The error identifies the alias and registering extension; fix the resolver or inventory-dependent target. |
REGISTRY_FROZEN | Registration ran after session_start. Move registration into the extension factory. |
MISSING_WORKFLOW | context.invoke() names a function that is not in the active registry, or uses a qualified name. |
RESULT_INVALID | Function input or output, or a replayed value does not satisfy its schema. Check the schema and return a JSON value. |
AGENT_FAILED, AGENT_TIMEOUT, SHELL_FAILED, WORKTREE_FAILED | A context operation failed. Bound side effects, honor cancellation, and use stable named worktree scopes. |
AGENT_RESULT_COLLECTED | A direct nested-agent result was already delivered and collected. Nested results are one-shot; do not retry a successful get_subagent_result call. |
CANCELLED | The workflow, alias resolver, or setup signal was aborted. Propagate the signal and stop resolver or hook work promptly. |
RESUME_INCOMPATIBLE | A persisted function is no longer registered, the snapshot is obsolete, a required capability is unavailable, or a resume action is not valid for that run state. Restore the extension or relaunch. |
Catalog NOT_FOUND | workflowCatalogDetail(name) and workflow_catalog return { error: { code: "NOT_FOUND", ... } }. It is a discovery result, not a thrown workflow error. |
Host failures are WorkflowError values with stable codes. A thrown hook error stops that agent setup. Inspect the run artifacts and catalog after correcting registration, schema, trust, or resume state.
Best practices
- Choose stable, descriptive, identifier-shaped names and never rely on collisions or load order.
- Use strict object input and output schemas, with required fields and
additionalProperties: falsewhere appropriate. - Keep inputs, outputs, schemas, options, and metadata JSON-compatible. Never place secrets in descriptions, schemas, catalog data, logs, or lifecycle payloads.
- Assume a function can replay. Prefer verification and bounded side effects; make external mutations idempotent when possible.
- Keep setup hooks minimal and explicit. Treat them as a trusted policy override, and check opt-in options before mutating an agent.
- Use
context.invoke()for registered-function composition instead of importing another registered implementation. - Document only shipped author APIs. Keep role-file format and lifecycle-event details on their canonical pages, and link to those references instead of copying their contracts.
Related references
Use the developer-page compatibility section for the short extension overview, lifecycle events, the roles guide for role files and call customization, and the bundled workflow skill for workflow authoring and recovery.