# Telnyx

> Move a Telnyx Call Control app onto FirstByte. The webhook/command ping-pong becomes one in-process function in an external agent. There are no round-trips and no call_control_id state machine.

You have a Telnyx Call Control app. Telnyx drives a call by webhook ping-pong:

1. `call.initiated` arrives. You send `answer`.
2. `call.answered` arrives. You send `speak`.
3. `call.speak.ended` arrives. You send the next command.

Every step is a REST command keyed by `call_control_id`, with a webhook
round-trip in between. Thus your app is a state machine, and every state
transition crosses the network twice.

On FirstByte, the whole flow is **one function**. Your code runs as an
**external agent**. The external agent is 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 coroutine. Audio flows in both directions as 20 ms `pcm16` frames at
8 kHz, in-process, with no command round-trips.

## What maps to what

| Telnyx Call Control | FirstByte |
| --- | --- |
| `call.initiated` webhook → `answer` command | the engine invokes your handler with a live, answered `Call` |
| `speak` / `playback_start` command | `await call.send_audio(pcm)`. Use any TTS, or a decoded 8 kHz WAV |
| `call.speak.ended` webhook | `call.flush()` + playout reports. There is no webhook wait |
| `playback_stop` | `await call.clear()` (barge-in: drops queued audio) |
| `start_recording` → `call.recording.saved` | the caller's frames arrive in `call.audio()`. Enable platform recording for a durable copy + `voice.recording.ready` |
| `gather_using_audio` / `transcription` | run your own ASR on `call.audio()`. DTMF and speech are both audio |
| `transfer` command | `voice.calls.transfer` on the management API |
| `Dial` (originate) | `voice.calls.originate {to, trunkId, agent?}` |
| `call_control_id` state machine | none. Per-call state is local variables in one coroutine |
| lifecycle webhooks as control flow | webhooks remain, but as **observability** (`voice.call.*`, `voice.cdr.created`), signed and verified |
| TeXML (`<Say>`, `<Play>`, `<Dial>`) | same mappings as the commands above |

## Port the voicemail example

The runnable starter is a port of Telnyx's `voicemail-python` Call Control
example. It ships in the provider starters repo next to the
`clutchcall-agents` SDK. Upstream, it is a Flask server plus a webhook state
machine. Here, it is one coroutine:

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

  2. **Collapse the state machine into a handler**
```python agent.py
from clutchcall_agents import AgentConfig, Call, run

GREETING = load_wav("greeting.wav")   # 8 kHz mono pcm16; chime fallback

async def voicemail(call: Call):
    print(f"Voicemail call from {call.caller_number} to {call.called_number}")

    # Telnyx: client.calls.actions.speak(call_control_id, payload=...)
    await call.send_audio(GREETING)
    await call.flush()
    call.send_transcript("assistant", "Please leave a message after the beep.")

    # Telnyx: start_recording + wait for call.recording.saved.
    # Here the message is just the caller's frames until hangup.
    message_ms = 0
    async for _pcm in call.audio():      # 20 ms pcm16 @ 8 kHz
        message_ms += 20

    print(f"Message length: {message_ms / 1000:.1f}s")

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

    A return from the handler ends the call. For a durable recording, enable
    platform recording on the route. You then get a `voice.recording.ready`
    webhook with the file. You do not poll for `call.recording.saved`.

  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, which
    `AgentConfig.from_env()` reads. It writes the `VENDOR_BRIDGE` config.
    Route a DID to the agent. Then dial the DID.

  4. **Keep lifecycle webhooks — as observability**
In Call Control, webhooks *were* the control flow. Here they only inform.
    Register one time with an `mpk_` management key (portal → Settings → API
    Keys) against `POST /mcp` through the SDK wrapper:

```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("webhooks.create", {
    "vertical": "voice",
    "url": "https://your-app.example/webhook",
    "eventTypes": ["voice.call.started", "voice.call.answered",
                   "voice.call.ended", "voice.cdr.created",
                   "voice.recording.ready"],
})
```

    Deliveries carry an `X-Clutchcall-Signature` header
    (`t=<epoch>,v1=<hmac_sha256(secret, "t.body")>`). Check each delivery with
    `clutchcall_agents.webhooks.verify_signature` before you trust the payload.
    Outbound dialing and transfer are `voice.calls.originate` /
    `voice.calls.transfer` on the same API.

## Keep your Telnyx trunk

Numbers do not have to move first. Point a **Telnyx SIP trunk** at the
platform as a carrier path. Telnyx keeps the numbers. FirstByte terminates
the SIP legs. The same external-agent routing applies. See
[SIP Trunking](/modalities/voice/transport-telephony/sip-trunking).

## Troubleshooting

  **You're waiting for an event that never comes**

There is no `call.speak.ended` here. Nothing blocks on webhooks. Sequence
    your flow in the coroutine itself: `send_audio` → `flush` → await playout.
    Then read `call.audio()`.

  **The greeting is distorted or the wrong speed**

`send_audio` is a raw passthrough at the call's sample rate. Resample your
    prompt files to 8 kHz mono 16-bit PCM offline. Do not send wideband
    samples onto a telephony leg.

  **The worker registers, but calls never arrive**

`vendor_room` in the agent config must exactly match the worker's handle
    (`CLUTCHCALL_AGENT`). The DID must route to that agent. Also check outbound
    UDP/443. Media rides QUIC datagrams. A TCP-only network stalls audio after
    a successful registration.

## Related

  - **[Plivo integration](/modalities/voice/integrations/plivo)** — The same port for the answer-URL/XML model.
  - **[LiveKit integration](/modalities/voice/integrations/livekit)** — The external-agent seam with a full agent framework on top.
  - **[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)** — Trunk setup, which includes how to point your Telnyx trunk at the platform.
