# 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](https://voice.sphoro.com/docs/knowledge-base). |
| Cannot look up this caller's order | No. That is a [function](https://voice.sphoro.com/docs/functions). |
| Rambles, or answers at too much length | No. That is [the prompt](https://voice.sphoro.com/docs/prompting) 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**

```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

| Type | Does |
| --- | --- |
| `llm` the 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 |
| --- | --- | --- |
| `id` required | string | Names the node. Unique; edges and `start` point at it. |
| `type` optional | "llm" \| "static" \| "router" | Defaults to `llm`. |
| `description` optional | 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. |
| `prompt` optional | string | This node's instructions, appended to the agent's prompt while the call is here. Empty on a static or router node. |
| `say` optional | string | What a static node says, read out exactly as written with `{{variables}}` filled per call. |
| `say_in` optional | 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_seconds` optional | 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. |
| `examples` optional | 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. |
| `tool` optional | 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. |
| `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 | 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. |
| `intent` the 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 |
| --- | --- | --- |
| `to` required | string | The id of the node this leads to. |
| `type` optional | "intent" \| "expression" \| "always" \| "event" | Defaults to `intent`. |
| `when` optional | 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. |
| `expression` optional | object | `{"logic": "and"\|"or", "conditions": […]}`. Required on an expression edge. |
| `event` optional | string | The injected event to wait for. Required on an event edge. |
| `capture` optional | 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. |
| `priority` optional | 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.

**JSON**

```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**

```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**

```bash
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**

```json
{ "routing": { "model": "claude-haiku-4-5" } }
```

| Field | Type | Description |
| --- | --- | --- |
| `model` optional | string | Overrides the agent's model for routing decisions. A small fast one is the right choice. |
| `instructions` optional | 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.

**Response422 Unprocessable Entity**

```json
{
  "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**

```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**

```bash
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](https://voice.sphoro.com/docs/build-with-ai).
