Guides

Workflows

AdminUpdated Sep 19, 2026

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

wait

flow

Pause for a duration, then continue.

wait_until_time

flow

Pause until an absolute time or the next business-hours boundary.

branch

flow

Evaluate guards on the outgoing edges and take the first that holds. See Edges and guards.

loop

flow

For-each over a collection; the body cycles back to the loop node.

parallel

flow

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

flow

try / catch / finally handles around a sub-path.

end

flow

Terminate the run.

Commonly used action nodes

Name

Type

Description

send_message

comms

Send a message to the contact. Siblings: send_internal_note, send_email, send_template, send_form, send_carousel, send_media, send_quick_replies.

wait_for_event

flow

Pause until a matching event lands. Also wait_for_message, wait_for_contact_attribute and wait_for_webhook_callback, which each park the run on a listener row rather than a timer.

approval_gate

flow

Human-in-the-loop. Pauses until someone decides via the approvals API; approved resumes, denied fails the run.

assign

routing

Assign to an agent. Also assign_team, assign_round_robin, transfer_to_team, unassign.

tag / remove_tag

metadata

Apply or remove a tag. The removal node is remove_tag — there is no untag. (The legacy alias apply_tag still runs, for graphs saved before the catalog merge.)

set_variable

state

Write into the run's state bag. There is no set_state. Companion data-shaping nodes: format_date, math_expression, regex_extract.

call_llm

AI

Free-form LLM call whose output lands in the step's outputs. There is no node named llm.

ai_classify

AI

Classify the conversation into one of N labels. There is no llm_classify. Also ai_summarize, ai_translate, ai_suggest_reply, ai_extract, and the text-in/text-out variants ai_classify_text / ai_summarize_text.

fire_webhook

integration

Signed HMAC POST to your URL, with a retry policy. The node is fire_webhook, not webhook. Also fire_zapier, fire_make.

http_request

integration

Any HTTP call; the parsed response becomes the step's outputs.

create_jira_ticket, create_linear_issue, create_github_issue, create_asana_task, create_notion_page, create_hubspot_ticket

integration

File work externally. There is no generic create_ticket node — name the destination.

call_workflow / enqueue_workflow / fire_workflow_for_segment

flow

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[].type against 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

trigger.*

scope

The normalised trigger payload.

steps.<slug>.*

scope

Outputs of an earlier step, keyed by its slug.

state.*

scope

The run's state bag.

system.*

scope

now, today, day_of_week, hour_of_day, is_business_hours — all frozen at run start.

workspace.*

scope

id, name, timezone, plan, locale, slug.

agent.*

scope

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, truthy

Warning — 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 > 200 does 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 no now() / 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

retryPolicy.maxAttempts

integer

Total attempts, so the default is one try plus two retries. Default: 3.

retryPolicy.backoffMs

integer

Base delay before the first retry. Default: 1000.

retryPolicy.backoffMultiplier

number

Each retry multiplies the previous delay. There is no named fixed/linear/exponential mode — set the multiplier to 1 for a fixed delay. Default: 2.

retryPolicy.retryOn

string[]

Failure classes, not status codes. The accepted values are network, 5xx, timeout and all. A bare 429 is not one of them — use all if you want to retry everything. Default: ['network','5xx','timeout'].

Info — Nodes are not individually idempotent

The engine does not key side effects by (run, node, attempt) — a retried fire_webhook or http_request issues the request again. Idempotency exists at the run boundary instead: see idempotencyKey under 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

rateLimitConfig.maxRunsPerMinute

integer

Null (the default) means no cap.

rateLimitConfig.maxRunsPerHour

integer

Null means no cap.

rateLimitConfig.maxRunsPerDay

integer

Null means no cap.

rateLimitConfig.maxLlmCostCentsPerDay

integer

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 loop that never terminates, or a graph whose edges cycle. Set maxRunsPerHour on 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. A ck_ 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 publishedRevisionId at 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_time escape 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_attribute or to an external store via http_request.

Was this page helpful?