/play <source> works out which voice channel the invoker is sitting in, joins that one, and publishes audio into it. There is no channel id to paste.
The bot joins deafened and stays deafened. It publishes, and nothing arrives in the other direction. That is the recommended shape for a publisher, and this page explains how each half of it is enforced.
The full bot
dj-bot.ts
Run it
There is nonpm run example:dj script. From a checkout of the SDK, put CLOAK_TOKEN in a .env file and run the file directly:
/play in any text channel the bot can see. /stop leaves.
Which example to start from.
examples/music-bot.ts (npm run example:music) is the close relative: same publish-only shape, but you paste the voice channel id into a !play command instead of the bot finding you. It also predates client.voiceKeys and reaches the key lane through internals.keyManagerFactory, a seam that is explicitly not part of the stable API. Copy dj-bot.ts. Read music-bot.ts only for the text-command variant.How it works, piece by piece
Finding the invoker's voice channel
Finding the invoker's voice channel
The SDK keeps a per-channel voice roster, but it is indexed by channel.
client.voiceRoster(channelId) answers “who is in this channel”, not “which channel is this user in”.So the bot builds the reverse index itself off voiceJoin and voiceLeave. That is about ten lines and it is the only bookkeeping the feature needs.The catch: those events are cursor-gated. A bot receives them only for the server it currently has selected, or the one it is in voice on. The reverse index is therefore populated for the server the bot is actively talking to and empty for the rest. That is fine here, because the invocation tells the bot which server it is in, but it would break a bot trying to keep a global picture.When the invoker is not in the map, the bot says so rather than guessing a channel.Probing the engine before joining
Probing the engine before joining
probeVoiceRuntime() tries to load the native engine and reports what happened. It never throws, because a text-only bot must not be failed by an absent voice engine.On failure it returns { available: false, error, remedy }, where remedy is an actionable paragraph naming what to change on the machine (install it at the pin, apply the patch, move off Alpine, and so on). The bot echoes that straight into the channel.Probing beats letting an import throw halfway through a join, which would leave the bot holding a control-plane membership it cannot use.Joining deafened, and why both halves matter
Joining deafened, and why both halves matter
The bot joins with
{ deafened: true }, so the SFU forwards it nothing. Separately, it holds only its own participant-scoped frame key, so it is cryptographically unable to decrypt any member. Transport and crypto both say no.Two sentences, and you need both of them. A bot without voice_listen holds only its own frame key and cannot decrypt any member. A bot granted voice_listen can decrypt everyone in the channel, by design. Never quote the first without the second: a server owner who installs a voice_listen bot because “bots cannot hear you” has been misled.A bot is handed its voice key, never derives one
A bot is handed its voice key, never derives one
client.voiceKeys is the supported handle on the voice-key lane, and nothing else. It exposes acquireVoiceKey, getVoiceKey, voiceFrameKeyFor, and forgetVoiceKey. It deliberately cannot reach conversation keys, which is what keeps a media session structurally out of message content.acquireVoiceKey(serverId) waits for a human member to seal a key to this bot’s identity. If nobody holding the server key is online, this is the step that waits.The returned scope ('participant' or 'room') is read from inside the sealed blob, as an authenticated tag. The bot does not choose it, cannot override it, and here it refuses to continue if the answer is 'room' rather than quietly becoming a listening bot.Always install what voiceFrameKeyFor(serverId, identity) returns, never getVoiceKey().key. attach() does this for you. In room scope the held value is the room key, and installing that as your own frame key publishes audio nobody can decrypt.attach() installs the key before publishing
attach() installs the key before publishing
VoiceMediaSession.attach() connects, installs the frame key under the identity LiveKit reported, and only then publishes. Reverse that order and the frame cryptor is created without a key, so the bot transmits audio nobody can decrypt.voiceKey is passed for the scope and the length check. The bytes actually installed come from keys, the voiceFrameKeyFor() seam. deafened is diagnostic: can_subscribe is baked into the LiveKit token at join time and cannot be loosened here, but passing it lets attach() say the one useful thing when a listening bot hears nothing.play() is paced by the engine, not by you
play() is paced by the engine, not by you
session.player.play(resource) resolves when the source ends. The awaited captureFrame inside it is the only pacer.Do not add a 20 ms timer, a ring buffer, or an Opus encoder. The engine encodes internally. Any AsyncIterable<Int16Array> works as a resource, and pcmChunks() adapts a byte stream while handling the two silent corruption cases (odd-byte splits and misaligned buffers).A failed decode is an error, not a short song. An unreadable input makes ffmpeg write nothing and exit non-zero, which to a naive pump looks exactly like a clean end of stream. play() checks the exit code and throws a CloakEnvironmentError carrying the head of ffmpeg’s own stderr, plus a remedy. A deliberate stop() is exempt.ffmpeg for files, yt-dlp for pages
ffmpeg for files, yt-dlp for pages
ffmpegResource() takes a file path or a direct media URL. It has no site extractors. Hand it a YouTube, SoundCloud, or Bandcamp watch page and ffmpeg dutifully decodes the HTML, finds no audio, and exits non-zero. That is the commonest voice-bot mistake, and it is named by the SDK’s own remedy text.ytdlpResource() shells out to yt-dlp to turn the page into a real stream url first. It is async, unlike ffmpegResource. Its generic extractor passes a direct media url straight through, which is why the helper above routes every http(s) source through it and only sends local paths to ffmpeg.The resolved url is signed, expiring, and usually bound to this host’s IP. Resolve at play time. Never persist it, and never hand it to another machine. --no-playlist is passed by default; use the ytdlpArgs option for things like ['--cookies', 'cookies.txt'].Leaving in the right order
Leaving in the right order
leave() detaches the media session first, then drops the control-plane membership. Reverse that and the SFU keeps a publisher for a member who has left.voiceKeyUpdate fires when the bot’s voice key is reprovisioned: a new version, a new scope, or both. A scope flip re-pins at the same version, so a handler keyed only on the version would miss it.A scope change is not a re-pin. session.setVoiceKey() detaches and throws when the scope differs from the one it attached with, because subscription is fixed at connect time and the engine has no key-removal API. This bot handles that by tearing the session down. Rejoin to honor the new scope.Next
Voice runtime
Installing the engine at the pin, applying the patch, and the symptom-to-cause table for silence.
Publishing audio
VoiceMediaSession, VoicePlayer, and audio resources in full.Voice keys and scopes
Participant scope, room scope, and re-pinning.
Slash commands
Declaring commands and reading their arguments.