Cloudflare Realtime
CloudflareRealtimeTransport connects a bot to the Cloudflare Realtime SFU over its WebSocket adapters, so users talk over WebRTC while the bot runs in a Worker, a Durable Object or any server.
Overview
@pipedog/transport-cloudflare-realtime connects a bot to
Cloudflare Realtime, Cloudflare's WebRTC SFU
(formerly Calls). The user talks over WebRTC from a browser or an app, with the SFU handling the
network. The bot never speaks WebRTC: the SFU's
WebSocket adapters
convert its tracks to and from PCM. That makes WebRTC users possible where the bot can't open UDP
sockets, in particular Cloudflare Workers and Durable Objects.
This package is pipedog's own; Pipecat has no counterpart. It contains:
CloudflareRealtimeTransport: the transport. It takes the sockets the SFU dials, and the browser's messages socket, from your server.CloudflareRealtimeAPI: a small client for the Realtime HTTPS API: sessions, tracks and WebSocket adapters.CloudflareRealtimeCall: the API calls for one user talking to one bot, in order, keeping what it allocated so it can release it.- The adapter's packet codec (
/packet).
Example
A Worker, a Durable Object per call, and a browser page.
Source
transport.ts, api.ts and packet.ts.
Realtime SFU docs
Cloudflare's documentation for sessions, tracks and adapters.
Installation
bun add @pipedog/transport-cloudflare-realtimeimport {
CloudflareRealtimeParams,
CloudflareRealtimeTransport,
} from "@pipedog/transport-cloudflare-realtime/transport";
import { CloudflareRealtimeAPI, CloudflareRealtimeCall } from "@pipedog/transport-cloudflare-realtime/api";Prerequisites
- A Realtime SFU app: its App ID and API token (the app secret), from the Cloudflare dashboard (Realtime > SFU). Keep the secret on your backend.
- A public
wss://URL for the bot: the SFU dials it, and can't reachlocalhost. For local development, use a tunnel such ascloudflared tunnel.
How it works
browser ──WebRTC (Opus)──► Realtime SFU ──WebSocket adapter (PCM)──► bot: input socket
browser ◄──WebRTC (Opus)── Realtime SFU ◄──WebSocket adapter (PCM)── bot: output socket
browser ◄────────────── WebSocket (RTVI JSON) ─────────────────────► bot: messages socket- The SFU dials the bot. Each adapter connects one track to a WebSocket URL you choose, in one
direction. An adapter with
location: "remote"streams the user's microphone to the bot (the bot's input); one withlocation: "local"takes the bot's audio and publishes it as a new track (the bot's output). The SFU can't send headers, so authenticate them with a per-call token in the URL. - Audio on both adapters is 48 kHz stereo 16-bit PCM, one protobuf packet per WebSocket message, at most 32 KB. The transport converts to and from the pipeline's sample rates and channels, and paces the bot's audio in real time, so an interruption stops it at once.
- App messages can't ride the adapters, and SFU DataChannels need a WebRTC peer on both ends, so RTVI goes over a third WebSocket that the browser opens to the bot: the messages socket. It carries the same JSON messages SmallWebRTC sends over its data channel, one per text frame.
- Reconnects. The SFU reconnects a dropped input adapter for up to 15 seconds; the transport
waits for it (
inputReconnectTimeout). The output adapter doesn't reconnect.
The order of a call, which CloudflareRealtimeCall follows:
- The browser publishes its microphone: it sends an SDP offer; your backend creates a session,
publishes the track (
publishUser) and returns the SFU's answer. - Once the browser is connected, your backend creates both adapters (
attachBot). The SFU dials both endpoints before the request returns, so the transport must already exist. - Your backend subscribes the browser's session to the bot's track (
subscribeUser, orconnectBotfor steps 2 and 3) and returns the SFU's offer. The browser answers, and your backend submits the answer (renegotiate). - At the end,
close()closes the adapters and force-closes the browser's tracks. What it can't close stays incall.state, plain JSON you can store and retry from.
Configuration
new CloudflareRealtimeTransport(params?, options?). params is a CloudflareRealtimeParams,
with the usual transport parameters (audioInEnabled, audioOutEnabled, sample rates, channels,
filters) plus:
Prop
Type
options:
Prop
Type
CloudflareRealtimeAPI takes:
Prop
Type
Usage
The bot is an ordinary pipeline around the transport:
import { Pipeline } from "@pipedog/core/pipeline/pipeline";
import { PipelineParams, PipelineWorker } from "@pipedog/core/pipeline/worker";
import { WorkerRunner } from "@pipedog/core/workers/runner";
import {
CloudflareRealtimeAPI,
CloudflareRealtimeCall,
type SessionDescription,
} from "@pipedog/transport-cloudflare-realtime/api";
import {
CloudflareRealtimeParams,
CloudflareRealtimeTransport,
} from "@pipedog/transport-cloudflare-realtime/transport";
/** Starts a bot on `transport`; it runs until the user leaves. */
async function runBot(transport: CloudflareRealtimeTransport): Promise<void> {
const pipeline = new Pipeline([
transport.input(),
// ... STT, LLM context aggregators, LLM, TTS
transport.output(),
]);
const worker = new PipelineWorker(pipeline, {
// The transport converts to and from the SFU's 48 kHz stereo.
params: new PipelineParams({ audioInSampleRate: 16000, audioOutSampleRate: 24000 }),
});
const runner = new WorkerRunner({ handleSigint: false, handleSigterm: false });
transport.eventHandler("on_client_connected", async () => {
console.log("the SFU attached both adapters");
});
transport.eventHandler("on_client_disconnected", async () => {
await runner.cancel();
});
await runner.addWorkers(worker);
await runner.run();
}Your server does the signaling for the browser and hands the transport the sockets. As a Durable Object, or any fetch handler that keeps one object per call:
/** One call, e.g. a Durable Object: signaling, the sockets the SFU dials, and the bot. */
class VoiceCall {
readonly #env: Env;
#call: CloudflareRealtimeCall | null = null;
#transport: CloudflareRealtimeTransport | null = null;
#token = crypto.randomUUID();
constructor(env: Env) {
this.#env = env;
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
const action = url.pathname.split("/").slice(4).join("/"); // /api/call/<id>/<action>
if (action === "offer") {
// 1. The browser's microphone offer: publish it, and start the bot.
const { sessionDescription, mid } = (await request.json()) as {
sessionDescription: SessionDescription;
mid: string;
};
const api = new CloudflareRealtimeAPI({
appId: this.#env.CLOUDFLARE_REALTIME_APP_ID,
appSecret: this.#env.CLOUDFLARE_REALTIME_APP_SECRET,
});
this.#call = new CloudflareRealtimeCall(api);
this.#transport = new CloudflareRealtimeTransport(
new CloudflareRealtimeParams({ audioInEnabled: true, audioOutEnabled: true }),
);
void runBot(this.#transport).finally(() => this.#call?.close());
const answer = await this.#call.publishUser(sessionDescription, mid);
return Response.json({ sessionDescription: answer.sessionDescription });
}
if (action === "connect" && this.#call) {
// 2. The browser is connected: the SFU dials both endpoints during this call.
const media = `wss://${url.host}${url.pathname.replace(/connect$/, "media")}`;
const offer = await this.#call.connectBot({
input: `${media}/input?token=${this.#token}`,
output: `${media}/output?token=${this.#token}`,
});
return Response.json({ sessionDescription: offer.sessionDescription });
}
if (action === "answer" && this.#call) {
// 3. The browser's answer to the bot's track.
const { sessionDescription } = (await request.json()) as {
sessionDescription: SessionDescription;
};
await this.#call.renegotiate(sessionDescription);
return Response.json({ ok: true });
}
if (this.#transport && (action === "media/input" || action === "media/output")) {
// The SFU can't send headers: check the token from the URL.
if (url.searchParams.get("token") !== this.#token) {
return new Response("forbidden", { status: 403 });
}
return this.#transport.acceptSocket(action === "media/input" ? "input" : "output", request);
}
if (this.#transport && action === "messages") {
// The browser's RTVI messages.
return this.#transport.acceptSocket("messages", request);
}
return new Response("not found", { status: 404 });
}
}acceptSocket(role, request) upgrades the request and attaches the socket. It upgrades from the
request alone on Workers, Durable Objects and Deno; on Bun, pass { server } and serve with
bunWebSocket, and on Node serve with serveFetch(), as for the
WebSocket transport. With a socket you upgraded yourself, call
attachSocket(role, WebsocketConnection.fromWebSocket(ws)).
The browser
There's no Pipecat client SDK transport for Cloudflare Realtime yet, so the browser side is plain WebRTC: publish the microphone, play the bot's track, and speak RTVI over the messages socket:
/** The browser: publish the microphone, play the bot, and speak RTVI over the messages socket. */
async function call(base: string): Promise<{ pc: RTCPeerConnection; messages: WebSocket }> {
const post = async (action: string, body: unknown = {}) => {
const response = await fetch(`${base}/${action}`, {
method: "POST",
body: JSON.stringify(body),
});
return (await response.json()) as { sessionDescription: RTCSessionDescriptionInit };
};
const gathered = (pc: RTCPeerConnection) =>
new Promise<void>((resolve) => {
if (pc.iceGatheringState === "complete") return resolve();
pc.addEventListener("icegatheringstatechange", () => {
if (pc.iceGatheringState === "complete") resolve();
});
setTimeout(resolve, 2000);
});
const mic = await navigator.mediaDevices.getUserMedia({ audio: true });
const pc = new RTCPeerConnection({
iceServers: [{ urls: "stun:stun.cloudflare.com:3478" }],
bundlePolicy: "max-bundle",
});
const audio = new Audio();
audio.autoplay = true;
pc.addEventListener("track", (event) => {
audio.srcObject = new MediaStream([event.track]);
});
// 1. Publish the microphone.
const transceiver = pc.addTransceiver(mic.getAudioTracks()[0]!, { direction: "sendonly" });
await pc.setLocalDescription(await pc.createOffer());
await gathered(pc);
const published = await post("offer", {
sessionDescription: pc.localDescription,
mid: transceiver.mid,
});
await pc.setRemoteDescription(published.sessionDescription);
await new Promise<void>((resolve) => {
pc.addEventListener("connectionstatechange", () => {
if (pc.connectionState === "connected") resolve();
});
});
// 2. The messages socket (RTVI), then the bot's track.
const messages = new WebSocket(`${base.replace(/^http/, "ws")}/messages`);
await new Promise((resolve) => messages.addEventListener("open", resolve, { once: true }));
const subscription = await post("connect");
await pc.setRemoteDescription(subscription.sessionDescription);
await pc.setLocalDescription(await pc.createAnswer());
await gathered(pc);
await post("answer", { sessionDescription: pc.localDescription });
// 3. Ready: the bot can greet the user now.
messages.send(
JSON.stringify({
label: "rtvi-ai",
type: "client-ready",
id: crypto.randomUUID(),
data: { version: "2.0.0", about: { library: "my-app" } },
}),
);
return { pc, messages };
}
RTVI works as with any transport, since the bot's RTVIProcessor sends and receives through the
transport's message frames: the bot answers client-ready with bot-ready, and sends
transcriptions and bot output. A Pipecat client SDK transport would need to do the signaling above
and carry RTVI over the messages socket.
Event handlers
| Event | Description |
|---|---|
on_client_connected | Both adapter sockets are attached. Receives the client (getClientId gives its id). |
on_client_disconnected | The input adapter didn't reconnect within inputReconnectTimeout, the output adapter closed, or the messages socket closed (with disconnectOnMessagesClose, the default). Not when the transport itself closes. |
on_app_message | A JSON message arrived on the messages socket. Receives the message and the client's id. |
Notes
- Greet the user when their browser can hear the bot: the bot's track only plays once the browser
has answered the subscription offer. The example greets on RTVI's
on_client_ready, which the browser triggers afterrenegotiate. - The transport doesn't end the call at the SFU: call
CloudflareRealtimeCall.close()when the bot stops, or the SFU keeps redialing a closed input adapter for 15 seconds. - Input is mono at the pipeline's rate; with
audioInChannels: 2and a 48 kHz pipeline it stays stereo. SetaudioOutSampleRate: 48000to skip output resampling. - Audio that arrives before the pipeline starts is dropped; bot audio sent while the output adapter isn't attached is dropped.
RealtimeKit
RealtimeKit, Cloudflare's meeting SDK, is out of scope for now: its agent participant can't send or receive audio yet (it is "planned" in Cloudflare's capability matrix). This transport uses the SFU directly.
Verification
The transport, the API helper and the example are tested against fakes built from Cloudflare's
documentation: a fake SFU that dials the bot's endpoints as the adapters do, and a fake API. They
haven't been run against the real Realtime service yet. A live API test runs when
CLOUDFLARE_REALTIME_APP_ID and CLOUDFLARE_REALTIME_APP_SECRET are set.
Runtime notes
| Runtime | Works | What it needs |
|---|---|---|
| Bun | ✅ yes | Nothing. |
| Node.js | ✅ yes | Nothing. |
| Deno | ✅ yes | Nothing. |
| Cloudflare Workers | ✅ yes | Nothing. |
Generated from pipecat.runtimes and pipecat.compat in packages/transport-cloudflare-realtime/package.json: ✅ means the compat smoke test passes on that runtime.
pipedog is an unofficial community TypeScript port of Pipecat. It is not affiliated with or endorsed by Daily or the Pipecat project.
Portions adapted from the Pipecat documentation, Copyright (c) 2024–2025 Daily, BSD-2-Clause (see apps/docs/LICENSE-pipecat-docs).
WebSocket
WebSocket transports with FastAPIWebsocketTransport (bring your own socket), SingleClientWebsocketServerTransport, WebsocketClientTransport and RTVIClientTransport, plus endpoint helpers for each runtime.
SmallWebRTC
Peer-to-peer WebRTC with SmallWebRTCTransport, SmallWebRTCConnection, SmallWebRTCRequestHandler and the smallWebRTCOfferHandler signalling endpoint.