DEV Community

Harpreet Singh Seehra
Harpreet Singh Seehra

Posted on

Turn a Text Chatbot Into a Voice Bot With Telnyx Conversation Relay

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:

  1. Caller dials a Telnyx number — the call hits your TeXML application
  2. TeXML returns <Connect><ConversationRelay> — Telnyx opens a WebSocket to your app
  3. Caller speaks — Telnyx transcribes the speech to text and sends it as a prompt frame
  4. Bridge forwards to your chatbot — calls your existing OpenAI-compatible /chat/completions endpoint
  5. Chatbot replies — the bridge streams the reply back as text frames
  6. 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/completions endpoint (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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

In another terminal:

ngrok http 8000
Enter fullscreen mode Exit fullscreen mode

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

  1. Go to the Telnyx PortalTeXML Applications → create a new app
  2. Set the Voice URL (inbound) to https://abc.ngrok.app/texml/inbound
  3. 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>"""
Enter fullscreen mode Exit fullscreen mode

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})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Resources

Top comments (0)