Symptom index
The dependency report
The SDK ships a doctor that reports what your host actually resolved: the Node version and whether it satisfiesengines, the platform, arch and glibc, whether a secure RNG is reachable, the resolved @noble/* versions, and which optional natives are present.
It is pure and offline. It opens no socket, spawns no process, reads no clock, and never loads a native addon to learn its version. It returns a string and never prints, so printing is your one line.
Run it from a file
node -e form hides the fact that the runtime itself has no secure RNG and the SDK is carrying it, which is exactly what you need to know when your own code (or another dependency) reaches for randomness without the SDK’s shim. Run it as a file, or through node --input-type=module on stdin.
What the output looks like
NOT SATISFIEDon the node row stops everything. Nothing else matters until you fix it.secure random: MISSINGmeans every key operation will fail. This is the row that otherwise shows up only as a 20 secondTimed out acquiring key.not installedunder Transport and Voice is normal and healthy. Those are opt-in extras. A text bot on the defaultwss://lane needs none of them.unknownon a package means the probe could not resolve it and the report says why in the note. That is a real signal, unlikemissing.
The programmatic surface
Every row is available as data, and the individual probes are exported so you can gate on them.DependencyReport
The same data as
generateDependencyReport(), structured. Fields: sdk, runtime, secureRandom, crypto, transport, voice.string
The human-readable form. Returns a string and never prints. Never throws, even on the broken host it exists to describe.
boolean
Whether
globalThis.crypto.getRandomValues is a function right now. Read per call, never cached, because the crypto layer reads it late too.'global' | 'node:webcrypto' | 'none'
Where the RNG came from.
'node:webcrypto' means the SDK’s own shim supplied it and the runtime did not.boolean
Whether a version string satisfies
^20.19.0 || >=22.12.0. Defaults to process.version. Deliberately lenient about an unparseable version.readonly string[]
@noble/ciphers, @noble/curves, @noble/hashes. Every bot uses these.readonly string[]
The two WebTransport packages. Absent is normal.
readonly string[]
@livekit/rtc-node. Absent is normal.string
ffmpeg. Probed for presence on PATH only. The doctor never executes it, so it never reports a version.string
@cloak-software/bot-sdk. The name the report prints, so your own preflight output can match it.interface
The full report.
dependencyReport() returns this.interface
One package row inside a report: its name, whether it resolved, and its version when known.
type
'global' | 'node:webcrypto' | 'none', the return type of secureRandomSource().interface
The injection bag both report functions accept as
deps?. It exists so tests can simulate a broken host. You will not normally pass it.Node version
login() calls the runtime guard before it opens a socket, so an unsupported host fails immediately with a CloakEnvironmentError rather than dying much later inside a key operation.
The supported range is ^20.19.0 || >=22.12.0. Concretely: 20.x needs 20.19.0 or newer, 21.x never qualifies, 22.x needs 22.12.0 or newer, and 23 and up qualify.
The reason is the crypto stack, not the transport. HPKE draws randomness from globalThis.crypto.getRandomValues, which Node 18 leaves undefined in ESM. The default wire is a plain wss:// WebSocket over the pure-JS ws package and npm install compiles nothing, so nothing about the transport imposes a floor.
If the guard fires on a host you believe is running a supported Node, the process is probably not running the Node you installed. generateDependencyReport() prints the version the guard actually read.
Secure random
crypto.getRandomValues must be defined means a @noble/* call reached for randomness and found none. The SDK installs a globalThis.crypto shim at import time, so this should be unreachable through the SDK. It is still reachable from your own code, or from another dependency, on a runtime that has no crypto global.
The tell is the source column, not the available column:
This is the row
node -e fakes. Run the report from a file.
Keystore failures
All three of these throw before any real work happens, which is deliberate.the keystore (…) exists but is corrupt or unreadable
the keystore (…) exists but is corrupt or unreadable
login() refuses to start with a fresh identity, because publication is write-once server-side and regenerating would permanently break decryption. Restore from a backup. Clear the store only if you also reset the bot’s identity server-side. The message names the store, using whatever your adapter’s describe() returns.the server already holds a published identity for this bot
the server already holds a published identity for this bot
The server has your bot’s original identity and the local keystore is new, so the freshly generated identity was rejected. Members wrap conversation keys to the original identity, so this bot could never decrypt anything (“Bad MAC”). Point
keystorePath or your adapter at the keystore from the bot’s first run, or reset the bot’s identity server-side. If the message adds that no keystore was configured, there is nothing to restore: reset server-side, then set a keystore so the new identity survives.a NEW bot identity was generated and stored only in process.env
a NEW bot identity was generated and stored only in process.env
An
envKeystore with no onWrite hook. The identity is gone the moment the process exits, and the next run’s replacement is rejected forever. This warning is printed unconditionally, not behind debug, because its only other symptom is a bricked bot on the next restart. Do not run past it. See Keystore backends.Transport failures
Every failure in this section belongs to the opt-in WebTransport lane. A bot on the defaultwss:// lane can hit none of them, and the fix for all of them is available in one line: drop transport: 'webtransport' (and the https:// url) and the bot connects over a plain WebSocket with no native code at all.
login() recognizes these on the first connect and rethrows them as a CloakEnvironmentError carrying a remedy, which is one actionable paragraph about the machine rather than the code. Anything it does not recognize propagates unchanged. Reconnects keep their normal backoff and log the remedy under debug.
diagnoseTransportError flattens the whole cause chain before matching, because the transport wraps the real failure.
Login rejections
Login failures arrive as codes, not as thrown exceptions once the bot is running. They emitdisconnect.
On a terminal rejection the SDK emits
disconnect and stops. It does not throw, and it does not exit your process, so a bot that only supervises on exit will sit there alive and idle. If you want your supervisor to notice, exit from the handler:
-2 needs a new token, and a -12 needs a newer SDK. See What the server must support for the gate behind -12.
Key acquisition
Timed out acquiring key for server … means the bot asked for a server’s conversation key and nobody answered inside the window. Your bot never generates a server key: a human member’s client wraps it and uploads it.
Common causes, roughly in order:
- No human client was online to answer. The wrap depends on one online member’s client responding to a push. The SDK re-arms while it waits, but an empty server has nobody to ask.
- A peer is on a pre-HPKE client. When the SDK sees an old-format wrap it appends the peer’s id and a cutover hint to the timeout message. That client must upgrade before the bot can hold a key for that server.
- The bot’s identity was never published, or was published under a different bot id. Check the keystore section above.
- No secure RNG. This is the failure mode that looks like a 20 second timeout instead of an obvious crypto error. Run the dependency report.
Reading a live bot
A bot that starts and then behaves strangely needs a different set of tools.Listen for error
This is the single funnel for failures the SDK catches and keeps going from. Without a listener they stay silent by design.
Three things to know:
- A handler that throws no longer disappears,
asynchandlers included. The SDK wraps every listener at registration, so a rejected promise surfaces here. Dispatch continues either way. - Registering the listener changes no control flow. It only makes failures visible.
- Because listeners are wrapped,
client.listeners(event)returns the wrappers, not your original functions.off(),removeListener()andremoveAllListeners()all still work: pass the same function you registered.
CloakActionError, and a rejected send fires sendRejected.
Listen for sendRejected
send() is fire-and-forget. Its promise resolves when the frame goes out, so a server-side denial can only arrive as an event.
sendRejected is not proven delivered.
Turn on debug
debug: true logs every frame in and out. The bot token is redacted. It is verbose enough that you want it behind an environment variable rather than on permanently.
Resolve stack traces
The publisheddist/ is minified, but the package ships source maps, so run with:
index.js:1:8321. The maps carry positions only, no source text.
Watch the wire
client.raw is the escape hatch for opcodes the SDK does not map, and the raw event is its read side: every inbound frame, verbatim, including the ones the SDK consumes itself. It is a firehose, and it is only emitted when something is listening.
ClientOptions.internals is a test seam for injecting fakes, not stable API. Do not build on it.What to put in a bug report
1
The dependency report
Run it from a file and paste the whole output. It is the first thing anyone will ask for.
2
The exact error text
Including the
remedy if it is a CloakEnvironmentError, and the cause if it is a CloakClientError.3
Which lane you are on
Default
wss://, or transport: 'webtransport'. Almost every host failure belongs to the second.4
Which keystore backend
keystorePath, envKeystore, a custom adapter, or none.5
A debug frame log around the failure
A few frames either side of the moment it went wrong, with the token still redacted by
debug as it is by default.Next
Known limitations
Things that are broken on purpose, so you stop debugging them.
What the server must support
Failures your bot cannot detect, because they are server-side.
Keystore backends
Fixing the keystore errors above for good.
Errors
The full error class reference.