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:
| Kind | What acts | What the run does |
|---|---|---|
trigger | the world | consumes the signal that matched trigger-source |
tool | an integration | dispatches a receiver, or tools:composio |
skill | a capability | dispatches skill:run |
agent | an actor | dispatches agent:run |
condition | logic | evaluates the guard inline and routes |
human | a person | suspends |
delay | time | suspends |
sell | a buyer | dispatches 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:
| Kind | Parks as | Resumes on |
|---|---|---|
human | awaiting | human:resolve |
delay | awaiting_delay | a Durable Object alarm |
sell | awaiting_payment | checkout: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:
| Act | Receiver |
|---|---|
| start | workflow:run |
| stop / pause | workflow:stop |
| resume a human step | human:resolve |
| edit the graph | workflow: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
awaitchaining does not close. Atoolstep bound toworkflow:triggerwithawait: truespawns a child and parks the parent, but__awaitParentis written by the executor and read nowhere —settle()has no parent-resume leg, so that parent stays parked. Chain in call mode instead: fireworkflow:triggerand have the consumer declare that source as itstrigger_source. The await path caps chain depth at 3.- A
sellstep suspends even when the checkout session could not be minted. Thecheckout:createdispatch is best-effort; a missing key still parks the run. delaycaps 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:resolvesettles 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