# Twilio

> Move a Twilio Voice app onto FirstByte. Media Streams becomes an in-process Call API. TwiML routing becomes an external-agent config. The REST surface maps to management-API operations.

You have a Twilio Voice app: TwiML webhooks, a Media Streams WebSocket, and
REST calls. You want it on FirstByte. The seam is the **external agent**.
Your code runs as a worker. The worker dials **one outbound QUIC/WebTransport
session** to the engine. It authenticates with a media app key/secret. It
registers under an agent handle. The engine routes calls to the worker through
a `VENDOR_BRIDGE` agent config. It hands each call to your process as a `Call`
object. Audio flows in both directions as 20 ms `pcm16` frames at 8 kHz.

The biggest practical difference is the connection direction. Twilio's cloud
calls **into** your server. Thus you need a public URL, and usually ngrok
during development. Here the worker dials **out**. There are no inbound ports,
no public URL, and no tunnel.

## What maps to what

| Twilio | FirstByte |
| --- | --- |
| `/incoming-call` webhook returning TwiML | none. Route the number to your external agent in the console |
| `<Connect>` | the engine dispatches a `Call` to your registered worker |
| Media Streams `"media"` events (base64 μ-law) | `async for pcm in call.audio()`: 20 ms `pcm16` frames (`clutchcall_agents.g711` converts to/from μ-law) |
| `"clear"` event on the stream | `await call.clear()`: barge-in, drops buffered agent audio |
| `"mark"` queue for playback tracking | playout reports on the same session (`call.flush()` / `call.next_playout()`) |
| `request.values["From"]` / `["To"]` | `call.caller_number` / `call.called_number` |
| status callbacks | `voice.call.started/answered/ended/failed/transferred` [webhooks](#webhooks-instead-of-status-callbacks) |
| `client.calls.create(to, from_, url=…)` | `voice.calls.originate {to, trunkId, agent?}` on the management API |

## Port the speech-assistant sample

The runnable starter is a port of Twilio's
`speech-assistant-openai-realtime-api-python` sample. The OpenAI Realtime
bridge is the upstream file's code, near-verbatim. That bridge covers session
setup, μ-law audio in both directions, and barge-in via
`conversation.item.truncate`. The FastAPI app, TwiML route, and WebSocket
plumbing are gone. The starter ships in the provider starters repo next to
the `clutchcall-agents` SDK.

  1. **Install the SDK**
```bash
pip install "clutchcall-agents[quic]" \
  --extra-index-url https://artifacts.clutchcall.dev/pip/simple/
```

  2. **Replace the WebSocket handler with a call handler**
The Twilio sample handles `"start"` / `"media"` / `"mark"` JSON events on
    a WebSocket. The port is a plain coroutine for each call:

```python main.py
from clutchcall_agents import AgentConfig, Call, run
from clutchcall_agents.g711 import pcm_to_ulaw, ulaw_to_pcm

async def handle_call(call: Call):
    print(f"Call from {call.caller_number} to {call.called_number}")
    async with connect_openai_realtime() as openai_ws:

        async def caller_to_openai():
            async for pcm in call.audio():          # 20 ms pcm16 @ 8 kHz
                await append_audio(openai_ws, pcm_to_ulaw(pcm))

        async def openai_to_caller():
            async for event in openai_ws:
                if event["type"] == "response.output_audio.delta":
                    await call.send_audio(ulaw_to_pcm(decode(event["delta"])))
                elif event["type"] == "input_audio_buffer.speech_started":
                    await call.clear()              # barge-in: stop playback now
                    await truncate_response(openai_ws)

        await asyncio.gather(caller_to_openai(), openai_to_caller())

run(handle_call, AgentConfig.from_env())
```

    You can send outbound audio in any chunk size. The SDK paces it onto the
    wire as steady 20 ms frames. Thus bursty Realtime deltas need no
    re-chunking on your side.

  3. **Create the external agent and route a number**
The console's **External agents** create flow (`agent.firstbyte.fast`)
    reserves the agent handle. It mints the media key/secret
    (`CLUTCHCALL_MEDIA_KEY` / `CLUTCHCALL_MEDIA_SECRET`, which
    `AgentConfig.from_env()` reads). It writes the `VENDOR_BRIDGE` config that
    routes calls to the handle. Attach the agent to a DID or trunk (see
    [SIP Trunking](/modalities/voice/transport-telephony/sip-trunking)). Then
    dial.

  4. **Originate calls without an answer URL**
Twilio's `calls.create` takes a TwiML `url` to control the answered call.
    Here you pass the **agent** instead. On answer, the engine connects the
    callee to your worker. Management operations use an `mpk_` API key
    (portal → Settings → API Keys) against `POST /mcp`. The SDK wraps that
    endpoint:

```python
from clutchcall_agents.api import ClutchCallAPI

api = ClutchCallAPI(base_url="https://app.firstbyte.fast",
                   api_key=os.environ["CLUTCHCALL_API_KEY"],  # mpk_...
                   org_id="org_abc")

api.call("voice.calls.originate",
         {"to": "+15552223333", "trunkId": "trunk_main",
          "agent": "speech-assistant"})
```

    `voice.calls.transfer` and `voice.calls.hangup` cover mid-call control in
    the same way.

## Webhooks instead of status callbacks

Register one endpoint for the voice vertical and select your events:

```python
api.call("webhooks.create", {
    "vertical": "voice",
    "url": "https://your-app.example/webhook",
    "eventTypes": ["voice.call.started", "voice.call.answered",
                   "voice.call.ended", "voice.call.failed",
                   "voice.cdr.created", "voice.recording.ready",
                   "voice.transcript.ready"],
})
```

Deliveries are signed Stripe-style: an `X-Clutchcall-Signature` header of the
form `t=<epoch>,v1=<hmac_sha256(secret, "t.body")>`. Check each delivery with
`clutchcall_agents.webhooks.verify_signature(secret, header, body)` before you
trust the payload.

## Keep your Twilio numbers

You do not have to port numbers on day one. A **Twilio Elastic SIP trunk** can
point at the platform as a carrier path. Twilio keeps ownership of the
numbers. FirstByte terminates the SIP legs. The same external-agent routing
applies. See [SIP Trunking](/modalities/voice/transport-telephony/sip-trunking)
for trunk setup. See the
[Twilio migration guide](/modalities/voice/migration/from-twilio) for the full
cutover sequence.

## Troubleshooting

  **Audio is garbled after the port**

The Media Streams payload was 8 kHz **μ-law**. The Call API speaks 8 kHz
    **pcm16**. If your code still base64-decodes into μ-law bytes and sends
    them without conversion, every sample is wrong. Convert with
    `clutchcall_agents.g711.ulaw_to_pcm` / `pcm_to_ulaw` at the provider
    boundary only.

  **Barge-in stops the model but the caller still hears speech**

A truncate of the Realtime response only stops **generation**. Also call
    `await call.clear()`. It drops the audio that is already queued for
    playout. The sample's `"clear"` event did this on Twilio.

  **Webhook signature never verifies**

Check the signature against the **exact raw request body**. A
    re-serialized JSON body (reordered keys, changed whitespace) computes a
    different HMAC.

## Related

  - **[From Twilio](/modalities/voice/migration/from-twilio)** — The migration path: numbers, trunks, and rollout order.
  - **[LiveKit integration](/modalities/voice/integrations/livekit)** — The same external-agent seam for LiveKit Agents workers.
  - **[Run a LiveKit Agent on the Transport](/modalities/voice/recipes/livekit-transport)** — The end-to-end external-agent recipe on the same seam.
  - **[SIP Trunking](/modalities/voice/transport-telephony/sip-trunking)** — Point an Elastic SIP trunk (or any carrier) at the platform.
