Cannot get Deepgram STT working — server native or bot approach

LiveKit Server: 1.9.11 (self-hosted on Ubuntu)
SDK: JS/Next.js (livekit-client 2.16.0)
Config: livekit.yaml with:
agents:
stt:
provider: deepgram
api_key: xxx
model: nova-3
What we tried:

  1. Server-native approach (agents.stt):
  • Added agents.stt block to livekit.yaml
  • Server logs show zero agent/Deepgram activity — no agent flags in --help either
  • No TranscriptionReceived events ever fire on the client
  • Binary appears to be compiled without agent support
  1. Node.js bot approach (fallback):
  • Built a bot using @livekit/rtc-node that joins the room, subscribes all audio tracks via AudioStream, streams PCM16 to Deepgram WebSocket, and publishes transcripts via localParticipant.publishData(topic=‘transcript’)

  • Bot successfully joins the room and connects to Deepgram (token valid, HTTP 200)

  • Client listens for RoomEvent.DataReceived with topic ‘transcript’ (and RoomEvent.TranscriptionReceived)

  • Bot joins, bot log shows no errors, but no data ever arrives on the client — no DataReceived events with topic ‘transcript’ fire on any client in the room

  • PublishData succeeds on bot side (no errors), but messages never reach other participants
    Question:

  • For the server-native approach — is a separate livekit-agents Python process still required, or should agents.stt in livekit.yaml be enough on 1.9.11?

  • For the bot approach — why does publishData from the bot not trigger DataReceived on other clients? Are there known issues with @livekit/rtc-node data publishing or do bot participants need special grants beyond canPublishData?

    Is there a proper Production level solution for transcriptions generation in livekit(with paid deepgram or any other solution).

Hi Meghana,**
**
Server-native approach — that config block doesn’t exist.

The open-source livekit-server binary has no agent or STT support in any build — it’s not that your binary was “compiled without it”. The server is purely an SFU (media routing); it never touches Deepgram or generates transcriptions itself. An agents.stt: block in livekit.yaml is simply an unknown key that gets silently ignored, which is why you see zero activity in the logs and no flags in --help. So yes — a separate agent worker process is always required on self-hosted. That’s true on every version, including 1.9.x. (LiveKit Cloud has hosted agents, which is probably where the confusion comes from.)

  1. Why your bot’s publishData never arrives.
    Data messages aren’t persisted — they’re only relayed between participants connected to the same room on the same server at that moment. The usual culprits, in order
    of likelihood:
  • Bot is in a different room or on a different server instance. First diagnostic: do your clients see the bot fire RoomEvent.ParticipantConnected? If the bot isn’t
    visible in room.remoteParticipants on the client, it’s connected to a different room name or a different LiveKit URL, and everything else is moot.
  • Missing canPublishData grant. If the bot’s token was created with an explicit grant that sets some permissions but omits/falses canPublishData, the SFU drops the
    packets silently — the publisher side gets no error. That matches your symptom exactly. Make sure the bot token has roomJoin: true, canPublishData: true.
  • Options shape in @livekit/rtc-node. It should be await localParticipant.publishData(data, { reliable: true, topic: ‘transcript’ }). If the options object is wrong
    (old positional API, destination_identities vs destinationIdentities, etc.), the message can go out without your topic set. To rule this out, temporarily log every
    DataReceived event on the client with no topic filter — if messages appear with topic: undefined, it’s the options shape.

There’s no known systemic bug in rtc-node data publishing — it’s used heavily in production.

  1. The production-grade answer: use the LiveKit Agents framework instead of a hand-rolled bot.
    This is exactly what the framework is for, and it’s fully self-hosted-compatible with paid Deepgram:
  • Run a worker with livekit-agents (Python) or @livekit/agents (Node) plus the Deepgram plugin (livekit-plugins-deepgram, model nova-3). Point it at your server with
    your normal LIVEKIT_URL / API key / secret — no Cloud account needed.
  • The worker auto-joins rooms via dispatch, subscribes to audio, streams it to Deepgram, and transcription forwarding is built in — you don’t write any of the PCM
    plumbing or data publishing yourself.
  • On the client (livekit-client 2.x), receive transcripts via the text-stream API on the lk.transcription topic:

