> ## Documentation Index
> Fetch the complete documentation index at: https://docs.kugelaudio.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Twilio Integration

> Speak KugelAudio voices on a Twilio phone call through a bidirectional Twilio Media Stream

Connect a Twilio phone number to KugelAudio with a [bidirectional Media Stream](https://www.twilio.com/docs/voice/twiml/stream).
Twilio opens a WebSocket to a small server of yours. The server turns text (a greeting, an LLM reply) into
speech with the KugelAudio Python SDK and sends the audio back into the call.

KugelAudio can output G.711 µ-law at 8000 Hz directly (`output_format="ulaw_8000"`), which is the format
Twilio plays. The server forwards each audio chunk as it arrives, with no resampling or transcoding.

<Tip>
  Building on LiveKit Agents or Pipecat? Both frameworks connect phone calls for you: LiveKit through
  [SIP trunks](https://docs.livekit.io/telephony/) and Pipecat through its
  [Twilio WebSocket transport](https://docs.pipecat.ai/pipecat/telephony/twilio-websockets). Use the
  [LiveKit](/integrations/livekit) or [Pipecat](/integrations/pipecat) plugin there instead of this page.
</Tip>

## How it works

1. A call reaches your Twilio number. Twilio requests your webhook and gets TwiML with
   `<Connect><Stream>`.
2. Twilio opens a WebSocket to your server and sends `connected`, then `start` with the `streamSid`.
3. Your server streams KugelAudio TTS with `output_format="ulaw_8000"` and sends every chunk to Twilio
   as a `media` message (base64 µ-law, 8000 Hz, mono).
4. After each reply the server sends a `mark`. Twilio echoes it when that audio has finished playing.
5. When the caller interrupts, the server stops the KugelAudio stream and sends `clear`, which empties
   the audio Twilio has buffered.

## 1. Install

```bash theme={null}
pip install kugelaudio fastapi "uvicorn[standard]"
```

You also need:

* **A KugelAudio API key** from the [dashboard](https://kugelaudio.com/dashboard) (Settings, API Keys).
  See [Authentication](/api-reference/authentication).
* **A voice ID.** Pick one in [Voices](/features/voices); the example uses `1071`.
* **A Twilio phone number** with voice capability.
* **A public HTTPS host** for your server. Twilio connects to the stream over `wss://` only. For local
  testing, a tunnel such as [ngrok](https://ngrok.com/docs/start) works: `ngrok http 8000`.

## 2. The TwiML

Twilio needs this TwiML when a call comes in. The server below returns it from `POST /twiml`; you can
also paste it into a TwiML Bin. Replace `YOUR_PUBLIC_HOST` with your host name, without `https://`.

```xml theme={null}
<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Connect>
    <Stream url="wss://YOUR_PUBLIC_HOST/media" />
  </Connect>
</Response>
```

The stream URL cannot carry query string parameters. To pass values such as a customer ID to your
server, use nested `<Parameter>` elements; they arrive in `start.customParameters`.

## 3. The server

Save this as `server.py`. It greets the caller, answers each key press, and stops talking the moment a
key is pressed. Key presses stand in for barge-in here so the example runs without speech recognition.

```python server.py theme={null}
import asyncio
import base64
import json
import logging
import os

from fastapi import FastAPI, WebSocket
from fastapi.responses import Response

from kugelaudio import AudioChunk, KugelAudio, KugelAudioError

PUBLIC_HOST = os.environ["PUBLIC_HOST"]  # e.g. abc123.ngrok.app, without https://
VOICE_ID = 1071

logger = logging.getLogger("twilio-kugelaudio")
app = FastAPI()
kugel = KugelAudio(api_key=os.environ["KUGELAUDIO_API_KEY"])


@app.post("/twiml")
async def twiml() -> Response:
    xml = (
        '<?xml version="1.0" encoding="UTF-8"?>'
        f'<Response><Connect><Stream url="wss://{PUBLIC_HOST}/media" /></Connect></Response>'
    )
    return Response(content=xml, media_type="text/xml")


class Call:
    """One phone call: speaks queued text into the Twilio stream."""

    def __init__(self, ws: WebSocket, stream_sid: str):
        self.ws = ws
        self.stream_sid = stream_sid
        self.texts: asyncio.Queue[str] = asyncio.Queue()
        self.pending_marks: set[str] = set()
        self.replies = 0
        self.speaker = asyncio.create_task(self._speak())

    def say(self, text: str) -> None:
        """Queue text. Replies play in order, one after the other."""
        self.texts.put_nowait(text)

    async def interrupt(self) -> None:
        """Barge-in: stop generating, drop queued text, empty Twilio's buffer."""
        self.speaker.cancel()
        while not self.texts.empty():
            self.texts.get_nowait()
        self.pending_marks.clear()  # Twilio echoes cleared marks; ignore them
        await self._send({"event": "clear"})
        self.speaker = asyncio.create_task(self._speak())

    def on_mark(self, name: str) -> None:
        if name in self.pending_marks:
            self.pending_marks.discard(name)
            logger.info("%s finished playing", name)

    async def close(self) -> None:
        self.speaker.cancel()

    async def _speak(self) -> None:
        while True:
            text = await self.texts.get()
            try:
                async for item in kugel.tts.stream_async(
                    text=text,
                    model_id="kugel-3.5",
                    voice_id=VOICE_ID,
                    language="en",
                    output_format="ulaw_8000",  # what Twilio plays: µ-law, 8000 Hz, mono
                    reuse_connection=False,  # one connection per reply, so a cancel closes it
                ):
                    if isinstance(item, AudioChunk):
                        payload = base64.b64encode(item.audio).decode("ascii")
                        await self._send({"event": "media", "media": {"payload": payload}})
            except KugelAudioError:
                # Log and keep the call up, so the caller still hears the next reply.
                logger.exception("KugelAudio TTS failed for %r", text)
                continue
            self.replies += 1
            name = f"reply-{self.replies}"
            self.pending_marks.add(name)
            await self._send({"event": "mark", "mark": {"name": name}})

    async def _send(self, message: dict) -> None:
        await self.ws.send_text(json.dumps({**message, "streamSid": self.stream_sid}))


@app.websocket("/media")
async def media(ws: WebSocket) -> None:
    await ws.accept()
    call: Call | None = None
    try:
        async for message in ws.iter_text():
            data = json.loads(message)
            event = data["event"]
            if event == "start":
                call = Call(ws, data["start"]["streamSid"])
                call.say(
                    "Hello! You are hearing a KugelAudio voice on a phone line. "
                    "Press any key on your phone to interrupt me."
                )
            elif event == "media":
                pass  # the caller's audio: base64 µ-law at 8000 Hz, for your speech recognition
            elif event == "dtmf" and call:
                await call.interrupt()
                call.say(f"You pressed {data['dtmf']['digit']}.")
            elif event == "mark" and call:
                call.on_mark(data["mark"]["name"])
            elif event == "stop":
                break
    finally:
        if call:
            await call.close()
```

Run it:

```bash theme={null}
export KUGELAUDIO_API_KEY=YOUR_KUGELAUDIO_API_KEY
export PUBLIC_HOST=abc123.ngrok.app
uvicorn server:app --port 8000
```

`reuse_connection=False` gives every reply its own KugelAudio WebSocket. Cancelling a reply closes that
connection, so no audio of an interrupted reply reaches the next one.

## 4. Point your Twilio number at the server

In the Twilio Console, open your phone number's voice configuration and set the webhook for incoming
calls to `https://YOUR_PUBLIC_HOST/twiml` with HTTP `POST`. Call the number: you hear the greeting, and
pressing a key cuts it off and answers.

## Adding speech recognition and an LLM

The example shows where the rest of a voice agent connects:

* **Caller audio.** Each inbound `media` message carries base64 µ-law at 8000 Hz in
  `media.payload`. Send it to your speech recognition.
* **Replies.** Call `call.say(text)` with the LLM reply. For lower latency, call it once per complete
  sentence as the LLM streams; the sentences play in order. See
  [Chunking and latency](/streaming/chunking-and-latency) for where to split, and
  [Voice prompting](/guides/voice-prompting) for prompts that read well aloud.
* **Barge-in.** When your voice activity detection hears the caller speak over the agent, call
  `await call.interrupt()` instead of reacting to a key press. See [Barge-in](/streaming/barge-in).
* **End of turn.** `on_mark` runs when a reply has finished playing on the call. Start an
  end-of-turn timer or open the microphone there.

## Twilio messages used

| Direction | `event` | What the example does with it |
| - | - | - |
| Twilio to server | `start` | Reads `start.streamSid` and greets the caller |
| Twilio to server | `media` | Caller audio; ignored in the example |
| Twilio to server | `dtmf` | Interrupts and answers the key press |
| Twilio to server | `mark` | A reply finished playing, or was cleared |
| Twilio to server | `stop` | The call ended |
| Server to Twilio | `media` | One KugelAudio chunk, base64 µ-law 8000 Hz, no file header |
| Server to Twilio | `mark` | Sent after each reply to learn when it has played |
| Server to Twilio | `clear` | Empties Twilio's buffered audio on barge-in |

Twilio's [WebSocket messages reference](https://www.twilio.com/docs/voice/media-streams/websocket-messages)
has every field.

## Troubleshooting

<AccordionGroup>
  <Accordion title="The call connects, then hangs up">
    Twilio could not open the WebSocket. Check that the stream URL starts with `wss://`, that
    `PUBLIC_HOST` is the host only (no `https://`, no path), and that the tunnel forwards WebSocket
    upgrades to port 8000.
  </Accordion>

  <Accordion title="Noise or static instead of speech">
    The audio is not µ-law at 8000 Hz. Keep `output_format="ulaw_8000"` and do not send a WAV header
    in `media.payload`. See [Audio formats](/api-reference/tts/audio-formats).
  </Accordion>

  <Accordion title="The server logs an authentication error">
    Check `KUGELAUDIO_API_KEY`. See [Authentication](/api-reference/authentication) and
    [Errors](/api-reference/errors).
  </Accordion>
</AccordionGroup>

Before production, check that webhook requests really come from Twilio; see Twilio's
[webhook security guide](https://www.twilio.com/docs/usage/webhooks/webhooks-security).

## Next steps

<CardGroup cols={2}>
  <Card title="Audio formats" icon="file-audio" href="/api-reference/tts/audio-formats">
    Every output format, including µ-law and a-law at 8000 Hz
  </Card>

  <Card title="Barge-in" icon="hand" href="/streaming/barge-in">
    Stopping speech when the caller interrupts
  </Card>

  <Card title="Voices" icon="microphone" href="/features/voices">
    Find and pick a voice ID
  </Card>

  <Card title="Regions" icon="globe" href="/guides/regions">
    Keep traffic in the EU
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.