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

LiveKit

WebRTC rooms on a LiveKit server or LiveKit Cloud, with LiveKitTransport, LiveKitRESTHelper and the runner's token helpers.

Overview

LiveKitTransport joins a LiveKit room as a participant. Every other participant in the room (a browser with LiveKit's client SDK, a phone call through LiveKit SIP, another bot) can talk to the bot. LiveKit's server routes the media, so the bot needs no public UDP ports of its own.

  • LiveKitTransport: audio in and out (one pipecat-audio microphone track out, each participant's audio in), optional video out (one pipecat-video camera track) and video in (camera and screen share), data messages (RTVI travels over them), participant metadata, and SIP DTMF in and out.
  • LiveKitRESTHelper: LiveKit's room API over fetch (deleteRoomByName).
  • @pipedog/runner/livekit: token helpers (generateToken, generateTokenWithAgent, generateSessionTokens, configure) and the development runner's -t livekit.

Runtimes

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

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

BunNode ≥ 22DenoCloudflare Workers
LiveKitTransport (media)✅✅✅ (--allow-all)❌
LiveKitRESTHelper, @pipedog/runner/livekit (tokens, room API)✅✅✅not verified

The transport's media goes through LiveKit's Node SDK, @livekit/rtc-node, which loads a native N-API addon. The end-to-end test joins a real room on Bun 1.4, Node 24 and Deno 2.9 and checks data messages and audio both ways. Workers can't load native code, so the transport doesn't run there. Importing the package works everywhere: the addon is only loaded when the transport is set up.

The token helpers sign with livekit-server-sdk, which is plain JavaScript on WebCrypto and fetch.

Installation

bun add @pipedog/transport-livekit @livekit/rtc-node
import { LiveKitParams, LiveKitTransport } from "@pipedog/transport-livekit/transport";
import { LiveKitRESTHelper } from "@pipedog/transport-livekit/utils";
import { configure, generateSessionTokens } from "@pipedog/runner/livekit";

@livekit/rtc-node is an optional peer dependency, so install it yourself (1.1.0 or newer in the 1.x line). It ships prebuilt binaries for these platforms only:

OSArchitectures
macOSarm64 (darwin-arm64), x64 (darwin-x64)
Linux with glibcx64 (linux-x64-gnu), arm64 (linux-arm64-gnu)
Windowsx64 (win32-x64-msvc)

There is no build for musl (Alpine Linux), so use a Debian or Ubuntu based image. Without the package, or on another platform, setting up the transport fails with an error that says how to install it.

Prerequisites

  • A LiveKit server. Either LiveKit Cloud or your own livekit-server (for local development: docker run --rm -p 7880:7880 -p 7881:7881 -p 7882:7882/udp livekit/livekit-server --dev --node-ip 127.0.0.1, whose key is devkey and secret secret).
  • Its API key and secret, to sign access tokens: LIVEKIT_API_KEY and LIVEKIT_API_SECRET. The server URL goes in LIVEKIT_URL (wss://<project>.livekit.cloud, or ws://localhost:7880).
  • A client in the same room: LiveKit's Agents Playground, Meet, or your own app with a LiveKit client SDK.
  • A long-running Bun, Node or Deno process on one of the platforms above.

LiveKitTransport

Configuration

new LiveKitTransport({ url, token, roomName, params?, inputName?, outputName? }). The token is an access token whose identity is the bot's (see Tokens).

Prop

Type

LiveKitParams is a TransportParams with three more fields:

Prop

Type

Usage

bot.ts
import { Pipeline } from "@pipedog/core/pipeline/pipeline";
import { PipelineWorker } from "@pipedog/core/pipeline/worker";
import { WorkerRunner } from "@pipedog/core/workers/runner";
import { configure } from "@pipedog/runner/livekit";
import { LiveKitParams, LiveKitTransport } from "@pipedog/transport-livekit/transport";

// LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET and LIVEKIT_ROOM_NAME (or -r/--room).
// configure() mints an agent token for the bot and logs one for a user.
const [url, token, roomName] = await configure();

const transport = new LiveKitTransport({
  url,
  token,
  roomName,
  params: new LiveKitParams({ audioInEnabled: true, audioOutEnabled: true }),
});

const pipeline = new Pipeline([
  transport.input(),
  // ... STT, LLM context aggregators, LLM, TTS
  transport.output(),
]);
const worker = new PipelineWorker(pipeline);

transport.eventHandler(
  "on_first_participant_joined",
  async (_t: unknown, participantId: string) => {
    console.log(`${participantId} joined`);
  },
);
transport.eventHandler("on_participant_disconnected", async () => {
  await worker.cancel();
});

const runner = new WorkerRunner();
await runner.addWorkers(worker);
await runner.run();

Event handlers

Every participantId is the participant's LiveKit identity, the one set in its token, not its session id.

EventArgumentsWhen
on_connected(transport)The bot joined the room.
on_disconnected(transport)The bot left the room, or the server disconnected it.
on_before_disconnect(transport)Sync, just before the bot leaves.
on_first_participant_joined(transport, participantId)The first participant joined (or was already in the room).
on_participant_connected(transport, participantId)A participant joined.
on_participant_disconnected(transport, participantId)A participant left.
on_participant_left(transport, participantId, reason)Same, with "disconnected".
on_client_connected, on_client_disconnected(transport, { id })Aliases with the shape other transports use.
on_audio_track_subscribed, on_audio_track_unsubscribed(transport, participantId)A participant's audio started or stopped.
on_video_track_subscribed, on_video_track_unsubscribed(transport, participantId)A participant's video started or stopped.
on_app_message(transport, message, participantId)A JSON object arrived as a data message (RTVI uses this).
on_data_received(transport, data, participantId)Any data message, as bytes. participantId is null for messages a server SDK sent.
on_dtmf_event(transport, { tone, digit, code, participant_id })A SIP participant pressed a key. An InputDTMFFrame is pushed too.
on_active_speaker_changed(transport, { id })The room's loudest speaker changed.

Methods

  • getParticipants(), getParticipantMetadata(participantId), setMetadata(metadata).
  • muteParticipant(participantId) / unmuteParticipant(participantId): stop or resume receiving a participant's audio (LiveKit doesn't let one participant mute another's microphone).
  • sendMessage(message, participantId?) / sendMessageUrgent(...): a data message to everyone or to one participant. OutputTransportMessageFrames in the pipeline go out the same way.
  • captureParticipantVideo(participantId, framerate?, videoSource?, { onRequestOnly }).

Notes

  • Audio comes in at 48 kHz from LiveKit and is resampled to the pipeline's rate. With audioInUserTracks (the default) each participant's audio is a stream of UserAudioRawFrames with their identity; set it to false to mix everyone into one stream.
  • OutputDTMFFrame is sent as a native DTMF event (publishDtmf), one key per frame.
  • An InterruptionFrame clears the audio already queued in LiveKit's audio source, so the bot stops talking at once.
  • Connecting is retried 3 times, 4 seconds apart.

Tokens

The bot and each user need their own token for the room, signed with the API key and secret. @pipedog/runner/livekit signs them with livekit-server-sdk:

  • generateTokenWithAgent(room, identity, key, secret): the bot's token (agent: true, so LiveKit's clients show it as an agent).
  • generateToken(room, identity, key, secret): a user's token.
  • generateSessionTokens(room, sessionId, key, secret): both, with identities suffixed by the session id so sessions sharing a room don't evict each other.
  • configure(): reads LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET and the room (-r/--room or LIVEKIT_ROOM_NAME), and returns [url, botToken, roomName]. It logs a user token you can paste into a client.

All of them are async (signing uses WebCrypto).

With the development runner

bun bot.ts -t livekit serves POST /start. Each call creates a session: it mints a bot token and a user token for LIVEKIT_ROOM_NAME (or a new pipecat-xxxxxxxx room), starts your bot() with LiveKitRunnerArguments, and answers { url, token, sessionId } for the user. createTransport builds the transport from those arguments:

bot.ts
import type { RunnerArguments } from "@pipedog/runner";
import { createTransport } from "@pipedog/runner/utils";

/** The bot the development runner starts for each session (`bun bot.ts -t livekit`). */
export async function bot(runnerArgs: RunnerArguments): Promise<void> {
  const transport = await createTransport(runnerArgs, {
    livekit: () => new LiveKitParams({ audioInEnabled: true, audioOutEnabled: true }),
  });
  // ... build the pipeline around transport.input() and transport.output()
  void transport;
}

See Runner.

LiveKitRESTHelper

new LiveKitRESTHelper({ apiKey, apiSecret, apiUrl, fetch? }). deleteRoomByName(roomName) deletes a room and disconnects everyone in it. apiUrl is the server's HTTP URL (https://<project>.livekit.cloud). fetch replaces the global one (upstream takes an aiohttp session).

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