Workflows
- Written for
- + Written for
- Deprecated
- + Deprecated
- Applies to
- + Applies to
Workflows
Multi-step DAG automations with branches, waits, LLM nodes, and webhook calls.
Triggers are great for one-step automations. Workflows are how you do the rest: branching logic, waits, calls to AI, retries, durable state. A workflow is a directed graph of nodes; the worker walks it one node at a time, persisting state after every step so a worker crash doesn't lose progress.
Info — When to reach for workflows
You need a workflow if you have any of: branching ("if intent is X, do A, else B"), waiting ("wait 24h then follow up"), multi-step state, or calls to external systems with retries. Otherwise stay with triggers — they're simpler.
Node types
There is no start node: the workflow's own triggerEventKey is the entry condition, and the run begins at the first node in the graph. Two families of node type exist and they are stored in the same enum — control-flow primitives the runner implements directly, and every action key from the shared trigger/workflow action catalog.
Control-flow nodes
Name | Type | Description |
|---|---|---|
|
| Pause for a duration, then continue. |
|
| Pause until an absolute time or the next business-hours boundary. |
|
| Evaluate guards on the outgoing edges and take the first that holds. See Edges and guards. |
|
| For-each over a collection; the body cycles back to the loop node. |
|
| N branches. Executed sequentially against a snapshot — the engine has one cursor, so this is fan-out in shape, not in wall-clock. |
|
| try / catch / finally handles around a sub-path. |
|
| Terminate the run. |
Commonly used action nodes
Name | Type | Description |
|---|---|---|
|
| Send a message to the contact. Siblings: |
|
| Pause until a matching event lands. Also |
|
| Human-in-the-loop. Pauses until someone decides via the approvals API; approved resumes, denied fails the run. |
|
| Assign to an agent. Also |
|
| Apply or remove a tag. The removal node is |
|
| Write into the run's state bag. There is no |
|
| Free-form LLM call whose output lands in the step's outputs. There is no node named |
|
| Classify the conversation into one of N labels. There is no |
|
| Signed HMAC POST to your URL, with a retry policy. The node is |
|
| Any HTTP call; the parsed response becomes the step's outputs. |
|
| File work externally. There is no generic |
|
| Invoke a sub-workflow, hand off to another one, or fan one out across a segment at a configured rate. |
Warning — An unknown node type is a silent no-op
The API validates
nodes[].typeagainst the enum, so a typo is a 400 at save time. But the worker logs and skips any type it has no runner for, then advances the cursor. If you author a graph outside the builder, check every type against the list above.
Edges and guards
Edges connect nodes. An edge with no when is taken unconditionally. Otherwise the guard is parsed and evaluated against the run scope.
The stored form is a structured filter — that is what the visual builder writes and the only form with a full operator set:
{
"conditions": [
{ "field": "steps.classify.intent", "op": "eq", "value": "complaint" }
]
}Operators are the same six the rest of the automation surface uses: eq, neq, in, gt, lt, contains.
Paths resolve against this scope:
Parameters
Name | Type | Description |
|---|---|---|
|
| The normalised trigger payload. |
|
| Outputs of an earlier step, keyed by its slug. |
|
| The run's state bag. |
|
|
|
|
|
|
|
| Present only when a human started the run. |
A plain-text guard is also accepted, for graphs authored by hand. It recognises exactly three shapes:
state.intent === "complaint" equality against a quoted string
state.intent !== "complaint" inequality against a quoted string
state.escalated bare path, truthyWarning — A text guard the parser doesn't recognise is ALWAYS TAKEN
Anything outside those three shapes parses to an empty filter, and an empty filter is the identity — it evaluates to true. So
state.plan === "business" && state.len > 200does not fail loudly; the edge is taken unconditionally and every contact goes down the "business" arm. There is no&&, no||, no!, no>/<, no numeric literals, and nonow()/len()/lower()built-ins. For anything beyond a single string comparison, use the structured form — the builder writes it for you.
State bag
Each workflow run has its own JSON state object, persisted to workflow_runs.state after every node completes. If a worker dies mid-run, the next worker picks the run back up from _node with all prior state intact.
{
"_node": "node_5",
"intent": "complaint",
"ticketId": "ticket_01H7...",
"customerSatisfied": false
}_node is the runner's cursor. Step outputs live separately, on workflow_runs.context.steps[<slug>], and are read with {{steps.<slug>.<key>}}.
Templating in node params
Before a node runs, its parameters are interpolated against the run scope. A missing path resolves to the empty string, never to the literal {{…}}.
{{trigger.contact.email}}
{{steps.classify.intent}}
{{trigger.contact.first_name || trigger.contact.email}}
{{trigger.contact.name | default: "there"}}
{{trigger.message.body | truncate: 100}}
{{trigger.contact.created_at | date: "YYYY-MM-DD"}}
{{workspace.name}} {{system.today}}Coalesce (||), pipe filters, filter arguments and arithmetic are all opt-in — they only engage when their separator appears, so a plain dotted path takes the fast path. Escape a literal with \{{foo}}.
A complete example: intent-based triage
(trigger event: conversation.created)
│
▼
┌─────────────────────────────┐
│ ai_classify │
│ labels: [question, │
│ complaint, │
│ refund, other] │
│ → steps.classify.intent │
└────┬────────────────────────┘
▼
┌──────────┐
│ branch │
└─┬────────┘
│
┌────────────┼───────────────┬─────────────────┐
│intent= │intent= │intent= │otherwise
│complaint │refund │question │
▼ ▼ ▼ ▼
┌──────────┐ ┌─────────────┐ ┌────────────────┐ ┌──────────────┐
│assign_ │ │ assign_team │ │ call_llm │ │ tag │
│team │ │ team= │ │ "Draft a │ │ "triage- │
│ team= │ │ billing │ │ reply from │ │ unknown" │
│ retent. │ │ │ │ the KB" │ └──────┬───────┘
└────┬─────┘ └─────┬───────┘ └────────┬───────┘ │
│ │ ▼ │
│ │ ┌──────────────┐ │
│ │ │ send_message │ │
│ │ │ body= │ │
│ │ │ {{steps. │ │
│ │ │ draft.text}}│ │
│ │ └──────┬───────┘ │
│ │ │ │
└─────────────┴──────────────────┴────────────────┘
│
▼
┌─────┐
│ end │
└─────┘Build this in the visual builder (drag + drop). The last edge out of the branch carries no guard, which is how you write "otherwise" — guards are tried in order and the first that holds wins.
Retries
Retry policy is per node, read from the node's params:
Parameters
Name | Type | Description |
|---|---|---|
|
| Total attempts, so the default is one try plus two retries. Default: |
|
| Base delay before the first retry. Default: |
|
| Each retry multiplies the previous delay. There is no named |
|
| Failure classes, not status codes. The accepted values are |
Info — Nodes are not individually idempotent
The engine does not key side effects by
(run, node, attempt)— a retriedfire_webhookorhttp_requestissues the request again. Idempotency exists at the run boundary instead: seeidempotencyKeyunder Triggering below. Make anything a workflow POSTs to your systems safe to repeat.
Rate caps
Workflows have no built-in ceiling on node count, run wall-clock, concurrent runs, per-node duration, or state size. What you can cap is how often a workflow is allowed to start, plus its LLM spend:
Parameters
Name | Type | Description |
|---|---|---|
|
| Null (the default) means no cap. |
|
| Null means no cap. |
|
| Null means no cap. |
|
| Summed from each step's recorded cost. Null means no cap. |
The dispatcher checks these before enqueueing a run and writes a trigger_rate_limited audit row when a cap rejects one.
Warning — A runaway graph is your responsibility
Nothing stops a
loopthat never terminates, or a graph whose edges cycle. SetmaxRunsPerHouron anything driven by a high-volume event, and use the Runs panel's "cancel all in-flight" control if one gets away from you.
Triggering workflows
Event-driven
Set the workflow's triggerEventKey to one of the 66 catalog events. The dispatcher fires the workflow on every matching envelope, subject to the trigger-level "only fire when…" conditions and the rate caps above.
Manual
Agents can start one from the conversation view: the Run workflow button in the conversation header opens a picker. Useful for ad-hoc playbooks.
API
POST /v1/workflows/{workflowId}/fire — Bearer token
{
"triggerPayload": { "source": "external_integration" },
"conversationId": "0190f4a2-...",
"contactId": "0190f4a3-...",
"idempotencyKey": "order-7421-refund"
}Every field is optional except that triggerPayload defaults to {}. conversationId and contactId must be UUIDs. Returns:
{ "runId": "0190f4b1-...", "idempotent": false }The key may be sent either as this body field or as the canonical Idempotency-Key header. A repeat inside 24 hours returns the original runId with idempotent: true rather than starting a second run.
Poll the run with:
GET /v1/workflows/{workflowId}/runs/{runId} — Bearer token
which returns the run plus its step timeline. Note the workflowId is part of the path — there is no lookup by run id alone. Related:
GET /v1/workflows/{workflowId}/runs — Bearer token
GET /v1/workflows/{workflowId}/runs/live — Bearer token
POST /v1/workflows/runs/{runId}/cancel — Bearer token
runs is cursor-paginated; runs/live is an uncapped snapshot of in-flight runs. Cancel is the one route keyed by runId alone.
Warning — These endpoints need a signed-in user
Workspace API keys (
ck_…) are accepted only on the routes that opt in, and no workflow route does. Ack_key gets a 403 here.
Visual builder
The dashboard's workflow editor renders the graph on a canvas:
Drag nodes from the palette.
Connect with edges; click an edge to set a guard.
Click a node to edit its config in the right rail.
Copy / paste / duplicate / delete, with undo-redo.
Save writes a draft revision; Publish makes it the live one.
You can export a workflow as JSON from the workflows list and re-import it. There is no YAML format — nothing in the product reads or writes one.
Versioning
Each save creates a new revision row, and publishing points the workflow's publishedRevisionId at it. GET /v1/workflows/{id}/revisions lists them; a rollback is a publish of an earlier revision id.
Warning — In-flight runs follow the published revision
The runner re-reads
publishedRevisionIdat every step, so publishing mid-run moves runs already in progress onto the new graph from their next node onward. If a change is not safe to apply to a half-finished run, pause dispatch or drain the queue before publishing.
Observability
Every run produces OpenTelemetry spans:
workflow_run.execute [01:23 → 01:24, status=ok]
├─ workflow_step.execute [01:23 → 01:23]
│ └─ action.ai_classify [01:23 → 01:23]
├─ workflow_step.execute [01:23 → 01:23]
└─ workflow_step.execute [01:23 → 01:24]
└─ action.send_message [01:24 → 01:24]Tempo is the canonical view for one run. Cost is not a span attribute: per-step LLM spend is recorded on the run itself, at workflow_runs.context.steps[<slug>]._cost_cents, which is also what the daily spend cap sums. Step durations are persisted separately and exposed at GET /v1/workflows/{id}/step-timings.
The dashboard's Runs tab is the bird's-eye view, and GET /v1/workflows/{id}/dlq lists runs that exhausted their retries, each replayable via POST /v1/workflows/{id}/dlq/{dlqId}/replay.
Troubleshooting
Warning — A run is stuck on wait_for_event
Either the event the wait is keyed on never fired, or the matcher is wrong. These nodes park the run against a listener row and wake only on a matching resolution — nothing times them out on your behalf, so pair a long wait with a
wait_until_timeescape hatch.
Warning — Every contact takes the same branch
Almost always a text guard the parser didn't recognise, which evaluates to true. Open the edge in the builder and re-author the condition there so it saves in the structured form.
Info — A step's variable resolves to nothing
{{steps.<slug>.<key>}}reads the handler's returned object verbatim — there is no case-normalising layer. Check the slug (it defaults to the node type, not the node id) and the exact output key; the variable picker in the builder lists both.
Info — State drift between runs
State is per-run, not global. If you need shared state across runs, write to a contact attribute via
set_attributeor to an external store viahttp_request.