# Run a LiveKit Agent on FirstByte Transport

> End-to-end: install the transport plugin into your unmodified LiveKit agent, register a worker over QUIC, route a number to it, and take a real call. No LiveKit server is needed.

You have a working LiveKit agent. You want to put it on the phone, or on
FirstByte's QUIC transport. You do not want to rewrite it, and you do not
want to operate a LiveKit server. This recipe builds that end to end:

1. Install the **transport plugin** into your existing agent process.
2. Run the process as a worker that dials out to the edge.
3. Point a FirstByte agent at the worker.
4. Route an inbound number.
5. Place a real call.

Your agent code does not change. Your `AgentSession`, your STT/LLM/TTS plugins,
your provider keys, and your tool calls all stay verbatim. Only the bytes on
the wire are ours.

> **NOTE:**
> This is the **plugin** path. If you must keep your LiveKit server running
>   (other participants join the room), use the front-proxy sidecar instead:
>   [Bridge a LiveKit Agent](/modalities/voice/cookbooks/bridge-livekit).
>   [The integration reference](/modalities/voice/integrations/livekit) compares
>   both paths.

## What you'll build

```
 PSTN / SIP trunk ─▶ ┌──────────────────┐                   ┌────────────────────────────┐
                     │  voice gateway   │  ONE QUIC/WT      │ your livekit-agents worker  │
 browser (QUIC/MoQT) │  (owns the       │◀── session ──────▶│  + transport plugin         │
 on :443 ──────────▶ │  caller leg)     │  (agent dials out)│  (your STT/LLM/TTS keys)    │
                     └──────────────────┘                   └────────────────────────────┘
     caller audio ──▶ 8 kHz PCM bus ──▶ QUIC datagrams ──▶ session.input.audio
     agent audio  ◀── back onto the call ◀── QUIC datagrams ◀── session.output.audio
```

The gateway owns the hard edge: a SIP trunk or a browser leg over QUIC on the
single `:443` plane. It decodes that edge to the runtime's 8 kHz PCM bus. Your
worker holds one outbound QUIC/WebTransport session to the edge. The session
carries three lanes:

- presence (liveness),
- audio over datagrams, and
- per-call control (job assign, flush, barge-in, playback finished) over a
  stream.

> **INFO:**
> **Why the agent is the client.** Your worker dials out and opens **no
>   inbound ports**. It works behind NAT, in a private subnet, or in a container
>   with no public address. Nothing about your network has to change.

## Prerequisites

- **`A LiveKit agent`** (`required`, required) — A working `livekit-agents` (Python) or `@livekit/agents` (TypeScript) agent   with an `entrypoint`, plus the provider keys it already uses.
- **`FirstByte media credentials`** (`required`, required) — A media app key + secret for your tenant, in `CLUTCHCALL_MEDIA_KEY` and   `CLUTCHCALL_MEDIA_SECRET`. These are **distinct** from your API   credentials. Issue them from the console (`agent.firstbyte.fast`) or the   control-plane API.
- **`An inbound number or trunk`** (`required`, required) — See [SIP Trunking](/modalities/voice/transport-telephony/sip-trunking) /   [Number Provisioning](/modalities/voice/transport-telephony/number-provisioning).
- **`Outbound UDP/443`** (`required`, required) — The worker needs QUIC egress to `engine.firstbyte.fast`. The WebSocket fallback   covers presence only. Media over TCP is a degraded last resort.

## Build the mini-app

  1. **Start from your unmodified agent**
Any LiveKit agent works. It needs no FirstByte awareness. This is the
    file that you already have, untouched:

    
      
```python my_agent.py
from livekit.agents import AgentSession, Agent, JobContext
from livekit.plugins import openai, deepgram, elevenlabs, silero

async def entrypoint(ctx: JobContext):
    session = AgentSession(
        vad=silero.VAD.load(),
        stt=deepgram.STT(),
        llm=openai.LLM(model="gpt-4o-mini"),
        tts=elevenlabs.TTS(),
    )
    await session.start(
        room=ctx.room,
        agent=Agent(instructions="You are a helpful phone assistant."),
    )
```
      
      
```ts my-agent.ts
import { voice } from "@livekit/agents";
import * as openai from "@livekit/agents-plugin-openai";

export async function entrypoint(ctx: any): Promise<void> {
  const session = new voice.AgentSession({
    llm: new openai.realtime.RealtimeModel({ voice: "alloy" }),
  });

  await session.start({
    agent: new voice.Agent({
      instructions: "You are a helpful phone assistant. Keep replies short.",
    }),
    room: ctx.room,
  });

  session.generateReply({ instructions: "Greet the caller and ask how you can help." });
}
```
      
    

    > **NOTE:**
> Your `entrypoint` still calls `session.start(room=ctx.room, ...)`. Leave
>       it. The plugin wraps `AgentSession.start`, binds our audio IO, and drops
>       the `room` argument. The SDK skips its own `RoomIO` whenever custom audio
>       IO is set, so the code never touches the room.

  2. **Install the transport plugin**

      
