@signalridge/pi-workflows

Deterministic JavaScript orchestration workflows backed by pi-subagents managed spawning.

Packages

Package details

extensionskill

Install @signalridge/pi-workflows from npm and Pi will load the resources declared by the package manifest.

$ pi install npm:@signalridge/pi-workflows
Package
@signalridge/pi-workflows
Version
1.7.2
Published
Sep 10, 2026
Downloads
1,305/mo · 282/wk
Author
signalridge
License
MIT
Types
extension, skill
Size
585.4 KB
Dependencies
3 dependencies · 3 peers
Pi manifest JSON
{
  "skills": [
    "skills/workflow-authoring",
    "skills/workflow-patterns",
    "skills/workflow-review"
  ],
  "extensions": [
    "./src/index.ts"
  ]
}

Security note

Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.

README

@signalridge/pi-workflows

JavaScript orchestration for Pi. A workflow is a raw JavaScript module executed in a determinism-guarded node:vm realm; the runtime supplies agent(), parallel(), pipeline(), orchestrate(), workflow(), quality helpers (verify, judgePanel, loopUntilDry, completenessCheck, retry, gate), human checkpoint(), phase(), log(), args, cwd, restricted process, and budget. Every live agent() call is dispatched through the additive subagents:rpc:spawn-managed event-bus protocol — pi-subagents remains the only owner of model, thinking, queue, concurrency, tools, skills, cwd, isolation, retry, and session policy.

Install

@signalridge/pi-workflows requires @signalridge/pi-subagents >=1.9.0 with protocol v4 managed-spawn, Agent-tier, and managed-policy support. They are separate Pi packages: the peer dependency documents the requirement but intentionally does not auto-load or duplicate the subagent extension.

pi install npm:@signalridge/pi-subagents
pi install npm:@signalridge/pi-workflows

Use from this checkout

From the repository root, load both local package directories once. Disable extension discovery so the npm-installed copies do not also load — an npm copy of one alongside a local checkout of the other loads both, and the duplicate registers its tools twice:

pi -ne -e ./packages/pi-subagents -e ./packages/pi-workflows

-ne skips the installed extension catalogue while keeping explicit -e paths; without it, first remove the installed copies (pi remove @signalridge/pi-subagents @signalridge/pi-workflows) or accept duplicate tool registration.

Pi activates each package from its own pi.extensions manifest.

Writing a workflow

A workflow script starts with the only legal export — a literal-only meta contract — and then runs inside an async function:

export const meta = {
  name: "example",
  description: "Fan out three checks and synthesize",
  phases: [{ title: "scan" }, { title: "synthesize" }],
};

phase("scan");
const findings = await parallel([
  () => agent("Check for cross-package imports", { label: "imports", strength: "low" }),
  () => agent("Check for secrets", { label: "secrets", strength: "low" }),
  () => agent("Check for dead code", { label: "dead-code", strength: "low" }),
]);

phase("synthesize");
const report = await agent("Summarize the findings: " + JSON.stringify(findings), {
  label: "report",
});
return report;

The meta statement must be the first statement and use only literals — spread, computed keys, methods, template interpolation, and the reserved keys __proto__/constructor/prototype are rejected. Date.now(), Math.random(), and no-argument new Date() are unavailable (an AST precheck plus in-realm stubs); prompt text containing those words remains valid. Pass timestamps and randomness through args. The vm is a determinism guardrail, not a security boundary — scripts are generated by the user's own LLM and run on the user's own machine.

