Skip to content
SIP.IO
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 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.

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
Compositionflow (nested sub-flow)
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 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.

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.
  • 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.