```bash
pip install "livekit-plugins-clutchcall[quic]"
```

The `quic` extra pulls in `aioquic`. Without it, the package imports but cannot
connect.
      
      
```bash
npm i @clutchcall/livekit-transport @fails-components/webtransport
```

`@livekit/agents` and `@livekit/rtc-node` stay yours. They are optional peers,
so the plugin never pins your SDK version.
      
    

  3. **Run it as a worker**
Replace your worker runner (`cli.run_app(...)`) with ours. This is the only
    new file in your repo.

    
      
```python worker.py
import asyncio, os
from livekit.plugins import clutchcall

from my_agent import entrypoint   # unchanged

asyncio.run(clutchcall.run_agent_worker(
    clutchcall.WebTransportConfig(
        engine_url="https://engine.firstbyte.fast",
        app_key=os.environ["CLUTCHCALL_MEDIA_KEY"],
        app_secret=os.environ["CLUTCHCALL_MEDIA_SECRET"],
        agent_name="salesbot",
        sample_rate=8000,
    ),
    host="engine.firstbyte.fast",
    entrypoint=entrypoint,
))
```

```bash
CLUTCHCALL_MEDIA_KEY=ck_... CLUTCHCALL_MEDIA_SECRET=... \
  OPENAI_API_KEY=sk-... python worker.py
```
      
      
```ts worker.ts
import { runAgentWorker, defaultConfig } from "@clutchcall/livekit-transport";
import { entrypoint } from "./my-agent.js";   // unchanged

const host = "engine.firstbyte.fast";

await runAgentWorker(
  defaultConfig({
    engineUrl:  `https://${host}`,
    appKey:     process.env.CLUTCHCALL_MEDIA_KEY!,
    appSecret:  process.env.CLUTCHCALL_MEDIA_SECRET!,
    agentName:  "salesbot",
    sampleRate: 8000,
  }),
  { host, port: 443, entrypoint },
);
```

```bash
CLUTCHCALL_MEDIA_KEY=ck_... CLUTCHCALL_MEDIA_SECRET=... \
  OPENAI_API_KEY=sk-... node --import tsx worker.ts
```
      
    

    The worker authenticates, registers under `agent_name`, and waits. You
    should see it report ready and await calls. It stays connected with a
    keepalive. If the link drops, it reconnects with exponential backoff. An
    engine restart therefore does not strand it on a dead socket.

    > **WARNING:**
> **`sample_rate` must match the call leg.** The `pcm16` codec is a raw
>       passthrough. A wideband TTS voice (24 kHz / 48 kHz) sent onto an 8 kHz
>       telephony leg plays at the wrong speed. The plugin declares the wire rate
>       on the session output, and the SDK resamples to it. But the SDK resamples
>       only to the rate that you set here. Use `8000` for PSTN.

  4. **Point a FirstByte agent at the worker**
The engine models a remote agent as a **`VENDOR_BRIDGE`** node whose
    `vendor_provider` is `clutchcall`. The decision lives in the agent
    config, so your dialplan and trunk routing stay untouched.

    ```json agent-config.json
    {
      "entry_node": "bridge",
      "pipeline": {
        "bridge": {
          "node_type": "VENDOR_BRIDGE",
          "vendor_provider": "clutchcall",
          "vendor_room": "salesbot"
        }
      }
    }
    ```

    | Field | Meaning |
    | --- | --- |
    | `vendor_provider` | `clutchcall` — your agent runs on our transport |
    | `vendor_room` | The `agent_name` your worker registered under |

    With this config, the runtime runs **no** turn detection and **no** model
    in the engine. It hands the caller's audio to your worker. It streams your
    agent's audio back onto the call. The full field reference is in
    [Runtime Configuration](/modalities/voice/runtime/configuration).

  5. **Route a number to the agent**
Attach an inbound DID or SIP trunk to this agent. Use the same routing that
    you would use for any native FirstByte agent. See
    [SIP Trunking](/modalities/voice/transport-telephony/sip-trunking) and
    [Number Provisioning](/modalities/voice/transport-telephony/number-provisioning).

  6. **Connect a caller**
Dial the number. To drive it programmatically, place an **outbound** call
    from the SDK. On answer, the engine dispatches to your worker in the same
    way as for an inbound call:

    
      
```ts
import { Voice } from "clutchcall_connect/voice";

const v = new Voice({
  baseUrl: "https://engine.firstbyte.fast",
  apiKey:  process.env.CLUTCHCALL_CREDENTIALS!,
  orgId:   "org_abc",
});

// The agent's VENDOR_BRIDGE node dispatches this call to your worker.
const call = await v.calls.originate({
  to:      "+15551234567",
  from:    "+15558675309",
  trunkId: "trunk_main",
  agent:   "livekit-on-quic",   // the VENDOR_BRIDGE agent above
  ringTimeoutSec: 25,
});

console.log("dialing", call.sid, "→ worker salesbot");
```
      
      
```python
import os
from clutchcall.voice import Voice

