Flows
A flow is a directed graph of nodes that converses with one contact. Campaigns enrol recipients into
it; each enrolment becomes an InboxFlowRun that walks the graph, sending messages through the
ordinary reply path and parking whenever it needs the customer to answer.
Three things make flows harder than they look, and they are what this page is mostly about: not every node type is supported to the same depth, what a node is allowed to be is a different question from whether it can be delivered, and a published flow is frozen so an in-flight conversation does not change graph under the customer's feet.
The 25 node types
FLOW_NODE_TYPES in flows.store.ts is the taxonomy. Three groups, by prefix.
SEND (13) — put a message on the wire:
send-template, send-text, send-image, send-audio, send-video, send-file,
send-location, send-reply-button, send-url-button, send-list, send-sticker, send-sms,
send-email.
RECV (7) — wait for the customer:
recv-user-response, recv-save-response, recv-csat, recv-single-product,
recv-multi-product, recv-user-location, recv-whatsapp-flows.
ACTION (8) — do something without messaging:
action-dialog, action-condition, action-api, action-webhook, action-close, action-delay,
action-goto-flow, action-check-delivery.
send-sms and send-email are the cross-channel nudges: they fire to the contact's phone or address
rather than into the flow's thread.
Three layers of support
The taxonomy is complete; the depth of support is deliberately layered, and the layer a type sits in is the honest statement of how far the port has reached for it.
| Layer | Contains | What works |
|---|---|---|
| Authorable | all 25 | Create, update, validate, round-trip through the version snapshot |
| Renderable | the send-* types |
Render or degrade to a deliverable message (flow-render.ts) |
| Runnable | all send-*, all recv-* (park), action-close, action-condition, action-check-delivery |
The runner dispatches them |
Everything else — action-delay, action-api, action-webhook, action-dialog,
action-goto-flow, and the recv engines beyond parking (recv-csat, recv-single-product,
recv-multi-product, recv-whatsapp-flows, recv-user-location beyond the park) — is authorable
and validated, but its run loud-fails at advanceTo's fall-through:
node type '<type>' is not supported
The run goes failed with that message. Loud, not hung — the same contract the runner honours for a
genuinely unknown type. Build a flow with one of these and it saves and publishes; the first run to
reach that node stops there.
The two capability axes
flow-capability.ts answers "does this node type belong in this flow at all". It is an author-time
question, distinct from the runtime render policy below, and it has two axes.
Axis A — channelKind, the flow mode
| Mode | Node types | Entry node |
|---|---|---|
whatsapp-api |
everything except AUTOMATION_ONLY_TYPES |
send-template |
whatsapp-qr |
text + media only; QR_BLOCKED_TYPES and AUTOMATION_ONLY_TYPES removed |
send-text |
automation |
all 25 — the sole holder of AUTOMATION_ONLY_TYPES |
a send on its primary channel |
whatsapp-api is the official Business API, which is why it opens on a Meta template: that is the
only node that may legally open a conversation outside the 24-hour window.
whatsapp-qr is the unofficial QR-session gateway. The interactive and official-only types are
removed — QR_BLOCKED_TYPES is send-template, send-reply-button, send-url-button, send-list,
send-sms, send-email, recv-single-product, recv-multi-product, recv-whatsapp-flows — and
choices become numbered text replies instead.
automation is the channel-agnostic multichannel mode, and the conversation is not its spine. It
drives one-way sends and advances on the delivery receipt rather than on a customer reply, which
is why it alone may hold AUTOMATION_ONLY_TYPES (action-check-delivery) and why its entry node is
not a fixed type.
Mode defaults to whatsapp-api when channelKind is null.
Axis B — channels[], the channels the flow spans
Drawn from whatsapp-api, whatsapp-qr, sms, email, plus the legacy bare whatsapp, which
normalizeChannels folds onto whatsapp-api (it always meant the official transport). Absent
channels defaults to ['whatsapp'].
A node whose channel was not selected is dropped:
CHANNEL_REQUIRED_TYPES—send-smsneedssms,send-emailneedsemail.QR_BLOCKED_TYPES— need thewhatsapp-apitransport.WHATSAPP_ONLY_TYPES— every WhatsApp-only send, everything that waits on a reply, andaction-dialog(the live-agent handover, which lands in a WhatsApp conversation) — need one of the two WhatsApp transports.
The predicate order matters. allowedTypesForFlow checks channel-required before
QR-blocked:
return base.filter((type) => {
const required = CHANNEL_REQUIRED_TYPES[type];
if (required !== undefined) return effective.includes(required);
if (QR_BLOCKED_TYPES.has(type)) return hasWhatsappApi;
if (WHATSAPP_ONLY_TYPES.has(type)) return hasWhatsapp;
return true;
});
send-sms and send-email appear in both CHANNEL_REQUIRED_TYPES and QR_BLOCKED_TYPES. Because
the channel-required arm runs first and returns, an SMS node in an SMS-only automation is gated on
the sms channel — not on the official WhatsApp API, which it does not have. Reverse the two
predicates and every multichannel automation loses its SMS and email nodes.
primaryChannel picks an explicit one-way channel over WhatsApp when both are selected, because
WhatsApp is first in ALL_CHANNELS for historical reasons only. entryTypesForFlow returns exactly
one legal opening node for a WhatsApp flow, but for an automation returns every allowed send-*
type — a flow spanning SMS and email can honestly start on either.
AI_EXCLUDED_TYPES is a third, narrower filter: the AI flow builder must not emit
action-goto-flow, because it needs a real target flow id only a human can pick.
Terminal nodes
TERMINAL_NODE_TYPES — nodes with no outgoing edge, which complete or hand off the run:
action-close, action-dialog, action-goto-flow, send-url-button, send-audio,
send-location, send-sticker, send-sms, send-email.
isTerminalForFlow un-terminalizes send-sms and send-email in an automation flow:
if (isAutomation(channelKind) && CHANNEL_REQUIRED_TYPES[type] !== undefined) return false;
return terminalTypes.has(type);
Inside a WhatsApp conversation an SMS is terminal — the contact cannot reply to it to advance the chat, so there is nowhere to go. In an automation flow those same sends are the spine: the run continues once the delivery receipt lands, so they must be able to carry outgoing edges.
Render policy
Capability asks whether a node may exist here. Render policy asks whether a permitted node can be
delivered on a given channel. One verdict per (nodeType, channel):
| Verdict | Meaning |
|---|---|
native |
The channel puts the node on the wire as-is |
degrade |
Render the text fallback — buttons → numbered list, list → numbered, location → address + map link, url-button → labelled link, template → saved body, media → attachment |
block |
The node has no meaningful form here: refuse it at author time, at publish validation, and at run time |
degrade is the default, which preserves the "degrade, not refuse" design the runner is built on.
Today nothing resolves to native unless flow-render.ts's CHANNEL_RENDER_CAPABILITIES says
so, because no adapter has an interactive or template surface — DmProvider.sendMessage takes only
{ body, attachmentUrl, attachmentType }.
Resolution order in resolveRenderVerdict:
flow override[type][channel] → default[type][channel] → default[type]['*'] → 'degrade'
An unknown node type is block. A null or absent channel is degrade — an unbound flow is never
blocked, because the check re-runs at publish and enrol once a connection pins the channel. A block
is never upgraded to native.
DEFAULT_RENDER_POLICY lists only the non-degrade pairs: catalog products and WhatsApp Flows block
off WhatsApp ('*': 'block', degrade on whatsapp / whatsapp_session); all media blocks on sms
(no MMS on the estate's route); send-sticker also blocks on livechat; send-sms blocks
everywhere but sms, send-email everywhere but email.
send-template is deliberately not blocked anywhere. Off WhatsApp it degrades to the builder's saved
preview body. When there is no saved body there is nothing honest to send, and that is a runtime
failure — whether a body exists is a per-message fact, not a channel capability.
Per-flow overrides live in flow.renderPolicy as a sparse { [nodeType]: { [channel]: verdict } },
so an operator can flip a specific pair between degrade and block without a code change.
The delivery gate
action-check-delivery holds an automation run until the preceding one-way send resolves.
- Branches, in match-priority order:
delivered,failed,timeout. - Wait window:
DELIVERY_WAIT_DEFAULT_MINUTES= 60. MaximumDELIVERY_WAIT_MAX_MINUTES= 10080 (7 days). - While nothing is conclusive the run parks as
waiting_deliverywithnextCheckAtset to the deadline; the tick brings it back to take thetimeoutbranch. readcounts as delivered.delivered_onchooses how strict the gate is:handset(the default) waits for the carrier's final confirmation;networkcontinues on the carrier's acceptance, which is what asentstep records.- A gate placed before any one-way send resolves
failedrather than hanging forever.
Operator note. Inbox writes sent at dispatch; delivered and read only appear if a provider
receipt is reconciled onto the step. On a channel whose provider sends no receipt, a handset gate
will always take its timeout branch. That is the honest answer, not a bug — use
delivered_on: "network" there.
Version pinning
POST /v1/flows/:id/publish mints an immutable InboxFlowVersion: a self-contained
{ meta, nodes, edges } snapshot, a 1-based versionNumber unique per flow, and denormalised
nodesCount / edgesCount so a version list renders without deserialising a 22 KB blob per row.
flow.currentVersionId is then what a new run enrols onto.
A run captures versionId at enrolment, from flow.currentVersionId, and the entry node is resolved
from that same snapshot rather than the live graph — otherwise a run could start at a node its own
pinned graph does not contain. A walk resolves every node and edge out of the frozen blob and never
touches InboxFlowNode / InboxFlowEdge.
This exists because of a measured fact: of 242 runs in the source estate, 68 (28%) are parked on a version that is no longer the flow's current one. Reading the graph live would silently move those 68 conversations onto the new graph — a customer answering a question that no longer exists, or routing down an edge that was deleted.
InboxFlowRun.versionId is nullable with onDelete: SetNull, and both properties are load-bearing:
- Null means walk the live graph. All 242 migrated runs predate versioning, and a run whose version row was pruned must still be able to walk.
SetNullrather thanCascade, because deleting a version must never delete the conversations that ran on it.
aiFallbackConfig is frozen into the version snapshot's meta at publish for the same reason: an
in-flight run keeps the config it was published under.
A campaign is refused at send if its flow has never been published — for a single legacy run the live-graph fallback is a kindness, but for thousands of runs enrolled at once it is precisely the defect version pinning closed.
Runs
InboxFlowRun.status is one of active, waiting_reply, ai_handoff, waiting_delivery,
completed, expired, failed, cancelled. The terminal set is completed, expired, failed,
cancelled.
waiting_delivery— the status a run parks in at a delivery gate — is written and read by the flow engine but is missing from theFLOW_RUN_STATUSESconstant, frompackages/types/src/flows.ts, and from the Prisma schema. Treat it as a real status when reading a run; a consumer that switches exhaustively over the declared union will miss it. This is a source inconsistency, not a documentation one.
expired is the one deliberate divergence from the source, and it is worth knowing why: all 220
failed runs in the source estate carry last_error = 'reply timeout'. Every "failure" in the estate
is a customer who did not answer — normal conversation, not a fault. Keeping them under failed made
the failure count useless for the one thing it exists for, which is spotting a broken flow.
nextCheckAt is the scheduler's queue key. A single flow-tick job sweeps by nextCheckAt (the
sweep is global, not tenant-scoped) and re-enqueues itself; it is what expires a parked run and what
takes a delivery gate's timeout branch.
dedupeKey is unique per (flowId, dedupeKey) and stops one trigger enrolling the same contact
twice. Campaign enrolments use campaign:<campaignId>:<recipientId> — see
Campaigns. campaignId and campaignRecipientId are both SetNull: a
run is a real conversation with a real person, and deleting the campaign must not delete it.
advanceTo carries a WALK_GUARD cycle cap; exceeding it fails the run with
walk guard exceeded — possible cycle in flow.
Routes
All scope inbox.
| Method | Path | Notes |
|---|---|---|
| GET | /v1/flows |
|
| POST | /v1/flows |
201 |
| GET | /v1/flows/:id |
|
| PATCH | /v1/flows/:id |
|
| DELETE | /v1/flows/:id |
|
| POST | /v1/flows/:id/duplicate |
201 |
| GET | /v1/flows/:id/graph |
Nodes + edges as they stand now |
| GET | /v1/flows/:id/validate |
Capability + render-policy + structural check |
| POST | /v1/flows/:id/publish |
201; mints the version and sets currentVersionId |
| GET | /v1/flows/:id/versions |
|
| POST | /v1/flows/:id/versions/:versionId/restore |
201 |
| POST | /v1/flows/:id/generate |
AI flow builder; cannot emit action-goto-flow |
| POST | /v1/flows/:id/simulate-intent |
Needs an Idempotency-Key — see below |
| POST | /v1/flows/:id/simulate-ai-handoff |
Needs an Idempotency-Key |
| POST | /v1/flows/:id/nodes |
201 |
| PATCH | /v1/flows/:id/nodes/:nodeId |
|
| DELETE | /v1/flows/:id/nodes/:nodeId |
|
| POST | /v1/flows/:id/edges |
201 |
| PATCH | /v1/flows/:id/edges/:edgeId |
|
| DELETE | /v1/flows/:id/edges/:edgeId |
|
| POST | /v1/flows/:id/enrol |
201; pins currentVersionId |
| GET | /v1/flows/:id/runs |
|
| GET | /v1/flows/:id/runs/:runId |
Both simulate endpoints only evaluate — they change nothing — but they are POSTs without
@ReadOnlyPost(), so the default dataWrite policy applies and an Idempotency-Key header is
required. See Idempotency.
Adding a node whose type is not legal for the flow's mode and channels is rejected at
POST /v1/flows/:id/nodes with Unsupported node type: <type>, not discovered at publish.