pipedog is an unofficial community port of Pipecat, not affiliated with Daily.
pipedog

SmallWebRTC

Peer-to-peer WebRTC with SmallWebRTCTransport, SmallWebRTCConnection, SmallWebRTCRequestHandler and the smallWebRTCOfferHandler signalling endpoint.

Overview

SmallWebRTC connects one browser (or any WebRTC peer) straight to the bot, with no media server in between. Audio goes over RTP as Opus, and RTVI and app messages go over a data channel. It works with the Pipecat browser SDK's SmallWebRTCTransport.

  • SmallWebRTCTransport: the pipeline transport for one peer connection: audio in and out, optional camera and screen share, and app messages.
  • SmallWebRTCConnection: the peer connection itself (upstream on aiortc, here on werift), with ICE servers, tracks, the data channel and renegotiation.
  • SmallWebRTCRequestHandler: answers SDP offers, creates or reuses connections and applies ICE candidates. Framework-agnostic.
  • smallWebRTCOfferHandler: the signalling endpoint (POST and PATCH /api/offer) as a fetch handler, (Request) => Promise<Response>.
  • nodeRequestListener: adapts a fetch handler to Node's http.createServer().

The voice bot guide builds a complete SmallWebRTC voice agent, with a browser client.

Installation

bun add @pipedog/transport-smallwebrtc
import { SmallWebRTCTransport } from "@pipedog/transport-smallwebrtc/transport";
import { IceServer, SmallWebRTCConnection } from "@pipedog/transport-smallwebrtc/connection";
import { SmallWebRTCRequestHandler } from "@pipedog/transport-smallwebrtc/request_handler";
import { nodeRequestListener, smallWebRTCOfferHandler } from "@pipedog/transport-smallwebrtc/http";

The package depends on werift (a pure TypeScript WebRTC stack: ICE, DTLS, SRTP, SCTP, RTP) and libopus-wasm (libopus compiled to WebAssembly). Neither needs native code.

Prerequisites

  • A long-lived server process that can send and receive UDP. Serverless functions and Cloudflare Workers can't run the media.
  • A client: the Pipecat browser SDK with @pipecat-ai/small-webrtc-transport, pointed at your offer endpoint.
  • For peers behind restrictive NATs, a TURN server (see ICE servers).

SmallWebRTCTransport

Configuration

new SmallWebRTCTransport(connection, params, options?). connection is the SmallWebRTCConnection from the request handler. params is a plain TransportParams (audioInEnabled, audioOutEnabled, videoInEnabled, videoOutEnabled and so on). options:

Prop

Type

Usage

Serve the offer endpoint and start a bot for each new connection:

server.ts
import { createServer } from "node:http";
import { createTask } from "@pipedog/asyncio";
import { Pipeline } from "@pipedog/core/pipeline/pipeline";
import { PipelineWorker } from "@pipedog/core/pipeline/worker";
import { TransportParams } from "@pipedog/core/transports/base_transport";
import { WorkerRunner } from "@pipedog/core/workers/runner";
import { IceServer, type SmallWebRTCConnection } from "@pipedog/transport-smallwebrtc/connection";
import { nodeRequestListener, smallWebRTCOfferHandler } from "@pipedog/transport-smallwebrtc/http";
import {
  ConnectionMode,
  SmallWebRTCRequestHandler,
} from "@pipedog/transport-smallwebrtc/request_handler";
import { SmallWebRTCTransport } from "@pipedog/transport-smallwebrtc/transport";

/** Runs one bot on a new peer connection. */
async function runBot(connection: SmallWebRTCConnection): Promise<void> {
  const transport = new SmallWebRTCTransport(
    connection,
    new TransportParams({ audioInEnabled: true, audioOutEnabled: true }),
  );

  const pipeline = new Pipeline([
    transport.input(),
    // ... STT, LLM context aggregators, LLM, TTS
    transport.output(),
  ]);
  const worker = new PipelineWorker(pipeline);
  const runner = new WorkerRunner({ handleSigint: false });

  transport.eventHandler("on_client_connected", async () => {
    console.log("client connected");
  });
  transport.eventHandler("on_client_disconnected", async () => {
    await runner.cancel();
  });

  await runner.addWorkers(worker);
  await runner.run();
}

// POST /api/offer takes the browser's SDP offer and answers it; PATCH sends ICE candidates.
const offer = smallWebRTCOfferHandler(new SmallWebRTCRequestHandler(), (connection) => {
  // Start the bot in the background: the answer is sent once this returns.
  createTask(() => runBot(connection), "voice-bot");
});

Bun.serve({
  port: 7860,
  fetch(request) {
    if (new URL(request.url).pathname === "/api/offer") return offer(request);
    return new Response("not found", { status: 404 });
  },
});

On Node, wrap the same handler with nodeRequestListener:

// On Node, serve the same handler with the http module instead of Bun.serve.
createServer(nodeRequestListener(offer)).listen(7860);

App messages from the peer arrive on the data channel:

async function runWithMessages(connection: SmallWebRTCConnection): Promise<void> {
  const transport = new SmallWebRTCTransport(
    connection,
    new TransportParams({ audioInEnabled: true, audioOutEnabled: true }),
  );

  transport.eventHandler("on_app_message", async (_transport, message: unknown, sender: string) => {
    console.log(`message from ${sender}:`, message);
    // Reply on the data channel.
    connection.sendAppMessage({ type: "ack" });
  });
}

Event handlers