room.registerTextStreamHandler(‘lk.transcription’, async (reader, participantInfo) => {
const text = await reader.readAll();
// reader.info.attributes[‘lk.transcribed_track_id’] tells you which track it belongs to
});

(RoomEvent.TranscriptionReceived is the legacy path — text streams are the current one.)

For an STT-only transcriber (no LLM/TTS), you can run the STT node standalone in the agent — the docs’ “Text and transcriptions” page
(docs.livekit.io/agents/multimodality/text) and the Deepgram STT plugin page cover it. This setup handles reconnects, Deepgram keepalives, interim vs final results,
and per-track attribution for you, and it scales by just running more workers.

Hope that unblocks you!

@meghana_reddy, Both paths are fighting the framework. STT is an Agents feature, not a server one: livekit-server (the SFU) does no STT and has no agents.stt key, which is why the binary shows no agent flags and the block is silently ignored (STT overview).

  • Server-native: not a thing on any version, 1.9.11 included. You need a separate livekit-agents worker process (Python or Node) running the Deepgram plugin. The server was never compiled with STT because no build has it.
  • Your client/bot path is on a deprecated API. TranscriptionReceived and publish_transcription() are deprecated; transcriptions now flow over the lk.transcription text-stream topic, read via registerTextStreamHandler('lk.transcription') (JS) or useTranscriptions (React), not a custom data topic (text and transcriptions). That is why nothing fires on the client. (A hand-rolled publishData also needs the bot token to carry canPublishData, but you do not need the bot at all.)

Production path: a livekit-agents worker runs STT and publishes transcripts natively over lk.transcription; the client just reads that stream.

# inside your agent entrypoint, separate worker process, BYO Deepgram key (DEEPGRAM_API_KEY in env)
from livekit.agents import AgentSession
from livekit.plugins import deepgram

session = AgentSession(stt=deepgram.STT(model="nova-3"))

On the client, remove the TranscriptionReceived listener and the transcript data topic, and use useTranscriptions (React) or registerTextStreamHandler('lk.transcription'). Server 1.9.11 is also quite old, worth upgrading once this works.

@Muhammad_Usman_Bashir

Agent code

await ctx.connect();

const session = new voice.AgentSession({
  stt: new deepgram.STT({
    model: "nova-3",
    language: "en",
  }),
});

const agent = new voice.Agent({
  instructions: "You are a silent transcription agent.",
});

await session.start({
  room: ctx.room,
  agent,
});

Expected behavior

If three participants join:

  • Participant A speaks → transcript

  • Participant B speaks → transcript

  • Participant C speaks → transcript

Each participant should generate transcripts independently.

Actual behavior

Only one participant ever receives transcripts.

The other participants trigger VAD, but no STT transcript is produced.

Logs

The agent subscribes to multiple participants:

onTrackSubscribed
participant: "User-A"

onTrackSubscribed
participant: "User-B"

When user-A speaks:

START_OF_SPEECH
END_OF_SPEECH

audioTranscript: ""

skipping EOU detection

When User-B speaks:

received user transcript
user_transcript: "Hi ..."

So:

  • VAD detects speech for both participants.

  • Both tracks are subscribed.

  • Only one participant ever produces a Deepgram transcript.

Question

Is voice.AgentSession intended for single-participant conversational agents, or should it support room-wide transcription for multiple participants?

If voice.AgentSession is not the correct abstraction for meeting transcription, what is the recommended production approach with @livekit/agents 1.5.5 to generate transcripts for every participant in a meeting?

Is there an official Node.js example for multi-participant meeting transcription using Deepgram STT?

Is voice.AgentSession intended for single-participant conversational agents?

Yes. voice.AgentSession is architected specifically for 1-on-1 agent-to-user conversational loops (an end-user interacting with an LLM bot).

