Extension author path

Build trusted workflow extensions

Use this page to register reusable workflow functions, run-scoped variables, dynamic model aliases, and trusted per-agent setup hooks. It is the canonical authoring reference for the shipped extension API.

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",
    description: "Provides a reusable greeting function.",
    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 of functions, variables, modelAliases, or agentSetupHooks must contain an entry. The accepted fields are:

FieldTypeContract
versionstringRequired strict semantic version, such as 1.0.0.
headlinestringRequired non-empty short label shown in trusted catalog details.
descriptionstringRequired non-empty extension description. Keep it safe for catalog metadata.
functionsrecord of WorkflowFunctionOptional named reusable functions. Names must be identifier-shaped and globally unique.
variablesrecord of WorkflowVariableOptional named launch-scoped globals. Names share the function namespace.
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.
roleDirectoriesarray of absolute paths or file: URLsOptional 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 format is not accepted. Function and variable names cannot be reserved globals such as agent, args, JSON, extensions, or workflow_catalog, and names beginning with __pi_extensible_workflows_ are rejected. A function-variable collision in one extension or across extensions is a 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, role, outputSchema, retries, timeoutMs, and JSON-compatible extension options. A role cannot be combined with model, thinking, or tools.
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.
log(message)Adds a bounded TUI-only transcript entry.
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

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. For a registered function this is the validated function input.
signalAbortSignal for run cancellation. Check it in bounded work and stop promptly when aborted.

Completed function calls are journaled by structural path. A repeated call with the same path returns the validated stored output without running the implementation again. The journal is durable across cold resume and retry, while phase and log side effects are applied around the call. 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. Cold resume of a registered launch requires the named function to be registered again, otherwise it fails with RESUME_INCOMPATIBLE.

Direct launch validates the input before creating a run and validates the final result against output. Each function is also available to sandboxed workflow scripts as a global that takes one object argument and returns a promise, so scripts must await it. An inline launch uses a required non-empty name; a registered launch uses workflow and uses that function name as the run name.

Complete function and launch

import { registerWorkflowExtension, type WorkflowExtension } from "pi-extensible-workflows";
const reviewExtension: WorkflowExtension = {
  version: "1.0.0",
  headline: "Task review",
  description: "Runs one reusable review task.",
  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); }
{
  "workflow": "reviewTask",
  "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",
  description: "Composes two reusable functions.",
  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); }

Run-scoped variables

A WorkflowVariable has exactly description, schema, and resolve(run). The resolver receives a read-only WorkflowRunContext, may return a JSON value or promise, and must satisfy its schema. Resolvers are evaluated concurrently before workflow execution starts. If one fails, the run fails and the shared cancellation signal aborts the other resolvers.

Values are recomputed for a cold resume rather than read from the function journal. They are not persisted as catalog metadata. Once injected into a sandbox, a variable is deeply frozen and exposed as a read-only global. It is available to inline scripts and registered-function launch scripts through the workflow global scope, not as a property on WorkflowFunctionContext.

Complete resolver and use as a workflow global

import { registerWorkflowExtension, type WorkflowExtension } from "pi-extensible-workflows";
const runLabelExtension: WorkflowExtension = {
  version: "1.0.0",
  headline: "Run labels",
  description: "Provides the current run label.",
  variables: {
    runLabel: {
      description: "The workflow name and durable run ID.",
      schema: { type: "string" },
      resolve(run) {
        if (run.signal.aborted) throw new Error("Run cancelled");
        return run.workflow.name + ":" + run.runId;
      }
    }
  }
};
export default function extension() { registerWorkflowExtension(runLabelExtension); }
return { label: runLabel };

For a launch named reviewTask, a resolver can return a value such as reviewTask:550e8400-e29b-41d4-a716-446655440000. Do not use resolver values as secrets or as a replacement for durable journal state.

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.

registerWorkflowExtension({
  version: "1.0.0",
  headline: "Model policy",
  description: "Selects an available review model",
  modelAliases: {
    reviewer: {
      async resolve({ availableModels }) {
        return availableModels.has("anthropic/opus") ? "anthropic/opus:high" : "openai/gpt:high";
      },
    },
  },
});

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 native Pi session is created. 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.

AgentSetup and AgentSetupContext

ContractFields
AgentSetupprompt: string, mutable; options: AgentOptions, mutable; sessionInput: SessionInput, mutable; and createSession: SessionFactory, a function returning Promise<NativeSession> for the prepared input.
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 session factory. Those mutations can bypass ordinary workflow policy, so hook code must remain trusted. Unknown JSON-compatible options are preserved for the current setup but are not inherited by child agents. A hook's custom session input is not serialized into run snapshots; cold resume loads the hooks currently registered by the host.

One scoped customization

import { registerWorkflowExtension, type WorkflowExtension } from "pi-extensible-workflows";
const advisorExtension: WorkflowExtension = {
  version: "1.0.0",
  headline: "Scoped advisor",
  description: "Adds an advisor note only to opted-in agents.",
  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 });

Other extension-provided resources

The shipped WorkflowExtension has five capability fields: functions, variables, dynamic model aliases, agent setup hooks, 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 restrict workflow-agent skills and extension sources through its disabledAgentResources frontmatter, 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.

APIResult
workflowCatalog()Full trusted catalog of function, variable, 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, variable names, descriptions, schemas, and static or dynamic alias names with kind and provenance.
workflowCatalogDetail(name)One full function, variable, 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, variables, 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, fullCatalog.variables, 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 or variable name is reserved or already registered. Rename it; functions and variables share one namespace.
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_WORKFLOWA launch or context.invoke() names a function that is not in the active registry, or uses a qualified name.
RESULT_INVALIDFunction input or output, variable 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.
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 ordinary error from a variable resolver becomes an INTERNAL_ERROR tagged with the variable name; 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, variables, 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.
  • Make variable resolvers bounded, concurrent-safe, and cancellation-aware. Return only the declared JSON value.
  • 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 the developer page, and link to those canonical references instead of copying their contracts.

Related references

Use the developer-page compatibility section for the short extension overview, lifecycle events for event payloads, and the agent guide for workflow invocation and recovery.