What the connection is
The SDK speaks one opcode protocol over one of two lanes.
Stay on the default unless you have a specific reason not to.
npm install compiles nothing on the WebSocket lane, so a bot runs on any supported Node host with no Dockerfile, no compiler, and no glibc floor.
url defaults to DEFAULT_URL (wss://cloak.expatiate.net:2001), or to DEFAULT_WEBTRANSPORT_URL (https://cloak.expatiate.net:2000/wt) when you set transport: 'webtransport'. Both are exported if you want to read them.
The lane follows the URL scheme
resolveLane() decides which lane a URL speaks, and refuses combinations that would either leak your token or quietly ignore what you asked for.
Two related rules:
- A plain
ws://URL is refused withoutallowInsecure: true, because a bot token would otherwise cross the network in cleartext. serverCertificateHashesis WebTransport-only. Passing it with aws://orwss://URL throws rather than silently ignoring your pin request.
The WebTransport lane needs
@fails-components/webtransport and @fails-components/webtransport-transport-http3-quiche, installed at the same exact version, on a host with glibc 2.38 or newer. Nothing imports them unless you set transport: 'webtransport', so a text-only bot pays nothing for their existence.The transport types
You do not need these to write a bot. They are exported for the rare case where you supply your own transport throughClientOptions.internals, or you want to name one of these shapes in your own code.
type
'ws' | 'webtransport'. The same values ClientOptions.transport accepts.interface
One lane implementation: how it opens a connection, writes a frame, and closes.
interface
The callbacks a lane reports back through, for frames received and for connection state changes.
class
The lane-agnostic wrapper the
Client actually holds. It owns reconnect, backoff, and the keepalive.interface
What
Transport is constructed with. Note that only idleTimeoutMs is reachable from ClientOptions; the 15 second keepalive is fixed from a bot author’s point of view.interface
The minimum surface the
Client needs, and the return type of internals.transportFactory. Implement this to stub the network in a test.interface
The matching contract for
internals.keyManagerFactory, the other half of the same escape hatch.Logging in
Callinglogin() runs the full startup sequence. Three of these steps happen before a socket is opened, which is where a misconfigured host fails.
1
Check the Node runtime
The literal first statement of
login(). The supported range is ^20.19.0 || >=22.12.0, so 21.x never qualifies. The reason is that the crypto stack draws randomness from globalThis.crypto.getRandomValues, which Node 18 leaves undefined in ESM. Failing here replaces a cryptic randomness error much later in the run.2
Parse the token
The token has the shape
botid.tokenid.secret. Anything else throws.3
Read the keystore
Before any network traffic and before any identity decision. A keystore that exists but cannot be read or parsed throws here. See Keys and the keystore.
4
Connect and authenticate
The transport opens and the bot authenticates with its token, carrying the SDK’s wire version.
5
Publish the identity, if the server does not already hold it
On the very first login only. On later logins the SDK reuses the identity from your keystore. This publication is write-once.
6
Declare the permission manifest
Whatever you passed as
requiredPermissions. Best-effort: a refusal is reported on the error event and never blocks login.7
Publish the slash-command menu
Whatever is registered at that moment. Register commands before
login(). Also best-effort.8
Fetch servers and await their verdicts
guildCreate fires for each server the bot is already in, and the SDK awaits each server’s permission verdict.9
Signal readiness
ready fires. Because the verdicts were awaited, can() and permissions() are already populated inside your ready handler.Login rejections
Two rejection codes are terminal, and the difference decides whether an operator has a dead bot or a slow one.Detecting a drop
A closed socket is easy. A black-holed one produces no frames, no close, and no reconnect unless something watches for silence, so the SDK watches on two timers.- Keepalive, every 15 seconds. A protocol frame plus a wire-level WebSocket ping. The ping is the only thing that produces inbound traffic on a genuinely idle bot.
- Read-idle watchdog. Closes a connection that has received nothing for
idleTimeoutMs. The default is 60000 on the WebSocket lane, and disabled on WebTransport, which has QUIC’s own idle timeout. Pass0to disable it.
disconnect followed by reconnecting.
Recovering
On an unexpected disconnect the SDK reconnects on its own. You do not calllogin() again.
The backoff is full jitter over a capped exponential base, with a floor:
attempt resets to 0 after a successful reconnect, so a flaky network does not turn into a tight retry loop and a brief blip does not inherit yesterday’s backoff.
What a reconnect restores
- A fresh transport, and re-authentication.
- The permission manifest and the slash-command menu, re-declared.
- Your last
watch()target, if you set one. - Voice, by leaving explicitly and then rejoining with the prior muted and deafened state.
What a reconnect resets or drops
- The server, channel, and group selection cursor starts fresh. So does typing state.
- A
watch()target that can no longer be established is dropped, not retried. The bot continues on the firehose. - A voice channel that was deleted or denied while the bot was offline degrades the same way.
voiceConnection()becomes null and the bot carries on.
Observing it
Four events, and the fourth is the one that is easy to miss.error does not fire for backend denials of an awaited action, which reject with a CloakActionError, nor for denied sends, which arrive on sendRejected.
Being removed from a server
If your bot is kicked or banned, the SDK drops that server’s cached keys and permission state and firesguildDelete. This does not stop reconnection or tear down your other work.
Shutting down
Calldestroy() to close the connection and stop reconnecting. It also flushes pending keystore writes, which is why it must be awaited.
destroy() from its own guildDelete handler.
Next
Events reference
Every lifecycle event and its payload.
Keys and the keystore
What persists across a reconnect, and what must never be lost.
Client options
url, transport, allowInsecure, idleTimeoutMs, deviceId, and the rest.When a bot will not start
The dependency report, and what to paste into a bug report.