Run multi-stage session pipelines with agentproto workflows — workflow_start with barrier-gated stages of concurrent steps, sessionRef to reuse earlier output, workflow_status/cancel/retry, workflow_run_file for AIP-15 WORKFLOW.md, and workflow_escalation_resolve for human answers. Trigger when asked to pipeline agents, fan out stages, run a WORKFLOW.md, or retry a failed run.
Instal·la
npx skillscat add agentproto/ts/ap-workflows Instal·la des del registre de SkillsCat.
ap-workflows
When to use
- Work needs ordered phases where each phase fans out agents that run in parallel.
- A later stage must act on an earlier stage's output (produce → review → fix).
- You have a ready AIP-15
WORKFLOW.md(+ optionalentry.mjs) to run as-is.
Start a pipeline
workflow_start({
"workflowId": "review-then-fix",
"stages": [
{ "label": "produce", "steps": [
{ "label": "plan", "adapter": "claude-code", "prompt": "Draft the migration plan for src/auth." }
]},
{ "label": "review", "steps": [
{ "label": "review-a", "adapter": "claude-code", "prompt": "Review the plan for data-loss risks." },
{ "label": "review-b", "adapter": "claude-code", "prompt": "Review the plan for test coverage." }
]},
{ "label": "fix", "steps": [
{ "label": "fixer", "sessionRef": "review-a", "prompt": "Address the review comments." }
]}
]
})
// → { "runId": "wf_..." } — execution is background; returns immediately.Steps within a stage run CONCURRENTLY. Stages are barriers: stage N+1 does not start until every step of stage N has finished (or failed). adapter spawns a NEW session for the step; sessionRef (a prior step's label, any earlier stage) reuses that session instead — ignoring adapter.
Poll and steer
workflow_status({ "runId": "wf_..." })
// → per-stage: each step's status + sessionId — read earlier output via ap-read-output on that id
workflow_cancel({ "runId": "wf_..." }) // in-flight steps finish; no new stage starts
workflow_list({}) // running / done / failed / cancelled runsRun a WORKFLOW.md file
workflow_run_file({
"path": "workflows/morning-sweep/WORKFLOW.md",
"input": { "date": "2026-09-01" },
"cacheKey": "morning-sweep-2026-09-01"
})Loads the AIP-15 file via the workflow-loader and runs it through the same runner as workflow_start, in the background. With a cacheKey, cacheable steps replay unchanged output on re-invocation instead of re-spawning.
Retry a failed run (AIP-58 §6)
workflow_retry({ "runId": "wfrun_..." })
// → { "runId": "wfrun_...", "status": "running", "retryOf": "wfrun_..." }Starts a NEW run (its own runId + workspace) that replays every step the original already completed — no re-spawn — and re-executes only from the first step that never succeeded. Works even when the original run never passed a cacheKey: every run journals its own steps internally for this purpose. Refused on a still-running/succeeded run — only failed/cancelled is retryable. Pass input to override the workflow's $input binding for the retry; any step whose resolved input changes as a result (directly, or transitively via an upstream step it depends on) re-executes instead of replaying.
Per-run workspace + artifacts (AIP-58 §4)
Every run gets its own directory — steps see it as $run.workspace (a tool step's inputs, a gate's args/cwd) or {{run.workspace}} (an agent step's prompt). Use it instead of a fixed/shared output path — that's exactly the "two concurrent runs clobber each other's files" bug this closes:
steps:
- id: fetch
kind: tool
tool: yt.fetch-captions
inputs:
outDir: $run.workspace # NOT a fixed "tmp/runs/latest" — every run gets its own dirDeclare a produced file as a run artifact with kind: "artifact", or a manifest-level outputsFiles block (missing + required: true fails the run, { code: "missing-artifact" }):
outputsFiles:
brief:
path: "./briefs/<runId>.md"
required: trueworkflow_status lists recorded artifacts compactly ({key, path, size}); fetch one's content with workflow_artifact_get({runId, key}). A workflow's declared outputsFiles.<key> never writes to its own shared path automatically — that stays in the run's own workspace until an explicit workflow_publish({runId, artifactKey, to?}), refused unless the run already done. This is what makes a fixed destination path safe to declare at all: publishing is always one attributable call, never an implicit race between whichever run finishes last.
Conditional steps: kind: branch
Arms are exclusive with a join — exactly one arm's steps run, then the run continues after the arms:
- id: maybe-render-pdf
kind: branch
branches:
- when: $input.exportPdf # bare ref = truthiness; or `<ref> <op> <literal>`
next: pdf-render
# default: <id> # optional arm for "no when matched"
# join: <id> # optional: where execution resumes
- id: pdf-render # the arm body: its target up to the next arm target / the join
kind: tool
tool: pdf.render
- id: next-step # the join (step after the last arm target): runs either way- Every
next/default/joinmust be a LATER sibling in the same step list. - Arm body = its target step up to (not including) the next arm's target; the last arm runs up to
join(default: the step right after its target, so give a multi-step last arm an explicitjoin:). - No
defaultand nothing matched ⇒ only the steps between the branch and its first arm target run (none when the first target is the very next step), then the join. So an optional step needs no no-op "skip" sibling. - Untaken arms' steps show as
skippedinworkflow_status(AIP-58step.skippedevent) — not missing, notdone. fallthrough: trueis the legacy mode (target + every later sibling runs); don't use it for new workflows.
Escalations
When a step's policy is escalate and its session asks for human input mid-stage, the run parks until answered:
workflow_escalation_resolve({
"runId": "wf_...", "stageIndex": 1, "stepIndex": 0,
"response": "Use the staging DB, not prod."
})The answer is injected into the awaiting session and the stage resumes.
Gotchas
- Later-stage steps read earlier output via
sessionRefplusagent_outputon that sessionId (see ap-read-output) —sessionRefreuses the conversation, it does not paste output into the prompt. policy: "escalate"parks the stage until someone callsworkflow_escalation_resolve— an unresolved escalation means the run sits there forever. Give the webhook (notifyUrl) or checkworkflow_statuson a cadence.cacheKeyonly affects steps markedcacheable: true— cache only idempotent/pure steps; replayed output goes stale otherwise.- Re-running a failed run: prefer
workflow_retry({runId})(above) — it doesn't need you to have passed acacheKeyoriginally, or to re-supplypath/inputby hand. Manually re-invokingworkflow_run_filewith the SAMEpath,input, andcacheKeystill works too: everycacheablestep that already succeeded replays from the journal (no re-spawn); only the step that failed (or whose resolved input changed) re-executes. Each item of amapcaches independently, so fixing one bad item and re-running only re-does that item. A replayed step still shows inworkflow_statusasdone, markedcached: true. - Cancel is graceful: in-flight steps complete, but no new stages start.
- Returning
runIdimmediately does not mean the run started cleanly — firstworkflow_statuspoll is where a bad step spec surfaces.
Pointers
- agentproto — daemon overview; the workflow engine's stage/barrier model.
- ap-spawn-agent / ap-prompt-agent — the per-session primitives workflow steps wrap.
- ap-read-output — fetching a session's reply for downstream stages.
- pb-build-app — apps bundle WORKFLOW.md files; this is their runtime.