All documentation
Explanation Verified 2026-09-06

Workflows

How multi-step work runs on ONE: eight step kinds, all compiling to one signal, and the ones that suspend a run until something outside answers.

Reading this as an agent? The same page in raw markdown: /docs/workflows.md

A workflow is a directed graph of steps that a run walks. Every step compiles to one thing: signal(receiver, data). The step kind is not a separate execution model — it names which receiver family the step reaches, and whether the run keeps going or stops and waits. This page is the mental model behind the workflow receivers and the run surfaces that use them.

One compile target

There is no second execution path. A tool call, a skill invocation, an agent turn and a stage move are the same operation with different payloads: a receiver name and a JSON body, dispatched the way any other caller dispatches one. So a workflow can do anything the API can do, and nothing it does is invisible to the rest of the substrate.

The consequence for a language model driving a workflow is narrow. The graph holds the sequence; the model’s only probabilistic job is picking which workflow to run and filling its trigger payload. Everything after that executes the same way every time.

The eight kinds

StepKind is a closed enum. Verified against packages/sdk/src/schemas.ts lines 203-212 on 2026-09-06 — eight members, in this order:

KindWhat actsWhat the run does
triggerthe worldconsumes the signal that matched trigger-source
toolan integrationdispatches a receiver, or tools:composio
skilla capabilitydispatches skill:run
agentan actordispatches agent:run
conditionlogicevaluates the guard inline and routes
humana personsuspends
delaytimesuspends
sella buyerdispatches checkout:create, then suspends

gate is a dead name for human and does not appear in the enum.

tool → skill → agent is the autonomy ladder, ordered by how much judgment the step is allowed. A tool step is deterministic — it names a receiver and its arguments and exercises no discretion. A skill is a scoped, priced capability with a declared input and output. An agent step hands the work to an actor with open judgment. A workflow composes up into a skill; a skill never expands back down into a workflow.

The kind is not the whole binding

Four of the eight kinds resolve no step binding. trigger, condition, human and delay are handled by the executor’s own step.kind branch before stepBinding is ever consulted: a condition step’s guard is evaluated inline by evalCondition, and human and delay park the run. A park is not silence — after a human park the run layer fires a best-effort human:notify to deliver the approval card; it is the park, not the step’s config, that decides to send it. This is why the design names logic:eval, time:wait and human:ask do not appear in the receiver registry — the repo states it directly in one.ie/web/src/lib/factory/wire-parity.test.ts:164: “logic:eval / human:ask / mark will never be registry keys.” human:ask is the design name for the run-park surface; human:notify is the shipped receiver that posts the approval card, human:asked the event, human:resolve the resume.

For the kinds that do dispatch, the receiver comes from the step’s config, not from its kind. stepBinding reads the config keys in a fixed order and the first one present wins:

receiver   → that receiver, verbatim
composio   → tools:composio
entity     → a typed create/update for that dimension
lifecycle  → entity:tag, with by and source supplied
actorId    → agent:run
skill      → skill:run

A step whose config matches none of these is a no-op pass-through: the run traverses it without side effects rather than failing. The kind is what the canvas shows and what decides suspension; the config is what decides the call.

Suspending and resuming

Three kinds stop a run on their kind alone. The run persists a cursor and waits for something outside itself:

KindParks asResumes on
humanawaitinghuman:resolve
delayawaiting_delaya Durable Object alarm
sellawaiting_paymentcheckout:resume

A human step resolves with either a decision (approved / rejected) or a form payload, and it resolves the same way from every surface — a web card, a Telegram inline button, a Discord component. A sell step mints a checkout session and parks until the storefront webhook fires checkout:resume with the runId and stepId it stamped at create time. See Payments for the rails behind that step.

An agent step can park too, but only when the run is driven with the optional remote-suspend hook; absent that hook it dispatches agent:run normally.

A parked step’s output lands in the run’s cursor, so $step.<id>.<field> resolves downstream exactly as a dispatched step’s output does.

People and agents drive a run with the same verbs

There is no operator API separate from the agent API. Starting, stopping, resuming and editing a run are four receivers, and a human console and an agent send them identically:

ActReceiver
startworkflow:run
stop / pauseworkflow:stop
resume a human stephuman:resolve
edit the graphworkflow:apply-diff

All four carry the manage_workflows authority label. Who may call them is answered by the authority walk, not by which surface the call arrived on — see Authority and access. workflow:apply-diff is the only way a workflow’s shape changes, so every edit is one validated diff that can be checked before it persists.

Four things a first workflow gets wrong

These are laws the code enforces, not conventions.

A stage move is a tool step carrying a lifecycle config. kind:'lifecycle' is not in the enum. stepBinding reads config.lifecycle and dispatches entity:tag, supplying by and source itself.

Naming from makes a move conditional. When the entity does not hold the named from tag, entity:tag writes a durable rejected receipt and throws conflict: stage mismatch. One case is carved out: if the entity already holds the destination tag, the same move has arrived twice, and the call returns a no-op rather than throwing. That is a guard, not a bug — it stops a workflow recording a stage the world never entered — and only a genuine conflict fails the step and deposits a warn.

custom:<event> is a tracked event name, not a trigger source. No triggerWorkflows() call site in the platform emits a custom:* source, so a trigger step configured with one never fires on its own. It fires only when something calls workflow:trigger with that exact source by hand.

A condition guard that will not parse fails closed. The step fails and its downstream steps poison. It never silently routes true.

Known limits

  • await chaining does not close. A tool step bound to workflow:trigger with await: true spawns a child and parks the parent, but __awaitParent is written by the executor and read nowhere — settle() has no parent-resume leg, so that parent stays parked. Chain in call mode instead: fire workflow:trigger and have the consumer declare that source as its trigger_source. The await path caps chain depth at 3.
  • A sell step suspends even when the checkout session could not be minted. The checkout:create dispatch is best-effort; a missing key still parks the run.
  • delay caps at 90 days. A longer sleep fails the step loudly rather than parking.
  • A human park has no global timeout. The deadline is per-step config; a step configured without one waits until human:resolve settles it or the run is stopped.

Next

  • Receivers — the catalog every step dispatches into
  • Skills — the middle rung of the autonomy ladder
  • Agents — the top rung, and what open judgment costs
  • Authority and access — who may run, stop and edit