@signalridge/pi-workflows
Deterministic JavaScript orchestration workflows backed by pi-subagents managed spawning.
Package details
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 recoverablenullafter retries. Replayed journal calls consume no real-dispatch cap or token/phase budget.schemais a plain JSON Schema; the reply is parsed and validated client-side with bounded repair across attempts, and exhaustion throwsSCHEMA_NONCOMPLIANCE.strengthis the provider-neutral workflow route;threadchains sequential turns within a named conversation thread. A legacy programmaticmodeloption 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 becomenull; non-recoverable failures (token budget, agent limit) halt the run after the batch barrier settles. Fan-out that breachesmaxAgentscancels 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 }.onErrorisskip-dependents(default),continue, orfail-fast. Results are keyed by task id, and task lifecycle events are observable throughonRuntimeEvent.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 onagent()/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 declareddefault.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), withargs,background(default true),maxAgents(default 1000),concurrency(defaults to the host's pool size),agentRetries(default 0),tokenBudget(default unlimited),agentTimeoutMs(default 1800000,nulldisables), andresumeFromRunId. 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'
maxConcurrentbackground 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
maxConcurrentis the one place to change that, for workflows and for every other background agent at once.agentTimeoutMscounts 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, orrma run;rmwrites a durable removal tombstone.pi.eventsemitspi-workflows:runtimesnapshots ({ runId, event }) for phase, task, quality, retry, and nested-workflow progress; observer failures are contained./workflows: TUI navigator plusrun,status,watch,stop,pause,resume,rm,save <name> [runId], andstrengthsubcommands./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./effortand/ultracode: standingoff|high|ultraeffort 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, oron. See keyword arming below./<saved-name>: every saved workflow registers its own slash command atsession_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.
agentTypeand the tier catalogue resolve in pi-subagents; the workflow package supplies only a tier key its ownstrengthstable 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.