AI-native routing & multi-agent orchestration
AI-Native Routing: Replace the IVR with Intent Detection
Traditional IVRs force callers through DTMF menu trees — "press 1 for billing, press 2 for support." The entire premise of AI voice agents is that this is no longer necessary. The agent understands what the caller wants from natural conversation and routes them to the right place. This chapter builds that system using multi-agent orchestration.
What you'll learn
- Why AI intent detection replaces DTMF menu trees entirely
- How to design a multi-agent call flow as a state machine
- Building department-specific agents with isolated instructions and tools
- Orchestrating agent handoffs that preserve conversation context
- Managing the complete call lifecycle from greeting to resolution
The AI advantage over traditional IVR
A traditional IVR presents a fixed menu. The caller must listen to every option, remember a number, and press it. Three levels deep, callers are frustrated before they reach a human. AI-native routing eliminates this entirely:
| Traditional IVR | AI-Native Routing |
|---|---|
| "Press 1 for billing, 2 for support..." | "How can I help you today?" |
| Caller must listen to all options | Caller states their need naturally |
| Fixed menu depth (3+ levels) | Single conversational turn |
| DTMF only — no natural language | Full natural language understanding |
| Misroutes require starting over | Agent can re-route mid-conversation |
The key insight is that the greeting agent IS the IVR. Instead of a menu tree, you have an LLM that understands intent from natural speech. "I got overcharged on my last invoice" routes to billing. "My product stopped working" routes to support. No menus, no numbers, no frustrated callers. The agent even handles ambiguous requests — "I need to change something on my account" — by asking a clarifying question, something a DTMF tree cannot do.
Designing the state machine
An AI-native call flow is a directed graph. Each node is a state — greeting, department routing, specialized handling, escalation — and edges are triggered by the LLM's understanding of caller intent.
from enum import Enum
from dataclasses import dataclass, field
class CallState(Enum):
GREETING = "greeting"
BILLING = "billing"
SUPPORT = "support"
SALES = "sales"
TRANSFER_TO_HUMAN = "transfer_to_human"
GOODBYE = "goodbye"
@dataclass
class CallContext:
"""Shared state passed between agents during a call."""
caller_id: str
caller_name: str | None = None
account_number: str | None = None
current_state: CallState = CallState.GREETING
previous_states: list[CallState] = field(default_factory=list)
intent_history: list[str] = field(default_factory=list)
def transition(self, new_state: CallState, reason: str = ""):
self.previous_states.append(self.current_state)
self.current_state = new_state
if reason:
self.intent_history.append(reason)
# Valid transitions — prevents nonsensical jumps
VALID_TRANSITIONS: dict[CallState, set[CallState]] = {
CallState.GREETING: {CallState.BILLING, CallState.SUPPORT, CallState.SALES, CallState.GOODBYE},
CallState.BILLING: {CallState.TRANSFER_TO_HUMAN, CallState.SUPPORT, CallState.GOODBYE},
CallState.SUPPORT: {CallState.TRANSFER_TO_HUMAN, CallState.BILLING, CallState.GOODBYE},
CallState.SALES: {CallState.TRANSFER_TO_HUMAN, CallState.GOODBYE},
CallState.TRANSFER_TO_HUMAN: {CallState.GOODBYE},
CallState.GOODBYE: set(),
}No department selection node needed
Notice there is no "department_select" state. The greeting agent detects intent and routes directly. The caller says what they need, and the LLM figures out where to send them. This is the fundamental difference from a traditional IVR — intent detection replaces explicit menu navigation.
Building department agents
Each department gets its own agent with focused instructions and tools. The greeting agent routes callers based on natural language. Department agents handle domain-specific requests. Each agent knows only what it needs to know.
from livekit.agents import Agent, AgentSession, function_tool, RunContext
class GreetingAgent(Agent):
def __init__(self):
super().__init__(
instructions="""You are the front desk of Acme Corp. Greet the caller warmly
and determine which department they need from their natural speech.
Use the route_to_department tool once you understand their intent.
If the intent is ambiguous, ask ONE clarifying question — never present
a menu of options. Keep the greeting under 15 seconds.""",
tools=[route_to_department],
)
class BillingAgent(Agent):
def __init__(self):
super().__init__(
instructions="""You are a billing specialist at Acme Corp. Help callers with
invoices, payment status, refunds, and account balance inquiries.
Always verify the caller's account number before sharing details.
If you cannot resolve the issue, use transfer_to_human to escalate.""",
tools=[check_balance, process_refund, transfer_to_human],
)
class SupportAgent(Agent):
def __init__(self):
super().__init__(
instructions="""You are a technical support agent at Acme Corp. Help callers
troubleshoot product issues. Walk callers through solutions step by step.
If the issue requires hands-on intervention, escalate to a human agent.""",
tools=[lookup_ticket, create_ticket, transfer_to_human],
)
AGENT_MAP = {
"billing": BillingAgent,
"support": SupportAgent,
"sales": SalesAgent,
}
@function_tool
async def route_to_department(context: RunContext, department: str, reason: str) -> str:
"""Route the caller to a specific department based on their stated need.
Args:
department: The department to route to (billing, support, sales).
reason: Brief description of the caller's intent.
"""
call_ctx: CallContext = context.userdata
state_map = {
"billing": CallState.BILLING,
"support": CallState.SUPPORT,
"sales": CallState.SALES,
}
target = state_map.get(department.lower())
if target is None:
return f"Unknown department '{department}'. Valid options: billing, support, sales."
call_ctx.transition(target, reason)
# Perform the actual agent handoff
agent_cls = AGENT_MAP.get(department.lower())
if agent_cls:
context.session.update_agent(agent_cls())
return f"Routing to {department}. Reason: {reason}"import { Agent, AgentSession, RunContext } from "@livekit/agents";
class GreetingAgent extends Agent {
constructor() {
super({
instructions: `You are the front desk of Acme Corp. Greet the caller warmly
and determine which department they need from their natural speech.
Use the route_to_department tool once you understand their intent.
If the intent is ambiguous, ask ONE clarifying question — never present
a menu of options. Keep the greeting under 15 seconds.`,
tools: [routeToDepartment],
});
}
}
class BillingAgent extends Agent {
constructor() {
super({
instructions: `You are a billing specialist at Acme Corp. Help callers with
invoices, payment status, refunds, and account balance inquiries.
Always verify the caller's account number before sharing details.
If you cannot resolve the issue, use transferToHuman to escalate.`,
tools: [checkBalance, processRefund, transferToHuman],
});
}
}
class SupportAgent extends Agent {
constructor() {
super({
instructions: `You are a technical support agent at Acme Corp. Help callers
troubleshoot product issues. Walk callers through solutions step by step.
If the issue requires hands-on intervention, escalate to a human agent.`,
tools: [lookupTicket, createTicket, transferToHuman],
});
}
}Agent instructions must be strict boundaries
Each agent should only know about its own domain. The billing agent should not attempt to troubleshoot product issues. Clear boundaries prevent confused responses and make it obvious when a transfer is needed. This also makes each agent independently testable — you measure billing resolution rate separately from support resolution rate.
Orchestrating handoffs
The entrypoint creates the call context and starts the greeting agent. When the greeting agent detects intent and calls the routing tool, the controller swaps to the appropriate department agent while preserving conversation context.
from livekit.agents import AgentServer, AgentSession
server = AgentServer()
AGENT_MAP = {
CallState.GREETING: GreetingAgent,
CallState.BILLING: BillingAgent,
CallState.SUPPORT: SupportAgent,
CallState.SALES: SalesAgent,
}
@server.rtc_session
async def entrypoint(session: AgentSession):
call_ctx = CallContext(caller_id=session.room.name)
await session.start(
agent=GreetingAgent(),
room=session.room,
userdata=call_ctx,
)
# The GreetingAgent's route_to_department tool calls
# context.session.update_agent() to swap in the department
# agent — the handoff happens automatically when the LLM
# calls the routing tool.Complete flow walkthrough
Here is what happens when a caller dials in and says "I got overcharged on my last invoice."
Caller connects, GreetingAgent activates
The entrypoint creates a CallContext and starts the GreetingAgent. It says: "Thank you for calling Acme Corp. How can I help you today?"
AI detects intent from natural speech
The caller says: "I got overcharged on my last invoice." The LLM recognizes billing intent and calls route_to_department(department="billing", reason="invoice overcharge dispute"). No menu, no button press — just natural conversation.
State machine transitions, agent swaps
The CallContext updates from GREETING to BILLING. The controller swaps to BillingAgent via session.update_agent(), passing the conversation history.
BillingAgent takes over with full context
The BillingAgent receives the conversation context. It says: "I can help with that overcharge. Could you provide your account number so I can pull up the invoice?"
Resolution or escalation
If the agent resolves the issue, it transitions to GOODBYE. If not, it calls transfer_to_human, connecting the caller to a live representative with full context.
The power of multi-agent orchestration is separation of concerns. Each agent is small, focused, and testable. The greeting agent is measured on routing accuracy. The billing agent is measured on resolution rate. You can update one agent's instructions without touching the others. The state machine ensures the overall flow remains coherent even as individual agents evolve.
Test your knowledge
Question 1 of 3
What is the fundamental advantage of AI intent detection over a DTMF menu tree in a contact center?
What you learned
- AI intent detection replaces DTMF menu trees — callers state their need naturally and the LLM routes them
- A multi-agent call flow is modeled as a state machine with valid transition constraints
- Each department agent has focused instructions and isolated tools for separation of concerns
- Agent handoffs preserve CallContext (caller ID, account, intent history) across transitions
- The greeting agent IS the IVR replacement — one conversational turn instead of multi-level menus
Next up
Not every call can be resolved by AI. The next chapter builds the handoff from AI to human agents — with full context transfer so the human never asks the caller to repeat themselves.