Runtime globals

  • agent(prompt, { label, phase, schema, strength, isolation, thread, agentType, toolset, excludeTools, timeoutMs, retries }) — dispatch one subagent through the managed spawn protocol. Returns text, a schema-validated value, or recoverable null after retries. Replayed journal calls consume no real-dispatch cap or token/phase budget. schema is a plain JSON Schema; the reply is parsed and validated client-side with bounded repair across attempts, and exhaustion throws SCHEMA_NONCOMPLIANCE. strength is the provider-neutral workflow route; thread chains sequential turns within a named conversation thread. A legacy programmatic model option may still be accepted by non-managed callers, but workflow meta/phase model or thinking fields are rejected and are never a workflow policy source. Pre-schema-v4 journal runs are quarantined and cannot be resumed.
  • parallel(thunks) — run thunks concurrently, preserve input order. Recoverable thunk failures become null; non-recoverable failures (token budget, agent limit) halt the run after the batch barrier settles. Fan-out that breaches maxAgents cancels only its own batch; each call accepts at most 4096 thunks.
  • pipeline(items, ...stages) — items run concurrently, stages per item run sequentially with (previousValue, originalItem, index); each call accepts at most 4096 items and waits for the batch before surfacing fatal failures.
  • orchestrate(tasks, { onError }) — execute a named dependency graph in deterministic declaration-order layers. Each task is { id, dependsOn?, phase?, retries?, run }; its callback receives { id, attempt, results, statuses }. onError is skip-dependents (default), continue, or fail-fast. Results are keyed by task id, and task lifecycle events are observable through onRuntimeEvent.
  • workflow(savedName, childArgs?) — run a saved workflow inline; one nested level, sharing the limiter, counters, token accounting, and store. The child result is journaled as one parent call with a generation-scoped namespace, so unchanged nested work replays without spawning again after resume.
  • verify(item, { reviewers, threshold, lens }), judgePanel(attempts, { judges, rubric }), loopUntilDry({ round, key, consecutiveEmpty, maxRounds }), completenessCheck(taskArgs, results), retry(thunk, { attempts, until }), gate(thunk, validator, { attempts }) — quality helpers built purely on agent()/parallel() so call sequencing stays stable and resume keeps working.
  • checkpoint(prompt, { default, headless, kind, choices, timeoutMs }) — journaled human confirmation. Foreground runs thread the real UI; background runs are headless and take the declared default.
  • phase(title, { budget }), log(message), args, cwd, process.cwd(), budget ({ total, spent(), remaining() }).

Resume

A script edit replays the longest unchanged prefix from the journal and runs live from the first changed or inserted agent()/checkpoint() call. The original invocation args and execution limits are frozen and reused on manual or automatic resume. Pass resumeFromRunId with the edited script; unchanged calls report zero tokens, and edited calls rotate their managed spawn key generation so pi-subagents never raises a fingerprint conflict.

Skills

The package ships three model-routable skills:

  • workflow-authoring — design, review, and debug custom JavaScript workflows; it teaches topology selection, stable work IDs, bounded failure handling, and resume-safe authoring.
  • workflow-patterns — choose and invoke a reviewed built-in for research, adversarial review, code review, multi-perspective analysis, or codebase audit.
  • workflow-review — audit an existing workflow for topology, data flow, recovery, resume safety, and publication readiness without editing it.

Use the pattern skill for a standard task, the authoring skill for a custom workflow, and the review skill before accepting a topology or recovery change.

