A publish-only bot, end to end
The five steps, assuming a logged-inclient. A complete runnable bot is further down the page.
VoiceMediaSession.attach
string
required
VoiceConnection.url.string
required
VoiceConnection.token. A live credential. Never log it.string
required
Your bot’s Cloak user id, from
client.user.id. The media identity is derived from it, and attach() asserts the SFU agrees.string
required
The server the voice channel belongs to. The key derivation binds it.
VoiceKey
required
The scope-tagged key from
acquireVoiceKey(). Used for the scope and the 32-byte length check. Its bytes are not what gets installed.VoiceKeyAccess
required
Where frame keys come from. Pass
client.voiceKeys.boolean
Whether the bot joined deafened. Purely diagnostic: it lets
attach() warn about the one combination that silently breaks a listening bot.number
default:"48000"
The publish format: what
session.player expects from an AudioResource.number
default:"2"
The publish channel count.
number
default:"48000"
Room scope only. The sample rate every received
PcmFrame arrives at. The engine resamples. Fixed at attach. See Receiving audio.number
default:"1"
Room scope only. The channel count of every received
PcmFrame. The engine downmixes or upmixes. Fixed at attach. Mono is the default because a voice channel is mono at the source and speech-to-text input wants mono.(state: 0 | 1) => void
The connection-state sender. Pass
client.voiceConnectionReporter(). Not optional in practice: without it a server-voice credential stays microphone-clamped on a current backend. attach() calls it with 0 before the publish, once the identity and key checks have passed, re-sends it every second until the admission lands or admissionTimeoutMs runs out, and calls it with 1 then 0 around an engine reconnect. Never from detach(). A plain function, so the media module never imports Client.number
default:"5000"
How long
attach() waits for the microphone admission after reporting the connection up. It polls the local participant’s canPublish permission every 100 ms, the only signal the engine exposes. On timeout it still publishes once, because an older single-phase backend never flips the flag but its credential already allows publishing; if that publish rejects, attach() fails closed with an error naming the two-phase admission, connectionReporter, and this option. Ignored without a connectionReporter.boolean
default:"false"
Logs which key fingerprint was installed under which identity. Never logs key material.
What attach enforces
Every step fails closed, because every failure mode in this path is otherwise silent.1
Load the engine
An actionable environment error before anything else happens.
2
Validate the key
Exactly 32 bytes, and a scope that came from the authenticated tag. A key that carries no scope is refused rather than defaulted.
3
Connect
Subscription is derived from the scope, never from a free-standing flag.
4
Assert the identity
If the SFU knows this session by a different name than the one the key was sealed for,
attach() fails. Publishing under a name nobody derives a key for produces audio nobody can decrypt.5
Install the bot's own key first
In both scopes.
6
Room scope only: install every other identity
Everyone already in the channel, plus a hook so late joiners get one too.
7
Report the connection up and wait for admission
With a
connectionReporter. The report goes out through it, the reconnect bracket is wired, and attach() polls the local participant’s canPublish permission every 100 ms, re-sending the report every second, until it flips or admissionTimeoutMs (default 5 s) runs out. On a current backend this is what admits the microphone: the credential was minted without it, and the backend re-applies the bot’s durable grant when it sees the report. Without a reporter there is no wait, and attach() warns under debug.8
Publish, only now
The local frame encryptor is created at publish time. With no key filed for the bot’s identity, frames are dropped rather than sent in the clear. After a timed-out wait the publish is still attempted once, because an older single-phase backend never flips the flag but its credential already allows it. If that publish rejects,
attach() fails closed with an error naming the two-phase admission, connectionReporter, and admissionTimeoutMs. A publish that rejects after a successful admission is rethrown as is.Microphone admission
A current Cloak backend admits a server-voice microphone in two phases. The join credential fromjoinVoice() carries no publish grant. After the media connection is up, the bot reports it ([621, 0] on the wire), and the backend re-resolves the bot’s durable grant and applies it through the media control plane. Only then does the SFU forward the bot’s audio.
attach() does the reporting for you when you pass connectionReporter: client.voiceConnectionReporter(). The order is fixed, and it is the desktop client’s order: connect, install keys, report 0, wait for the admission, then publish. The wait polls the local participant’s canPublish permission every 100 ms (the engine exposes no permissions event, so polling is the honest option), re-sends the report every second while the flag stays false, and gives up after admissionTimeoutMs (default 5 s). Around an engine reconnect the session reports 1 (reconnecting) then 0 (recovered), which re-arms the backend’s reconnect grace and re-admits the microphone. detach() reports nothing; leaveVoice() is what tells the backend the session is gone.
What happens at the end of the wait:
The reporter is a no-op when the bot is not in voice, and in a DM call, which the backend does not clamp. Sending the report twice is harmless: the backend re-applies the same grant.
Session surface
Playing audio
play() resolves when the resource ends or when stop() is called, and rejects when the source throws. It refuses to start a second resource while one is playing: call stop() first to switch tracks.
Player events: start, end, and error.
A successful
play() proves the bytes reached the encoder. It proves nothing about whether any member decrypted them. See the blind spot.Audio resources
AnAudioResource is any async iterable of interleaved 16-bit PCM, plus its format.
pcmChunks
ffmpegResource
A failed decode is an error, not a short song. An unreadable input makes ffmpeg write nothing to standard output 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 as its diagnosis. A deliberate stop() is exempt. The error also distinguishes “no audio at all” from “truncated N bytes in”, which usually means a dropped source rather than a wrong argument.
ytdlpResource and resolveMediaUrl
ytdlpResource() is resolveMediaUrl() followed by ffmpegResource(), so it accepts every FfmpegResourceOptions field plus:
--no-playlist is passed by default, so a link to a track that happens to sit in a playlist resolves that one track. When yt-dlp fails, the error’s remedy names the usual suspects in order: it is out of date (it breaks whenever a site changes), the media is private, region-locked or age-gated, or the URL is not a media page at all.
Stopping and tearing down
detach() reports nothing to the backend about the connection; leaveVoice() is what ends the session server-side.
detach() does not clear keys as a security measure. The process already held them, and forgetting a key it has already used proves nothing. Tearing the room down is, however, the only honest response to a voice_listen revoke, because the engine has no key-removal call.
Listening to members
When a server grantsvoice_listen, the key lane hands your bot the room key and attach() subscribes and installs a derived key for every participant. Two rules change:
- Join non-deafened. The subscribe grant is fixed at join time, so a deafened listening bot hears silence regardless of its keys.
- Handle the scope in your key update handler. A grant or revoke arrives at the same key version, and
setVoiceKey()detaches and throws on a scope change.
Receiving audio
In room scope, the session hands decrypted member audio out as PCM frames. The security property, stated in full: a bot withoutvoice_listen holds only its own frame key and is cryptographically unable to decrypt any member; a bot granted voice_listen can decrypt everyone in the channel, by design. Never state the first clause without the second.
Every receive entry point is room scope only. In participant scope, session.on('audio', ...), session.audio(), and session.setSubscribed() all throw with the message “this session holds a participant-scope key and can decrypt nobody; receive needs voice_listen”. They never install a listener that quietly never fires.
Per participant
Oneaudio event fires per subscribed remote audio track. Video tracks are ignored. The payload is a ParticipantAudio: the participant’s identity and a frames iterable of PcmFrame.
PcmFrame is { data: Int16Array, sampleRate, channels, samplesPerChannel }, already resampled to the session’s receive format. data is a copy out of the engine, so it is safe to keep after the next frame arrives. When several consumers share a session, they all see the same data: read it, or convert it with pcmFrameBytes(), but do not write into it.
Everyone at once
session.audio() merges every live participant into one iterable, tagged by identity. Participants who arrive later join the merge, and one who leaves drops out of it. Any number of concurrent audio() consumers may run, and each sees every frame.
Opt out of one participant
In room scope every remote track is subscribed at connect.setSubscribed(identity, false) is how a bot stops hearing one person, and true brings them back.
voiceJoin and voiceRoster() report as userId.
Piping to ffmpeg
pcmFrameBytes(frame) returns the frame as interleaved little-endian signed 16-bit bytes, which is the layout ffmpeg -f s16le, a WAV data chunk, and raw-PCM speech-to-text inputs expect. Here it feeds ffmpeg over stdin to write one file per participant.
pcmFrameBytes() is the whole of what it does with received audio. What a bot does with decrypted member audio is your business and your users’ consent problem, and the second clause of the security property still holds: a voice_listen bot decrypts everyone, by design.
The rules
- Every iterable ends with a normal return, never a throw. That is true on track unsubscribe, on
setSubscribed(identity, false), ondetach(), and on the detach a scope change forces. A muted track yields nothing and stays open. - At most one live iterable per identity. A later re-subscribe for the same identity fires a new
audioevent with a new iterable, and a duplicate subscribe ends the old one first. - Pull-driven and bounded. Nothing is read from the engine until a consumer asks. The underlying stream opens on first demand and is released when the last consumer leaves. A consumer slower than real time is capped at 500 frames (5 seconds at the engine’s 10 ms frame size) before the oldest frame is dropped, so a slow consumer hears a gap rather than a growing delay.
- Break out of loops you are done with. An iterator you hold open without reading leaves frames in the engine’s own queue, which the engine does not bound.
return(), which is whatbreakdoes, releases it. - The receive format is fixed at attach. The engine does the resampling. The SDK never touches the samples.
- Join non-deafened. The subscribe grant is baked into the credential at join time. A deafened
voice_listenbot hears silence no matter what, andattach()warns about exactly that combination.
A complete music bot
A slash command that joins whichever voice channel the invoker is sitting in.The SDK repo ships this as
examples/dj-bot.ts. Run it with npx tsx examples/dj-bot.ts. The older examples/music-bot.ts (npm run example:music) predates client.voiceKeys and reaches the key lane through an internals seam that is explicitly not stable across versions. Prefer the shape above.Next
Voice runtime
The engine patch, the executables, and a symptom-to-cause table.
Voice keys and scopes
Re-pinning, scope changes, and what the keys do not buy.
Voice reference
Every option type and constant on this page.
Joining voice
The control plane that produces
conn.