Under the hood, AgentSession assigns a single “linked participant” (roomIO.linkedParticipant) to manage turn-taking, VAD, STT, and LLM state. Even though onTrackSubscribed fires for multiple participants when they publish audio:

  1. AgentSession internally routes VAD triggers to the active session state.

  2. The internal STT stream pipeline locks onto the first active user or expects a single stream for turn-handling.

  3. Audio from Participant B arrives at the VAD, but because AgentSession’s internal turn state is either locked or expecting frames from the linked participant, it drops/skips EOU (End Of Utterance) for Participant B, producing audioTranscript: "".

Recommended Production Approach for Multi-Participant Meeting Transcription

To generate transcripts for every participant in a meeting room, you should not use voice.AgentSession. AgentSession includes unnecessary turn-handling, LLM orchestration, and single-user audio state tracking.

Instead, create a Transcriber Worker that connects directly to the LiveKit Room via ctx.connect(), listens to TrackSubscribed events for all participants, and attaches an independent STT stream (stt.stream()) to each audio track.

Official Node.js Pattern for Multi-Participant Transcription

Here is the clean production implementation in TypeScript / Node.js using @livekit/agents:

TypeScript

import {
  type JobContext,
  ServerOptions,
  cli,
  defineAgent,
} from '@livekit/agents';
import * as deepgram from '@livekit/agents-plugin-deepgram';
import { RemoteAudioTrack, Track, RoomEvent } from '@livekit/rtc-node';
import { fileURLToPath } from 'node:url';

export default defineAgent({
  entry: async (ctx: JobContext) => {
    // 1. Connect worker to room without AgentSession
    await ctx.connect();

    // 2. Initialize Deepgram STT instance
    const stt = new deepgram.STT({
      model: 'nova-3',
      // apiKeys handled automatically by DEEPGRAM_API_KEY env var
    });

    console.log(`[Transcriber] Joined room: ${ctx.room.name}`);

    // Helper function: Attach an independent STT stream per track
    const handleAudioTrack = async (
      track: RemoteAudioTrack,
      participantIdentity: string
    ) => {
      console.log(`[Transcriber] Starting STT stream for: ${participantIdentity}`);

      // Create an independent STT stream for this participant
      const sttStream = stt.stream();

      // Forward PCM audio frames from LiveKit track into Deepgram stream
      const audioStream = new track.AudioStream(track);
      
      const audioTask = (async () => {
        for await (const frame of audioStream) {
          sttStream.push(frame);
        }
      })();

      // Read transcription events coming back from Deepgram
      const transcriptTask = (async () => {
        for await (const event of sttStream) {
          if (event.type === 'final_transcript' && event.text.trim()) {
            console.log(`[Transcript] ${participantIdentity}: ${event.text}`);

            // Option A: Publish transcript as a native text stream / data message back to room
            const payload = new TextEncoder().encode(
              JSON.stringify({
                speaker: participantIdentity,
                text: event.text,
                timestamp: Date.now(),
              })
            );

            await ctx.room.localParticipant.publishData(payload, {
              topic: 'transcript',
              reliable: true,
            });
          }
        }
      })();

      // Clean up when track ends or participant leaves
      track.once('ended', () => {
        sttStream.close();
      });
    };

    // 3. Subscribe to existing audio tracks
    for (const participant of ctx.room.remoteParticipants.values()) {
      for (const publication of participant.trackPublications.values()) {
        if (
          publication.track &&
          publication.kind === Track.Kind.KindAudio &&
          publication.track instanceof RemoteAudioTrack
        ) {
          handleAudioTrack(publication.track, participant.identity);
        }
      }
    }

    // 4. Subscribe to new audio tracks as participants join/speak
    ctx.room.on(
      RoomEvent.TrackSubscribed,
      (track, publication, participant) => {
        if (
          track.kind === Track.Kind.KindAudio &&
          track instanceof RemoteAudioTrack
        ) {
          handleAudioTrack(track, participant.identity);
        }
      }
    );
  },
});

cli.runApp(new ServerOptions({ agent: fileURLToPath(import.meta.url) }));

Why this fixes the issue:

  1. Isolated Streams: Every participant gets their own stt.stream() instance connected to Deepgram. Participant A speaking into Stream 1 has zero impact on Participant B speaking into Stream 2.

  2. No Turn-Locking: Eliminates voice.AgentSession VAD bottlenecks, preventing skipping EOU detection or empty audioTranscript: "" errors.

  3. Infinite Scaling: Works cleanly for 2, 10, or 50 meeting participants simultaneously, bounded only by Deepgram API concurrency limits and host network bandwidth.

