Conversation flows
For the conversations one prompt cannot hold. A flow pins each step to a node with its own instructions, so the model is only ever asked to do one step's worth of work — and cannot skip the verification because the caller sounded impatient.
When to write one
The signal is specific, and it is not "the agent gives wrong answers".
| Symptom | Flow? |
|---|---|
| Does the steps out of order, or skips one | Yes. This is exactly what a flow fixes. |
| Confirms something it never collected | Yes. |
| Three departments need three different scripts on one number | Yes — a router node and three branches. |
| Confidently wrong about prices or policies | No. That is a knowledge base. |
| Cannot look up this caller's order | No. That is a function. |
| Rambles, or answers at too much length | No. That is
the prompt and max_tokens. |
The shape
Three kinds of node and four kinds of edge cover every flow anybody has asked for. The flow
is stored on the agent, under flow.
{
"flow": {
"start": "identify",
"variables": { "amount": "number" },
"nodes": [
{
"id": "identify",
"description": "Confirm who we are speaking to before saying anything about the account.",
"prompt": "Ask for the caller's full name and date of birth. Do not mention any amount or account detail until both are given.",
"edges": [
{ "to": "balance", "type": "intent", "when": "the caller gives a name and a date of birth",
"capture": { "caller_name": "string", "dob": "string" } },
{ "to": "goodbye", "type": "intent", "when": "the caller refuses to identify themselves" }
]
},
{
"id": "balance",
"prompt": "Tell {{caller_name}} the outstanding amount and ask whether they can pay today.",
"edges": [
{ "to": "arrange", "type": "intent", "when": "the caller cannot pay the full amount today" },
{ "to": "goodbye", "type": "always" }
]
},
{ "id": "arrange", "prompt": "Agree a date within thirty days and read it back." },
{ "id": "goodbye", "type": "static", "say": "Thank you for your time. Goodbye." }
]
}
}prompt is appended to it, never a replacement. That is what keeps
the agent's identity, tone and refusals in one place whichever node the call is on — so a node
does not have to re-state "never give medical advice".Nodes
| Type | Does |
|---|---|
llmthe default | The model answers, under the agent's prompt plus this node's. |
static | Says a fixed line, exactly as written, without the model. Rendered once and replayed — instant, and free. |
router | Never speaks. A call landing here moves on again in the same turn along whichever edge fires, and speaks from wherever it lands. Must have a catch-all edge, so it always leaves. |
| Field | Type | Description |
|---|---|---|
idrequired | string | Names the node. Unique; edges and
start point at it. |
typeoptional | "llm" | "static" | "router" | Defaults to llm. |
descriptionoptional | string | What the node is for, in a sentence. Read by the router as the node's objective when the node has no prompt of its own — so it is worth writing even on nodes that have one. |
promptoptional | string | This node's instructions, appended to the agent's prompt while the call is here. Empty on a static or router node. |
sayoptional | string | What a static node says, read out exactly as written
with {{variables}} filled per call. |
say_inoptional | object | The same line per language code, for an agent that
speaks more than one. The call's current language picks; say is the
fallback. |
repeat_after_silence_secondsoptional | integer | Re-prompts a caller who has
said nothing for this long while the call sits here — a static node replays its line, an
llm node is told "[silence]" and rephrases. 0 never re-prompts from here; the
agent's own silence settings still apply. |
examplesoptional | object | One example reply per language code, appended to the prompt — so a model answering in Hindi has seen what a Hindi answer from this node looks like. |
tooloptional | string | Forces the model to call this tool while the call is here — a transfer node that must transfer, a booking node that must book. The force is dropped once the tool has run on this visit, so the model can speak from the result. |
toolsoptional | string[] | Restricts which of the agent's tools the model may
see on this node. Absent means all of them; [] means none.
end_call and transfer_call are named like any other. |
knowledge_base_idsoptional | string[] | The bases this node grounds against,
overriding the agent's. Absent means the agent's; [] means no grounding
here. |
edgesoptional | array | Every way out of this node, in priority order within each kind. A node with none is an ending. |
Edges
Four kinds, and they are evaluated in a fixed order that is worth knowing: expression edges first — they are free and need no model — then intent edges, then the catch-all. An event edge is never considered when the caller speaks.
| Type | Fires when | Costs |
|---|---|---|
expression | expression evaluates true against the call's
variables. | Nothing. Evaluated before any model is asked. |
intentthe default | The model decides,
from what the caller said, against when. | One routing call. |
always | Nothing else did. The catch-all. At most one per node is read;
with several, the lowest priority wins. | Nothing. |
event | An outside system injects the named event into the call. Never fires on speech. | Nothing. |
| Field | Type | Description |
|---|---|---|
torequired | string | The id of the node this leads to. |
typeoptional | "intent" | "expression" | "always" | "event" | Defaults to
intent. |
whenoptional | string | The condition in a sentence — "the caller confirms the amount". On an intent edge this is what the router reads and it is required; on the others it is a label for your own benefit. |
expressionoptional | object | {"logic": "and"|"or", "conditions":
[…]}. Required on an expression edge. |
eventoptional | string | The injected event to wait for. Required on an event edge. |
captureoptional | object | Values the router extracts from the caller's words
when this intent edge fires — {"amount": "number"} — stored as call variables
for later nodes and expressions. Every named value is required for the edge to
fire, which is how you stop a call moving on from a step that did not actually
collect anything. |
priorityoptional | integer | Orders edges of the same type; lower fires first. |
Expressions
A flat list of conditions joined by one logic word. There is no nesting on purpose: a flow that needs it is a flow that needs a router node between two simpler ones.
{
"to": "arrange",
"type": "expression",
"expression": {
"logic": "and",
"conditions": [
{ "variable": "amount", "operator": "gt", "value": 5000 },
{ "variable": "dob", "operator": "exists" },
{ "variable": "caller.city", "operator": "in", "value": ["Pune", "Mumbai"] }
]
}
}Operators: eq, neq, gt, gte,
lt, lte, in, not_in, contains,
exists, not_exists. Variables are dotted paths.
variables
map. An undeclared variable is compared by inference, and inference reads
"18" > "9" as false — because it is comparing two strings. This is the single most
common flow bug, and it is silent: the edge simply never fires.{ "variables": { "amount": "number", "verified": "boolean", "city": "string" } }An empty condition list is false, not true — an edge nobody finished writing must not be the one that always fires.
Events injected from outside the call
An event edge lets something that is not the caller move the conversation: a payment clearing, an agent becoming free, your own system deciding the call should change course.
/v1/calls/{id}/eventscurl -s -X POST https://voice.sphoro.com/v1/calls/$CALL_ID/events \
-H "Authorization: Bearer $SPHORO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"event": "payment_received", "data": {"amount": 5000}}'Routing
Choosing between intent edges is a model call. It is a small one — one decision from a short prompt — so it is worth pointing at a fast model rather than the one holding the conversation.
{ "routing": { "model": "claude-haiku-4-5" } }| Field | Type | Description |
|---|---|---|
modeloptional | string | Overrides the agent's model for routing decisions. A small fast one is the right choice. |
instructionsoptional | string | Replaces the built-in routing instructions.
Rarely needed, and worth exhausting better when sentences first. |
Validation
A flow is validated in full when the agent is saved, and every problem is
reported at once under flow.<path> rather than one per attempt.
{
"type": "https://voice.sphoro.com/docs/authentication#validation_error",
"title": "validation_error",
"status": 422,
"errors": [
{"field": "flow.nodes[2].edges[0].to", "message": "no node with id \"confrim\""},
{"field": "flow.nodes[3]", "message": "router node must have a catch-all edge"}
]
}What is checked: start names a real node, every edge's to names a
real node, ids are unique, every router has a catch-all, intent edges have a when,
expression edges have an expression, and event edges have an
event.
Watching a call move
call.flow.moved fires on every transition, carrying from,
to and — most usefully — reason, which says which edge fired:
intent:…, expression:…, always, router:… or
event:….
{
"type": "call.flow.moved",
"call_id": "call_8b21…",
"data": { "from": "identify", "to": "balance", "reason": "intent:the caller gives a name and a date of birth" }
}This is the whole debugging story for a flow. A call that ended up somewhere unexpected has a trail saying exactly which edge took it there, and "always" appearing where you expected an intent means the router did not match anything you wrote.
Taking a flow away
curl -s -X PATCH https://voice.sphoro.com/v1/agents/$AGENT_ID \
-H "Authorization: Bearer $SPHORO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"flow": {}}'The agent goes back to being a plain prompted agent, keeping everything else. Sphoro Voice also lets a coding assistant build and validate a flow against the live schema, which is a better way to draft one than typing JSON — see build with AI.