sphoroVOICEdocs
Markdown

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

SymptomFlow?
Does the steps out of order, or skips oneYes. This is exactly what a flow fixes.
Confirms something it never collectedYes.
Three departments need three different scripts on one numberYes — a router node and three branches.
Confidently wrong about prices or policiesNo. That is a knowledge base.
Cannot look up this caller's orderNo. That is a function.
Rambles, or answers at too much lengthNo. That is the prompt and max_tokens.
Write the prompt first and let it fail. A flow is more work to write and much more work to change. The flows that survive contact with real callers are the ones written against a specific observed failure, not the ones designed up front.

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.

JSON
{
  "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." }
    ]
  }
}
The agent's own prompt still applies on every node. A node's 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

TypeDoes
llm
the default
The model answers, under the agent's prompt plus this node's.
staticSays a fixed line, exactly as written, without the model. Rendered once and replayed — instant, and free.
routerNever 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.
FieldTypeDescription
id
required
stringNames the node. Unique; edges and start point at it.
type
optional
"llm" | "static" | "router"Defaults to llm.
description
optional
stringWhat 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.
prompt
optional
stringThis node's instructions, appended to the agent's prompt while the call is here. Empty on a static or router node.
say
optional
stringWhat a static node says, read out exactly as written with {{variables}} filled per call.
say_in
optional
objectThe 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_seconds
optional
integerRe-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.
examples
optional
objectOne 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.
tool
optional
stringForces 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.
tools
optional
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_ids
optional
string[]The bases this node grounds against, overriding the agent's. Absent means the agent's; [] means no grounding here.
edges
optional
arrayEvery 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.

TypeFires whenCosts
expressionexpression evaluates true against the call's variables.Nothing. Evaluated before any model is asked.
intent
the default
The model decides, from what the caller said, against when.One routing call.
alwaysNothing else did. The catch-all. At most one per node is read; with several, the lowest priority wins.Nothing.
eventAn outside system injects the named event into the call. Never fires on speech.Nothing.
FieldTypeDescription
to
required
stringThe id of the node this leads to.
type
optional
"intent" | "expression" | "always" | "event"Defaults to intent.
when
optional
stringThe 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.
expression
optional
object{"logic": "and"|"or", "conditions": […]}. Required on an expression edge.
event
optional
stringThe injected event to wait for. Required on an event edge.
capture
optional
objectValues 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.
priority
optional
integerOrders 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.

JSON
{
  "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.

Declare anything numeric in the flow's 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.
JSON
{ "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.

POST/v1/calls/{id}/events
Shell
curl -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.

JSON
{ "routing": { "model": "claude-haiku-4-5" } }
FieldTypeDescription
model
optional
stringOverrides the agent's model for routing decisions. A small fast one is the right choice.
instructions
optional
stringReplaces 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.

Response422 Unprocessable Entity
{
  "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:….

JSON
{
  "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

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