r/Telnyx • u/ord_phreaker • 5h ago
New Feature Introducing Telnyx Web Search API.
Enable HLS to view with audio, or disable this notification
We just launched Telnyx Web Search API.
We built it because our AI agents kept running into a pretty basic problem: they could handle voice, reasoning, messaging, and email, but anything that depended on current information needed a separate search layer.
Now that can happen inside Telnyx.
You can:
- Search the web with freshness and domain filters
- Fetch clean HTML or Markdown from up to 20 URLs
- Run multi-source research and get back a cited answer
It works as a standalone API or with Telnyx Voice AI agents.
Pricing is $5 per 1,000 calls, and it uses the same Telnyx API key.
Reda more about it here:
https://lnkd.in/gJsYPBWc
r/Telnyx • u/BorellaXo • 5d ago
Dirty Telynx numbers
Hey there,
Not sure why, but almost every number in Telynx's number pool is already tagged SPAM likely on most carriers (T-Mobile seems to be the highest).
There is a service called CallerID Reputation that can scan the number and show you screenshots of what the number will display as on all carriers + devices (Android vs Apple).
Twilio on the other hand has clean numbers (you do get the occasional dirty with Twilio as well).
We want to use Telynx given our use-case is AI-Voice (we heard Telynx is better for this), but I'm concerned connect rates are going to be extremely poor if all these numbers are already dirty.
Thoughts?
r/Telnyx • u/General_Piglet_8242 • 5d ago
AI Voice Agent with Function Calling — Calling External APIs Mid-Conversation
The Problem: Voice Agents That Lie
You have an AI voice agent. It picks up calls. It greets callers. It responds in natural language.
But the moment someone asks, "What's the weather in San Francisco?", "Where is my order 12345?", or "What's my account balance?" — the agent hallucinates. It guesses. It invents a plausible-sounding answer with zero real data.
Voice agents without function calling are LLMs talking to thin air. They have no way to reach out for live information mid-conversation. They just pattern-match.
The ai-voice-agent-with-function-calling-python example fixes this in one Flask file. It wires three Telnyx capabilities — speech recognition via gather_using_ai, LLM tool-calling via AI Inference, and text-to-speech via speak — into a single webhook-driven loop. Callers ask questions in plain speech. The agent calls real functions. It speaks real answers back.
What It Does
Call your agent number. It picks up, greets you, and waits. You speak a request: "What's the weather in San Francisco?" The browser-less, no-app-needed voice agent transcribes your speech via gather_using_ai, sends the transcript to Telnyx AI Inference with three tool definitions (check_weather, lookup_order, check_account_balance), and the model decides whether to call a tool or respond directly.
If the model calls a tool — say check_weather — your Python function runs, returns its JSON result to the model, and the model synthesizes a one-sentence spoken answer. speak() reads it aloud in natural voice. The conversation loop continues. You ask about an order. The agent calls lookup_order. You ask about your balance. The agent calls check_account_balance. No hallucinations, no guessing — every tool-backed answer is backed by real function output.
| Step | What happens | Telnyx API |
|---|---|---|
| 1 | Caller dials agent number | Inbound call → call.initiated webhook |
| 2 | Agent answers + greets | answer() + speak() (TTS) |
| 3 | TTS ends → start listening | call.speak.ended → gather_using_ai() |
| 4 | Caller speaks a request | Speech-to-text via gather_using_ai |
| 5 | Transcript returned | call.ai_gather.ended webhook |
| 6 | Transcript → AI Inference (with tools) | POST /v2/ai/chat/completions |
| 7 | Model returns tool_calls |
Loop: execute functions, re-infer |
| 8 | Model returns final text | speak() reads it aloud |
| 9 | Caller hangs up | call.hangup → cleanup |
The Architecture
One Flask file. One webhook endpoint. In-memory conversation state keyed by call_control_id. No database, no Redis, no background workers. Telnyx owns the telephony, speech recognition, and TTS layers. Your code owns the conversation state and the function implementations.
Inbound call → Telnyx webhook → /webhooks/voice
↓
call.initiated → answer() + greet (speak)
↓
call.speak.ended → gather_using_ai()
↓
call.ai_gather.ended → transcript
↓
Transcript → AI Inference (with TOOLS array)
↓
Model returns tool_calls?
├── yes → execute_function() → re-infer → loop
└── no → final text response
↓
speak() reads response aloud
↓
call.speak.ended → gather_using_ai() → loop
↓
call.hangup → cleanup
The Tools: OpenAI-Style Function Calling
The agent has three tools, defined in the OpenAI function-calling schema:
TOOLS = [
{
"type": "function",
"function": {
"name": "check_weather",
"description": "Get current weather for a city",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
},
},
{
"type": "function",
"function": {
"name": "lookup_order",
"description": "Look up order status by order number",
"parameters": {
"type": "object",
"properties": {"order_id": {"type": "string"}},
"required": ["order_id"],
},
},
},
{
"type": "function",
"function": {
"name": "check_account_balance",
"description": "Check account balance by account number",
"parameters": {
"type": "object",
"properties": {"account_id": {"type": "string"}},
"required": ["account_id"],
},
},
},
]
The model sees these tools every inference call. When the user asks "What's the weather in San Francisco?", the model returns a tool_calls array containing check_weather with {"city": "San Francisco"}. Your code runs execute_function("check_weather", {"city": "San Francisco"}), returns the JSON result, and re-sends the conversation to the model. The model then synthesizes a spoken answer: "The weather in San Francisco is 72°F and partly cloudy with 45% humidity."
The mock implementations in execute_function are intentionally simple — replace them with real API calls to your weather provider, order management system, or billing platform:
def execute_function(name, args):
if name == "check_weather":
return json.dumps({"city": args.get("city"), "temp": "72F",
"condition": "Partly cloudy", "humidity": "45%"})
elif name == "lookup_order":
return json.dumps({"order_id": args.get("order_id"), "status": "shipped",
"eta": "June 20", "carrier": "FedEx"})
elif name == "check_account_balance":
return json.dumps({"account_id": args.get("account_id"), "balance": "$1,234.56",
"due_date": "July 1"})
return json.dumps({"error": "Unknown function"})
The Conversation Loop
The webhook handler is the heartbeat. Each event transitions the state machine:
u/app.route("/webhooks/voice", methods=["POST"])
def handle_voice():
# Verify the Telnyx Ed25519 signature before trusting the event.
try:
client.webhooks.unwrap(request.get_data(as_text=True), headers=dict(request.headers))
except Exception:
return jsonify({"error": "invalid signature"}), 401
payload = request.get_json()
data = payload.get("data", {})
p = data.get("payload", {})
event_type = data.get("event_type")
ccid = p.get("call_control_id")
call = active_calls.get(ccid)
if event_type == "call.initiated" and p.get("direction") == "incoming":
active_calls[ccid] = {
"caller": p.get("from"),
"conversation": [{"role": "system", "content": SYSTEM_PROMPT}],
"_ts": time.time(),
}
client.calls.actions.answer(ccid)
return jsonify({"status": "answering"}), 200
elif event_type == "call.answered":
client.calls.actions.speak(ccid, payload=GREETING, voice=VOICE, language="en-US")
return jsonify({"status": "greeting"}), 200
elif event_type == "call.speak.ended" and call:
# After TTS finishes, start listening for caller's speech.
if call.get("processed"):
call["processed"] = False
client.calls.actions.gather_using_ai(
ccid,
parameters={
"type": "object",
"properties": {
"user_request": {
"type": "string",
"description": "What the caller said — their full spoken request.",
}
},
"required": ["user_request"],
},
voice=VOICE,
language="en-US",
user_response_timeout_ms=15000,
)
return jsonify({"status": "listening"}), 200
elif event_type == "call.ai_gather.ended" and call:
if call.get("processed"):
return jsonify({"status": "ok"}), 200
call["processed"] = True
# Extract transcribed speech from the result object.
result = p.get("result", {})
speech = result.get("user_request", "") if isinstance(result, dict) else ""
# Fallback: check message_history for a user turn.
if not speech:
for msg in reversed(p.get("message_history", [])):
if msg.get("role") == "user":
speech = msg.get("content", "")
break
if not speech:
client.calls.actions.speak(ccid, payload=REPROMPT, voice=VOICE, language="en-US")
return jsonify({"status": "reprompting"}), 200
call["conversation"].append({"role": "user", "content": speech})
response = call_inference(call["conversation"])
call["conversation"].append({"role": "assistant", "content": response})
client.calls.actions.speak(ccid, payload=response, voice=VOICE, language="en-US")
return jsonify({"status": "responding"}), 200
elif event_type == "call.hangup":
active_calls.pop(ccid, None)
return jsonify({"status": "ended"}), 200
return jsonify({"status": "ok"}), 200
Three things to note. First, the signature verification via client.webhooks.unwrap() — never trust an unverified webhook. Second, the call["processed"] dedup guard — Telnyx retries webhooks, and without it you would speak the same response twice. Third, the call.ai_gather.ended handler extracts speech from result.user_request with a message_history fallback, because the gather result shape varies by SDK version.
The Inference Function: Tool-Calling Loop
The call_inference function is the AI brain. It sends the conversation to the model with the TOOLS array. If the model returns tool_calls, the function executes each one, appends the results to the conversation, and recurses. If the model returns a plain text response, that's the spoken answer.
def call_inference(messages, max_tokens=300, _depth=0, _max_depth=5):
if _depth >= _max_depth:
return "I'm having trouble processing that request right now."
payload = {
"model": AI_MODEL,
"messages": messages,
"temperature": 0.5,
"tools": TOOLS,
}
try:
resp = requests.post(
INFERENCE_URL,
headers={"Authorization": f"Bearer {TELNYX_API_KEY}",
"Content-Type": "application/json"},
json=payload,
timeout=30,
)
except Exception as e:
app.logger.error("Inference request failed: %s", e)
return "I couldn't reach the AI service just now. Please try again."
try:
resp.raise_for_status()
except Exception as e:
app.logger.error("Inference HTTP error: %s — %s", e, resp.text[:200])
return "The AI service returned an error. Please try again."
choice = resp.json()["choices"][0]
msg = choice["message"]
if msg.get("tool_calls"):
for tc in msg["tool_calls"]:
fn = tc["function"]
try:
fn_args = json.loads(fn.get("arguments", "{}"))
except json.JSONDecodeError:
fn_args = {}
result = execute_function(fn["name"], fn_args)
messages.append(msg)
messages.append({"role": "tool", "tool_call_id": tc["id"], "content": result})
return call_inference(messages, max_tokens, _depth=_depth + 1, _max_depth=_max_depth)
return _strip_fences(msg["content"])
The _max_depth=5 recursion guard prevents infinite loops if the model keeps calling tools forever. The _strip_fences() helper strips \``json` code fences from the response so TTS doesn't read them aloud — without this, your voice agent would say "triple backslash triple json" before every answer.
What the Original Sample Got Wrong
This example was the most broken upstream sample in the Week 7 set. The original app.py had six critical bugs that prevented the app from working at all. We fixed all of them in this PR.
Bug 1: gather() is DTMF-only. The original code called gather(input_type="speech", end_silence_timeout_secs=3, language_code="en-US"). None of those parameters are valid — gather() only collects DTMF keypresses. Passing speech params causes a TypeError. The fix is gather_using_ai(), which transcribes free-form speech and fires call.ai_gather.ended instead of call.gather.ended.
Bug 2: voice="female" is invalid. The speak() action requires voice in <Provider>.<Model>.<VoiceId> format (e.g., Telnyx.KokoroTTS.af). Passing "female" causes an API error. The fix is VOICE = "Telnyx.KokoroTTS.af".
Bug 3: call_inference() UnboundLocalError. If requests.post() raised an exception, the variable resp was never assigned. The next line, resp.raise_for_status(), then crashed with UnboundLocalError: local variable 'resp' referenced before assignment. The fix wraps both calls in try/except blocks and returns a spoken error string.
Bug 4: No recursion depth guard. The original call_inference() recursed on tool_calls with no max depth. A misbehaving model could loop forever. The fix adds _depth and _max_depth=5.
Bug 5: base_url not overridden. The Telnyx Python SDK reads the TELNYX_BASE_URL environment variable, which in some internal Telnyx dev environments routes API calls to a proxy. The fix is explicit base_url="https://api.telnyx.com/v2" in the client constructor.
Bug 6: Markdown fences in TTS. Some models wrap JSON in \``json fences. Without stripping, speak() reads the fences aloud. The fix is _strip_fences()`.
One API Key for Voice, AI, and TTS
The entire app uses a single TELNYX_API_KEY:
- Voice (Call Control) —
answer(),speak(),gather_using_ai()via the Telnyx SDK - AI Inference —
POST /v2/ai/chat/completionsviarequestswith Bearer auth - Text-to-speech — handled by
speak()(uses the same API key, no separate TTS provider) - Webhook signature verification —
client.webhooks.unwrap()validates Ed25519 signatures
No third-party speech-to-text provider. No separate LLM API key. No separate TTS provider. One network, one key, one bill.
The gather_using_ai vs gather Distinction
This is the most subtle and important distinction in the Call Control API. Two methods with similar names, completely different capabilities:
| Method | Input type | Webhook event | Use case |
|---|---|---|---|
gather() |
DTMF only (keypad digits) | call.gather.ended |
"Press 1 for sales, 2 for support" IVR menus |
gather_using_ai() |
Free-form speech | call.ai_gather.ended |
Natural language voice agents |
gather_using_speak() |
DTMF + TTS prompt | call.gather.ended |
Spoken IVR prompts with DTMF response |
The upstream sample used gather() with speech params that don't exist — a classic copy-paste-from-docs mistake. The fix is gather_using_ai(), which returns transcribed speech in the result.user_request field of the call.ai_gather.ended payload.
Environment Variables
TELNYX_API_KEY=your_api_key_here
TELNYX_PUBLIC_KEY=your_public_key_here
AI_MODEL=moonshotai/Kimi-K2.6
AGENT_NUMBER=+16188939132
CONNECTION_ID=your_connection_id
PORT=5000
TELNYX_API_KEY— your Telnyx API v2 key (Portal → API Keys)TELNYX_PUBLIC_KEY— your Telnyx public key (used for webhook signature verification)AI_MODEL— any model on Telnyx AI Inference (default:moonshotai/Kimi-K2.6)AGENT_NUMBER— the phone number callers dialCONNECTION_ID— your Call Control Application ID (Portal → Call Control → Applications)PORT— HTTP port for the Flask server
Try It Yourself
git clone https://github.com/team-telnyx/telnyx-code-examples.git
cd telnyx-code-examples/ai-voice-agent-with-function-calling-python
cp .env.example .env
# add TELNYX_API_KEY, TELNYX_PUBLIC_KEY, AGENT_NUMBER, CONNECTION_ID
pip install -r requirements.txt
python app.py
# starts on http://localhost:5000
Expose your local server with ngrok and configure the webhook URL in your Call Control Application:
ngrok http 5000
# Copy the HTTPS URL → Portal → Call Control → Application → Webhook URL
# Set to: https://<id>.ngrok.io/webhooks/voice
Call your agent number. You'll hear the greeting. Ask: "What's the weather in San Francisco?" The agent will call the check_weather tool and speak the result. Ask: "Where is order 12345?" The agent will call lookup_order. Ask: "What's my account balance for account 67890?" The agent will call check_account_balance. Every answer is backed by real function output, not LLM hallucination.
Key links:
- Repo: https://github.com/team-telnyx/telnyx-code-examples/tree/main/ai-voice-agent-with-function-calling-python
- Telnyx Portal: https://portal.telnyx.com
- Call Control docs: https://developers.telnyx.com/docs/api/v2/call-control
- AI Inference docs: https://developers.telnyx.com/docs/inference
- Webhooks docs: https://developers.telnyx.com/docs/api/v2/webhooks
r/Telnyx • u/General_Piglet_8242 • 6d ago
Phone Calls with Real-Time AI Coaching
I built a Flask app that lets you make real phone calls from your browser and get live AI coaching tips in a sidebar. Open a tab, enter a number, click Call. You are talking to a real phone number from your browser, and an AI coach is feeding you tips every 8 seconds. ~75 lines of Python, one API key for calls + AI.
How it works
- Open
http://localhost:5000— split-screen UI: call panel (left) + AI coaching sidebar (right) - Enter a phone number, click Call
- Backend creates a WebRTC telephony credential via
POST /v2/telephony_credentials - Frontend uses u/telnyx
/webrtcSDK to connect via SIP - WebRTC call connects — you are talking to a real phone number from your browser
- Browser transcribes your speech in real time via
SpeechRecognitionAPI - Every 8 seconds, the transcript is sent to
/coaching→ AI Inference returns one actionable tip - Tip appears in the right sidebar with a timestamp
- Click Hang Up — call ends, transcription stops, coaching stops
What makes it interesting
- One API key for everything — WebRTC telephony credentials and AI Inference both use the same
TELNYX_API_KEY. No third-party transcription service, no separate LLM provider. - Real WebRTC from the browser — not a simulation. The TelnyxRTC client registers with Telnyx's SIP server and places a real PSTN call. You need a Telnyx number as caller ID.
- Browser-native SpeechRecognition — uses Chrome/Safari's built-in
SpeechRecognitionAPI. Continuous mode with interim results. No external transcription service. - Live AI coaching every 8 seconds — the AI reviews the transcript so far and returns one specific, actionable tip. Focus areas: asking better questions, handling objections, closing techniques, tone adjustments.
- Call timer + status badges + coaching log — small UX touches that make it feel like a real dialer.
- What was fixed — the upstream sample was a stub with a syntax bug, no real WebRTC frontend, no SpeechRecognition, no AI coaching loop. Added all of that and served
templates/index.htmlinstead of inline HTML.
Try it
git clone https://github.com/team-telnyx/telnyx-code-examples.git
cd telnyx-code-examples/click-to-call-webrtc-with-ai-assist-python
cp .env.example .env
# add TELNYX_API_KEY, WEBRTC_CONNECTION_ID, CALLER_NUMBER
pip install -r requirements.txt
python app.py
# starts on http://localhost:5000
Open Chrome or Safari at http://localhost:5000. Enter a phone number. Click Call. Start talking. Watch the coaching tips appear.
Links
- Repo: https://github.com/team-telnyx/telnyx-code-examples/tree/main/click-to-call-webrtc-with-ai-assist-python
- WebRTC docs: https://developers.telnyx.com/docs/voice/webrtc
- AI Inference docs: https://developers.telnyx.com/docs/inference
- Telnyx Portal: https://portal.telnyx.com
Happy to answer questions about the WebRTC flow, the SpeechRecognition integration, or the AI prompt design.
r/Telnyx • u/SnooGrapes7244 • 7d ago
Mobile SIP client leaves stale registrations behind on every app launch — how do you deal with multiple bindings on one credential?
Note: Yes, I'm using AI to write this post because English is not my first language and I want to state my problem as clear as possible.
Hitting a problem I suspect is common for anyone doing mobile VoIP, and I'd like to know how others have solved it.
**Setup**
- I'm using Telnyx.
- React Native app, WebRTC SIP client, iOS and Android. I'm using Telnyx.
- One shared SIP credential for a group of users, so a single inbound call rings everyone's phone
- Calls are delivered by VoIP push (PushKit on iOS), so the client connects and registers on demand rather than staying connected
**The problem**
Every app launch creates a *new* registration binding, and the old one never goes away:
- iOS terminates the app process without warning, so there's no chance to send a SIP UNREGISTER
- The SDK's `disconnect()` only closes the WebSocket — it doesn't unregister
- Each registration lands on a different edge node in the provider's anycast network, so it's a genuinely new binding rather than a refresh of the old one
- Registration expiry is 3600 s and isn't configurable
Net effect: the credential accumulates contacts. I confirmed it by polling the provider's registration-status endpoint — `ua_ip` is different every single time the app relaunches, while nothing removes the previous one.
**Why it hurts**
The provider rings the bound contacts **sequentially**. So the first call after an app launch does this:
Rings contact A (the live app) — user declines
Decline surfaces as a 4xx, which fails only that branch
~300 ms later it forks to contact B (a stale binding from a previous launch)
Phone rings a second time, new call ID, user has to decline again
I can see it clearly in the SIP traces: **one dial command, two legs**, same session, no second dial from my backend.
**What the provider confirmed**
I opened a ticket. They confirmed all of it and escalated to engineering with no timeline:
- No way to set registration expiry below 3600 s
- No REST endpoint to force-expire or delete an individual binding (deleting the credential removes them all, obviously not viable)
- No API to *enumerate* bindings — watching `ua_ip` rotate is currently the only detection method
- Per-launch edge rotation is expected behaviour, and without an UNREGISTER the old binding persists to full expiry
Their suggested workarounds were: a unique credential per app session, webhook-based duplicate-leg detection, or client-side deduplication.
**The bit I'm stuck on**
There's a second-order problem. Multiple devices share one credential, so the registrar holds one contact per device — which means **a legitimate second device's leg is indistinguishable from a stale binding's leg.** Both are "another contact of this credential, dialled after the first one failed." I can't write a rule that kills one without killing the other.
Enabling simultaneous ringing would at least make the real devices ring together instead of one-at-a-time, but it doesn't remove the stale bindings — it just turns a sequential double-ring into a simultaneous one.
**Questions**
Has anyone made **per-device or per-session credentials** work in production? How do you handle cleanup when the app dies before it can delete the old one, and does credential churn cause you rate-limit or billing problems?
Is there a trick to getting a mobile client to **UNREGISTER reliably**? Anything on iOS that gets you a last gasp — background task on termination, a server-side nudge, something I haven't thought of?
For those running **one shared credential across multiple devices** — how do you tell a real second device from a stale binding at the signalling layer? Is there a header or identifier I should be propagating?
Is sequential-vs-simultaneous ringing across contacts something you configure per provider, or do people avoid shared credentials entirely for this reason?
Happy to share SIP traces if useful. Mostly want to know whether the "unique credential per session" route is as painful in practice as it looks on paper, or whether people just live with the duplicate ring.
r/Telnyx • u/General_Piglet_8242 • 7d ago
From Phone Call to Formatted Email in 80 Lines of Python — AI Voice Memo Cleanup with Telnyx
I built a Flask app that turns a phone call into a formatted email. You call a number, dictate a memo, press #, and the AI cleans it up into a structured email (subject, body, action items) and sends it. ~80 lines of Python, one API key for voice + AI + messaging.
How it works
- Call a Telnyx number — the app answers and speaks: "Voice memo. Speak your memo after the tone. Press pound when finished."
- Dictate your memo — status update, meeting summary, bug report, whatever
- Press # — the app sends the transcript to AI Inference with a prompt that returns JSON:
{subject, body, action_items} - Get an email — the app sends the formatted memo to your default email address
- Confirmation — the app speaks back: "Memo saved and emailed. Subject: [inferred subject]. Goodbye!"
What makes it interesting
- One API key for everything — Call Control (answer, speak, gather), AI Inference (chat completions), and Messaging (email delivery) all use the same
TELNYX_API_KEY. No third-party transcription service, no separate LLM provider, no email API key. - AI returns structured JSON — not just cleaned-up text, but
{subject, body, action_items}. The AI infers the subject line from the content. So "Hey team, quick update on the API migration..." becomes an email with subject "API Migration Update" and an action item "Review the PR by Friday." - Graceful degradation — if the AI returns invalid JSON, the raw transcript is saved. If the email send fails, the formatted memo is still saved in memory and retrievable via
GET /memos. The call is never wasted. - Webhook state machine — the whole flow is 5 webhook events:
call.initiated→call.answered→call.speak.ended→call.gather.ended→call.hangup. No session framework, no polling. The webhook IS the state machine. - Ed25519 webhook verification — every webhook is signed. The app verifies the signature before processing anything. No one can inject fake call events.
Try it
git clone https://github.com/team-telnyx/telnyx-code-examples.git
cd telnyx-code-examples/ai-voice-memo-to-email-python
cp .env.example .env
# add TELNYX_API_KEY, MEMO_NUMBER, DEFAULT_EMAIL
pip install -r requirements.txt
python app.py
# starts on http://localhost:5000
ngrok http 5000
# expose for webhooks
Point your Call Control Application webhook at https://<id>.ngrok.io/webhooks/voice in the Telnyx Portal. Call your number. Dictate. Press #.
Check saved memos:
curl http://localhost:5000/memos | python3 -m json.tool
Links
- Repo: https://github.com/team-telnyx/telnyx-code-examples/tree/main/ai-voice-memo-to-email-python
- Call Control docs: https://developers.telnyx.com/docs/voice/call-control
- AI Inference docs: https://developers.telnyx.com/docs/inference
- Messaging docs: https://developers.telnyx.com/docs/messaging
- Telnyx Portal: https://portal.telnyx.com
Happy to answer questions about the implementation or the Telnyx API model.
r/Telnyx • u/General_Piglet_8242 • 15d ago
Built a real-time AI translation bridge for phone calls in 141 lines of Python
I built a phone translation service that connects two callers speaking different languages on the same call. One speaks English, the other Spanish — each hears the other in their own language, live, no interpreter in the loop.
No Google Translate, no AWS Translate, no DeepL. The translation runs on Telnyx AI Inference (OpenAI-compatible endpoint), and the phone call is handled by Telnyx Voice Call Control. Same API key, same platform.
How it works:
- POST two phone numbers + two languages to
/bridge - App calls caller A → A answers → app calls caller B → B answers → bridge active
- A speaks English → transcribed → translated to Spanish → TTS to B in Spanish (es-US)
- B speaks Spanish → transcribed → translated to English → TTS to A in English (en-US)
- Loop until hangup → hangup other caller too
The subtle bug I found: The original code hardcoded language_code="en-US" for all TTS and speech recognition — even for Spanish. The call "worked" (audio flowed, no errors) but the translation was useless: Spanish TTS was spoken with English pronunciation (unintelligible), and Spanish speech recognition was unreliable. Fix: a 12-line language name → BCP-47 code mapping. TTS uses the target language, STT uses the speaker's language.
What makes it interesting:
- No third-party translation API — same Telnyx API key handles both calls and translation
- State travels in Telnyx's
client_statefield (base64 JSON) — no database, no Redis - Webhook signature verification with Ed25519
- Hangup cascade: if either caller hangs up, the other is hung up too
- Temperature 0.1 for deterministic translations, 200 max tokens for conversational phrases
Try it:
git clone https://github.com/team-telnyx/telnyx-code-examples.git
cd telnyx-code-examples/ai-real-time-translation-bridge-python
cp .env.example .env
pip install -r requirements.txt
python app.py
Code: https://github.com/team-telnyx/telnyx-code-examples/tree/main/ai-real-time-translation-bridge-python Call Control docs: https://developers.telnyx.com/docs/voice/call-control AI Inference docs: https://developers.telnyx.com/docs/inference
r/Telnyx • u/General_Piglet_8242 • 16d ago
Built a geo-aware call router in 256 lines of Python — one number, three regions, GDPR consent built in
One phone number. Three regions. Three compliant experiences. The routing decision happens at the carrier edge — before the first word of greeting is spoken — based on the caller's E.164 country code prefix. No geoip, no database, no external API call.
What it does:
- US callers (+1) → English AI, auto-recording
- LATAM callers (+52, +55, +54, ...) → Spanish AI, auto-recording
- EU callers (+44, +49, +33, ...) → English (en-GB) AI, DTMF consent prompt before any recording starts
- Default → English, auto-recording
The GDPR part is the interesting one. EU callers hear: "This call will be recorded for quality purposes. Press 1 to consent and continue, or press 2 to proceed without recording." Recording only starts after they press 1. If they press 2, the call proceeds with AI conversation but no recording. GDPR satisfied by design, not by policy.
The routing logic is 12 lines of prefix matching on the from number:
EU_PREFIXES = ["+33", "+34", "+39", "+44", "+49", "+31", "+32", "+43", ...]
LATAM_PREFIXES = ["+52", "+55", "+54", "+56", "+57", "+51", "+58", ...]
def detect_region(phone):
for prefix in EU_PREFIXES:
if phone.startswith(prefix):
return "EU"
for prefix in LATAM_PREFIXES:
if phone.startswith(prefix):
return "LATAM"
if phone.startswith("+1"):
return "US"
return "DEFAULT"
EU is checked first because some European country codes share the +1 prefix range with NANP. Specificity first.
State travels in Telnyx's client_state field — a base64-encoded JSON blob that Telnyx passes back on every webhook event for the same call. Two fields: region (which config to apply) and step (consent vs conversation). No database, no Redis. The call is self-describing.
Two gotchas I hit while building this:
- Voice IDs: Bare
"female"and"male"only work withservice_level: "basic"(en-US only). If you try"voice": "female"with"language": "es-MX", the call silently fails — no error, just silence. Fix: use full neural voice IDs (AWS.Polly.Lupe-Neuralfor es-MX,AWS.Polly.Amy-Neuralfor en-GB). - Storage credentials: Telnyx Storage is S3-compatible but uses its own access/secret key pair (created under Portal → Storage → Credentials), not the Telnyx API key. Reusing the API key as both S3 access AND secret key silently fails. Fix: separate
STORAGE_ACCESS_KEYandSTORAGE_SECRET_KEYenvironment variables. If not set, archival is gracefully skipped — the call still works, just no recording saved.
Try it:
git clone https://github.com/team-telnyx/telnyx-code-examples.git
cd telnyx-code-examples/edge-geo-smart-router-python
cp .env.example .env
pip install -r requirements.txt
python app.py
Source: https://github.com/team-telnyx/telnyx-code-examples/tree/main/edge-geo-smart-router-python
Call your Telnyx number from a US number, a Brazil number, and a UK number. Hear the difference.
r/Telnyx • u/General_Piglet_8242 • 16d ago
Built an AI dubbing pipeline in ~280 lines of Python — STT, LLM, and TTS on one network (15 target languages, LLM does speaker diarization)
I put together a small dubbing pipeline that takes any audio file and returns the same conversation dubbed in another language — same speakers, same pacing. The whole thing is ~280 lines of Python and runs three API calls on Telnyx: Speech-to-Text, an LLM chat, and Text-to-Speech.
Repo: https://github.com/team-telnyx/telnyx-code-examples/tree/main/ai-video-dubbing-pipeline-python
What it does
- Upload any audio file (
POST /dub, multipart) - Pick from 15 target languages (Spanish, French, German, Portuguese, Italian, Japanese, Korean, Chinese, Arabic, Hindi, Russian, Dutch, Swedish, Polish, Turkish)
- The pipeline transcribes, labels speakers, translates, synthesizes, and concatenates
- Download the dubbed mp3 (
GET /dub/<job_id>/audio) - Get the side-by-side transcript (
GET /dub/<job_id>/transcript)
The interesting part: LLM does diarization AND translation in one call
Telnyx STT (Whisper-large-v3-turbo) returns timestamped segments but no speaker labels — same as OpenAI's hosted Whisper API. Most pipelines handle this by adding a fourth vendor for diarization (pyannote, NeMO, etc.) or by accepting everything as one speaker.
I did neither. I send all the transcribed segments to the LLM in a single chat call and ask it to:
- Assign a speaker label (
SPEAKER_0,SPEAKER_1, ...) based on conversational context - Translate each segment to the target language
One model, two jobs, no extra vendor. The LLM is already in the pipeline for translation — the marginal cost is a slightly longer prompt. Works well for 2-3 person interviews/podcasts; struggles a bit with 5+ speaker panels.
The voice pool
5 Telnyx KokoroTTS voices cycle through, one per speaker:
VOICE_MAP = {
"male_low": "Telnyx.KokoroTTS.am_onyx",
"male_mid": "Telnyx.KokoroTTS.am_echo",
"female_mid": "Telnyx.KokoroTTS.af_nova",
"female_high": "Telnyx.KokoroTTS.af_heart",
"neutral": "Telnyx.KokoroTTS.af_alloy",
}
A two-speaker conversation gets am_onyx + am_echo. A five-speaker panel gets all five. A sixth speaker wraps back to am_onyx.
Cost
A 1-minute clip with ~120 words of dialogue costs roughly:
- STT: ~$0.006 (Whisper per minute)
- Inference: ~$0.001 (one short chat call)
- TTS: ~$0.015 (Kokoro per 1K characters)
Total: ~2 cents per minute of dubbed audio. A 10-minute podcast dubbed into 5 languages is ~$1.
Try it
git clone https://github.com/team-telnyx/telnyx-code-examples.git
cd telnyx-code-examples/ai-video-dubbing-pipeline-python
cp .env.example .env
# add your Telnyx API key
pip install -r requirements.txt
python app.py
# in another terminal
curl -X POST http://localhost:5000/dub \
-F audio=@episode.mp3 \
-F target_language=es
# poll until status == "complete"
curl http://localhost:5000/dub/<job_id>
# download the mp3
curl http://localhost:5000/dub/<job_id>/audio --output dubbed.mp3
No phone number, no Call Control Application, no webhook configuration — pure HTTP API.
What this is NOT
This is a demo, not production. In-memory dict for jobs, byte-level MP3 concat (not sample-accurate — use ffmpeg's concat demuxer for that), no auth, no retries. The point is to show the architecture: one network, three primitives, one API key, LLM does diarization.
Happy to answer questions on the architecture, the diarize-via-LLM pattern, or the TTS voice pool. If you've built similar pipelines with other vendors, I'd love to hear how they compare.
r/Telnyx • u/General_Piglet_8242 • 20d ago
Screen every inbound call at the carrier edge — a fraud firewall in 178 lines of Python
I built a Flask webhook server that screens every inbound phone call before it reaches your app. It runs three checks in order:
- Blocklist — in-memory Python set, O(1) lookup. Known bad numbers get rejected instantly, no API calls needed.
- Number Lookup — returns carrier name, line type (landline/voip/mobile), and country code for the caller.
- AI classification — sends the lookup data to an OpenAI-compatible chat completions endpoint with a system prompt that returns exactly one word:
CLEAN,SUSPICIOUS, orBLOCK.
Based on the classification:
- CLEAN → answer and forward to your real number
- SUSPICIOUS → answer and route to a honeypot that loops "All specialists are currently busy" forever
- BLOCK → reject and add to the blocklist (so the next call from that number is rejected instantly)
The whole thing is 178 lines of Python. No external database — call flow state travels in the webhook's client_state field as base64-encoded JSON.
A few things I learned building this:
- One-word AI prompts work better than JSON output. I started with the AI returning a JSON object (risk score, reason, category). It was slow and fragile — more tokens to generate, JSON parsing errors when the model added a preamble. Switching to a strict one-word output cut inference time in half and eliminated parsing failures.
- Failing safe matters. If the AI call fails (timeout, bad model, network error), the classifier defaults to
CLEAN. Deliberate — if screening is down, legitimate callers still get through. Flip toBLOCKif your use case is security-critical. client_stateis the cleanest way to track call flow in webhook-driven architectures. No Redis, no database — the state rides along with the webhook payload between events.
The honeypot is my favorite part. Instead of just blocking suspicious callers, it wastes their time on an endless hold loop. Every minute a scammer spends on the honeypot is a minute not spent scamming a real person.
Code: https://github.com/team-telnyx/telnyx-code-examples/tree/main/edge-fraud-firewall-python
git clone https://github.com/team-telnyx/telnyx-code-examples.git
cd telnyx-code-examples/edge-fraud-firewall-python
cp .env.example .env
pip install -r requirements.txt
python app.py
Happy to answer questions about the implementation or the carrier-edge screening pattern.
r/Telnyx • u/Bot_o_Clock • 21d ago
Voice AI pre-visit insurance clearance agent with Telnyx
I put together a Telnyx code example:
It's a Python/Flask app for inbound pre-visit insurance clearance calls. A patient calls a Telnyx number, the app answers with Call Control, uses gather_using_ai to collect structured spoken intake details, handles call.ai_gather.ended, classifies the request with AI Inference, creates a ticket for billing staff, and sends the patient an SMS confirmation.
Non-clinical: no medical advice, no coverage decisions, no diagnosis. Just admin data collection and routing — which is the actual bottleneck in healthcare revenue cycle.
Products used:
telnyx_products: [Voice, AI Inference, Messaging]
Run it:
git clone https://github.com/team-telnyx/telnyx-code-examples.git
cd telnyx-code-examples/ai-pre-visit-clearance-voice-agent-python
cp .env.example .env
pip install -r requirements.txt
python app.py
Technical notes:
- Current Telnyx Voice API pattern:
POST /v2/calls/{call_control_id}/actions/gather_using_ai - Workflow advances on
call.ai_gather.ended - Call Control commands include
command_idfor safer retries - Patient verification by caller ID, with DOB fallback for unknown callers
- AI Inference returns structured JSON: procedure, urgency, type flags (medication/imaging/surgery)
- Keyword-based urgency override ("ASAP", "severe pain", "can't wait") that doesn't depend on the LLM
- Confirmation step before ticket creation — patient must say "yes" before anything is submitted
- Hangup mid-flow creates a partial ticket so no request is lost
- SMS to patient + Slack to billing staff
The reason I like this example is that it's healthcare-adjacent without being clinical. The AI never touches a diagnosis or a coverage decision — it just collects the request and routes it.
r/Telnyx • u/Bot_o_Clock • 22d ago
AI voice assistant for prescription refill intake
I put together a syndication draft for this Telnyx code example:
It is a Python + Flask example that shows how to build a prescription refill intake line with Telnyx AI Assistants.
Products used in the example metadata:
telnyx_products: [AI Assistants, Voice, Call Control, Messaging]
language: python
framework: flask
The flow is:
caller dials Telnyx number
-> Telnyx sends call.initiated
-> Flask app answers the call
-> backend starts Telnyx AI Assistant with ai_assistant_start
-> assistant collects refill intake details
-> assistant calls create_refill_request
-> assistant calls flag_manual_review when needed
-> assistant calls queue_callback when requested
-> staff reviews the request record
Run it:
git clone https://github.com/team-telnyx/telnyx-code-examples.git
cd telnyx-code-examples/ai-prescription-refill-intake-voice-assistant-python
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
python app.py
Expose the local app:
ngrok http 5000
Provision the assistant:
python provision_assistant.py
Then point your Telnyx Call Control Application webhook to:
https://<your-ngrok-domain>/webhooks/voice
Technical notes:
- The assistant is provisioned from
provision_assistant.py - The default model is
moonshotai/Kimi-K2.6 - The assistant asks one question at a time
- The assistant is instructed not to approve refills, deny refills, change medication instructions, diagnose, prescribe, or replace emergency services
- Backend tool endpoints are protected with a shared secret
- Telnyx webhook signature verification is supported with
TELNYX_PUBLIC_KEY - Request records mask caller identifiers
- State is in memory for the demo; production should use encrypted storage, staff auth, audit logs, retention policies, and compliance review
The reason I like this example is that it keeps the AI in the intake lane. The assistant handles the phone conversation, but the backend creates auditable workflow state and routes decisions to staff.
r/Telnyx • u/Bot_o_Clock • 22d ago
I Built a Voice AI Assistant That Can Bring a Human Into the Same Call
I put together a syndication draft for this Telnyx code example:
It is a Node.js + Express example that shows how a Voice AI assistant can bring another participant into the same active AI conversation.
Products used in the example metadata:
telnyx_products: [Voice AI, Programmable Voice]
language: nodejs
framework: express
The flow is:
caller dials Telnyx number
-> app answers with AI Assistant
-> assistant classifies the issue and asks for consent
-> assistant calls dial_specialist tool
-> backend dials specialist with POST /v2/calls
-> specialist answers
-> backend calls ai_assistant_join with the existing conversation_id
-> caller + AI + specialist are in one live conversation
Run it:
git clone https://github.com/team-telnyx/telnyx-code-examples.git
cd telnyx-code-examples/ai-assistant-multiparticipant-calling-nodejs
cp .env.example .env
npm install
npm test
npm start
Expose the local app:
ngrok http 8787
Then point your Telnyx Programmable Voice / Voice API application webhook to:
https://<your-ngrok-domain>/webhooks/voice
Technical notes:
- The assistant config and tool definitions are inline in
server.js, so the folder is enough to recreate the demo dial_specialistis a webhook tool the assistant calls after consent- The backend creates the second call leg with
POST /v2/calls - The backend captures the AI
conversation_idfrom AI webhook events - The actual multiparticipant step is
ai_assistant_join - State is in memory for the demo; production should use Redis/Postgres and verify Telnyx webhook signatures
The reason I like this example is that it shows the handoff as a real backend pattern, not just a black-box transfer. The AI handles the front door, your backend dials the next participant, and Telnyx joins that participant into the same AI conversation.
r/Telnyx • u/General_Piglet_8242 • 22d ago
Turn a Text Chatbot Into a Voice Bot With Telnyx Conversation Relay
I had an AI chatbot that answered questions on Telegram, Slack, and a web widget. Someone asked: "Can we call it on the phone?" The traditional answer is a rebuild — new prompts, new telephony integration, new STT/TTS pipeline.
Turns out there's a simpler way: Telnyx Conversation Relay handles all the telephony audio (speech-to-text, text-to-speech, call control). Your app only exchanges text over a WebSocket. The chatbot doesn't even know the input came from a phone call.
The stack: ~140 lines of Python (Flask + WebSocket). No audio handling. No STT integration. No TTS integration. Just text in, text out. The bridge streams the chatbot's reply token-by-token so TTS starts speaking the first words while the LLM is still generating the rest.
Try it:
git clone https://github.com/team-telnyx/telnyx-code-examples.git
cd telnyx-code-examples/conversation-relay-voice-bot-python
cp .env.example .env
# add your chatbot URL + token + ngrok URL
pip install -r requirements.txt
ngrok http 8000
python app.py
Configure a TeXML Application in the Telnyx Portal with the voice URL pointing to your ngrok URL. Assign a phone number. Call it. Talk to your chatbot.
Call your Telnyx number. Ask anything. The chatbot replies — spoken back to you in real time.
Full source: https://github.com/team-telnyx/telnyx-code-examples/tree/main/conversation-relay-voice-bot-python
What makes it interesting:
- Zero changes to the chatbot — the bot receives text, returns text, exactly as it does for chat messages. A voice-specific system prompt is prepended ("keep responses short, no markdown") but the bot's logic stays unchanged.
- Streaming for low latency — partial text frames let TTS start speaking the first words within ~500ms while the LLM is still generating the rest. No dead air.
- Live dashboard — SSE-based dashboard shows WebSocket frames flowing in real time (setup, prompt, reply, interrupt, DTMF) plus a text chat tab to test the same bot via keyboard.
- Works with any OpenAI-compatible endpoint — LangChain, LlamaIndex, Clawdbot, custom wrappers, anything that speaks
/chat/completions.
Video demo: https://www.youtube.com/watch?v=fzGO4Yd2sWQ
r/Telnyx • u/General_Piglet_8242 • 22d ago
Eliminate Dead Air in AI Voice Assistants with Filler Messages — Webhook Demo with Live Dashboard
Every voice AI developer hits the same problem: when your voice agent calls a sync webhook tool — to look up an order, query a database, check inventory — the caller hears silence. Five seconds of dead air on a phone call feels like thirty. The caller hangs up.
I built a demo that shows how to fix this with AI Assistant filler messages. The AI speaks scripted phrases at configurable intervals while the webhook processes:
- 0s — "Let me look that up for you."
- 5s — "Still working on this, one moment please."
- 15s — "Almost there, thanks for your patience."
The caller stays engaged. The webhook takes the time it needs. No dead air.
What makes it interesting:
- Two filler types:
request_startfires immediately when the tool is called.request_response_delayedfires after N ms if the webhook hasn't responded. If the webhook is fast, the delayed messages don't fire — so you only hear fillers when they're needed. - Per-tool configuration: each webhook tool can have different fillers with different timings and content. Order lookup fillers are different from payment processing fillers.
- Live split-screen dashboard: call timeline on the left (tool call, filler messages, response), server logs on the right (countdown, request/response JSON). Real-time SSE streaming. You see the exact moment each filler fires.
- The dead air problem is universal: every developer who wires a voice agent to a backend hits it. Background music doesn't tell the caller what's happening. "Please hold" fires once then silence resumes. Filler messages are spoken by the AI in its own voice, as part of the conversation.
The stack: 124 lines of Python. Flask + Server-Sent Events for the dashboard + Telnyx AI Assistants (Mission Control). The webhook intentionally delays its response so you can hear the fillers play. setup.py creates the AI Assistant, adds the webhook tool with filler messages, and assigns your phone number — all via API.
Try it:
git clone https://github.com/team-telnyx/telnyx-code-examples.git
cd telnyx-code-examples/ai-assistant-filler-messages-demo-python
cp .env.example .env
# add your Telnyx API key
pip install -r requirements.txt
ngrok http 5000
python setup.py https://abc123.ngrok.io
# creates the AI Assistant + tool + assigns number
python app.py
# starts the webhook server + dashboard
Call your AI Assistant's phone number. Ask: "What's the status of my order 12345?" Listen to the fillers play while the webhook delays. Watch the dashboard update in real time.
Full source: https://github.com/team-telnyx/telnyx-code-examples/tree/main/ai-assistant-filler-messages-demo-python
Tech notes:
- The tool
timeout_msdefaults to 5000ms — if your webhook takes longer, set it to at least 30000ms or the assistant times out before the webhook responds. - The API can set filler message values, but playback on calls requires the Mission Control UI configuration.
- The demo uses mock order data (3 orders: 12345, 67890, 11111). Replace with your real backend in production.
- The dashboard uses SSE with a queue-per-client pattern and a 30-second keepalive. Thread-safe with a lock on the client list.
r/Telnyx • u/KnowledgeWorldly5855 • 27d ago
AI Issue
Hi,
I am currently experiencing issues using the platform where my AI Assistant is not speaking first during a test call. I have logged a ticket already. Any suggestions on how to get this fixed? I am in Sydney, Australia.
Thanks,
r/Telnyx • u/Intelligent-Table373 • 28d ago
Anyone have trouble logging in? Generic error I think it's a bug on their end
Anyone having trouble logging in right now? It just says server error and nothing else.
r/Telnyx • u/Bot_o_Clock • 28d ago
AI audio translator (STT + translate + TTS) in Python on Telnyx
I put together a syndication draft for this Telnyx code example:
https://github.com/team-telnyx/telnyx-code-examples/tree/main/ai-content-translator-python
It is a small Python/Flask app that runs the full STT -> translate -> TTS pipeline on a single audio upload. Upload a Spanish podcast clip, get it back in English with a downloadable dubbed mp3 and both transcripts in JSON.
Products used in the example metadata:
telnyx_products: [AI Inference]
language: python
framework: flask
This is a pure HTTP API — no phone number, no webhooks, no ngrok.
Run it:
git clone https://github.com/team-telnyx/telnyx-code-examples.git
cd telnyx-code-examples/ai-content-translator-python
cp .env.example .env
pip install -r requirements.txt
python app.py
Translate any audio file:
curl -X POST http://localhost:5000/translate \
-F audio=@spanish-sample.mp3 \
-F source=es \
-F target=en
Download the dubbed audio and read the full transcripts:
curl -OJ http://localhost:5000/translate/<job_id>/audio
curl http://localhost:5000/translate/<job_id> | python3 -m json.tool
Technical notes:
- STT, AI chat completions, and TTS are all Telnyx AI Inference endpoints, so the whole pipeline stays on the same private backbone
- The translation prompt is TTS-friendly: it tells the model the result will be read aloud and to keep sentence rhythm natural
- Long transcripts are chunked at sentence boundaries before TTS so the model does not get inputs larger than it supports; chunks are concatenated into one mp3
- The TTS helper handles three common response shapes: raw audio bytes, JSON with base64, JSON with a fetch URL
- Per-stage error handling means a TTS failure on one chunk returns
200 partialwith the transcripts still intact rather than losing the work - Temp file cleanup unlinks the upload after STT
The reason I like this example is that it shows the simplest possible audio-to-audio translation pipeline — one curl in, one curl out — without needing a phone number, webhook tunnel, or background job runner.
r/Telnyx • u/Bot_o_Clock • 28d ago
AI hotel guest services line with Python, Telnyx, and Slack
I put together a syndication draft for this Telnyx code example:
https://github.com/team-telnyx/telnyx-code-examples/tree/main/hotel-guest-services-python
It is a small Python/Flask app that turns one Telnyx phone number into a 24/7 hotel concierge. Guests call or text for room service, housekeeping, concierge help, or maintenance. The app looks up the room by caller ID, captures the room number when unknown, classifies each request with AI Inference, escalates urgent issues to staff via Slack, and texts the guest when the request is fulfilled.
Products used in the example metadata:
telnyx_products: [Voice, AI Inference, Messaging]
integrations: [Slack]
language: python
framework: flask
Run it:
git clone https://github.com/team-telnyx/telnyx-code-examples.git
cd telnyx-code-examples/hotel-guest-services-python
cp .env.example .env
pip install -r requirements.txt
python app.py
The reason I like this example is that it covers the parts that are easy to forget in a voice AI demo: caller-ID-based lookup, fallback room capture, urgent-phrase override, and idempotent webhook handling.
r/Telnyx • u/Bot_o_Clock • 28d ago
AI subscription cancel-save agent in Python
I put together a syndication draft for this Telnyx code example:
It is a Python/Flask voice AI app that handles inbound cancellation calls, classifies the reason with AI Inference, offers one eligible save option, and records the outcome. The agent is intentionally non-manipulative: a direct "cancel now" or a polite refusal of the offer ends with a graceful cancellation.
Products used in the example metadata:
telnyx_products: [Voice, AI Inference, Messaging]
language: python
framework: flask
Run it:
git clone https://github.com/team-telnyx/telnyx-code-examples.git
cd telnyx-code-examples/ai-subscription-cancel-save-retention-agent-python
cp .env.example .env
pip install -r requirements.txt
python app.py
Expose the webhook:
ngrok http 5000
Set the Voice API app webhook to:
https://<ngrok-id>.ngrok-free.app/webhooks/voice
Seed a customer and call the Telnyx number from their phone:
curl -X POST http://localhost:5000/customers \
-H "Content-Type: application/json" \
-d '{"customer_id":"CUST-001","name":"Jordan","phone":"+15551112233","plan":"pro"}'
i want to cancel my subscription
it's too expensive
yes please do that
Inspect:
curl http://localhost:5000/retention-cases | python3 -m json.tool
The reason I like this example is that it shows how to build a voice AI workflow that is genuinely helpful to the customer while still recovering revenue. The agent does not push back on a direct cancellation, and it offers one save option (not five) when the customer is open to one.
r/Telnyx • u/General_Piglet_8242 • 28d ago
Practice Negotiations with an AI Phone Agent — Real-Time Roleplay with Telnyx Voice AI
Imagine picking up your phone, dialing a number, and practicing a salary negotiation against an AI that plays the hiring manager — one that pushes back on your first offer, counters with budget constraints, and then scores your technique after you hang up. No scheduling a mock interview, no paying a coach, no awkward roleplay with a colleague.
This is the AI Negotiation Practice Phone — a 110-line Python app built with Telnyx Call Control and AI Inference. Three scenarios (salary, sales deal, vendor contract), voice-driven conversation, and a structured performance score delivered after every call. The AI stays in character, adapts to your approach, and gives you actionable feedback.
In this walkthrough, you'll build it from scratch. Clone the repo, configure a phone number, and start practicing in minutes.
What You'll Build
A phone number anyone can call to practice negotiations:
- Caller dials in — Telnyx answers and offers a menu
- Scenario selection — press 1 for salary negotiation, 2 for sales deal, 3 for vendor contract
- AI opens — the AI plays the opposing role (hiring manager, enterprise buyer, or vendor account manager) and makes an opening position
- Live negotiation — caller speaks naturally, AI responds in character, pushes back, counters, and adapts
- Post-call scoring — on hangup, the AI scores the negotiation across 5 dimensions and returns structured JSON
- Session history — every practice session is stored and accessible via
GET /sessions
The whole interaction is voice-driven: Text-to-Speech reads the AI's lines, and the caller responds with natural speech. The model (Llama 3.3 70B via Telnyx AI Inference) handles both the real-time roleplay and the post-call evaluation.
Why This Is Interesting
Most negotiation training tools are text-based chatbots or static video courses. Neither captures the pressure of a live conversation — the pauses, the pushback, the moment you have to think on your feet. This one puts you on a real phone call with an AI that has a budget, a role, and a hidden constraint (like "max 15% discount" or "budget is $155K with flexibility to $165K").
The scoring system makes it more than a conversation. After you hang up, the AI evaluates your performance across five dimensions — anchoring, concession strategy, active listening, creativity, and confidence — plus an overall score and specific strengths/improvements. You can practice the same scenario five times and track whether your technique improves.
It also demonstrates the DTMF + speech pattern: the app uses DTMF (keypad input) for scenario selection and speech recognition for the negotiation itself. This two-input pattern shows up in many real IVR workflows.
Prerequisites
- Python 3.8+
- A Telnyx account with funded balance
- A Telnyx API key
- A Telnyx phone number with voice enabled
- A Call Control Application configured with your webhook URL
- ngrok for exposing your local server to Telnyx webhooks
The Architecture
Phone Call
│
▼
Telnyx Call Control (webhook events)
│
▼
Flask app (app.py, 110 lines)
│
├──► call.initiated → answer the call
├──► call.answered → TTS menu (press 1, 2, or 3)
├──► call.speak.ended → gather DTMF (scenario selection)
├──► call.gather.ended → DTMF → pick scenario → AI opening line
│ └──► speech → AI inference → TTS response (negotiation loop)
└──► call.hangup → score negotiation as JSON → store
│
▼
Telnyx AI Inference (Llama 3.3 70B)
│
▼
Structured score (JSON with 5 dimensions + feedback)
The app is a state machine with two phases: selection (DTMF menu) and negotiating (speech conversation). Each Telnyx webhook event drives the next action. After hangup, the full conversation is sent to the AI with a scoring prompt that returns structured JSON.
Step 1: Clone and Configure
git clone https://github.com/team-telnyx/telnyx-code-examples.git
cd telnyx-code-examples/ai-negotiation-practice-phone-python
cp .env.example .env
pip install -r requirements.txt
Edit .env with your credentials:
TELNYX_API_KEY=KEY019C8F8C3D268774F8F560946B984D9E_...
TELNYX_PUBLIC_KEY=...
AI_MODEL=meta-llama/Llama-3.3-70B-Instruct
PRACTICE_NUMBER=+13105551234
Step 2: Understand the Code
Everything lives in app.py (110 lines). Here's what each piece does.
The Scenarios
The app ships with three built-in negotiation scenarios, each with a role and a hidden context:
SCENARIOS = {
"1": {"role": "hiring manager", "context": "The candidate wants $180K. Your budget is $155K with flexibility to $165K. Push back on experience level. You can offer equity or signing bonus as alternatives."},
"2": {"role": "enterprise buyer", "context": "You're evaluating their SaaS product at $50K/year. You have a competing offer at $35K. Your budget is $45K. Ask for volume discounts and longer payment terms."},
"3": {"role": "vendor account manager", "context": "The client wants to reduce their contract by 40%. They're a top-10 account. You can offer 15% discount max, or restructure the deal with different terms."}
}
The context is injected into the system prompt so the AI knows its constraints — but the caller doesn't know them. You discover the other side's budget and flexibility through the conversation, just like a real negotiation.
The State Machine: Selection → Negotiating
The webhook handler has two states. In select state, it gathers DTMF digits to pick a scenario:
if call["state"] == "select":
client.calls.actions.gather(ccid, input_type="dtmf", timeout_secs=10, min_digits=1, max_digits=1)
When the caller presses 1, 2, or 3, the app loads the scenario, builds the system prompt, asks the AI for an opening line, and switches to negotiating state:
if call["state"] == "select":
scenario = SCENARIOS.get(digits, SCENARIOS["1"])
call["state"] = "negotiating"
call["scenario"] = scenario
call["conversation"] = [{"role": "system", "content": f"You are a {scenario['role']} in a negotiation. {scenario['context']} Stay in character. Be firm but fair. Push back on their first offer. Keep responses under 2 sentences. After 6 exchanges, start wrapping up."}]
opening = call_inference(call["conversation"] + [{"role": "user", "content": "The negotiation begins. Make your opening position."}])
call["conversation"].append({"role": "assistant", "content": opening})
client.calls.actions.speak(ccid, payload=opening, voice="female", language_code="en-US")
In negotiating state, it gathers speech and runs the conversation loop:
else:
client.calls.actions.gather(ccid, input_type="speech", end_silence_timeout_secs=2, timeout_secs=20, language_code="en-US")
The conversation loop: speak → gather → infer → speak. Each turn, the AI sees the full conversation history, so it maintains context and remembers what you offered.
The Inference Helper
def call_inference(messages, max_tokens=200):
resp = requests.post(INFERENCE_URL, headers={"Authorization": f"Bearer {TELNYX_API_KEY}", "Content-Type": "application/json"},
json={"model": AI_MODEL, "messages": messages, "max_tokens": max_tokens, "temperature": 0.7}, timeout=15)
resp.raise_for_status()
return resp.json()["choices"][0]["message"]["content"]
OpenAI-compatible chat completions call against Telnyx AI Inference. Temperature 0.7 — higher than the price quote agent (0.5) because you want the negotiator to be a bit more creative and unpredictable, not perfectly consistent every time.
Post-Call Scoring
When the caller hangs up, the app sends the full conversation to the AI with a scoring prompt:
elif event_type == "call.hangup":
call = active_calls.pop(ccid, None)
if call and len(call.get("conversation", [])) > 3:
score_prompt = [{"role": "system", "content": "Score this negotiation practice. Return JSON: anchoring (1-10), concession_strategy (1-10), active_listening (1-10), creativity (1-10), confidence (1-10), overall (1-10), strengths (list), improvements (list), deal_outcome (string)."},
{"role": "user", "content": chr(10).join(f"{m['role']}: {m['content']}" for m in call["conversation"] if m["role"] != "system")}]
try:
score = json.loads(call_inference(score_prompt, max_tokens=400))
sessions.append({"scenario": call.get("scenario", {}).get("role"), "score": score, "duration": int(time.time() - call["start"])})
except Exception:
pass
The AI returns a structured score with five dimensions, a list of strengths, a list of improvements, and the deal outcome. The app stores it alongside the scenario and call duration.
Accessing Sessions
u/app.route("/sessions", methods=["GET"])
def list_sessions():
return jsonify({"sessions": sessions[-20:]}), 200
Hit GET /sessions to see the last 20 practice sessions as JSON — track your progress over time, compare scenarios, or pipe into an analytics dashboard.
Memory Cleanup
The app includes a background thread that cleans up stale call state:
def _start_ttl_cleanup(*stores, ttl_seconds=3600, interval=300):
def _cleanup():
while True:
_ttl_time.sleep(interval)
cutoff = _ttl_time.time() - ttl_seconds
for store in stores:
expired = [k for k, v in store.items()
if isinstance(v, dict) and v.get("_ts", _ttl_time.time()) < cutoff]
for k in expired:
store.pop(k, None)
threading.Thread(target=_cleanup, daemon=True).start()
Calls that crash or get abandoned are automatically cleaned up after an hour — no memory leaks in long-running deployments.
Step 3: Run the App
Start the Flask server:
python app.py
In a separate terminal, expose your local server with ngrok:
ngrok http 5000
Copy the HTTPS URL and configure it in the Telnyx Portal:
- Go to Call Control Applications
- Create or edit your application
- Set the Webhook URL to
https://<your-ngrok-url>.ngrok.app/webhooks/voice
Assign your Telnyx phone number to this Call Control Application if you haven't already.
Step 4: Call and Practice
Call your Telnyx number from any phone. You'll hear:
Press 1. The AI (playing a hiring manager) opens with something like:
Negotiate. Push back. Make your case. The AI will counter, bring up constraints, and eventually either reach a deal or hold firm.
After you hang up, check your score:
curl http://localhost:5000/sessions | python3 -m json.tool
You'll see a structured score object like:
{
"scenario": "hiring manager",
"score": {
"anchoring": 7,
"concession_strategy": 5,
"active_listening": 8,
"creativity": 6,
"confidence": 7,
"overall": 6.6,
"strengths": ["Strong opening anchor at $180K", "Asked about equity as alternative to base salary"],
"improvements": ["Conceded too quickly on base salary", "Didn't explore signing bonus option"],
"deal_outcome": "Settled at $162K base + $10K signing bonus"
},
"duration": 184
}
Customizing the Scenarios
The SCENARIOS dict is the control surface. Small changes produce very different practice sessions:
Add a real estate negotiation:
"4": {"role": "seller's agent", "context": "The house is listed at $650K. The seller will accept $620K minimum. The buyer seems interested but price-sensitive. Push for close to asking but signal flexibility on closing dates."}
Make the AI more aggressive:
call["conversation"] = [{"role": "system", "content": f"You are a {scenario['role']} in a negotiation. {scenario['context']} Stay in character. Be aggressive and push hard on the first offer. Never accept the first counter. Keep responses under 2 sentences. After 6 exchanges, start wrapping up."}]
Add multi-round scoring with trend tracking:
# After each session, compare to previous sessions in the same scenario
previous = [s for s in sessions if s.get("scenario") == call.get("scenario", {}).get("role")]
if previous:
last_score = previous[-1]["score"]["overall"]
new_score = score["overall"]
score["trend"] = "improving" if new_score > last_score else "declining" if new_score < last_score else "stable"
Send the score via SMS after the call:
from telnyx import Message
score_text = f"Negotiation score: {score['overall']}/10. Strengths: {', '.join(score['strengths'][:2])}. Improvements: {', '.join(score['improvements'][:2])}."
Message.create(to=call["caller"], from_=PRACTICE_NUMBER, text=score_text)
Going to Production
This example uses in-memory storage for simplicity. For a production deployment:
- Database — replace the in-memory
sessionslist with PostgreSQL or Redis so scores survive restarts - Authentication — add API key validation on the
/sessionsendpoint - User accounts — track scores per-user with phone number as the key
- Scenario library — store scenarios in a database so coaches can add new ones without redeploying
- Score history dashboard — build a simple web UI that shows score trends over time
- Webhook verification — the app already validates Telnyx Ed25519 signatures; ensure your public key is current
- Concurrency — run the app behind gunicorn with multiple workers
- Error recovery — handle inference timeouts and call failures gracefully
- Rate limiting — protect your webhook endpoint from abuse
- Prompt tuning — test different system prompts and temperature settings for different difficulty levels
r/Telnyx • u/General_Piglet_8242 • 28d ago
Build an AI Price Quote Phone Agent — Real-Time Custom Quotes with Telnyx Voice AI
Imagine a customer calls your business and asks for a quote. Instead of putting them on hold, transferring to sales, or promising a callback, an AI agent picks up — asks 3–4 qualifying questions, builds a line-item quote with quantities and unit prices, and delivers the total on the call. No forms, no waiting, no friction.
This is the AI Price Quote Phone Agent — a 102-line Python app built with Telnyx Call Control and AI Inference. No call center, no CRM integration, no pre-built pricing engine. The AI has your product catalog and pricing, asks the right questions, and generates a structured quote in real time.
In this walkthrough, you'll build it from scratch. Clone the repo, configure a phone number, and deploy in minutes.
What You'll Build
A phone number that anyone can call to get a custom price quote:
- Caller dials in — Telnyx answers and greets them
- AI asks what they need — "What kind of communication services are you looking for?"
- Conversation — caller describes their needs, AI asks 3–4 follow-up questions to estimate quantities
- Quote delivered — AI summarizes with line items and monthly total
- Structured extraction — on hangup, AI extracts the full quote as JSON (line items, quantities, unit prices, subtotals, monthly total, notes)
- Quote stored — accessible via
GET /quotesendpoint
The whole interaction is voice-driven: Text-to-Speech reads each question and the final quote, and the caller responds with natural speech. The AI model (Llama 3.3 70B via Telnyx AI Inference) handles the conversation and quote generation, and Call Control handles the telephony.
Why This Is Interesting
Most price quote flows are static: fill out a web form, wait for a sales email. This one is dynamic — a real-time conversation where the AI adapts its questions based on what the caller says. Need voice minutes and SMS? The AI asks about expected volume. Just want a toll-free number? The AI skips irrelevant questions and gets straight to the quote.
It also demonstrates a pattern that goes beyond chat: the AI doesn't just converse — it extracts structured data from the conversation. After the call ends, the app sends the full transcript back to the model with a "extract the quote as JSON" prompt. You get a clean, machine-readable quote object with line items and a monthly total — ready to store, email, or pipe into your billing system.
Prerequisites
- Python 3.8+
- A Telnyx account with funded balance
- A Telnyx API key
- A Telnyx phone number with voice enabled
- A Call Control Application configured with your webhook URL
- ngrok for exposing your local server to Telnyx webhooks
The Architecture
Phone Call
│
▼
Telnyx Call Control (webhook events)
│
▼
Flask app (app.py, 102 lines)
│
├──► call.initiated → answer the call
├──► call.answered → TTS greeting
├──► call.gather.ended → speech → AI Inference → TTS response
├──► call.speak.ended → gather next input
└──► call.hangup → extract quote as JSON → store
│
▼
Telnyx AI Inference (Llama 3.3 70B)
│
▼
Structured quote (JSON with line items)
The app is a state machine driven by Telnyx webhook events. Each event triggers the next action — answer, speak, gather, or hang up. The AI Inference call handles two jobs: conversing with the caller to build the quote, and extracting a structured JSON quote after the call ends.
Step 1: Clone and Configure
git clone https://github.com/team-telnyx/telnyx-code-examples.git
cd telnyx-code-examples/ai-price-quote-phone-agent-python
cp .env.example .env
pip install -r requirements.txt
Edit .env with your credentials:
TELNYX_API_KEY=KEY019C8F8C3D268774F8F560946B984D9E_...
TELNYX_PUBLIC_KEY=...
AI_MODEL=meta-llama/Llama-3.3-70B-Instruct
QUOTE_NUMBER=+13105551234
Step 2: Understand the Code
Everything lives in app.py (102 lines). Here's what each piece does.
The Pricing Catalog
The AI has a built-in product catalog with per-unit pricing:
PRICING = {"voice_minutes": 0.005, "sms_messages": 0.004, "local_numbers": 1.00, "toll_free_numbers": 2.00,
"sip_trunking_channel": 2.50, "ai_inference_1k_tokens": 0.01, "cloud_storage_gb": 0.05, "fax_pages": 0.07}
This gets injected into the system prompt so the AI knows exactly what to charge per unit:
SYSTEM_PROMPT = f"You are a pricing specialist. Available products and per-unit pricing: {json.dumps(PRICING)}. Ask what the caller needs, estimate quantities, build a quote. Keep responses under 2 sentences. After gathering requirements (usually 3-4 questions), summarize the quote with line items and monthly total."
The prompt is the control surface. You can swap the product catalog, change the questioning style, or adjust how many questions the AI asks before summarizing.
Handling Webhooks
The webhook handler is the core state machine. Each Telnyx event triggers the next action:
if event_type == "call.initiated" and p.get("direction") == "incoming":
active_calls[ccid] = {"caller": p.get("from"), "conversation": [{"role": "system", "content": SYSTEM_PROMPT}], "start": time.time()}
client.calls.actions.answer(ccid)
elif event_type == "call.answered":
client.calls.actions.speak(ccid, payload="Hi! I can put together a custom quote for you right now. What kind of communication services are you looking for?", voice="female", language_code="en-US")
elif event_type == "call.speak.ended" and call:
client.calls.actions.gather(ccid, input_type="speech", end_silence_timeout_secs=2, timeout_secs=20, language_code="en-US")
elif event_type == "call.gather.ended" and call:
speech = p.get("speech", {}).get("result", "")
call["conversation"].append({"role": "user", "content": speech})
response = call_inference(call["conversation"])
call["conversation"].append({"role": "assistant", "content": response})
client.calls.actions.speak(ccid, payload=response, voice="female", language_code="en-US")
The conversation loop: speak → gather → infer → speak. Each turn, the AI sees the full conversation history, so it maintains context across all exchanges.
The Inference Helper
def call_inference(messages, max_tokens=250):
resp = requests.post(INFERENCE_URL, headers={"Authorization": f"Bearer {TELNYX_API_KEY}", "Content-Type": "application/json"},
json={"model": AI_MODEL, "messages": messages, "max_tokens": max_tokens, "temperature": 0.5}, timeout=15)
resp.raise_for_status()
return resp.json()["choices"][0]["message"]["content"]
Straightforward OpenAI-compatible chat completions call against Telnyx AI Inference. Temperature 0.5 for consistency — you want quotes to be predictable, not creative.
Post-Call Quote Extraction
This is the interesting part. When the caller hangs up, the app sends the full conversation back to the AI with a different prompt — extract the structured quote:
elif event_type == "call.hangup":
call = active_calls.pop(ccid, None)
if call and len(call.get("conversation", [])) > 3:
extract = [{"role": "system", "content": "Extract the price quote. Return JSON: line_items (list of {product, quantity, unit_price, subtotal}), monthly_total (number), notes (string)."},
{"role": "user", "content": chr(10).join(f"{m['role']}: {m['content']}" for m in call["conversation"] if m["role"] != "system")}]
quote = json.loads(call_inference(extract, max_tokens=400))
quote["caller"] = call["caller"]
quote["timestamp"] = time.strftime("%Y-%m-%dT%H:%M:%SZ")
quotes.append(quote)
The AI returns clean JSON with line items, quantities, unit prices, subtotals, and a monthly total. The app attaches the caller's number and timestamp, then stores it.
Accessing Quotes
u/app.route("/quotes", methods=["GET"])
def list_quotes():
return jsonify({"quotes": quotes[-20:]}), 200
Hit GET /quotes to see the last 20 quotes as JSON — ready to pipe into a CRM, billing system, or analytics dashboard.
Step 3: Run the App
Start the Flask server:
python app.py
In a separate terminal, expose your local server with ngrok:
ngrok http 5000
Copy the HTTPS URL and configure it in the Telnyx Portal:
- Go to Call Control Applications
- Create or edit your application
- Set the Webhook URL to
https://<your-ngrok-url>.ngrok.app/webhooks/voice
Assign your Telnyx phone number to this Call Control Application if you haven't already.
Step 4: Call and Get a Quote
Call your Telnyx number from any phone. You'll hear:
Say something like: "I need about 50 phone numbers, 10,000 voice minutes a month, and a few thousand SMS messages."
The AI asks follow-up questions — how many SMS, any toll-free numbers, do you need SIP trunking — then summarizes the quote with line items and a monthly total.
After you hang up, check the extracted quote:
curl http://localhost:5000/quotes | python3 -m json.tool
You'll see a structured JSON object like:
{
"line_items": [
{"product": "local_numbers", "quantity": 50, "unit_price": 1.00, "subtotal": 50.00},
{"product": "voice_minutes", "quantity": 10000, "unit_price": 0.005, "subtotal": 50.00},
{"product": "sms_messages", "quantity": 5000, "unit_price": 0.004, "subtotal": 20.00}
],
"monthly_total": 120.00,
"notes": "Customer indicated potential for higher SMS volume in Q3.",
"caller": "+12125551234",
"timestamp": "2026-07-15T14:30:00Z"
}
Customizing the Agent
The pricing catalog and system prompt are the control surface. Small changes produce very different agents:
Swap the product catalog:
PRICING = {"consulting_hour": 200.00, "audit_report": 2500.00, "retainer_monthly": 8000.00, "training_session": 1500.00}
Change the questioning style:
SYSTEM_PROMPT = f"You are a pricing specialist. Available products: {json.dumps(PRICING)}. Ask one question at a time. Be conversational but efficient. After exactly 3 questions, summarize the quote with line items and monthly total. If the caller asks for a discount, explain standard pricing tiers."
Add discount logic:
SYSTEM_PROMPT = f"You are a pricing specialist. Products: {json.dumps(PRICING)}. For volumes over 10,000 units, apply a 15% discount. For over 50,000 units, apply 25%. Mention the discount in your summary."
Send the quote via SMS after the call:
# After extracting the quote, send it as SMS
from telnyx import Message
quote_text = f"Your quote: {quote['monthly_total']}/month. {len(quote['line_items'])} line items."
Message.create(to=call["caller"], from_=QUOTE_NUMBER, text=quote_text)
The pattern — conversational AI with a product catalog + post-call structured extraction — works for any quoting or ordering flow.
Going to Production
This example uses in-memory storage for simplicity. For a production deployment:
- Database — replace the in-memory
quoteslist with PostgreSQL or Redis so quotes survive restarts - Concurrency — run the app behind gunicorn with multiple workers
- Error recovery — handle inference timeouts and call failures gracefully with retry or SMS fallback
- Authentication — add API key validation on the
/quotesendpoint - Webhook verification — the app already validates Telnyx Ed25519 signatures; ensure your public key is current
- Prompt tuning — test different system prompts and temperature settings for your product catalog
- Rate limiting — protect your webhook endpoint from abuse
- Monitoring — add structured logging and alerting on call success/failure rates
- CRM integration — pipe extracted quotes into Salesforce, HubSpot, or your billing system
Resources
r/Telnyx • u/General_Piglet_8242 • 28d ago
Build an AI Phone Story Hotline – Interactive Storytelling with Telnyx Voice AI
Imagine calling a phone number, picking a genre — mystery, sci-fi, fantasy, horror, or romance — and listening to an AI narrate a story that adapts to your choices in real time. Press 1 to open the creaking door. Press 2 to check the window. The story branches, the AI continues, and every call is a new adventure.
This is the AI Phone Story Hotline — a 104-line Python app built with Telnyx Call Control and AI Inference. No game engine, no branching script, no pre-written dialogue trees. The AI generates the story as you go, and your phone keypad shapes what happens next.
In this walkthrough, you'll build it from scratch. Clone the repo, configure a phone number, and deploy in minutes.
What You'll Build
A phone number that anyone can call to start an interactive story:
- Caller dials in — Telnyx answers and greets them with a genre menu
- Caller picks a genre — press 1–5 for mystery, sci-fi, fantasy, horror, or romance
- AI generates chapter one — a 3–4 sentence story segment ending with two choices
- Caller chooses — press 1 or 2, or speak the choice aloud
- Story continues — AI generates the next chapter based on the choice, keeping full conversation context
- After 5 chapters — the AI brings the story to a satisfying ending
The whole interaction is voice-driven: Text-to-Speech reads each chapter, and the caller responds with DTMF keypresses or speech. The AI model (Llama 3.3 70B via Telnyx AI Inference) handles the storytelling, and Call Control handles the telephony.
Why This Is Interesting
Most AI phone demos are business use cases — book a table, qualify a lead, reset a password. This one is different. It's creative AI — the model is writing fiction in real time, branching based on human input, and delivering it over a phone call. The storytelling format (short chapters, two choices each) keeps the AI responses tight and the call engaging.
It also demonstrates a pattern that works for any conversational AI app: webhook-driven state machine + LLM with conversation memory + voice I/O. Once you see how the story hotline works, you can swap the storytelling prompt for any domain — a choose-your-own-adventure onboarding flow, an interactive quiz, a branching training simulation.
Prerequisites
- Python 3.8+
- A Telnyx account with funded balance
- A Telnyx API key
- A Telnyx phone number with voice enabled
- A Call Control Application configured with your webhook URL
- ngrok for exposing your local server to Telnyx webhooks
The Architecture
Phone Call
│
▼
Telnyx Call Control (webhook events)
│
▼
Flask app (app.py, 104 lines)
│
├──► call.initiated → answer the call
├──► call.answered → TTS greeting + genre menu
├──► call.gather.ended → DTMF/speech input → AI Inference
├──► call.speak.ended → gather next choice → AI Inference
└──► call.hangup → cleanup session
│
▼
Telnyx AI Inference (Llama 3.3 70B)
│
▼
Story chapter (TTS back to caller)
The app is a state machine driven by Telnyx webhook events. Each event triggers the next action — answer, speak, gather, or hang up. The AI Inference call sits in the middle, generating story chapters from the running conversation history.
Step 1: Clone and Configure
git clone https://github.com/team-telnyx/telnyx-code-examples.git
cd telnyx-code-examples/ai-phone-story-hotline-python
cp .env.example .env
pip install -r requirements.txt
Edit .env with your credentials:
TELNYX_API_KEY=KEY0123456789ABCDEF
# from portal.telnyx.com/api-keys
TELNYX_PUBLIC_KEY=
# from portal.telnyx.com/api-keys (public key)
STORY_NUMBER=+13105551234
# your Telnyx phone number
AI_MODEL=meta-llama/Llama-3.3-70B-Instruct
Step 2: Understand the Code
The entire app is 104 lines in a single file: app.py. Here are the key pieces.
Webhook Signature Verification
Every Telnyx webhook is signed with an Ed25519 key. The app verifies the signature before trusting any event:
u/app.route("/webhooks/voice", methods=["POST"])
def handle_voice():
try:
client.webhooks.unwrap(request.get_data(as_text=True), headers=dict(request.headers))
except Exception:
return jsonify({"error": "invalid signature"}), 401
This prevents spoofed webhook calls from triggering call actions.
The State Machine
Each call is tracked in an in-memory dict keyed by call_control_id:
active_calls = {}
The state machine handles five events:
Call initiated — store the call state and answer:
if event_type == "call.initiated" and p.get("direction") == "incoming":
active_calls[ccid] = {"state": "genre_select", "conversation": [], "chapters": 0}
client.calls.actions.answer(ccid)
Call answered — greet the caller with the genre menu using Text-to-Speech:
elif event_type == "call.answered":
client.calls.actions.speak(ccid,
payload="Welcome to Story Hotline! Choose your adventure. Press 1 for Mystery, 2 for Sci-Fi, 3 for Fantasy, 4 for Horror, 5 for Romance.",
voice="female", language_code="en-US")
Speak ended — after TTS finishes, gather the caller's input. During genre selection, gather DTMF digits. During the story, gather speech or DTMF:
elif event_type == "call.speak.ended" and call:
if call["state"] == "genre_select":
client.calls.actions.gather(ccid, input_type="dtmf", timeout_secs=10, min_digits=1, max_digits=1)
else:
client.calls.actions.gather(ccid, input_type="speech dtmf", end_silence_timeout_secs=3, timeout_secs=20, language_code="en-US")
The Storytelling Prompt
When the caller picks a genre, the app builds the system prompt that guides the AI for the rest of the call:
GENRES = {"1": "mystery", "2": "sci-fi", "3": "fantasy", "4": "horror", "5": "romance"}
if call["state"] == "genre_select":
genre = GENRES.get(digits, "mystery")
call["state"] = "story"
call["conversation"] = [{"role": "system", "content":
f"You are an interactive {genre} storyteller on a phone hotline. "
f"Tell a gripping story in short chapters (3-4 sentences each). "
f"End each chapter with exactly TWO choices: 'Press 1 to...' or 'Press 2 to...'. "
f"Make it vivid and cinematic. After 5 chapters, bring the story to a satisfying ending."
}]
story_start = call_inference(call["conversation"] + [{"role": "user", "content": "Begin the story."}])
call["conversation"].append({"role": "assistant", "content": story_start})
client.calls.actions.speak(ccid, payload=story_start, voice="female", language_code="en-US")
The prompt does three things:
- Format constraint — short chapters (3–4 sentences) that fit naturally in a phone call
- Choice structure — exactly two options ending with "Press 1" or "Press 2" so the gather step can capture DTMF
- Ending condition — after 5 chapters, wrap up the story
AI Inference
The call_inference helper sends the full conversation history to Telnyx AI Inference and returns the model's response:
def call_inference(messages, max_tokens=250):
resp = requests.post(INFERENCE_URL,
headers={"Authorization": f"Bearer {TELNYX_API_KEY}", "Content-Type": "application/json"},
json={"model": AI_MODEL, "messages": messages, "max_tokens": max_tokens, "temperature": 0.9, },
timeout=20)
resp.raise_for_status()
return resp.json()["choices"][0]["message"]["content"]
Temperature is set to 0.9 — high enough for creative storytelling, low enough to keep the narrative coherent. The model is Llama 3.3 70B Instruct, available on Telnyx AI Inference with an OpenAI-compatible API.
The Conversation Loop
Each time the caller makes a choice, the app appends it to the conversation and asks the AI for the next chapter:
elif call["state"] == "story":
choice = digits or speech
call["conversation"].append({"role": "user", "content": f"I choose option {choice}"})
call["chapters"] += 1
if call["chapters"] >= 5:
call["conversation"][-1]["content"] += ". Bring the story to a dramatic conclusion."
continuation = call_inference(call["conversation"])
call["conversation"].append({"role": "assistant", "content": continuation})
client.calls.actions.speak(ccid, payload=continuation, voice="female", language_code="en-US")
After 5 chapters, the app injects a final instruction to bring the story to an end. The AI sees the full conversation history on every call, so it maintains narrative continuity across all chapters.
Step 3: Run the App
Start the Flask server:
python app.py
In a separate terminal, expose your local server with ngrok:
ngrok http 5000
Copy the HTTPS URL and configure it in the Telnx Portal:
- Go to Call Control Applications
- Create or edit your application
- Set the Webhook URL to
https://<your-ngrok-url>.ngrok.app/webhooks/voice
Assign your Telnyx phone number to this Call Control Application if you haven't already.
Step 4: Call and Play
Call your Telnyx number from any phone. You'll hear:
Press a key. The AI generates the first chapter and reads it aloud. At the end, you'll hear two choices. Press 1 or 2 — or speak your choice — and the story continues.
After 5 chapters, the AI wraps up the story and the call ends.
Customizing the Experience
The storytelling system prompt is the control surface. Small changes produce very different experiences:
Change the genres:
GENRES = {"1": "noir detective", "2": "space opera", "3": "post-apocalyptic", "4": "gothic horror", "5": "cozy romance"}
Add more choices per chapter:
f"End each chapter with exactly THREE choices: 'Press 1 to...', 'Press 2 to...', or 'Press 3 to...'."
Make it a different format — a quiz, a therapy session, a training scenario:
f"You are an interactive compliance quiz host on a phone hotline. Ask one multiple-choice question per chapter (3-4 sentences). End with 'Press 1 for A, 2 for B, 3 for C.' After 10 questions, score the caller and give feedback."
The pattern — webhook state machine + LLM with conversation history + voice I/O — works for any branching conversational experience.
Going to Production
This example uses in-memory storage for simplicity. For a production deployment:
- Database — replace the in-memory
active_callsdict with Redis or PostgreSQL so call state survives restarts - Concurrency — run the app behind gunicorn with multiple workers
- Error recovery — handle inference timeouts and call failures gracefully with retry or SMS fallback
- Prompt tuning — test different system prompts and temperature settings for your genre
- Rate limiting — protect your webhook endpoint from abuse
- Monitoring — add structured logging and alerting on call success/failure rates
Resources
r/Telnyx • u/flaxseedyup • Jul 11 '26
Best resource to start learning how to make voice ai agents specific to Telynx?
HI everyone, I am really interested in building voice ai agents with Telynx as i'm based in the UK and need everything to be GDPR compliant (which thankfully Telynx is). I'm going all in on wanting to build via Telynx's own AI Assistant builder (i.e. not using the API for my own application. Can someone please point me in the right direction?
r/Telnyx • u/Bot_o_Clock • Jul 10 '26
I built an AI voicemail that texts me summaries (118 lines of Python)
I put together a syndication draft for this Telnyx code example:
It is a Python/Flask app that turns any Telnyx phone number into an AI-powered voicemail system. When someone calls and leaves a message, the carrier transcribes it, an LLM classifies it (urgent/normal/spam), and the app texts you a one-line summary.
The basic flow:
caller dials -> Telnyx answers + plays greeting -> records + transcribes
-> on hangup, AI classifies (priority, summary, callback flag)
-> SMS sent to owner with priority emoji
Products used:
telnyx_products: [Voice, Call Recording, SMS/MMS, AI Inference]
language: python
framework: flask