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, and attempt actions.

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 and its dynamic model aliases. Package-level slash-command prompts and the optional roles plugin are independent.
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 and five dynamic model aliases, not roles. The independent @piewf/pi-ext-roles extension owns the five always-available, model-free fallback roles. 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. Role authoring, fallback behavior, and precedence belong to the optional roles plugin.

PieceNameDefault
FunctionreviewLoopOne persistent developer handle, then a fresh reviewer each iteration, until pass or maxIterations (default 5).
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 plugin fallback with the same name; global or trusted project roles override extension roles. The optional roles extension owns discovery and precedence.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 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

Register roles separately with registerRoleContribution(pi, { owner: import.meta.url, roleDirectories: ["./roles"], extension: { headline: "My extension", version: "1.0.0" } }) from @piewf/pi-ext-roles. This independent contribution API belongs to the optional roles package, not the workflow template. The @piewf/pi-ext-roles package documents its own CLI and discovery boundaries.

WorkflowExtension is the workflow capability registration object. Its own keys are strict, and at least one capability such as functions, modelAliases, validateSettings, agentPreparationHooks, agentSetupHooks, or agentAttemptActions 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 without a Pi API, so guard native registrations with if (pi).
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.
validateSettingscallbackOptional validator for the complete effective extensionSettings object. It is called with source context for global, trusted project, and effective prepared settings. Select the keys owned by this extension; namespaces are not registered or collision-checked by the core.
agentPreparationHooksrecord of AgentPreparationHookGeneric pre-resolution policy hooks with optional JSON options schemas.
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.

Unknown WorkflowExtension keys, including roleDirectories, fail with INVALID_METADATA. Core has no role registration bridge.

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.

A global or trusted project settings file can provide arbitrary extension-owned values without any core change:

{
  "extensionSettings": {
    "acme": { "endpoint": "https://example.invalid" }
  }
}

Trusted project extensionSettings replaces the complete global map. Generic preparation hooks may refine the resulting settings. Core validates JSON objects and safe namespace keys; extension validators own their values.

In-process transports receive the frozen map as AgentTransportContext.settings, and local Pi extensions receive it on the session_start event as event.settings. External-process transports must provide the same map before starting the child. The Herdr transport writes it to a private temporary JSON file and sets PI_EXTENSIBLE_WORKFLOWS_SETTINGS_FILE; an external extension can read and freeze that JSON before registering its handlers.

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.
settingsRead-only effective workflow extension settings from global and trusted project settings.
agent(prompt, options?)Returns a bare JSON value. Core options are label, model, tools, excludeTools, skills, extensions, contextFiles, outputSchema, retries, and timeoutMs. Unknown JSON keys are forwarded to plugins; role has no core semantics. Thinking uses the model suffix.
agent.create(options)Creates a persistent handle with a stable explicit name and the remaining options frozen for every turn. handle.send(prompt, options?) accepts only outputSchema and timeoutMs, continues the handle transcript, and returns the same bare value as agent(...). Await each send: overlapping sends, sends from another parallel/pipeline branch, and sends from another withWorktree scope fail with INVALID_METADATA. The journal identity is scoped by the function invocation (including repeated and nested calls), branch, worktree, and name, so one name is reusable across functions and invocations but not twice in one scope. Names starting with function-handle/ are reserved. Each turn is its own agent node; handles are not grouped automatically.
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 and run creation. The resolved map is available to generic preparation hooks and direct agent options, including thinking suffixes and chained aliases. Specify thinking in the model reference; a separate thinking option is not supported.

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; agents with frozen configurations retain their concrete models; calls not yet prepared and new identities 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.

Generic preparation before model resolution

Register agentPreparationHooks: { policy: { priority, optionsSchema?, prepare(configuration, context) } }. Names are unique across extensions. Lower priority runs first, with name as the deterministic tie-breaker. All loaded schemas validate the immutable original options before any hook runs, including options for which a hook would be a no-op. Schemas must allow unrelated extension keys when composition is intended.

context contains options, effective cwd, launch-project projectCwd (differs from cwd inside worktree scopes), agentDir, projectTrusted, root defaults (defaults.dynamicModelAliasNames lists the modelAliases entries resolved by extensions rather than static settings), authorized capabilities, knownModels, availableModels, signal, and mode (execution or inspection). Hooks mutate model text, selectors, settings, context scopes, and prompts in AgentPreparation. Prepared model references accept provider/model[:thinking] without inventing a thinking level; the original call still requires thinking for an explicit physical reference. Removed aliases in original calls are rejected before hooks can transform them, while frozen configurations bypass preparation. An absent systemPrompt retains native Pi behavior; systemPromptAppend appends instructions without replacing it.

Before any shell, tool, or run effect, launch prepares each distinct option object of the script's agent(...) and agent.create(...) calls, in inspection mode, when every option in it is a static literal. Any dynamic option, including label, retries, or timeoutMs, leaves the whole call to runtime, because any hook may read any option; a computed outputSchema is the exception and is omitted from the inspected options. Unreachable calls are inspected too, as before 6.0; identical option objects are inspected once, each costing one package resolution and skill scan. Because a handle's first turn merges its send options, agent.create(...) options are inspected only when every use provably passes no send options: agent.create(...).send(prompt), or a const handle whose every reference is handle.send(prompt); computed access, destructuring and aliases leave the handle to runtime. Any preparation failure rejects the launch with its code, or INTERNAL_ERROR with the hook's message for a code core does not know; cancellation stops the remaining inspections with CANCELLED, and is checked again after the session lease and before the run is created. Inspection uses the launch cwd, also for calls that run inside withWorktree(...); hooks must be side-effect free, honor signal, and must not fail inspection on checkout-specific state. The same inspection covers agent(...) calls in the source of a registered function that the script calls by name, when that source can be parsed; functions they invoke in turn are not inspected. A failed nested child keeps a core error code, except that only core raises CANCELLED for an aborted child and BUDGET_EXHAUSTED; other errors become AGENT_FAILED.

Pi discards a failed extension factory's lifecycle, but not workflow registrations it made before failing. When extensions fail to load, the headless CLI therefore requires every workflow registration (functions, model aliases, preparation and setup hooks) to declare a source that is a loaded extension's entry module or a portable bundle module registered before Pi loaded extensions; otherwise it stops with the load errors before running anything. Register with source: import.meta.url from the extension entry. It then passes the failures as extensionLoadErrors, with those proven sources as loadedExtensionPaths, in the workflow tool context; the host keeps them for every later executor, including paused resume, cold resume and retry. Each agent option that is not a core option then needs a top-level properties key in the optionsSchema of a preparation hook with a proven source; otherwise it fails with INVALID_METADATA, because an extension that failed before registering may have owned it. Without load failures an unowned option is ignored, as with an intentionally absent plugin. Pi does not expose other extensions' load failures to extensions, so in interactive Pi this check does not apply; Pi reports those failures at startup.

agentPreparationHooks: {
  policy: { prepare(configuration, context) {
    if (context.options.advisor !== true) return;
    configuration.systemPromptAppend += "\nCall out one concrete risk.";
  } }
}