I first look at it to see if it is networking issue, in routing, or from the backend. But, here is a good overview of what was happening with this.

When using LiveKit’s voice.AgentSession, the backend code explicitly binds the state machine to a single turn-based conversational loop.

Here is what happens behind the scenes:

  1. The Subscriptions Work (Not Routing): LiveKit Server correctly routed the WebRTC audio tracks from Participant A and Participant B to the node process. That’s why onTrackSubscribed fired for both users, and why VAD registered speech start/end events for both.

  2. The Backend State Lock: AgentSession internally expects one primary user (linkedParticipant). It assumes “User speaks $\rightarrow$ Bot listens $\rightarrow$ Bot responds.”

  3. The Buffer Collision: When Participant A and Participant B spoke, their audio frames hit the exact same underlying AgentSession pipeline. The session tried to process both inputs through a single VAD and STT pipeline designed for 1-on-1 turns.

  4. The Silent Failure: Because Participant B’s audio arrived while the single session was tracking Participant A’s speech window, the internal state machine discarded Participant B’s frame boundaries. It triggered skipping EOU detection (End Of Utterance) and spat out an empty transcript (audioTranscript: "").

The Fundamental Shift

  • AgentSession = A single brain for 1-on-1 conversational bots. It blends/locks state on purpose.

  • Per-Track STT Streams (stt.stream()) = Independent workers. Participant A gets Stream A, Participant B gets Stream B. They run completely isolated in parallel on the backend, which is why individual stt.stream() instances solve the problem.

We are now trying to implement the recommended architecture:

  • ctx.connect()
  • subscribe to every audio track
  • create one stt.stream() per participant
  • push audio frames into each stream
  • publish transcripts back to the room

However, the sample code shared on the forum doesn’t match the current npm packages.

We’re using:


@livekit/agents 1.5.5
@livekit/agents-plugin-deepgram 1.5.5
@livekit/rtc-node 0.13.31
LiveKit Server 1.9.11

For example:

  • Track.Kind.KindAudio does not exist.
  • The examples around AudioStream and pushing frames into stt.stream() don’t compile exactly with the published SDK.
  • There doesn’t appear to be an official end-to-end example of a production transcription worker.

Specifically we’d like to know:

  1. Which SDK version officially supports per-participant transcription workers?
  2. Is there an official example repository?
  3. What is the recommended way to connect AudioStreamDeepgram stt.stream()?
  4. Is upgrading to a newer Agents SDK required?

@meghana_reddy, Yes, that is the crux: AgentSession is a single-participant abstraction. Its RoomIO links to exactly one participant, the first to connect unless you set participantIdentity, and once that link resolves it skips every other participant (room_io.ts#L74). STT only runs on that one linked participant, which is why A or B transcribes but not both, even though VAD and subscription fire for everyone.

For room-wide meeting transcription, do not use one AgentSession for the room. Run an STT stream per subscribed audio track yourself, which is what AgentSession does internally for its single participant. STT.stream() returns an async-iterable stream you feed with pushFrame and read for FINAL_TRANSCRIPT (stt.ts#L282):

import { AudioStream } from '@livekit/rtc-node';
import { stt } from '@livekit/agents';
import * as deepgram from '@livekit/agents-plugin-deepgram';

// once per subscribed remote audio track:
const sttStream = new deepgram.STT({ model: 'nova-3' }).stream();
(async () => { for await (const f of new AudioStream(track)) sttStream.pushFrame(f); })();
for await (const ev of sttStream) {
  if (ev.type === stt.SpeechEventType.FINAL_TRANSCRIPT) {
    // final transcript for this participant's track: publish/attribute it here
  }
}

There is no official Node example for multi-participant meeting transcription; every transcription example in agents-js, including realtime_streaming_transcript.ts, is a single-participant AgentSession. If you would rather stay fully framework-managed, the alternative is one AgentSession per participant, each pinned with participantIdentity, but for a pure transcriber the per-track STT loop above is the lighter path.