Skip to main content

Flow execution model

A flow is a static definition: nodes, edges, and one trigger. A flow execution is one live run of that definition — a row that is created when a trigger fires, advanced node by node, parked on long waits, and finalized with a terminal status. This page explains that runtime as a state machine, the same way delivery lifecycle explains outbound messages and voice call lifecycle explains calls. Read it to understand what an execution can do, when it stops being cheap, and where its state is visible.

Definition vs execution

Two separate records, two separate lifecycles:
  • The flow definition is the graph you author — the definition.nodes / definition.edges JSON plus its saved snapshots. Creating or editing a flow never runs it.
  • The execution is one traversal of that graph against one trigger payload. Each trigger fire creates exactly one execution row (exec_…), which holds the trigger data, the per-node step trace, and the run-level status. Two executions of the same flow are independent — they share no state.
A flow graph is bulk-cloned metadata; an execution is durable working state. Everything below describes the execution’s lifecycle, not the definition’s.

The trigger catalog

Every execution starts from exactly one trigger firing. The five trigger_type values partition into two shapes: Whatever the trigger supplies becomes the execution’s initial context — the seed for all variable resolution the run does. The full per-trigger payload matrix is in the Flows trigger catalog.

Node semantics

An execution walks the graph node by node. Four node families behave differently at runtime: Two invariants govern node advancement:
  1. Edge routing is by handle, not by drawing order. A condition node has yes and no handles; an abTest node has one handle per variant; any node can carry an explicit error handle. When no handle matches, the executor falls back to the node’s unlabeled default edge; when that is absent too, the run ends. The per-node handle choice lands in the step trace, so a branch that went the wrong way is inspectable per node.
  2. An unrecognized node type is a no-op, not a failure. The executor logs the node as unhandled and takes its default edge — a node does nothing unless its type is one the executor implements.

Execution states

The run-level status is a short state machine: Two points about how states change:
  • waiting → running is automatic. There is no call you make to resume a parked execution — the webhook-worker flow scheduler polls flow_executions every 30 seconds, finds waiting rows whose delay step’s resumeAt has elapsed, and POSTs an internal resume. You observe the transition through the status flip, not through any API call.
  • A run has no cancelled state. Execution rows only ever finalize to completed, completed_with_errors, failed, or timeout — unlike a message, which you can cancel between queued and sent. To stop a flow you unpublish the flow itself (POST /flows/:id/unpublish), which rejects new executions; in-flight executions keep finishing.

Async hand-off: how a wait survives a restart

The interesting property of the runtime is that a long wait does not hold a process open. When an execution reaches a delay (or sto_wait) node whose wait exceeds the inline budget (60 seconds), the executor:
  1. Writes a step with status: "waiting" and a resumeAt timestamp (for a days wait in a timezone-aware flow, resolved by calendar arithmetic so the resume lands on the same wall-clock hour across DST).
  2. Persists the whole run — steps, context, status waiting — into flow_executions and returns, freeing the worker.
The process that started the run is now irrelevant to it. The webhook-worker flow scheduler ticks every 30 seconds, atomically claims due waiting rows (an in_flight claim update under FOR UPDATE SKIP LOCKED, capped to a bounded batch per cycle so two scheduler replicas can never double-resume one row), and POSTs POST /flows/executions/:id/resume over the internal API. The resume path re-reads the persisted step trace and trigger data, reconstructs the context, and continues the traversal from the node after the wait. A stale row left in in_flight by a pod that died mid-resume is re-flipped to waiting by the same scheduler’s stale-row reaper after a five-minute window, so a crash costs at most one scheduler tick of recovery — the run is never stranded. The consequence for you: a flow that says “wait 24 hours, then send the reminder” costs nothing during the 24 hours. The run exists only as a row, and the resume is indistinguishable from a fresh start except that the step trace and context carry over. This is the queue backbone described in async processing model applied to flow pauses.

Variables and context