Tools and commands

  • workflow: run a script (script) or a saved/built-in workflow (name), with args, background (default true), maxAgents (default 1000), concurrency (defaults to the host's pool size), agentRetries (default 0), tokenBudget (default unlimited), agentTimeoutMs (default 1800000, null disables), and resumeFromRunId. The execution limits are tuned defaults: a caller that names one narrower than the script needs stops the run rather than shaping it.

    Concurrency is not configured here at all. A workflow agent occupies one of pi-subagents' maxConcurrent background slots like any other background agent, so that setting is both the width a run defaults to and the ceiling it is clamped to — read live from the peer at each start and resume, so one setting governs the whole fleet and a run neither guesses it nor exceeds it. The peer publishes it only to a caller that requests it by name, so an older pair still handshakes; a run that learns no pool size falls back to 16. A request above the pool is clamped and the run log says so.

    In practice that pool is usually the narrower number, not the wider one: it defaults to 4 background slots, so a default pair runs a fan-out four at a time. That is the honest width rather than a new restriction — dispatching twelve into four slots only ever queued eight — but it does mean a wide fan-out finishes in waves, and maxConcurrent is the one place to change that, for workflows and for every other background agent at once.

    agentTimeoutMs counts from the moment the host reports the agent left its queue, so waiting for a slot is never charged against the work.

  • workflow_control: list, get, pause, resume, stop, or rm a run; rm writes a durable removal tombstone.

  • pi.events emits pi-workflows:runtime snapshots ({ runId, event }) for phase, task, quality, retry, and nested-workflow progress; observer failures are contained.

  • /workflows: TUI navigator plus run, status, watch, stop, pause, resume, rm, save <name> [runId], and strength subcommands.

  • /workflows strength: [<low|medium|high> <tier>|off] — which Agent tier each workflow strength runs on, and the tiers this host defines. Unconfigured hosts start on a shipped default table (identity, where the host defines the name). The tiers themselves are still configured only in pi-subagents' /agents → Model tiers; there is deliberately no second tier configuration file.

  • /effort and /ultracode: standing off|high|ultra effort guidance that auto-arms substantive messages.

  • /workflows-progress: compact|detailed|status|max <N> controls the persisted live panel.

  • /deep-research, /adversarial-review, /code-review, /multi-perspective, /codebase-audit: the five built-in workflows. A saved workflow with the same name takes precedence.

  • /workflows-trigger: set <keyword>, off, or on. See keyword arming below.

  • /<saved-name>: every saved workflow registers its own slash command at session_start (Pi cannot unregister commands, so registration happens there rather than at factory time, and existing commands are never overwritten).

Keyword arming

Typing the bounded word workflow or workflows in an ordinary message counts as an explicit opt-in to multi-agent orchestration: the message is annotated to tell the model the workflow tool is authorized for this turn.

Arming authorizes; it does not compel. The annotation says the model may run a workflow and may still decline, so "how do workflows work?" stays an ordinary question with an ordinary answer. Nothing is swallowed and no UI opens.

Only a standalone word arms. A workflow is also a thing people write code about, so none of myworkflow, workflow_name, WorkflowEngine, --workflow-id, src/workflow-editor.ts, workflow.ts, or the /workflows command matches. A sentence-ending dot is not a filename dot: "please run a workflow." arms, and workflow.json does not. Set a different word with /workflows-trigger set <word> (matched exactly — only the default word also matches its plural) or turn it off entirely with /workflows-trigger off.

Saved workflows use project scope first and a user scope fallback; writes are atomic and names are path-safe.

Model routing

A workflow speaks one word about cost: strength, this package's own name for how much effort a step deserves. low, medium, high — that is the whole vocabulary, and a script may name nothing else.

await agent("summarize this diff", { strength: "low" })
await agent("design the migration", { strength: "high" })
await agent("the ordinary case")                          // no strength: see below

There is no default strength. A call that names none dispatches with no tier, exactly as an unmapped strength does, and pi-subagents resolves it the way it resolves any other spawn: the agent type's own frontmatter tier, then agentTiers.defaultTier. That matters because a tier a call requests outranks the agent type's own — so a default strength would have pinned every unlabelled call to whatever it mapped to, overruling Explore's shipped tier: low and re-pricing the one agent this indirection exists to leave alone. Label the calls whose cost you mean to steer; leave the rest to the host.

A strength is not an Agent tier. A strengths table is the only thing that binds one to a tier:

// <agent dir>/workflows/settings.json, or the per-project settings file
{ "strengths": { "low": "cheap-search", "high": "deep" } }

An unmapped strength dispatches with no tier at all and takes the agent's ordinary default — the agent's own frontmatter tier, then the host's configured default. {} is a real table meaning exactly that for every strength.

The default table

A host that has configured nothing does not get "no mappings". It gets a table this package ships: each strength on the catalogue tier of the same name, wherever the host defines one. On a stock install that is low → low, medium → medium, high → high; on a host whose tiers are called cheap and deep it is empty, and every strength is unmapped. It is computed against the live catalogue rather than hardcoded, so it is never an assertion about someone else's tier names and never produces a start-up complaint about configuration you did not write.

Without it, every shipped script's low/medium distinction would collapse onto pi-subagents' managed default — which is the more expensive rung, so an unconfigured machine would run its fan-outs dearer, not cheaper.

That it is a table and not a fallback rule is the whole design. A rule saying "an unmapped strength runs on the tier of the same name" would be unremovable, so making a 26-agent fan-out cheaper would still mean editing tier low itself — dragging the Explore agent and everything else that names it. A table is replaced by writing one, and a written table that omits low leaves it unmapped even on a host that defines a tier called low. Nothing is ever inferred from spelling at dispatch: the binding is always a table entry.

So low → low in a written table is a real entry and not a no-op — a table you write replaces the default outright, so the entries you leave out are unmapped, not inherited.

Editing it

/workflows strength edits the table and also prints the tiers this host defines — the one place both halves are visible, since the strengths come from this package and the tiers from pi-subagents:

/workflows strength              # show the effective table and the host's tiers
/workflows strength low cheap    # run workflow strength `low` on Agent tier `cheap`
/workflows strength low off      # unmapped; back to the agent's own default

Edits land in this project's settings, like every other setting this package owns. Because the table replaces rather than merges at each level, the command seeds the write from the table a run would actually use — the project file, else global, else the shipped default — so setting one entry cannot silently discard the rest, and off clears a mapping this project never wrote. The write is therefore a full copy of the current table into the project; edit <agent dir>/workflows/settings.json by hand for a machine-wide table.

Any key the host's catalogue defines is a legal target — an existing tier, or a profile added for exactly this purpose. There is no shortlist and no naming convention: the target is checked against the live catalogue at run start, so a profile added to subagents.json is usable the moment it exists. One hop, never chained. An entry whose tier the host does not define is reported once at run start and ignored, leaving that strength unmapped — a stale entry costs the redirect, not every workflow on the machine.

This is not the retired workflow.tiers key and must not become it. That one carried its own model and thinking values, which made it a second model policy resolved by a second resolver. A value here is a key in the host's one catalogue: pi-subagents still owns every model, every thinking level, and the only resolveAgentTier(), and cannot tell a mapped call apart from a spawn that named the key itself.

There is no per-call model, thinking, or tier, and meta/phase metadata may not carry them either. A strength is the only model policy a workflow can express, which is what keeps a script from pinning a vendor the host did not choose.

The vocabulary is closed so that a word outside it is a typo rather than a strength nobody configured: agent({ strength: "lowe" }) fails before any dispatch instead of silently running at the default.

The same argument applies one level up, to the option name. agent() and checkpoint() reject any key they do not read, rather than ignoring it:

await agent("x", { tier: "low" })      // → name a strength instead: one of low, medium, high
await agent("x", { model: "…" })       // → a strength is the only model policy a workflow can express
await agent("x", { strenght: "low" })  // → valid options are: label, phase, schema, strength, …

An unknown key is dropped before the call hash is built, so it changes neither the dispatch nor the resume identity — the run would simply spend at a policy nobody chose, and a resume would replay it without noticing. A script is plain JavaScript authored against this contract, so there is no other gate: a misspelled option would run every step untiered, and a misspelled checkpoint({ headles: "abort" }) would auto-approve in headless mode instead of aborting.

Helpers that dispatch for you

verify(), judgePanel(), and completenessCheck() spawn agents on the script's behalf, so the script cannot label those calls. Each carries a documented default and takes an override, which keeps every dispatch inside the vocabulary:

await verify(finding)                              // reviewers run at "low"
await verify(finding, { strength: "high" })        // unless you say otherwise
await completenessCheck(args, results)             // one open-ended read: "medium"

These defaults are an opinion, and an opinion outranks the host's: a tier a call requests beats agentTiers.defaultTier, so on a machine whose default is high a completenessCheck now runs at whatever medium maps to rather than at high. That is the trade for making the helpers re-routable at all — they previously named no tier and could never be steered — and the override above is how a script takes it back.

Resume

The table is read fresh at each start and resume — including the resumes the engine starts itself, /workflows resume and the provider-limit retry — never frozen onto a run.

Resume identity keys a call on the tier it will actually request, so editing the table and resuming re-routes the calls that have not run — and also re-runs the finished calls whose strength moved, plus everything after the first such miss, because their cached answers came from a different tier. Re-routing mid-run is therefore a re-spend; if the point of the edit was to spend less, start a new run instead of resuming one that is mostly done. The provider-limit retry resumes on a timer with no one watching, so a table edited while a run is rate-limit-paused is charged the same way without being asked — stop that run before editing if the re-spend is not worth it. A nested workflow() boundary folds the table into its own key for the same reason: the value it caches for the whole child frame would otherwise survive a change that re-routes the child.

Fresh installs of pi-subagents ship low/medium/high tier profiles, all inheriting their model — which is what the default table above resolves against, so the built-ins run on a new machine, at their own strengths, with no configuration. A managed call that still names no tier falls back to medium; that is where an unmapped strength lands. The fallback is scoped to managed calls: it is not the catalogue's defaultTier, so an ordinary Agent spawn on the same machine keeps falling through to defaultModel and the parent session. A host whose default has been cleared (noDefaultTier) rejects an untiered managed call rather than quietly inheriting the parent session's model — which an unmapped strength can reach.

The managed wire, invocation record, journal, tombstone, and resume state all carry the one resolved tier — including one the host defaulted to, so a run's record says what actually ran. Resume keys each cached call on the policy for that call's tier: editing an unrelated tier does not invalidate work that never used it.

Intentional adaptations from upstream

  • No host-side web tools. Agents reach the web through configured pi-web-access/MCP tools or an agent type's tool policy.
  • Agent registry and tier catalogue ownership. agentType and the tier catalogue resolve in pi-subagents; the workflow package supplies only a tier key its own strengths table chose, avoiding a second model/thinking policy.
  • Schema validation is client-side. The managed request does not assume a structured-output tool; the runtime asks for JSON, parses, validates (including additionalProperties), and repairs across bounded attempts.

State is persisted as pi.appendEntry("pi-workflows:journal", ...) custom entries (schema v4), including frozen invocation args, resolved tier identity, terminal result previews, script revisions, named nested-workflow result boundaries, and durable removals. Interrupted/provider-limited runs replay from the journal; pre-schema-v4 journals are quarantined rather than replayed. Foreground AbortSignals stop owned children through explicit owner-scoped stop/quiescence, and dispose/branch replacement reject stale waiters.