Turn a Text Chatbot Into a Voice Bot With Telnyx Conversation Relay
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)
return f"""<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Connect action="{action_url}">
<ConversationRelay
url="{ws_url}"
welcomeGreeting="{greeting}"
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 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:
while True:
raw = ws.receive()
frame = json.loads(raw)
ftype = str(frame.get("type") or "unknown")
if ftype == "setup":
session_id = str(frame.get("sessionId"))
sessions[session_id] = {"history": [], "started": time.time()}
elif ftype == "prompt" and frame.get("last") is True:
caller_text = frame.get("voicePrompt") or frame.get("text")
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, ws) -> str:
payload = {"model": CHATBOT_MODEL, "messages": messages, "stream": True}
resp = requests.post(url, json=payload, stream=True)
for line in resp.iter_lines(decode_unicode=True):
chunk = json.loads(line.removeprefix("data: "))
token = chunk.get("choices", [{}])[0].get("delta", {}).get("content")
if token:
ws.send(text_frame(token, last=False)) # partial frame
ws.send(text_frame("", last=True)) # final frame
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." This adapts the chatbot's output for voice without changing the chatbot itself.
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.
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 |
Frequently Asked Questions
What is Telnyx Conversation Relay? Conversation Relay is a Telnyx product that bridges phone calls to your application over a WebSocket. Telnyx handles all the telephony audio (speech-to-text, text-to-speech, call control), and your app only exchanges text — no audio handling required.
Does my chatbot need to know it's on a phone call? No. Your chatbot receives text and returns text, exactly as it does for chat messages. The bridge prepends a voice-specific system prompt but your chatbot's logic stays unchanged.
Can I use this with any chatbot framework? Yes, as long as your chatbot exposes an OpenAI-compatible /chat/completions endpoint. This includes LangChain, LlamaIndex, custom OpenAI wrappers, Clawdbot, and any endpoint that accepts the standard chat completions payload.
What transcription providers are supported? Conversation Relay supports Deepgram (default), Google, and Telnyx's own transcription provider. Set TRANSCRIPTION_PROVIDER in .env to switch.
How does streaming work? The bridge sends partial text frames (last: false) as tokens arrive from the LLM, so TTS starts speaking the first words while the LLM is still generating. The final frame (last: true) tells Telnyx the reply is complete.
What happens when the caller interrupts? Telnyx sends an interrupt frame when the caller talks over TTS playback. You can use this signal to stop the in-flight LLM request.
Can I handle DTMF input? Yes. Telnyx sends a dtmf frame when the caller presses a digit. You can extend the handler to use DTMF for menu navigation or input collection.
Top comments (0)