Voice AI

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

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

  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)
    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):

FrameMeaning
setupFirst frame — session ID and call info
promptTranscribed caller speech. last:false = partial, last:true = final
dtmfCaller pressed a digit
interruptCaller barged in over TTS playback
errorError frame

Your app → Telnyx (sent):

FrameMeaning
textText 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

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. That's the key design principle. Your chatbot receives text and returns text, exactly as it does for chat messages. The bridge prepends a voice-specific system prompt ("keep responses short, no markdown") 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 rest. 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. The bridge logs it on the dashboard. You can use this signal to stop the in-flight LLM request if you want to save compute.

Can I handle DTMF input? Yes. Telnyx sends a dtmf frame when the caller presses a digit. The bridge logs it on the dashboard. You can extend the handler to use DTMF for menu navigation or input collection.

How much does a call cost? Telnyx Conversation Relay is priced per minute. A typical call with a 5-turn conversation uses about 2-3 minutes — a few cents per call.

Ready to build with low-latency voice AI?

Join developers building the future of real-time conversations