Extension paths

Use and build trusted workflow extensions

Install the core workflow package with its review starter and Subagents tools, add the Herdr integration when needed, or author reusable workflow functions, dynamic model aliases, setup hooks, attempt actions, and packaged roles.

Shipped extensions

PackageUseInstall
pi-extensible-workflowsDeterministic workflows, the reviewLoop implementation-and-review starter, and durable standalone subagent tools.pi install npm:pi-extensible-workflows
@piewf/herdrLive handoff, completed-session inspection, and fully inspectable workflow agents in Herdr.pi install npm:@piewf/herdr

The core package includes the starter and Subagents tools. Herdr complements the core workflow extension and activates only in a Herdr-managed pane. Each shipped capability has a dedicated guide linked above.

Disable bundled features

The core package loads the workflow host plus three optional extension entries. Exclude only the entries you do not want; keep dist/src/index.js loaded for workflow tools and /workflow. Independent Trajectory filtering requires version 5.8.0 or newer; earlier versions loaded it from the workflow host entry. Since 5.10.0 the published entries are bundled dist/*.js files; update pre-5.10 .ts filters to the paths below.

FeaturePackage pathWhat exclusion removes
reviewLoop starterdist/starter/index.jsThe reviewLoop function, packaged developer/reviewer/scout/oracle/researcher roles, dynamic model aliases, and role-driven slash-command prompts.
Standalone Subagentsdist/subagents/index.jsThe five subagents_* tools and /subagents.
Trajectorydist/trajectory/index.jsThe browser server and automatic attachment. /workflow trajectory remains available and reports that Trajectory is disabled.

Use exact package-root exclusions in Pi's global settings.json. This example disables all three optional entries; delete an exclusion to keep that feature:

{
  "packages": [
    {
      "source": "npm:pi-extensible-workflows",
      "extensions": [
        "-dist/starter/index.js",
        "-dist/subagents/index.js",
        "-dist/trajectory/index.js"
      ]
    }
  ]
}

The same exclusions apply to the documented source install, pi install "$PWD/packages/core", because that directory is the package root. A repository-root package source loads the TypeScript source entries instead, so filters there use the packages/core/ prefix with .ts paths. Restart Pi after changing the filter.

Bundled starter

The core package loads dist/starter/index.js next to the workflow host. It registers one function, five packaged roles, and five dynamic model aliases. The package manifest also ships slash-command prompts (/scout, /parallel-scout, /oracle, /council, /review, /parallel-review, /review-loop, /deep-research) that launch subagents or workflows with these roles; prompts are package-level and are not removed by the starter filter. Bundled starter roles are low-precedence fallbacks: role precedence is starter roles < user extension roles < global roles < trusted project roles.

PieceNameDefault
FunctionreviewLoopDeveloper then reviewer until pass or maxIterations (default 5).
Roledevelopermodel: developer-model. No tool restriction.
Rolereviewermodel: reviewer-model. Tools read, grep, find, ls. Evidence-filtered P0/P1/P2 findings and a merge verdict.
Rolescoutmodel: scout-model. Tools read, grep, find, ls. Read-only recon brief.
Roleoraclemodel: oracle-model. Tools read, grep, find, ls, bash. Second opinion; no edits.
Roleresearchermodel: researcher-model. All session tools except edit, write, bash; uses web tools when present.
Aliasdeveloper-model, reviewer-model, scout-model, oracle-model, researcher-modelResolve to the launching session model when settings do not define them.

Not everything is overridable the same way.

PieceOverrideReplace
RolesA regular user extension role overrides a bundled starter role with the same name; global or trusted project roles override extension roles. piewf doctor shows overrides / overriddenBy.Unnecessary if you only need different policy.
AliasesStatic modelAliases in workflow settings shadow the dynamic resolvers. The resolver is not called.Unnecessary if you only need a concrete target.
reviewLoopNone. A second function with the same name is GLOBAL_COLLISION.Disable the starter extension, then register your own function.

To replace reviewLoop, exclude dist/starter/index.js as described above, then register the replacement.

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",
    source: import.meta.url,
    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:

FieldTypeContract
versionstringRequired strict semantic version, such as 1.0.0.
headlinestringRequired non-empty short label shown in trusted catalog details.
sourcestringOptional module provenance, normally import.meta.url. Required when a workflow is portable-bundled; it identifies the module whose default extension factory is replayed in the bundle.
dependenciesarray of package namesOptional package imports used by the extension module. Portable bundling inlines declared packages; undeclared third-party imports fail during bundling. Node builtins and pi-extensible-workflows remain external; declare every other package to inline it. Pi packages (@earendil-works/*) cannot be bundled; use the pi-extensible-workflows API instead.
modelAliasesrecord of WorkflowModelAliasOptional named dynamic model resolvers. Alias names are case-sensitive and must match [A-Za-z][A-Za-z0-9_-]*; names are unique across extensions.
agentSetupHooksrecord of AgentSetupHookOptional trusted hooks. Hook names must be identifiers and unique across extensions.
agentAttemptActionsrecord of AgentAttemptActionOptional 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.
roleDirectoriesarray of absolute paths or file: URLsOptional packaged role directories. They are scanned as extension defaults; bundled starter roles are lower precedence than regular extension roles, and global and trusted project roles override extension roles. Duplicate names among regular extension directories 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

FieldContract
descriptionNon-empty author and catalog description. Do not put secrets or operational data here.
inputPlain JSON-compatible schema with type: "object". Unknown keys can be rejected with additionalProperties: false.
outputPlain JSON-compatible schema for the final JSON value returned by run.
runReceives 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.

MemberBehavior
runRead-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, contextFiles, role, outputSchema, retries, timeoutMs, and JSON-compatible extension options. role is a name string. Role files provide defaults; model, thinking, tools, skills, extensions, and contextFiles on AgentOptions override them for the call. overrideSystemPrompt stays on the role file.
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)Persists each bounded message with a timestamp in the run events. Attached foreground logs render in the live workflow item; background or detached logs append as bounded TUI transcript entries and are shown in Trajectory, capped at 4 KB.
invoke(name, input, label?)Calls another registered function by unqualified name, with input and output schema validation. An optional non-empty label replaces the function name in presentation breadcrumbs; journal identity remains the function name and occurrence. Use this for composition instead of importing an implementation.

WorkflowRunContext

MemberType and meaning
cwdResolved workflow launch directory.
sessionIdOwning Pi session ID.
runIdDurable workflow run ID.
workflowRead-only WorkflowMetadata with name and optional description.
argsRead-only JSON launch arguments passed to the script; scripts can pass them to registered globals.
signalAbortSignal 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. workflowCatalog(), doctor, and the navigator retain both same-named rows with their static or dynamic provenance; workflow_catalog and workflowCatalogIndex() expose one row per alias name, using the static settings entry when it shadows a dynamic resolver. Catalog discovery never executes resolvers or exposes dynamic targets to agents; trusted settings, 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

ContractFields
AgentSetupprompt: string, mutable; options: AgentOptions, mutable; mutable sessionInput: SessionInput; immutable prepared: PreparedAgentSession; and the current transport: AgentTransport.
SessionInputcwd: string, model: ModelSpec, tools: SessionTools, and sessionLabel: string; optional agentDir: string, customTools: ToolDefinition[], resultTool: ToolDefinition, systemPromptAppend: string, extensionFactories: InlineExtension[], resourcePolicy: AgentResourcePolicy, and options: AgentOptions.
AgentSetupContextRead-only run: WorkflowRunContext, read-only identity: AgentIdentity, attempt: number starting at 1, and cancellation signal: AbortSignal.
AgentIdentityRead-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), registeredWorkflowFunctions(), and registeredWorkflowFunctionSources().

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.

APIResult
workflowCatalog()Full trusted catalog of function and alias metadata, including both same-named static and dynamic entries with 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 one winning static or dynamic alias entry per name. A static settings alias wins over a same-named dynamic resolver.
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.
registeredWorkflowFunctionSources()Trusted host access to source module, export, and declared dependency metadata keyed by function name. This is used by the portable bundle exporter 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 resultCommon authoring cause and fix
INVALID_METADATAMissing or empty extension metadata, unknown keys, malformed capability records, invalid function fields, or an obsolete workflows field. Keep contracts exact.
INVALID_SCHEMAA schema is not a plain JSON-compatible schema object. Use JSON values only and provide a recognized schema shape.
GLOBAL_COLLISIONA function name is reserved or already registered. Rename it.
DUPLICATE_NAMEAn agent setup hook or model alias name is already registered. Choose a globally unique name.
UNKNOWN_MODEL, CONFIG_ERRORA 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_FROZENRegistration ran after session_start. Move registration into the extension factory.
MISSING_WORKFLOWcontext.invoke() names a function that is not in the active registry, or uses a qualified name.
RESULT_INVALIDFunction 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_FAILEDA context operation failed. Bound side effects, honor cancellation, and use stable named worktree scopes.
AGENT_RESULT_COLLECTEDA direct nested-agent result was already delivered and collected. Nested results are one-shot; do not retry a successful get_subagent_result call.
CANCELLEDThe workflow, alias resolver, or setup signal was aborted. Propagate the signal and stop resolver or hook work promptly.
RESUME_INCOMPATIBLEA 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_FOUNDworkflowCatalogDetail(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: false where 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.