Skip to main content
The control plane puts your bot into a voice channel and tells you who else is there. It is pure JavaScript. You do not need the native audio engine, ffmpeg, or anything else on the host to use everything on this page.
joinVoice() mints a credential and stops. It sends and receives no media. Audio is the separate, opt-in layer described in Publishing audio, which takes conn.url and conn.token as its input.

Permissions

Two grants get the bot in, and the second is not the one you would guess.
1

voice_connect, server-wide

Checked across the whole server. joinVoice() pre-checks it locally when a verdict has already arrived and refuses early, but the server stays authoritative.
2

Channel-level view

The view check for a voice channel runs through the backend’s permission ladder, whose server baseline column is view_text, not view_voice. Declaring view_voice alone is not enough.
Those two govern joining. Hearing is a separate third grant.
voice_listen (code 30) is what decides whether your bot can hear anything, and nothing else implies it. Without it, the SFU forwards your bot no member media at all, and the key lane never seals it a key that could decrypt any member. Joining is never refused for lacking it: publishing works fine while deaf, and that is the normal configuration for a music or text-to-speech bot. With debug: true the SDK warns at join time when the permission is known to be absent. Read Voice keys and scopes before you declare it.

joinVoice

string
required
The server the voice channel belongs to. joinVoice() moves the server cursor for you.
string
required
The voice channel to join.
string
The channel’s group. Resolved from the server’s channel list when omitted.
boolean
default:"false"
Join with the self-mute flag set.
boolean
default:"false"
Join with the self-deafen flag set. Deafened implies muted, mirroring both shipped Cloak clients. Join deafened when the bot only publishes: the SFU then forwards it nothing at all.
Rejects with a CloakActionError carrying the backend’s code on any failure. See Handling errors.
groupId must be the channel’s real group, and must never equal the channel id. A group reference equal to the channel id is Cloak’s direct-message call shape, which the backend rejects. The SDK refuses it locally before anything reaches the wire.

VoiceConnection

token is a live credential. Never log it, never echo it into a channel, never put it in an error message. With debug: true the SDK redacts it on the wire log. Log its length if you need a signal that one arrived.
url defaults to the same host as the transport on port 7880, derived by the exported livekitUrlFor() helper. Override it with ClientOptions.livekitUrl when your deployment puts the SFU somewhere else.
selfDeafened is not the answer to “can this bot hear”. It is the self-flag the SDK put in the join frame, and it says nothing about the SFU grant. A bot that joins with deafened: false and no voice_listen reads selfDeafened === false and still hears nothing, because its credential carries no subscribe grant. To ask whether the bot may hear, call client.can('voice_listen', serverId) (advisory, and false also means “no verdict has arrived yet”), or read the bot’s own entry in voiceRoster(channelId).

leaveVoice

A safe no-op server-side when the bot is not in voice, so you can call it defensively.
Leave before you rejoin. The backend holds a dropped session’s voice membership open for 60 seconds and only completes the leave when the SFU confirms the media is gone, which never happens for a bot that publishes none. leaveVoice() is the escape hatch out of that grace window. The SDK’s own reconnect path calls it before rejoining for exactly this reason. Do the same if you manage voice yourself.

voiceConnection

The bot’s own live voice membership, or null. A synchronous cache read.

setVoiceSelfState

Sets the bot’s own mute and deafen flags. Requires a live join: it throws when voiceConnection() is null. The backend broadcasts this and never acknowledges it, so the local state updates optimistically. Deafening implies muting.

voiceRoster

The cached roster for one voice channel. Synchronous. Seeded by a snapshot the backend pushes on every server select, and kept current by the delta events below.
The roster is indexed by channel, not by user. voiceRoster(channelId) answers “who is in this channel”, never “which channel is this user in”. Building the reverse index is about ten lines and every music bot needs it. See Finding which channel a member is in below.
The returned array is a fresh copy, but its entries are the live cache objects. Copy an entry if you need a snapshot of it.

VoiceParticipant

p2pWebcam and p2pStream are dead API. Peer-to-peer video has been removed from the Cloak platform. The fields remain on the type so the wire column map stays stable, and they are permanently false. Do not branch on them and do not present peer-to-peer as a feature.

refreshVoiceRoster

Re-seeds the roster for a server by re-selecting it, because the roster snapshot is a side effect of a server select. Use it after a reconnect, or when you need a known-good picture before acting.

The voice events

Five events, all of which fire for the bot’s own actions too.
Filter the bot’s own echo with p.userId === client.user?.id where it matters. The voiceJoin payload is the live roster entry, so later updates mutate it in place.
Roster events are cursor-gated, not a firehose. Unlike messageCreate, these four reach a bot only for the server it currently has selected or is in voice in. Do not build a global cross-server picture on them.
When voiceDisconnect fires, voiceConnection() is already cleared and the SDK does not auto-rejoin. Decide for yourself whether rejoining is right.

Finding which channel a member is in

Because the roster is keyed by channel, keep your own reverse index off voiceJoin and voiceLeave.
The index is populated for the server the bot is actively watching and empty for the rest, which is a direct consequence of the cursor gating above. That is fine when the command that reads it also tells you the server, and it would bite a bot trying to hold a global picture.

A complete control-plane bot

This bot runs with no native dependency at all. It answers commands in any text channel it can see.
Two things this sample does on purpose. It uses msg.channel.send() rather than msg.reply(), because these are plain answers and a true reply fires a mute-piercing “replied to you” notification. And it does not declare voice_listen, so it is deaf: it can publish (with the audio layer) and can hear nothing.
send() and channel.send() are fire-and-forget. A server-side denial does not reject the promise, it arrives on the sendRejected event. See Sending messages.

Through the guild handle

Both server-scoped voice methods are bound on client.guild(serverId).
leaveVoice(), voiceConnection(), voiceRoster(), and setVoiceSelfState() stay on the client, because a bot holds at most one voice membership at a time. See The guild handle.

Next

Voice keys and scopes

Where the frame key comes from, and why installing the wrong one is silent.

Publishing audio

Turn conn.url and conn.token into sound in the channel.

Voice reference

Every voice export, type, and constant.

Events

The complete client event map.