# Keep Your Existing Agent Runtime

> Bring your own agent runtime or media stack and use FirstByte for transport only — via the LiveKit Agents transport plugin, the browser drop-in shim, or the raw MoQT primitives.

You already have a working voice stack. It can be a LiveKit app, a Pipecat
bot, a bespoke agent loop, or your own ASR/LLM/TTS chain. You do not have to
rewrite any of it to run on FirstByte. This page shows how to keep your
runtime and adopt **only** the transport. The transport moves real-time audio
over QUIC/MoQT on the single `:443` plane. There is no ICE/DTLS/SRTP
negotiation and no media server to operate.

There are three on-ramps:

  - **LiveKit Agents plugin** — A **server-side agent** package. Install it into your `livekit-agents`
    worker. Your `entrypoint` runs verbatim over our QUIC transport, with no
    room.
  - **LiveKit drop-in shim** — A **browser** package. Swap one import. Your `Room`, `RoomEvent`, and
    track code still compiles, but runs over our transport instead of
    LiveKit's SFU.
  - **Raw transport primitives** — Wire your own runtime directly to `publishAudio` / `subscribeAudio`. You
    own capture, encode, and playback. We move the frames.

## Option 1 — the LiveKit Agents transport plugin

If your runtime is a server-side **LiveKit agent**, this is the shortest
path. The LiveKit Agents SDK skips its own `RoomIO` whenever a session
already has custom audio IO set. The plugin binds that seam to a
QUIC/WebTransport connection to FirstByte. Your `AgentSession`, plugins,
provider keys, and tool calls run unchanged. There is no room, no SFU, and no
LiveKit server.

  
```bash
pip install "livekit-plugins-clutchcall[quic]"
```
  
  
```bash
npm i @clutchcall/livekit-transport @fails-components/webtransport
```
  

You then swap your worker runner (`cli.run_app`) for `run_agent_worker`. That
runner dials out to `:443`, registers the worker, and serves engine-assigned
calls. The end-to-end walkthrough is in
[Run a LiveKit Agent on FirstByte Transport](/modalities/voice/recipes/livekit-transport).
The limits (pcm16 today, register-and-wait only, outbound UDP required) are
in [the integration reference](/modalities/voice/integrations/livekit).

## Option 2 — the LiveKit drop-in shim (browser)

The compat shim exposes a `livekit-client`-compatible surface (`Room`,
`RoomEvent`, `createLocalAudioTrack`, participants, tracks). The shim maps
that surface onto our MoQT/QUIC transport. The common **voice** path
publishes the mic, subscribes to remote audio, and exchanges data messages.
For that path, migration is a one-line import swap.

> **NOTE:**
> **Preview.** The `livekit-compat` shim is early-access. It covers the core
>   voice path (publish/subscribe audio, data messages). But it is not yet a
>   drop-in for every `livekit-client` API. Pin a version and test your flows.
>   If you only need transport, the raw MoQT primitives below are the stable
>   path.

  1. **Install the compat package**
```bash
    npm install @clutchcall/livekit-compat
    ```
  2. **Swap the import**
Point your existing `livekit-client` imports at the shim. Nothing else in
    your component changes.

    ```diff
    - import { Room, RoomEvent, createLocalAudioTrack } from "livekit-client";
    + import { Room, RoomEvent, createLocalAudioTrack } from "@clutchcall/livekit-compat";
    ```
  3. **Connect and go**
Your call sites are unchanged. The shim reads the room and identity out
    of your existing access token.

    ```ts
    const room = new Room();
    await room.connect(url, token);              // to your relay.firstbyte.fast endpoint
    await room.localParticipant.setMicrophoneEnabled(true);
    room.on(RoomEvent.TrackSubscribed, (track) => track.attach());
    ```

### What maps to what

Under the hood, the shim keeps LiveKit semantics but replaces the media plane
entirely. There is no SFU and no ICE. WebRTC's transport is never used.

| LiveKit concept | Runs as |
| --- | --- |
| `Room.connect(url, token)` | A MoQT session over WebTransport on `:443` |
| Participant identity | A room-scoped MoQT namespace (relay enforces isolation via the token) |
| Microphone track | Encoded Opus frames published as MoQT objects |
| Remote audio track | A MoQT subscription decoded with WebCodecs into a Web Audio graph |
| `publishData` / `DataReceived` | A reliable MoQT data track |

The microphone still goes through the browser's Opus encoder. But the shim
extracts and sends only the **encoded frames**. See
[One-Line WebRTC Diversion](/modalities/voice/transport-web/webrtc-diversion)
for how that codec tap works.