Data flows through an execution as a context — the mutable key/value map the run resolves {{...}} placeholders against before every node’s config is used. The context is built by layer, in this order:
  1. Trigger data — whatever the trigger supplied (for an inbound message: message, from, to, channel, message_id).
  2. Contact fields — when the run carries a contact, its fields are available as flat keys ({{first_name}}, not {{contact.first_name}}).
  3. Node outputs — action and AI nodes add their results under internal _flow_* keys (for example a webhook node’s HTTP status), which downstream conditions and functions can read.
  4. function outputs — a function node’s derived variables land back in the context for all downstream nodes.
Three scoping rules:
  • Scoping is per execution. Context lives on the execution row, and a split branch receives an isolated copy of the context — additions inside one parallel branch never leak into its sibling branches, and nothing leaks between executions.
  • A missing key resolves to an empty string, not an error — a placeholder whose key is absent renders empty, so a condition on an unset variable is a legitimate branch.
  • Internal keys are prefixed. Anything the executor writes itself rides the _flow_ namespace, so a trigger payload can’t shadow runtime state (and a trigger-supplied _flow_ key is overwritten by the runtime).

Versioning and publishing

A flow moves through three states: draftpublished → (optionally back to) draft via unpublish. The critical property for executions is how a run binds to a graph:
  • Only published flows can execute. Starting a draft flow returns an error; the dashboard Run action against a draft is blocked the same way. The gate is the flow row’s status, checked before any step runs.
  • Publishing snapshots the graph atomically. POST /flows/:id/publish writes an immutable snapshot into flow_versions (the publish transaction covers both the snapshot insert and the status flip, so there is no half-published state) and marks that snapshot is_published. The published snapshot is the graph the live executor reads — you can edit a published flow’s draft while its current version serves traffic, and the next publish swaps the live graph atomically. GET /flows/:id/versions lists the history; POST /flows/:id/versions/:versionId/restore promotes an older snapshot back to being the current draft without touching the live version.
  • The resume re-reads the current published graph. When a run parks on a long wait, the resume path re-loads the flow’s published definition at resume time — so unpublishing (or re-publishing a different graph) while a run is parked changes what the resume continues with. Treat a flow with parked 24-hour executions like a running API schema: breaking the graph mid-wait fails-or-misroutes those resumes.

Where executions surface

An execution is observable from three angles:
  1. The step trace — every node that ran records a step: node id, node type, per-step status (pending, running, completed, failed, skipped), duration, and when applicable input/output/error. Flow Executions is the API surface for it.
  2. flow.* webhook eventsflow.execution.started, flow.execution.completed, and flow.execution.failed fire on the run’s lifecycle transitions. Subscribe and consume per the webhook events reference.
  3. Run-level fieldsstatus, steps_completed, total_steps, duration_ms, and the surfaced error on terminal failures, all queryable via GET /flows/executions with a status filter.

Common pitfalls

  1. Branching on display names instead of node ids. Two nodes with the same label still resolve edges by node id and handle. Inspect a mis-routed branch by its node_id in the step trace, not by its rendered label.
  2. Treating completed_with_errors as green. A node that failed but routed via an error edge still records a failed step and a run error; the run-level status says completed_with_errors. Alarm on completed, completed_with_errors, and failed separately.
  3. Assuming a cancel exists. There is no cancel-execution endpoint; unpublish the flow to stop new executions, knowing the in-flight ones finish on their own.
  4. Editing a flow with long waits while executions are parked on it. A 24-hour delay creates a 24-hour window where the resume re-evaluates the flow’s current published definition. Changing the graph during that window affects which nodes the resume walks — see Versioning and publishing.
  5. Expecting waiting to need a kick. A parked execution resumes by itself on the 30-second scheduler tick; there is no call you make to restart it, and it never needs one after a worker restart.
The per-step payload schema and the query/filter parameters for reading these records are in Flow Executions; the authoring side (triggers, nodes, edges) is in Flows and Flow Builder.