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:

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.

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:

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 the FLOW_RUN_STATUSES constant, from packages/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.