← All packages

Communications · Convex component

convex-livekit

Sync LiveKit rooms, participants, tracks, egress, and ingress into Convex reactively, and manage them directly from Convex functions.

npm install convex-livekit
27downloads last week
1,329downloads last 12 months
v0.0.9latest, Sep 18, 2026
9releases since Sep 13, 2026

Downloads

Download history

Loading download history…

Build this

Build with convex-livekit

The install and setup steps from the package documentation.

  1. 1

    Install

    sh
    npm install convex-livekit
  2. 2

    Quick Start

    1. Add the component

    ts
    // convex/convex.config.ts
    import { defineApp } from "convex/server";
    import convexLivekit from "convex-livekit/convex.config";
    
    const app = defineApp();
    app.use(convexLivekit);
    
    export default app;

    2. Set environment variables

    sh
    npx convex env set LIVEKIT_API_KEY APIxxxxxxxx
    npx convex env set LIVEKIT_API_SECRET your-api-secret
    npx convex env set LIVEKIT_HOST https://your-project.livekit.cloud

    These come from your LiveKit Cloud project settings (or your self-hosted server's configured key/secret pair).

    3. Mount the webhook handler

    ts
    // convex/http.ts
    import { httpRouter } from "convex/server";
    import { components } from "./_generated/api";
    import { LiveKit } from "convex-livekit";
    
    const livekit = new LiveKit(components.convexLivekit, {
      apiKey: process.env.LIVEKIT_API_KEY!,
      apiSecret: process.env.LIVEKIT_API_SECRET!,
      host: process.env.LIVEKIT_HOST!,
    });
    
    const http = httpRouter();
    
    http.route({
      path: "/webhooks/livekit",
      method: "POST",
      handler: livekit.webhookHandler,
    });
    
    export default http;

    4. Register the webhook in your LiveKit project

    In your LiveKit Cloud project settings (or your self-hosted server's webhook config), set the webhook URL to https://<your-deployment>.convex.site/webhooks/livekit. LiveKit signs every webhook with your project's own API key/secret pair — there's no separate webhook secret to configure.

    5. Initialize the client

    ts
    // convex/example.ts
    import { action, query } from "./_generated/server";
    import { components } from "./_generated/api";
    import { LiveKit } from "convex-livekit";
    import { v } from "convex/values";
    
    const livekit = new LiveKit(components.convexLivekit, {
      apiKey: process.env.LIVEKIT_API_KEY!,
      apiSecret: process.env.LIVEKIT_API_SECRET!,
      host: process.env.LIVEKIT_HOST!,
    });
    
    export const listRooms = query({
      args: {},
      handler: async (ctx) => {
        return await livekit.listRooms(ctx, {});
      },
    });
  3. 3

    Setup

    The component needs no schema changes in your app — its tables (rooms, participants, tracks, egress, webhookEvents) live entirely inside the component's own isolated schema. All you need is the webhook mounted (step 3) and a LiveKit client instance wherever you call its methods.

    Unlike the other components in this series, convex-livekit never stores a long-lived credential in a header — every server API call and every room-join token is a fresh, short-lived JWT this component signs itself with your API key/secret, following the same convention as LiveKit's own server SDKs.

  4. 4

    Usage

    Create a room

    ts
    export const openRoom = action({
      args: { name: v.string(), maxParticipants: v.optional(v.number()) },
      handler: async (ctx, args) => {
        return await livekit.createRoom(ctx, args);
      },
    });

    Returns { sid, name } and immediately records the room in Convex — you don't have to wait for the room_started webhook to see it in a query.

    Mint a join token for a client

    ts
    export const getJoinToken = action({
      args: { roomName: v.string(), identity: v.string() },
      handler: async (ctx, args) => {
        return await livekit.createRoomToken(args);
      },
    });

    createRoomToken touches no database — it's a plain signing operation, so it also works from a query if you'd rather not spend an action round-trip. The returned { token } is what you pass to a LiveKit client SDK (room.connect(url, token)).

    Remove a participant

    ts
    export const kick = action({
      args: { roomName: v.string(), identity: v.string() },
      handler: async (ctx, args) => {
        await livekit.removeParticipant(ctx, args);
        return null;
      },
    });

    Update a participant, or mute their track

    ts
    export const setAgentState = action({
      args: { roomName: v.string(), identity: v.string(), state: v.string() },
      handler: async (ctx, args) => {
        await livekit.updateParticipant(ctx, {
          roomName: args.roomName,
          identity: args.identity,
          attributes: { "lk.agent.state": args.state },
        });
        return null;
      },
    });
    
    export const muteMic = action({
      args: { roomName: v.string(), identity: v.string(), trackSid: v.string() },
      handler: async (ctx, args) => {
        await livekit.mutePublishedTrack(ctx, { ...args, muted: true });
        return null;
      },
    });

    Both call LiveKit's server API first, then patch the corresponding Convex row so the change is visible in queries immediately — no round trip through a webhook required.

    Record or stream a room (egress)

    ts
    export const startRecording = action({
      args: { roomName: v.string() },
      handler: async (ctx, args) => {
        return await livekit.startRoomCompositeEgress(ctx, {
          roomName: args.roomName,
          filepath: `recordings/${args.roomName}-{time}.mp4`,
        });
      },
    });
    
    export const stopRecording = action({
      args: { egressId: v.string() },
      handler: async (ctx, args) => {
        return await livekit.stopEgress(ctx, args);
      },
    });

    startRoomCompositeEgress records or livestreams the whole room (mixed audio/video of every participant) to a file, one or more RTMP(S) URLs, or both. It uses LiveKit's StartRoomCompositeEgress RPC — the file/stream actually lands wherever your LiveKit server's own storage config (S3/GCP/Azure/local) sends it. Both calls patch the egress row immediately, same pattern as the room/participant/track actions above.

    Bring an external stream into a room (ingress)

    ts
    export const createStreamKey = action({
      args: { roomName: v.string(), identity: v.string() },
      handler: async (ctx, args) => {
        return await livekit.createIngress(ctx, {
          inputType: "rtmp",
          name: `${args.roomName}-obs`,
          roomName: args.roomName,
          participantIdentity: args.identity,
          participantName: args.identity,
        });
      },
    });

    Returns { ingressId, url, streamKey } — hand url/streamKey to OBS or any RTMP encoder, and it joins the room as a regular participant. updateIngress changes a reusable (RTMP/WHIP) ingress's target room or identity; deleteIngress removes it permanently (unlike the other tables, the row is actually deleted, not kept as history — see ).

    Read rooms, participants, tracks, egress, and ingress reactively

    tsx
    const rooms = useQuery(api.example.listRooms, {});
    const participants = useQuery(api.example.listParticipantsByRoom, {
      roomName: "standup",
    });
    const tracks = useQuery(api.example.listTracksByRoom, { roomName: "standup" });
    const ingress = useQuery(api.example.listIngressByRoom, { roomName: "standup" });

    Every room_started/room_finished, participant_joined/participant_left/participant_connection_aborted, track_published/track_unpublished, egress_started/egress_updated/egress_ended, and ingress_started/ingress_ended webhook event patches or inserts a row, so these queries re-render live — no polling.