Skip to main content
Your bot maintains a live connection to Cloak. The SDK handles the hard parts of keeping that connection healthy, and gives you events so you can observe what is happening.

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 without allowInsecure: true, because a bot token would otherwise cross the network in cleartext.
  • serverCertificateHashes is WebTransport-only. Passing it with a ws:// or wss:// 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 through ClientOptions.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.
internals is an escape hatch for tests and for the SDK’s own harness. It is not covered by semver, and a bot that reaches for it can break on a patch release.

Logging in

Calling login() 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.
A terminal rejection emits disconnect and does not throw. It is not followed by reconnecting. A disconnect with no reconnecting after it is the signal that the bot is permanently offline. Unless debug: true is set, that is the only signal you get.

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. Pass 0 to disable it.
The watchdog feeds the same close path as a real drop, so it surfaces as an ordinary disconnect followed by reconnecting.

Recovering

On an unexpected disconnect the SDK reconnects on its own. You do not call login() 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.
guildCreate is not re-emitted on a reconnect. It fires only during login(). A bot that rebuilds per-server state inside guildCreate will not get a second chance after a drop. Build that state somewhere that survives, or rebuild it in ready.
Your keys and identity carry across reconnects, so the bot picks up where it left off.

Observing it

Four events, and the fourth is the one that is easy to miss.
error is the single funnel for every failure the SDK catches and keeps going from: a handler of yours that threw, a key pull that failed, a frame that could not be dispatched. With no listener registered, those stay silent by design. Register one on every bot you intend to run unattended.
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 fires guildDelete. This does not stop reconnection or tear down your other work.

Shutting down

Call destroy() to close the connection and stop reconnecting. It also flushes pending keystore writes, which is why it must be awaited.
A shutdown right after a key landed would otherwise drop that key and cost a re-acquire, or a deaf window, on the next start. A bot that has been removed from everything and has nothing left to do can call 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.