> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tazolabs.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Graph agents

> Route a voice call through a graph of steps by asking Tazo which next step the caller wants, straight from their audio.

A common voice agent architecture is to use a state machine in the form of a graph to track
the state of the current call. Let's take the example of a simple customer support call
agent which has 3 stages: support triage, then billing, then a person. A corresponding graph
describes how the call moves among these states.

```mermaid theme={null}
flowchart LR
    T["TRIAGE<br/>Welcome & triage"] -->|payment help| B["BILLING"]
    T -->|asks for a person| H["HUMAN<br/>Human specialist"]
    B -->|asks for a person| H
```

The process of the voice agent determining the next stage is called "Graph routing". The
usual way is to transcribe the audio and ask an LLM. With Tazo you can use the **audio
itself** to make this decision.

In this cookbook we will try to build the backend of a call router over a small graph with
three states using the [Tazo Python SDK](https://docs.tazolabs.com/sdk/python/overview.md).

## Setup

```bash theme={null}
python -m pip install tazo-sdk
export TAZO_API_KEY='your-key'
```

Each caller turn is a WAV clip of at most 30 seconds.

## Define the graph

Every turn asks one question: **Which next step does the caller want?** Its options are
only the nodes reachable from the current node, additionally we define 3 built-in answers:

| Option | Meaning |
| - | - |
| A reachable node, for example `BILLING` | The caller clearly asks for that step. |
| `CURRENT_STEP` | The caller is answering the current question, so stay here. |
| `UNCERTAIN` | The reply is unclear or matches no listed step. |
| `NO_TOOL` | Nothing fits, such as silence. |

The model only picks from this bounded list. **Your application moves the call**, so the
model can never send it somewhere the graph does not allow.

Each node has a label, a description that tells Tazo when to choose it, and routing
instructions for that node. The instructions are the most useful
tuning lever: they say what should and should not count as a request to move.

```python theme={null}
import asyncio
from dataclasses import dataclass, field
from pathlib import Path

from tazo import AsyncTazo
from tazo.types import ChoiceAnswer

MODEL = "tazo-large"
MIN_CONFIDENCE = 0.65
MIN_MARGIN = 0.12
TEXT_BUDGET = 1000  # Tazo limits state + instructions + criteria to roughly this many characters

TITLE = "Customer support"
NODES = {
    "TRIAGE": {
        "label": "Welcome & triage",
        "description": "Find out whether the caller needs payment help or a person.",
        "instructions": "Identify the main reason for this support call. Choose Billing for charges or payments. For an explicit request for a person, choose the human destination. Do not route from a greeting alone.",
    },
    "BILLING": {
        "label": "Billing",
        "description": "The caller needs help with a charge, payment, invoice or billing issue.",
        "instructions": "The caller is already in the billing workflow. Keep payment details and answers to the billing question at CURRENT_STEP. Use the human path only for an explicit request for personal help.",
    },
    "HUMAN": {
        "label": "Human specialist",
        "description": "The caller explicitly asks to speak with a person or specialist.",
        "instructions": "This is the end of the route. Only continuing here or asking for clarity can be selected.",
    },
}
START = "TRIAGE"
EDGES = [("TRIAGE", "BILLING"), ("TRIAGE", "HUMAN"), ("BILLING", "HUMAN")]
```

## Build the question for a node

The question is rebuilt for the current node on every turn: the state describes where the
call is, and the options are the outgoing edges plus the
built-in answers. Descriptions are clipped, and the function checks the total size, because
a request has a text budget for the state, question and options together.

```python theme={null}
def clip(text: str, limit: int) -> str:
    text = " ".join(text.split())
    return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"


def build_question(current: str) -> tuple[str, dict[str, str]]:
    """The state text and the choices for one node: its reachable next nodes plus three built-ins."""
    node = NODES[current]
    state = (
        f"Voice call: {TITLE}. Now at {node['label']} ({current}). "
        f"{clip(node['instructions'], 170)}"
    )
    criteria = {to: clip(NODES[to]["description"], 80) for src, to in EDGES if src == current}
    criteria["CURRENT_STEP"] = "The caller answers the current question."
    criteria["UNCERTAIN"] = "Unclear reply, or matches no listed step."
    criteria["NO_TOOL"] = "None of the options fits: the clip is silent, unclear or unrelated to the question."
    size = len(state) + sum(len(k) + len(v) for k, v in criteria.items())
    if size > TEXT_BUDGET:
        raise ValueError(f"Node {current} needs {size} characters; shorten descriptions or remove edges.")
    return state, criteria
```

## Decide where the call goes

A route is applied only when the answer is confident (at least 65% and a 12-point lead over the
runner-up), is not `UNCERTAIN`, `CURRENT_STEP` or `NO_TOOL`, and is a node reachable from
the current one. Everything else stays put, so an unclear reply never moves the call.

```python theme={null}
def is_confident(answer: ChoiceAnswer) -> bool:
    ranked = sorted(answer.probabilities.values(), reverse=True)
    runner_up = ranked[1] if len(ranked) > 1 else 0.0
    return answer.confidence >= MIN_CONFIDENCE and answer.confidence - runner_up >= MIN_MARGIN


def next_node(current: str, answer: ChoiceAnswer) -> str:
    """The application, not the model, moves the call: only reachable nodes are allowed."""
    reachable = {to for src, to in EDGES if src == current}
    if not is_confident(answer) or answer.choice in {"UNCERTAIN", "CURRENT_STEP", "NO_TOOL"}:
        return current
    return answer.choice if answer.choice in reachable else current


@dataclass
class Call:
    node: str = START
    path: list[str] = field(default_factory=lambda: [START])

    async def turn(self, client: AsyncTazo, audio: Path) -> ChoiceAnswer:
        state, criteria = build_question(self.node)
        result = await client.decisions.create(
            audio=audio,
            model=MODEL,
            state=state,
            questions={"action": {"type": "choice", "instructions": "Which next step does the caller want?", "criteria": criteria}},
        )
        answer = result.answers["action"]
        assert isinstance(answer, ChoiceAnswer)
        new = next_node(self.node, answer)
        if new != self.node:
            self.node = new
            self.path.append(new)
        return answer
```

## Run a call

`Call.turn` sends one caller recording and moves the call. Pass the caller's turns in order:

```python theme={null}
async def main() -> None:
    call = Call()
    async with AsyncTazo() as client:
        for wav in ["caller_billing.wav", "caller_wants_person.wav"]:
            answer = await call.turn(client, Path(wav))
            print(f"{wav}: {answer.choice} ({answer.confidence:.2f}) -> now at {call.node}")
    print(" → ".join(call.path))
```

With a billing question first and then a request for a person, the output is a path like
`TRIAGE → BILLING → HUMAN`.

## Build on it

* **Add a node.** Add it to `NODES`, connect it in `EDGES`, and describe when to choose it.
* **Act on arrival.** When `turn` moves the call, run your own side effect, such as a transfer.
* **Sub-graph per graph node.** A node such as `BILLING` will most likely have multiple sub-steps in real life, which can be represented as a sub-graph for that node. This works as long as the main routing keeps the call at `CURRENT_STEP` for every new reply from the customer, and the sub-graph decides what happens inside the node. Feel free to experiment with different graph representations and nested graphs.
* **Live audio.** For a call that streams audio, see [Speculative tool calling](https://docs.tazolabs.com/build-with-tazo/cookbooks/speculative-tool-calling.md).

## Limitations

* The 65% and 12-point thresholds are starting points, not calibrated guarantees.
* The size check raises an error rather than silently dropping options, so very connected nodes need shorter descriptions or fewer edges.
* A node with no outgoing edges ends the route: only `CURRENT_STEP`, `UNCERTAIN` and `NO_TOOL` remain.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.