EventDescription
on_client_connectedThe peer connection is up. Receives the SmallWebRTCConnection.
on_client_disconnectedThe peer connection closed. Receives the SmallWebRTCConnection.
on_app_messageA data channel message arrived. Receives the message and the sender.

Notes

  • After on_client_connected runs, the input pushes a ClientConnectedFrame. App messages are also pushed into the pipeline as InputTransportMessageFrames.
  • Video sources listed in params.videoInSources are captured before on_client_connected runs, so a handler that captures a source itself wins. captureParticipantVideo() captures the camera ("camera") or the screen share ("screenVideo"), at 30 frames per second by default.
  • getClientId(client) returns the peer connection's pcId.
  • When the peer replaces the connection (restart_pc), the old track completes its pending writes instead of leaving them waiting. Upstream leaves them pending, so the output transport's write timeout fails the pipeline.

SmallWebRTCConnection

You rarely build one yourself: SmallWebRTCRequestHandler creates one per offer, with the options from its connectionOptions and iceServers.

Configuration

Prop

Type

Event handlers

EventDescription
app-messageA data channel message arrived. Messages that arrive before the client is connected are queued and delivered on connect.
track-startedA remote track started.
track-endedA remote track ended.
track-statusThe peer turned a source on or off.
new, connecting, connected, disconnected, closed, failedPeer connection state changes.

Notes

  • If the connection stays in the connecting state longer than connectionTimeoutSecs (default 60), it is closed.
  • sendAppMessage(message) sends a JSON message on the data channel.
  • Audio is decoded straight to the pipeline's input rate and channel count when Opus supports it (8, 12, 16, 24 or 48 kHz). aiortc always decodes to 48 kHz stereo.
  • Opus decoding uses packet loss concealment and in-band FEC. Pass opusCodec, or call setOpusCodec(), to replace libopus-wasm (with a native binding, for example).
  • There is no built-in video codec. Without videoDecoderFactory, incoming video is negotiated but produces no frames; without videoEncoderFactory, outgoing video frames are dropped. A warning is logged once in each case. VP8 and H.264 depayloading and packetization are built in.
  • The SCTP data chunk payload is 1100 bytes, so data channel packets fit a 1280-byte MTU. Change it with the PIPECAT_SCTP_MAX_CHUNK_SIZE environment variable.

SmallWebRTCRequestHandler

Configuration

Prop

Type

ICE servers

Without ICE servers, only host candidates are gathered, which works on a LAN or localhost. Add a STUN server to cross NATs, and a TURN server for networks that block direct UDP:

ice.ts
// A TURN server, for peers behind NATs that block direct connections.
const handler = new SmallWebRTCRequestHandler({
  iceServers: [
    new IceServer({ urls: "stun:stun.l.google.com:19302" }),
    new IceServer({
      urls: "turn:turn.example.com:3478",
      username: process.env.TURN_USERNAME ?? "",
      credential: process.env.TURN_CREDENTIAL ?? "",
    }),
  ],
  // Accept one peer at a time.
  connectionMode: ConnectionMode.SINGLE,
});

updateIceServers() changes the list for connections created afterwards.

Notes

  • ConnectionMode.MULTIPLE (the default) accepts several peers. ConnectionMode.SINGLE keeps one: an offer with a different pc_id, or with none while a connection exists, gets HTTP 400.
  • An offer with the pc_id of an existing connection renegotiates it instead of creating a new one.
  • esp32Mode removes the SHA-384 and SHA-512 fingerprints ESP32 peers don't support and, with host, keeps only the ICE candidates that match it.

smallWebRTCOfferHandler

smallWebRTCOfferHandler(handler, onConnection) serves one URL, such as /api/offer, with the same contract as Pipecat's development runner:

MethodBodyResponse
POST{sdp, type, pc_id?, restart_pc?, request_data?}{sdp, type, pc_id}
PATCH{pc_id, candidates: [{candidate, sdp_mid, sdp_mline_index}]}{"status": "success"}

onConnection(connection, request) runs for each new connection, with the parsed SmallWebRTCRequest (its requestData carries the client's custom data). Start the bot in the background and return: the answer is sent once it returns.

Errors follow FastAPI: an HTTPException becomes its status with {"detail": ...}, a malformed body a 400, another method a 405 and anything else a 500.

It is a standard fetch handler, so it mounts in Bun.serve, Deno.serve, Hono and most frameworks; see Frameworks. On Node, nodeRequestListener(fetchHandler) turns it into a listener for http.createServer().

Runtime notes

RuntimeWorksWhat it needs
Bun✅ yesNothing.
Node.js✅ yesNothing.
Deno✅ yesNothing.
Cloudflare Workers❌ noNothing.

Generated from pipecat.runtimes and pipecat.compat in packages/transport-smallwebrtc/package.json: ✅ means the compat smoke test passes on that runtime.

BunNodeDenoCloudflare Workers
yesyesyesno

WebRTC media runs over UDP. Bun, Node and Deno can open UDP sockets; Cloudflare Workers can't, so the package does not run there. Use the WebSocket transport on Workers. See Runtimes.

The signalling endpoint is plain HTTP, but the connection it creates lives in the same process, so the offer endpoint and the media must run on the same long-lived server.

Upstream encodes and decodes with PyAV (FFmpeg). Here Opus comes from libopus-wasm and video codecs are pluggable, as described above. werift's API wears an aiortc-shaped adapter (rtc.ts), so the ported code keeps upstream's structure.

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).

On this page