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

# Build an IVR flow with NLU intent routing

> Replace press-1 menus with natural-language routing: model intents, wire them to ACD queues or AI agents, test utterances for confidence, handle fallbacks, and tune multilingual classifications.

An NLU-routed IVR replaces the "press 1 for sales" pattern with free-form speech: the caller says what they want, a classifier scores their utterance against your intent buckets, and the call routes to the matching ACD queue or AI agent. The endpoint reference is the [IVR intents page](/voice/ivr-intents) — this guide takes one routing problem end to end: a support-triage menu (sales, billing, support) built, tested, fallback-hardened, and wired into an IVR flow.

## 1. When NLU beats DTMF

DTMF menus are still the right tool for short, closed-option menus ("press 1 to hear your balance"). Reach for NLU when any of these apply:

* **Wide or flat option sets.** Once the menu runs past 3–4 options, DTMF abandonment climbs. One spoken utterance beats "press 1, then 3, then 2".
* **Open-ended requests.** DTMF trees can only encode options you anticipated. NLU routes utterances you never planned a branch for — "someone charged me twice" maps to `billing` without a dedicated key.
* **Accessibility and mobile callers.** Spoken requests beat keypad entry for callers on hands-free devices.
* **Continuous improvement.** Fallback analytics show the utterances that failed to match, so you add intents for real caller language instead of designing menus in the dark.

The trade-off: NLU adds one classification round-trip (P95 under 1s) and behaves probabilistically — you manage the threshold yourself (section 4). A common middle ground is DTMF for the first split, NLU for the sub-route.

## 2. Model your intents