> **WARNING:**
> `room.connect()` **refuses to proceed** unless the browser supports encoded
>   media transforms (`RTCRtpScriptTransform`). The shim enforces this before
>   any audio flows. Thus a browser that would silently fall back to plaintext
>   WebRTC fails loudly instead. Today that means Chromium-family browsers. See
>   [Browser Compatibility](/modalities/voice/transport-web/browser-compatibility)
>   for the fallback ladder.

> **NOTE:**
> **Scope of the shim.** The voice surface is shipped and proven end-to-end.
>   Two browsers exchanged live Opus over the transport. **Video, screenshare,
>   and per-participant E2EE key rotation are deferred.** The shim is
>   audio-first. Data-track auto-subscribe is off by default. A subscription to
>   a track that a peer never published tears down the session. Thus pass
>   `subscribeData: true` only when peers are known to publish data. A
>   session-setup race can occasionally deliver zero frames on connect. If the
>   first attempt is silent, retry the connect.

> **NOTE:**
> **This shim is browser-only.** It re-implements the `livekit-client`
>   surface for a browser app. There is no server-side `livekit.rtc`
>   equivalent. A Python or Node **agent** should use the
>   [transport plugin](#option-1-the-livekit-agents-transport-plugin) above.
>   The plugin covers the server-side case a different way: it replaces the
>   agent session's audio IO and does not emulate a room.

## Option 3 — use only the transport

You may not be a LiveKit app. You may have your own capture, encode, and
playback, or a non-browser runtime. In that case, talk to the transport
primitives directly. You keep your runtime. We only carry the frames.

  
    ```ts
    import { MoqtClient, captureMicrophone, OpusPlayer } from "@clutchcall/agents";

    // 1. Open a MoQT/QUIC session (Chromium-family requires encoded transform).
    const client = await MoqtClient.connect(url, token, onState, {
      requireEncodedTransform: true,
    });
    client.setRoomScope(roomId);   // namespaces must be room-scoped (>= 1 element)

    // 2. Publish your mic as Opus objects.
    const pub = client.publishAudio(identity, "microphone");
    const capture = await captureMicrophone(pub);   // or pass your own MediaStreamTrack

    // 3. Subscribe to a peer and play it back.
    const player = new OpusPlayer(new AudioContext());
    await player.start();
    client.subscribeAudio(peerId, "microphone", (tsUs, frame) => player.push(tsUs, frame));

    // 4. Side-channel data on a reliable MoQT track.
    const data = client.publishFrame(identity, "data");
    data.write(BigInt(Date.now()) * 1000n, payload, /* priority */ 0);
    ```
  
  
    The native SDK bindings (Python, Go, Rust, Java, .NET) expose the same
    MoQT publish/subscribe primitives over the QUIC transport through a
    shared core.

    > **NOTE:**
> Native bindings speak QUIC/MoQT directly. They do **not** yet include
>       the browser's WebSocket/WebRTC fallback leg. Plan for a QUIC-reachable
>       network. Consult the per-language SDK reference for the exact method
>       and event names. These names vary by binding.
  

Everything on the wire is **Opus over MoQT**. What sits behind
`captureMicrophone` and `OpusPlayer` is yours: your VAD, your turn-taking,
and your model. The transport does not impose a runtime.

> **NOTE:**
> **Namespace rule.** Every track lives under a room-scoped namespace tuple
>   with a non-empty prefix and suffix. The media-over-QUIC engine rejects
>   empty tuples. `setRoomScope()` (and the shim's `Room`) handle this for you.
>   If you build namespaces by hand, keep them room-scoped, so peers can
>   discover each other.

## Runtime on your side, or ours?

You can keep your runtime and use only the transport. That is one end of a
spectrum. You can also hand us the conversation loop. Then FirstByte runs
the agent for you, and you bring only the **models**:

  - **[From Self-Hosted LiveKit](/modalities/voice/migration/from-livekit)** — The full migration path — trunks, tokens, and rollout — for a LiveKit
    deployment that moves onto our transport.
  - **[BYO Speech-to-Speech](/modalities/voice/runtime/byo-speech-to-speech)** — Keep our runtime. Point it at your own realtime speech-to-speech endpoint
    with per-tenant credentials.
  - **[WebRTC Diversion](/modalities/voice/transport-web/webrtc-diversion)** — Learn how the encoded-frame tap moves an existing WebRTC pipeline onto
    QUIC.
  - **[Agent Runtime Overview](/modalities/voice/runtime/overview)** — Learn what our managed runtime does when you want us to drive the
    conversation.
