ffmpeg, and no patched dependency. npm install compiles nothing. If you want the bot to publish audio, start from the DJ bot instead.
joinVoice() hands back the LiveKit credential the backend minted and stops there. Audio is a separate, opt-in media layer with its own optional native engine. See Publishing audio.The full bot
voice-bot.ts
Run it
From a checkout of the SDK, putCLOAK_TOKEN in a .env file and run:
How it works, piece by piece
Two permissions to get in, and the second is not the obvious one
Two permissions to get in, and the second is not the obvious one
voice_connect is checked server-wide. Then channel-level view is enforced, and the server baseline column for that is view_text, not view_voice.Those two govern joining only. Hearing is a separate, third grant. See the next section.This bot is deaf on purpose
This bot is deaf on purpose
A bot receives no other member’s media unless the server has granted it
voice_listen (permission code 30). voice_connect does not imply it, and joinVoice() never refuses a join for lacking it.That is the intended configuration for a publisher, not a degraded one. A music or TTS bot works perfectly while deaf. This example does not declare voice_listen in requiredPermissions, and that omission is the demonstration.Two things follow, and you need both.Without voice_listen, the bot is sent nothing by the SFU and is never given a key that could decrypt it. With voice_listen, the bot can decrypt everyone in the channel, by design. Declare it only if your bot genuinely needs to hear members, and expect a human to think about it on the consent screen. See Voice keys and scopes.The token is a credential
The token is a credential
VoiceConnection.token is a live LiveKit JWT. Never log it, never echo it into a channel, and never put it in an error message. The example logs only its length.debug: true redacts it on the SDK’s own wire log.A bot session is structurally Free tier, so the credential grants microphone only. Camera and screenshare are denied at token-mint time, not by the SDK.Roster events are cursor-gated, not firehose
Roster events are cursor-gated, not firehose
Unlike
messageCreate, the voice events reach a bot only for the server it currently has selected or is in voice in. Right after a join, that is the server you just joined.Do not build a global picture of who is in voice across every server from these events. It will be empty for every server the bot is not currently talking to.The roster itself is eventually consistent. A snapshot seeds it on every server select, and the deltas keep it current. client.refreshVoiceRoster(serverId) forces a fresh snapshot. voiceRoster(channelId) is a synchronous cache read, so an empty array can mean “nobody is there” or “no snapshot yet”.The roster is indexed by channel, so it answers “who is in this channel” and not “which channel is this user in”. If you need the reverse, build the index yourself off voiceJoin and voiceLeave, as the DJ bot does.The groupId rule
The groupId rule
groupId must be the voice channel’s real group, and it must never equal the channel id. groupRef === channelId is Cloak’s DM-call shape, and the SDK refuses it locally before anything reaches the wire.Omit groupId and the SDK resolves it from the server’s channel list, which is why !join accepts it as an optional argument rather than requiring it.Being moved, disconnected, or reconnected
Being moved, disconnected, or reconnected
voiceDisconnect fires when a moderator moves (reason: 'moved', with toChannelId) or disconnects (reason: 'forced') the bot. voiceConnection() is already cleared when it fires, and the SDK does not auto-rejoin. Deciding whether to come back is the bot’s call.On a transport reconnect the SDK restores voice for you, and it leaves before it rejoins. That order matters: the backend holds a dropped session’s membership open for 60 seconds and completes the leave only when the SFU confirms the media is gone, which never happens for a bot publishing none. Do the same if you manage voice yourself.What a VoiceParticipant tells you
What a VoiceParticipant tells you
Each roster entry carries
userId, channelId, muted, deafened, serverMuted, serverDeafened, webcam, stream, and reconnecting (true while that member’s session sits inside the backend’s 60 second grace window).Entries are the live cache objects, so later updates mutate them in place. Copy one if you need a snapshot.p2pWebcam and p2pStream still exist on the type and are permanently false. Peer-to-peer video was removed from the platform. Do not build on them.Next
Voice overview
The control plane, the audio layer, and what each one needs.
Joining voice
joinVoice, rosters, and the five voice events in full.Voice keys and scopes
Why a bot is handed a key rather than deriving one.
DJ bot
The same control plane, plus audio.