Skip to content
DocsStart free

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.

A flow is a small JSON document:

{
"id": "flow_01jzqwjn803wtnhc445bvjenzc",
"name": "Support line",
"version": 3,
"start": "welcome",
"nodes": [ /* FlowNode[] */ ],
"edges": [ /* FlowEdge[] */ ]
}
  • nodes are actions. Each has an id, a type, an editor-only position, and typed data (discriminated by kind).
  • edges connect nodes. Each has a source, a target, and, crucially, an optional sourceHandle that names the branch outcome it represents ("digit:1", "timeout", "answered", "in_hours", …).
  • start is 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" }

Each live call walking a flow gets its own CallSessionDO (one stateful edge object per call). The engine runs an poll-based command loop:

/flow start ─▶ CallSessionDO.start(graph)
│ emits the first COMMAND (e.g. "play this menu prompt")
the media engine executes it ─▶ /flow step { callId, outcome, data }
│ CallSessionDO.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.

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.

The interpreter selects the next node by matching the returned outcome against each outgoing edge’s sourceHandle:

  • A node with multiple outcomes (a menu returns digit: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 holiday outcome with no holiday edge falls back to the out_of_hours edge. This lets you treat holidays and after-hours the same with a single edge, or split them when you need to.

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.

CategoryNodes
Entryentry
Play / speakplay (audio), say (TTS)
Gather inputmenu (IVR/DTMF), collect (capture digits → variable)
Connectdial, ringGroup, enqueue (queue), forward, transfer, conference
Messagingvoicemail
Logiccondition, timeCondition, httpRequest, setVar
Contact-centercallback, lastCalledUser
Terminalhangup

See the full Flow Node Reference for every node’s data fields and outcomes.

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" }
]
}

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,
  • setVar nodes,
  • collect nodes (which save captured digits to a named variable),
  • httpRequest responses (saved via saveAs).

condition nodes branch on these; prompts and downstream nodes can reference them.

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.

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.

  • The say node (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.
  • callback and lastCalledUser are present in the model but currently fall through to their default edge (sticky-agent and callback-lead behaviors are designed, not yet wired).
  • Splicing a nested published sub-flow from a synthesized exit is a roadmap item.

Next: the Flow Node Reference.