Call a phone number and talk to your existing AI chatbot — the same one that already answers your Telegram, Slack, or web messages. Telnyx Conversation Relay handles all the telephony audio (speech-to-text, text-to-speech, call control). Your application only exchanges text over a WebSocket, so the chatbot needs zero changes: it doesn't even know the input came from a phone call instead of a chat message.
What You'll Build
A bridge app that connects a phone call to an existing text-in/text-out AI chatbot:
- Caller dials a Telnyx number — the call hits your TeXML application
- TeXML returns
<Connect><ConversationRelay>— Telnyx opens a WebSocket to your app - Caller speaks — Telnyx transcribes the speech to text and sends it as a
promptframe - Bridge forwards to your chatbot — calls your existing OpenAI-compatible
/chat/completionsendpoint - Chatbot replies — the bridge streams the reply back as
textframes - Telnyx speaks the reply — TTS converts the text to speech for the caller
The whole flow is visible on a live dashboard: the WebSocket frames, the conversation history, and the chatbot's streaming tokens — all updating in real time via Server-Sent Events.
Why This Matters
Every team that builds an AI chatbot eventually faces the same question: "Can we make this a voice bot too?" The traditional answer involves rebuilding the bot for voice — new prompts, new latency constraints, new telephony integration, new audio handling.
Conversation Relay skips all of that. If your bot already takes text in and produces text out, it's already a voice bot. Telnyx converts caller speech to text (your bot's input), and your bot's text to speech (back to the caller). Your bot's logic, personality, tools, and knowledge base stay exactly the same.
This is the carrier-grade solution: no custom STT/TTS pipeline, no audio streaming code, no SIP integration. Just a WebSocket that moves text.
Prerequisites
- Python 3.10+
- A Telnyx account with a phone number
- An existing AI chatbot with an OpenAI-compatible
/chat/completionsendpoint (any framework, any language) - ngrok for exposing your local server
The Architecture
Caller speaks into phone
│
▼
┌─────────────────────────────┐
│ Telnyx (Conversation Relay) │ STT + TTS + call control
│ transcribes speech → text │
└──────────────┬──────────────┘
│ WebSocket text frame: {"type":"prompt","text":"...","last":true}
▼
┌─────────────────────────────┐
│ This bridge app (app.py) │ ~140 lines of glue
│ forwards text to your bot │
└──────────────┬──────────────┘
│ POST /v1/chat/completions (OpenAI-compatible)
▼
┌─────────────────────────────┐
│ Your existing AI chatbot │ ← NO CHANGES
│ returns a text reply │
└──────────────┬──────────────┘
│ text reply
▼
┌─────────────────────────────┐
│ Bridge sends text frame │ {"type":"text","token":"...","last":true}
└──────────────┬──────────────┘
│
▼
Telnyx TTS speaks the reply to the caller
The key insight: if your bot already takes text in and produces text out, Conversation Relay makes it a voice bot. Telnyx converts caller speech to text (your bot's input), and your bot's text to speech (back to the caller). Your bot's logic, personality, tools, and knowledge base stay exactly the same.
Step 1: Clone and Configure
git clone https://github.com/team-telnyx/telnyx-code-examples.git
cd telnyx-code-examples/conversation-relay-voice-bot-python
cp .env.example .env
pip install -r requirements.txt
Edit .env with your credentials:
TELNYX_API_KEY=your_telnyx_api_key_here
CHATBOT_BASE_URL=http://localhost:18789 # your existing chatbot's URL
CHATBOT_TOKEN=your_gateway_auth_token # bearer token for your chatbot
CHATBOT_MODEL=openclaw # model name your endpoint expects
TELNYX_PUBLIC_BASE_URL=https://abc.ngrok.app # your ngrok URL (set in Step 2)
The CHATBOT_BASE_URL, CHATBOT_TOKEN, and CHATBOT_MODEL point to your existing AI chatbot — any endpoint that accepts OpenAI-compatible /chat/completions requests works.
Step 2: Start the Bridge and Expose It
Conversation Relay needs a public WebSocket URL. Start the app, then start ngrok:
python app.py # starts on http://localhost:8000
In another terminal:
ngrok http 8000
Copy the HTTPS URL (e.g. https://abc.ngrok.app) and set it as TELNYX_PUBLIC_BASE_URL in .env, then restart the app.
Step 3: Configure a TeXML Application
- Go to the Telnyx Portal → TeXML Applications → create a new app
- Set the Voice URL (inbound) to
https://abc.ngrok.app/texml/inbound - Assign a phone number to this TeXML application
When a caller dials that number, Telnyx fetches the TeXML, which returns <Connect><ConversationRelay> — telling Telnyx to open a WebSocket to your app and start the text bridge.
Step 4: Understand the Code
Everything lives in app.py. Here's what each piece does.
The TeXML Response
When Telnyx fetches the voice URL, the app returns TeXML that tells Telnyx to use Conversation Relay:
def texml_response() -> str:
ws_url = escape(conversation_relay_ws_url(), quote=True)
action_url = escape(public_base_url() + "/callbacks/conversation-relay", quote=True)
greeting = escape(WELCOME_GREETING, quote=True)
voice = escape(VOICE, quote=True)
language = escape(LANGUAGE, quote=True)
provider = escape(TRANSCRIPTION_PROVIDER, quote=True)
return f"""<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Connect action="{action_url}">
<ConversationRelay
url="{ws_url}"
interruptible="none"
welcomeGreeting="{greeting}"
welcomeGreetingInterruptible="none"
voice="{voice}"
language="{language}"
transcriptionProvider="{provider}"
dtmfDetection="true"
/>
</Connect>
</Response>"""
The url attribute is the WebSocket endpoint Telnyx connects to. The welcomeGreeting is spoken immediately when the call connects. The voice and transcriptionProvider control TTS and STT.
The WebSocket Bridge
The core of the app is the WebSocket handler. It receives frames from Telnyx and forwards text to your chatbot:
@sock.route("/ws/conversation-relay")
def conversation_relay_socket(ws) -> None:
session_id: str | None = None
while True:
raw = ws.receive()
if raw is None:
break
frame = json.loads(raw)
ftype = str(frame.get("type") or "unknown")
if ftype == "setup":
session_id = str(frame.get("sessionId") or "unknown")
sessions[session_id] = {"history": [], "started": time.time(), "from": caller}
elif ftype == "prompt" and frame.get("last") is True:
caller_text = str(frame.get("voicePrompt") or frame.get("text") or "").strip()
session = sessions[session_id]
session["history"].append({"role": "user", "content": caller_text})
reply = ask_chatbot_streamed(session["history"], ws)
session["history"].append({"role": "assistant", "content": reply})
The Streaming Chatbot Call
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:
def ask_chatbot_streamed(history: list[dict[str, str]], ws) -> str:
url = f"{GATEWAY_BASE_URL}/chat/completions"
headers = {"Content-Type": "application/json"}
if GATEWAY_TOKEN:
headers["Authorization"] = f"Bearer {GATEWAY_TOKEN}"
messages = [{"role": "system", "content": VOICE_SYSTEM_PROMPT}] + history
payload = {"model": CHATBOT_MODEL, "messages": messages, "max_tokens": MAX_TOKENS, "stream": True}
full_reply = ""
resp = requests.post(url, headers=headers, json=payload, timeout=REQUEST_TIMEOUT, stream=True)
for line in resp.iter_lines(decode_unicode=True):
if line.startswith("data: "):
data = line.removeprefix("data: ").strip()
if
break
chunk = json.loads(data)
token = chunk.get("choices", [{}])[0].get("delta", {}).get("content")
if token:
full_reply += token
ws.send(text_frame(token, last=False))
ws.send(text_frame("", last=True))
return full_reply
The VOICE_SYSTEM_PROMPT prepends a phone-specific instruction: "You are on a phone call. Keep responses short and conversational — 2-3 sentences max. No markdown, no bullet points." This adapts the chatbot's output for voice without changing the chatbot itself.
The Live Dashboard
The app includes a dashboard at / with two tabs:
- Chat (messaging bot) — type to the chatbot directly, see the text interface
- Live frames (voice call) — watch the WebSocket frames flow in real time via SSE
The dashboard uses Server-Sent Events to push each frame to the browser as it happens — setup, prompt, reply, interrupt, DTMF, error. The conversation history updates alongside, so you can see the full call transcript as it builds.
Step 5: Call and Talk
Dial your Telnyx number. You'll hear the welcome greeting, then speak — Telnyx transcribes your speech, the bridge forwards it to your chatbot, and your chatbot's reply is spoken back to you.
Try asking complex questions. Interrupt the bot mid-sentence (barge-in). The interrupt frame fires when the caller talks over TTS playback, and the bridge logs it on the dashboard.
Conversation Relay Frame Types
Telnyx → your app (received):
| Frame | Meaning |
|---|---|
setup | First frame — session ID and call info |
prompt | Transcribed caller speech. last:false = partial, last:true = final |
dtmf | Caller pressed a digit |
interrupt | Caller barged in over TTS playback |
error | Error frame |
Your app → Telnyx (sent):
| Frame | Meaning |
|---|---|
text | Text to speak via TTS. last:false = stream tokens, last:true = finalize |
Testing Without a Phone Call
Verify the TeXML and health endpoints locally:
# TeXML should return the <Connect><ConversationRelay> document
curl http://localhost:8000/texml/inbound
# Health check
curl http://localhost:8000/health
To exercise the full WebSocket path without a real call, connect a WebSocket client to ws://localhost:8000/ws/conversation-relay, send a setup frame, then a prompt frame with last: true and a text field — the bridge will call your chatbot and send back a text frame.
Customizing the Voice
The bridge supports several configuration options for voice and transcription:
VOICE=Telnyx.NaturalHD.orion # premium voice (NaturalHD = premium)
TRANSCRIPTION_PROVIDER=google # STT provider: deepgram, google, or telnyx
WELCOME_GREETING="Hi! This is Nyx. Ask me anything."
LANGUAGE=en # TTS + transcription language
The VOICE_SYSTEM_PROMPT is the key tuning knob. The default keeps responses short and conversational for phone calls. For a more detailed bot, remove the length constraint — but be aware that long TTS playback increases latency and the risk of caller impatience.
Going to Production
This example runs locally with ngrok and a single chatbot endpoint. For production:
- Deploy to a public server — remove ngrok, run on a VPS or Edge Compute with TLS
- Add webhook signature verification — validate Telnyx webhook signatures on the TeXML endpoint
- Add conversation persistence — store session history in Redis or a database for multi-call continuity
- Add monitoring — track call duration, latency, error rates, and chatbot response times
- Tune the voice system prompt — adjust length, tone, and style for your use case
- Handle concurrent calls — the bridge uses a threaded Flask server; for high concurrency, consider an async framework (FastAPI + websockets)
- Add fallback handling — if the chatbot is unreachable, speak a graceful error message instead of dropping the call