Core resolves and validates the concrete model and authorized resources only after preparation. Consumer defaults are ordered selectors, not authorization ceilings: role or call selectors may re-enable a resource excluded by those defaults, but never a resource outside the root authorization or effective parent ceiling. The resulting configuration is persisted by operation or named-handle identity before session creation. Resume and retry reuse it without rerunning preparation, while revalidating current trust and availability. Old snapshot versions fail closed. Inspection uses this same path without writing snapshots. Frozen configuration includes model, plugin instructions, selectors, context scopes, and settings, not extension code, skill/AGENTS/SYSTEM/APPEND bytes, or external state. Those resources are reread under current trust and ceilings; this is not an exactly-once guarantee for external effects. Root and parent tool/skill/extension ceilings apply before resource factories load; setup hooks operate inside the prepared ceiling. Nested agents inherit the effective parent ceiling after setup-hook narrowing for trust, tools, skills, and extensions, without mutating the frozen baseline; each attempt recomputes that narrowing. A parent trust denial also excludes project resources from child preparation and factory materialization. Skills that loaded, authorized extensions return from resources_discover follow the agent's selectors rather than the frozen static inventory. Like Pi, core attributes a skill to such a path in the current resource generation only, after trimming it, expanding ~, ~/, and file:// URLs, resolving relative paths against the session cwd, and matching by path without following symlinks; after a reload, a skill under a withdrawn path is static again and needs the frozen inventory. A nested agent is bounded by the skills its parent session has loaded when the child spawns; static or discovered skills the parent no longer loads leave that ceiling.

A nested agent shares its parent's structural scope, breadcrumb, and worktree, but has its own identity: call site child:<parent identity path>/spawn:<n>/<label>, where n numbers the parent's scheduler node and so changes when a cold-resumed or retried parent runs again, and an occurrence counted per parent and label, without the parent's handle or turn. Nested agents are new spawns of their parent's attempt. Retry and cold resume rerun an unfinished parent with its frozen configuration; the children it spawns again prepare against current roles, aliases, and settings. A child's prepared configuration is reused only by that child's own retries.

Agent setup hooks

An AgentSetupHook is trusted code that runs after generic preparation and concrete model, tool, context, and cwd 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, and a custom tool named like a root tool the prepared policy left out, for example through excludeTools or a parent ceiling, fails it with UNKNOWN_TOOL. Custom tools whose names the agent or an ancestor excluded with excludeTools are removed before the session is created, using the names frozen in the agent's prepared configuration. Hooks cannot change the agent cwd, through sessionInput.cwd or a cwd option, not even to a subdirectory of the launch project, because Pi derives project trust, settings, and .pi resources from cwd; this intentionally differs from versions before 6.0. Preparation produces complete ordered selectors; setup-hook narrowing is applied last. Hook negations retain their original order, including when interleaved with positive selectors, while being applied as the final overlay.

AgentSetup and AgentSetupContext

The setup context's settings is the validated, frozen map produced by generic preparation. The same read-only settings object is passed to AgentTransport.createSession as AgentTransportContext.settings.

ContractFields
AgentSetupprompt: string, mutable; options: AgentOptions, mutable; mutable sessionInput: SessionInput; immutable prepared: PreparedAgentSession; and the current transport: AgentTransport.
SessionInputread-only cwd: string, model: ModelSpec, tools: SessionTools, and sessionLabel: string; optional agentDir: string, customTools: ToolDefinition[], resultTool: ToolDefinition, systemPromptAppend: string, extensionFactories: InlineExtension[], resourcePolicy: AgentResourcePolicy, read-only settings: WorkflowExtensionSettings, and options: AgentOptions.
AgentSetupContextRead-only run: WorkflowRunContext, effective read-only settings, 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; handle: string and turn: number only for named-handle turns, never for their nested children.

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, system prompt, custom tools, extension factories, resource policy, or transport, but not cwd. 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. PreparedAgentSession.turnControl carries transient retry and soft-budget text separate from the task. The runner steers it before the first prompt unless the returned session sets turnControlDelivered: true because the transport already sent it, for example in a launch prompt. 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 six capability fields: functions, dynamic model aliases, settings validators, generic agent preparation hooks, agent setup hooks, and latest-attempt actions. Packaged role directories are independent contributions registered through registerRoleContribution() from @piewf/pi-ext-roles, not a WorkflowExtension field. Role directories use absolute filesystem paths, owner-relative 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 page for the optional roles integration, and the bundled workflow skill for workflow authoring and recovery.