Nobody likes phone trees. "Press 1 for billing, press 2 for support." Miss an option? Start over. It is friction at its worst.
The voice-ivr-with-agent-backend
example replaces that with a natural language conversation. Callers just say what they need, and the app routes them to the right department.
Code: https://github.com/team-telnyx/telnyx-code-examples/tree/main/voice-ivr-with-agent-backend
A Python/Flask app that handles inbound calls with a conversational IVR:
Inbound Call
-> answer with Call Control
-> look up menu config from KV
-> LLM generates a dynamic greeting
-> gather(speech) — caller says what they need
-> LLM routes intent to a department
-> transfer call
The app combines four Telnyx primitives:
answer()
, speak()
, gather_using_speech()
, transfer()
telnyx.ai.openai.chat.completions.create()
for greetings and intent routingIVRAgent
class that tracks call state, turn count, and retry logicInstead of a hardcoded "Press 1 for billing," the app generates a conversational greeting from the KV config:
def generate_dynamic_menu_prompt(menu_config: dict) -> str:
departments = menu_config.get("departments", [])
dept_list = "\n".join(
f"- {d['name']}: {d['description']}" for d in departments
)
return (
f"You are an IVR assistant for {menu_config['business_name']}. "
f"Available departments:\n{dept_list}\n\n"
f"Greet the caller briefly and ask how you can help. "
f"Keep it conversational and under 2 sentences."
)
The LLM generates the greeting through the OpenAI-compatible Telnyx Inference binding. If it fails, the app falls back to a static greeting from the KV config.
When the caller speaks, the transcription is passed to route_intent_with_llm
. The LLM is instructed to respond with only the department name for reliable parsing:
completion = telnyx.ai.openai.chat.completions.create(
model="telnyx-llm",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_input},
],
max_tokens=20,
temperature=0.1,
)
intent = completion.choices[0].message.content.strip().lower()
If the LLM fails or returns "unknown," the app falls back to keyword matching. After max_turns
(3 by default), the call transfers to a default operator.
The gather_using_speech
primitive plays a prompt and captures the caller's speech in one call:
telnyx.Call.gather_using_speech(
call_control_id,
payload=prompt,
voice="female-en-US",
language="en-US",
max_duration=15,
)
Every webhook request is verified with Ed25519 signature verification:
telnyx.Webhook.construct_event(
payload, signature, timestamp, TELNYX_PUBLIC_KEY
)
This prevents spoofed requests from triggering call actions.
The IVRAgent
class manages each call:
on_connect()
: fetch menu config, generate LLM greeting, speak, gatheron_gather_ended(speech)
: route intent via LLM, transfer or retryturn_count
and max_turns
before falling back to a default transferPUT /api/menu-config/<phone_number>
: update KV menu config dynamicallyGET /api/menu-config/<phone_number>
: retrieve current configGET /api/agents
: list active IVR agents (debugging)GET /health
: health check
git clone https://github.com/team-telnyx/telnyx-code-examples.git
cd telnyx-code-examples/voice-ivr-with-agent-backend
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
Fill in your Telnyx API Key, Public Key, Connection ID, and default transfer number. Then:
python app.py
ngrok http 5000
Point your Call Control Application webhook to https://<your-ngrok-url>.ngrok-free.app/webhooks/voice
.
Before using with real callers, add:
Resources: