Call Flows
A call flow is how you program what happens on a call. It’s a versioned JSON node-graph: the same document drives a React-Flow visual builder and the runtime interpreter. If you’ve used Twilio Studio, the model will feel familiar; the difference is that every live call runs inside its own stateful edge object.
The shape of a flow
Section titled “The shape of a flow”A flow is a small JSON document:
{ "id": "flow_01jzqwjn803wtnhc445bvjenzc", "name": "Support line", "version": 3, "start": "welcome", "nodes": [ /* FlowNode[] */ ], "edges": [ /* FlowEdge[] */ ]}nodesare actions. Each has anid, atype, an editor-onlyposition, and typeddata(discriminated bykind).edgesconnect nodes. Each has asource, atarget, and, crucially, an optionalsourceHandlethat names the branch outcome it represents ("digit:1","timeout","answered","in_hours", …).startis the id of the entry node.
// a node{ "id": "welcome", "type": "menu", "data": { "kind": "menu", "promptText": "Press 1 for sales, 2 for support", "maxDigits": 1, "timeoutSec": 5 } }
// an edge: "when the menu returns digit 1, go to salesQ"{ "id": "e3", "source": "welcome", "target": "salesQ", "sourceHandle": "digit:1" }How a flow executes
Section titled “How a flow executes”Each live call walking a flow gets its own CallSessionEngine (one stateful edge object per call). The engine runs an poll-based command loop:
/flow start ─▶ CallSessionEngine.start(graph) │ emits the first COMMAND (e.g. "play this menu prompt") ▼ the media engine executes it ─▶ /flow step { callId, outcome, data } │ CallSessionEngine.resume(outcome) ▼ follows the edge whose sourceHandle == outcome ─▶ next COMMAND ▼ ... until a terminal node (hangup) or a 2-party bridge ...Each node emits a concrete command to the edge (play, gather, dial, enqueue, record, conference, hangup). The edge executes it and calls /flow back with the outcome; the interpreter follows the matching edge and emits the next command. The loop is driven entirely by the brain, and the edge holds no flow logic.
Pure-logic nodes resolve in-object
Section titled “Pure-logic nodes resolve in-object”Nodes that don’t need the edge (condition, timeCondition, httpRequest, setVar) are resolved inside the stateful edge object with no extra round-trip. A timeCondition evaluates your schedule and picks an edge; an httpRequest calls your API and branches on the result; a condition evaluates an expression. Only nodes that touch the caller (prompts, menus, dial) cost an edge round-trip.
Edges and outcomes
Section titled “Edges and outcomes”The interpreter selects the next node by matching the returned outcome against each outgoing edge’s sourceHandle:
- A node with multiple outcomes (a
menureturnsdigit:1,digit:2,timeout,invalid) has one edge per handle. - If no edge matches the outcome, the engine follows the default edge (the one with no
sourceHandle). - Special fallback: a
holidayoutcome with noholidayedge falls back to theout_of_hoursedge. This lets you treat holidays and after-hours the same with a single edge, or split them when you need to.
Bridges don’t “advance”
Section titled “Bridges don’t “advance””When a dial, ringGroup, forward, or transfer node reports answered and you’ve drawn no explicit answered edge, the engine emits a no-op: the call is now a 2-party bridge and simply lives until someone hangs up. You only draw an answered edge if you want to do something after the bridge ends.
The node catalog (at a glance)
Section titled “The node catalog (at a glance)”| Category | Nodes |
|---|---|
| Entry | entry |
| Play / speak | play (audio), say (TTS) |
| Gather input | menu (IVR/DTMF), collect (capture digits → variable) |
| Connect | dial, ringGroup, enqueue (queue), forward, transfer, conference |
| Messaging | voicemail |
| Logic | condition, timeCondition, httpRequest, setVar |
| Contact-center | callback, lastCalledUser |
| Composition | flow (nested sub-flow) |
| Terminal | hangup |
See the full Flow Node Reference for every node’s data fields and outcomes.
A worked example
Section titled “A worked example”Business hours → IVR → queue, with after-hours voicemail and an invalid-input fallback:
{ "id": "flow_01jzqwjn803wtnhc445bvjenzc", "name": "Support line", "version": 1, "start": "hours", "nodes": [ { "id": "hours", "type": "timeCondition", "data": { "kind": "timeCondition", "scheduleId": "sch_01j0jvpe0wzc6g05bktfew0xyx" } }, { "id": "welcome", "type": "menu", "data": { "kind": "menu", "promptText": "Press 1 for sales, 2 for support", "maxDigits": 1, "timeoutSec": 5, "maxFailures": 2 } }, { "id": "salesQ", "type": "enqueue", "data": { "kind": "enqueue", "queueId": "q_01jx67hmyhnks8yq7m76g5qrrj" } }, { "id": "supQ", "type": "enqueue", "data": { "kind": "enqueue", "queueId": "q_01j160r23gw5zkpfjme3fwzs5k" } }, { "id": "vm", "type": "voicemail", "data": { "kind": "voicemail", "mailboxId": "vm_01jg8asp59ps9rtt7azhrhvcre" } }, { "id": "bye", "type": "hangup", "data": { "kind": "hangup" } } ], "edges": [ { "id": "e1", "source": "hours", "target": "welcome", "sourceHandle": "in_hours" }, { "id": "e2", "source": "hours", "target": "vm", "sourceHandle": "out_of_hours" }, { "id": "e3", "source": "welcome", "target": "salesQ", "sourceHandle": "digit:1" }, { "id": "e4", "source": "welcome", "target": "supQ", "sourceHandle": "digit:2" }, { "id": "e5", "source": "welcome", "target": "vm", "sourceHandle": "timeout" }, { "id": "e6", "source": "welcome", "target": "bye", "sourceHandle": "invalid" } ]}Variables
Section titled “Variables”A flow carries a vars bag through the call. Sources include:
- the route target’s
dest_xform(small JSON applied on the hop into the flow), - the inbound caller-ID, exposed as
caller_cid, setVarnodes,collectnodes (which save captured digits to a named variable),httpRequestresponses (saved viasaveAs).
condition nodes branch on these; prompts and downstream nodes can reference them.
Synthesized flows for direct destinations
Section titled “Synthesized flows for direct destinations”You don’t have to build a flow for simple routing. If a DID or extension points directly at a ring_group, queue, voicemail, conference, or forward, the platform synthesizes a one-node flow on the fly and runs it through the same interpreter, so direct routes and full visual flows share one execution path.
Synthesis even chains the destination’s configured exits: a DID pointed straight at a queue whose timeout_dest is a voicemail becomes a real multi-node graph (enqueue → on-timeout → voicemail) automatically, up to a small depth cap. See Numbers & Extensions and Overflow & Exit Destinations.
Nested flows (reusable sub-flows)
Section titled “Nested flows (reusable sub-flows)”A flow can route into another published flow via the flow node, or as a synthesized destination kind (for example a queue’s timeout exit pointed at a flow instead of a single node). This is how you build a reusable block once, a “check business hours,” “collect a callback number,” or “play the legal disclaimer” sub-flow, and reference it from many parent flows instead of copy-pasting the same nodes everywhere.
The composition happens at load time, not at runtime: when a flow (or a synthesized graph) is loaded, every flow node is resolved by fetching the referenced published flow and splicing its nodes and edges directly into the parent graph, with every id namespaced so it can’t collide with the parent’s ids. The interpreter itself never runs a “call a sub-flow” operation; by the time a CallSessionEngine starts walking the graph, it’s already one flat graph. This keeps the interpreter’s single-graph model simple, no call stack, no cross-session object hops to enter or leave a sub-flow.
Because splicing is a destination, not a subroutine, there’s no implicit return to the parent after the sub-flow ends: the sub-flow’s own terminal nodes (its hangup, or a bridge it starts) decide how that branch of the call ends. Nesting is depth-bounded (up to 4 levels) and the combined graph is size-bounded (400 nodes), so a broken reference, an unpublished target, or an accidental cycle between two flows can’t strand a caller in a broken state: the platform terminates that branch with a fail-safe hangup instead.
Versioning & publishing
Section titled “Versioning & publishing”A call_flow row has a version and an is_published flag. The routing brain only loads published flows, and the version that ran is recorded on the call’s CDR, so you can change a flow safely and know exactly which version handled any given call. Bump the version on publish; in-flight calls keep running the version they started on.
Limitations & roadmap
Section titled “Limitations & roadmap”- The
saynode (free-text TTS) requires a TTS provider for arbitrary text; today the platform ships a fixed, pre-generated multilingual prompt catalog. Free-text synthesis at call time is an open item. - A flow schema validator / linter (“no dangling edge, no unreachable node”) shared by the visual editor and the runtime is not yet built.
Next: the Flow Node Reference.