v = Voice(
    base_url="https://engine.firstbyte.fast",
    api_key=os.environ["CLUTCHCALL_CREDENTIALS"],
    org_id="org_abc",
)

# The agent's VENDOR_BRIDGE node dispatches this call to your worker.
call = v.calls.originate(
    to="+15551234567",
    from_="+15558675309",
    trunk_id="trunk_main",
    agent="livekit-on-quic",   # the VENDOR_BRIDGE agent above
    ring_timeout_sec=25,
)

print("dialing", call.sid, "→ worker salesbot")
```
      
    

## What your agent sees

Your `entrypoint` gets a `JobContext` whose `ctx.room` is a **veneer**, not a
LiveKit room. It carries the fields that agents actually read:

| Field | Value |
| --- | --- |
| `ctx.room.name` | The dispatched agent/call name. It is the identity, so "get agent by room" still works |
| `ctx.job.id` | The platform **call sid** — what the CDR, transcripts, recordings, and usage rows are keyed by |
| `ctx.job.metadata` | Operator-defined context KVs as a JSON string; `""` when none |

Caller and called numbers mirror LiveKit's SIP participant attributes, but the
two plugins expose them differently today:

  
```python
p = next(iter(ctx.room.remote_participants.values()), None)
ani  = p.attributes.get("sip.phoneNumber")      if p else ""
dnis = p.attributes.get("sip.trunkPhoneNumber") if p else ""
# `ctx.room.attributes` and `sip.trunkID` are NOT exposed by the Python plugin.
```
  
  
```ts
const ani   = ctx.room.attributes["sip.phoneNumber"];
const dnis  = ctx.room.attributes["sip.trunkPhoneNumber"];
const trunk = ctx.room.attributes["sip.trunkID"];
// plus flat fields: ctx.callerNumber, ctx.calledNumber, ctx.trunkId
```
  

> **NOTE:**
> The engine does not source arbitrary participant `attrs` today — only the
>   caller/called/trunk fields above are populated. Anything else you read from
>   the attribute bag will be empty.

Room **APIs** beyond these fields are not backed. This includes track
publication, participant enumeration, and room data messages. There is no room
behind the veneer.

## Dial out with the same worker

Nothing extra to run. The engine dials on your trunk and dispatches the
answered call to the worker you already registered — see
[Outbound Calls from a LiveKit Agent](/modalities/voice/recipes/livekit-outbound)
for single dials, paced campaigns, and campaign control.

## Verify it worked

The gateway gives this agent everything that it gives a native agent. The
caller edge is identical:

- **Call events & CDR** — the call appears in reports with an
  `initiated → established → cleared` lifecycle and a Q.850 clear cause.
- **Media quality** — per-call MOS / jitter / loss scoring lands on the CDR.
- **Recording** — the engine captures both legs if recording is enabled for the
  trunk.
- **Transcripts** — the plugin forwards finalized turns over the control
  stream. Your remote agent's history lands in the same store as a native
  agent's history.

See [Observability](/modalities/voice/observability/overview) for where these
appear.

## Troubleshooting

  **The worker registers, but calls find no agent**

Presence is tied to a live connection. If the worker's session drops and
    the reconnect did not land yet, dispatch finds nothing. Check that the
    worker logs a ready line **after** the most recent reconnect. Check that
    `vendor_room` in the agent config exactly matches `agent_name` in the
    worker config.

  **The agent speaks, but the caller hears nothing or a click**

The cause is almost always a sample-rate mismatch. Check `sample_rate` /
    `sampleRate` against the leg (`8000` for PSTN). The plugin paces and
    re-chunks outbound audio into steady 20 ms frames, so it handles bursty
    TTS. It does not handle a wrong rate.

  **The worker connects but no audio flows**

Media travels on QUIC **datagrams**. Check that outbound UDP/443 to
    `engine.firstbyte.fast` is open. A network that permits TCP/443 but drops UDP
    will complete presence and stall media.

  **Audio bandwidth is higher than expected**

The wire codec is raw `pcm16` today. Opus is not yet wired. Budget for
    uncompressed audio between your worker and the edge.

## Related

  - **[LiveKit integration reference](/modalities/voice/integrations/livekit)** — All three paths side by side: plugin, front-proxy, browser shim.
  - **[Bridge a LiveKit Agent](/modalities/voice/cookbooks/bridge-livekit)** — The front-proxy sidecar. Use it when your LiveKit room must keep running.
  - **[From Self-Hosted LiveKit](/modalities/voice/migration/from-livekit)** — The full migration plan: trunks, tokens, rollout.
  - **[Runtime Configuration](/modalities/voice/runtime/configuration)** — Every pipeline node field, including `VENDOR_BRIDGE`.
  - **[Outbound Calls from a LiveKit Agent](/modalities/voice/recipes/livekit-outbound)** — One dial or a paced campaign, from the worker you just built.
  - **[Embed an Agent Widget](/modalities/voice/recipes/web-embed)** — Reach the same worker from a browser instead of the PSTN.