An intent is a named bucket ([object fields](/voice/ivr-intents#intent-object)). Three rules of thumb:

* **Name it like a handle.** Lowercase alphanumeric plus `-` / `_`, max 64 chars, unique per tenant: `billing`, `sales-eu`, `tech_support`. You branch on this slug in your flow, so keep it stable — the API id (`ivri_*`) survives renames, the slug is what your code reads.
* **Write a rich description.** The classifier evaluates the utterance against the description text, not the slug. "Billing" matches little; "Caller has a question about an invoice, charge, payment, or refund" matches a lot. Name the real vocabulary callers use.
* **Analytics-only intents are valid.** Leave both route fields null and the match is recorded without routing — useful for sizing a new queue before you staff it, or for topic reporting. See section 3.

Keep the active set under 50 intents — the classifier caps each prompt at 50 and slices anything past that, so burying your best intents at positions 51+ silently drops them. The full constraint list lives on the [reference page](/voice/ivr-intents).

## 3. Route to queues or AI agents

Each intent routes to exactly one destination:

| Field             | Destination  | Notes                                                                                                               |
| ----------------- | ------------ | ------------------------------------------------------------------------------------------------------------------- |
| `target_queue_id` | An ACD queue | 1–128 chars, your queue id (for example `que_sales_us`). A staffed agent picks up from the dashboard.               |
| `target_agent_id` | An AI agent  | An `agt_*` agent that takes the call end to end ([hand back to human agents and return](/voice/ai-agent-handback)). |

The two fields are mutually exclusive — set both and create/update returns `422 VALIDATION_ERROR`. Set neither and you get an analytics-only intent. To switch an intent's destination, PATCH one field to the new value and the other to `null`.

## 4. Test utterances before going live

`POST /api/v1/voice/ivr-intents/test` classifies an utterance against your **active** intents without placing a call. Use it as a pre-publish gate: candidate descriptions that can't beat the threshold stay out of production.

The contract: a match commits at `confidence ≥ 0.7`; below that, `matched_intent_id` is `null` and `fallback_reason` is one of:

| `fallback_reason` | Meaning                              | What to fix                                                                   |
| ----------------- | ------------------------------------ | ----------------------------------------------------------------------------- |
| `low_confidence`  | A candidate intent scored below 0.7. | The utterance was partially covered — enrich the description (see section 8). |
| `no_match`        | No plausible mapping at all.         | Either add a new intent, or let your fallback edge handle it.                 |
| `ambiguous`       | Two or more intents tied.            | Overlapping descriptions — differentiate or merge (see section 8).            |

The response also carries the model's `reasoning`, so a failed match tells you which direction to move the description. Note: a `422 VALIDATION_ERROR` here only means the request shape was wrong (missing utterance or over 500 chars) — a clean `no_match` runs the classifier anyway so you see why an empty active set can't route.

## 5. Worked example: support triage

Build a three-way triage — `sales`, `billing`, `support`.

```bash cURL theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/voice/ivr-intents" \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "sales",
    "description": "Caller wants to buy a product, ask about pricing, or discuss upgrading their plan.",
    "target_queue_id": "que_sales"
  }'
```

Repeat with `billing` → `que_billing` and `support` → `que_support`. If your support destination is an AI agent, send `target_agent_id: "agt_..."` instead — the classifier doesn't care which target type sits behind the slug.

Now test candidate utterances:

```bash cURL theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/voice/ivr-intents/test" \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "utterance": "someone charged my card twice", "language": "en" }'
```

**200 OK — above threshold:**

```json theme={null}
{
  "data": {
    "matched_intent_id": "billing",
    "confidence": 0.93,
    "reasoning": "Utterance describes a duplicate card charge, a billing concern."
  },
  "meta": { "request_id": "req_abc123", "timestamp": "2026-08-23T12:00:00Z" }
}
```

**200 OK — below threshold:**

```json theme={null}
{
  "data": {
    "matched_intent_id": null,
    "confidence": 0.62,
    "reasoning": "Caller asks about their plan price, which is partially a sales topic.",
    "fallback_reason": "low_confidence"
  },
  "meta": { "request_id": "req_abc123", "timestamp": "2026-08-23T12:00:00Z" }
}
```

Below threshold means the flow should take the fallback edge, not pick the weak match. Keep a fixture list of utterances per intent and re-run the tests whenever you change a description — treat it as a routing regression harness, the same role the flow simulator plays for graph edges.

## 6. Apply the intents in an IVR flow

A flow that speaks, gathers one utterance, classifies it, and branches:

```json theme={null}
{
  "nodes": [
    { "id": "start", "type": "ivrStart" },
    { "id": "welcome", "type": "playAudio", "data": { "ttsText": "Thanks for calling — tell us what you need." } },
    { "id": "ask", "type": "speechInput", "data": { "prompt": "How can we help today?" } },
    { "id": "sales", "type": "queue", "data": { "label": "Sales" } },
    { "id": "billing", "type": "queue", "data": { "label": "Billing" } },
    { "id": "support", "type": "queue", "data": { "label": "Support" } },
    { "id": "fallback", "type": "voicemail", "data": { "label": "Fallback" } },
    { "id": "end", "type": "hangup" }
  ],
  "edges": [
    { "source": "start", "target": "welcome" },
    { "source": "welcome", "target": "ask" },
    { "source": "ask", "target": "sales", "sourceHandle": "sales" },
    { "source": "ask", "target": "billing", "sourceHandle": "billing" },
    { "source": "ask", "target": "support", "sourceHandle": "support" },
    { "source": "ask", "target": "fallback", "sourceHandle": "default" },
    { "source": "fallback", "target": "end" }
  ]
}
```

The classifier runs at the `speechInput` node; edges from it branch by intent slug, and unmatched utterances take the `default` edge. The voice guides — the [flow builder walkthrough](/guides/build-ivr-flow) for graph validation, simulation, and publishing, and the [voice quickstart](/voice/quickstart) for the inbound attach path — run in parallel with the visual [Flow Builder](/flows/builder), which shares the same node palette. Use either editing surface; the published graph is what the runtime walks.

<Warning>
  Outbound termination stays on the Devotel softswitch. A `transfer` node pointed at a raw SIP URI fails validation with `transfer_external_sip` — route by E.164 number or on-net extension.
</Warning>

Simulate the fallback path too — send an empty turn list and confirm the caller lands on `voicemail` rather than hanging in the classifier node.

## 7. Multilingual calls

Set `language` per classification to a BCP-47 tag (`en-US`, `tr`, `es-419`, …). The tag goes into the classifier prompt, so it changes how the model weighs the utterance — pass the real caller locale rather than defaulting to `en`. If you serve multilingual queues, re-run your section-5 fixture utterances per language; a description that reads well in English can still be thin for another locale. Keep the descriptions themselves in one language and let the tag do the work — mixing languages inside the same description hurts all of them.

## 8. Observability and hygiene

* **Audit logs.** Every `create` / `update` / `delete` / `test` writes an audit entry (`voice.ivr_intent.<verb>`), so you can see who changed which description and when — trace a routing regression back to the edit.
* **Prompt hygiene.** The classifier builds its prompt from the active set. Deactivate rather than delete during experiments: deactivation drops the intent out of classification without erasing its edit history, and reactivation is one PATCH of `active: true`.
* **Deactivate noisy intents.** An intent that keeps matching the wrong utterances skews live routing — drop it out of the active set while you rewrite the description.
* **Watch your base rate.** Frequent `ambiguous` fallback on intent pairs (`sales` vs `billing`, `support` vs `returns`) usually means both descriptions phrase the same middle ground — differentiate by the decisive word (invoice vs pricing, broken vs refund).

## 9. Troubleshooting

**Low-confidence clusters.** If tests against one intent keep landing in 0.4–0.69, the description is thin. Add the callers' literal vocabulary ("invoice", "charged", "refund", "payment plan") and retest — a 500-char budget is room for a lot of synonyms.

**Ambiguous ties.** Split by the decisive feature — put "upgrade / pricing / new order" into `sales` and "invoice / charge / refund" into `billing` — then rerun the utterances that tied.

**Empty active set.** Every intent deleted or `active: false` returns `fallback_reason: "no_match"` on every classification — if the dashboard route seems frozen but live calls keep hitting the fallback edge, check for an all-inactive set. That state is valid for analytics but routes nothing.

**Duplicate-name conflicts.** `409 IVR_INTENT_NAME_CONFLICT` on create, or a `409` raced against another writer. `GET /` first and reuse one slug set across your routing code and flow edges.

## See also

* [IVR intents endpoint reference](/voice/ivr-intents)
* [Build your first IVR flow](/guides/build-ivr-flow)
* [Voice quickstart](/voice/quickstart)
* [Flow Builder](/flows/builder)
* [AI agent handback](/voice/ai-agent-handback)
