Which failure gets which class
All six classes are exported from the package root:
CloakActionError
CloakActionError is what an awaited action rejects with when the server denies it. It carries four fields.
string
A human-readable explanation of the denial. Always present, and the field to log or relay.
PermissionName | undefined
The named permission the bot was missing, when the denial maps to one of the 31 permissions. Undefined otherwise.
number
A raw numeric diagnostic naming the action. For logging only.
number
A raw numeric diagnostic for the specific failure. For logging only.
.message and .permission.
The canonical pattern
Catch, check withinstanceof, and re-throw anything else so you never swallow an unrelated bug.
missing suffix is appended only when .permission is set, which keeps the line readable for denials that have no named permission.
Why .permission can be undefined
.permission is set only when the denial maps to a named permission. Deleting another member’s message without message_manage sets it to 'message_manage'.
Some denials are permission-style but have no name. A pin denial is gated on a channel-level pin permission that does not exist in the PermissionName vocabulary, so .permission comes back undefined even though the failure is a permission failure. Always fall back to .message.
.message is the field you can always rely on. Read .permission when it is set, and never assume it is set.Which calls reject with it
Any action the server can deny rejects withCloakActionError when it does:
- Message actions:
react,unreact,edit,delete,pin,unpin. - Moderation:
kick,ban,unban,timeout,removeTimeout,fetchBans. - Channels and groups:
createChannel,editChannel,deleteChannel,moveChannel,createGroup,editGroup,deleteGroup. - Reads:
fetchMessageswhen read history is denied,fetchRoles,fetchEmojis. - Direct messages:
sendDM, from the create step when the server refuses the DM. - Presence and voice:
setStatus,joinVoice,leaveVoice. - Webhook management:
webhooks.list,webhooks.create,webhooks.edit,webhooks.regenerate,webhooks.delete. - The REST lane:
client.rest.*, when the credential mint itself is refused (opcode 638). The HTTP legs raiseCloakRestErrorinstead.
setVoiceSelfState() and refreshVoiceRoster() reject with a plain Error, not a CloakActionError: the first when the bot is not in voice, the second when the server select behind it fails. sendCommand() rides the same fire-and-forget send lane as send(), so it throws locally or not at all.Sending is fire-and-forget
send(), sendDM(), msg.reply() and msg.channel.send() do not reject when the server refuses delivery. Opcode 1 has no success acknowledgement, so the promise resolves once the frame goes out. A denial can only arrive later, as an event.
{ code, message, permission?, serverId, channelId, frame }.
Every op-1 deny code now reaches the wire, including the rate limit, the timeout, the oversize sidecar and the missing attach-files denial. Two paths still answer with nothing at all, both on the DM side, so silence is not proof of delivery.
sendRejected does not also fire error. A backend denial is an expected outcome, not a plumbing failure. Forward it yourself if you want one funnel.The two local throws from send()
send() has exactly two ways to throw synchronously, both before anything reaches the wire, and both a plain Error:
- The bot holds no conversation key for that server yet.
- You passed
replyTowith a target that has nocreatedAt. The server re-reads the original row keyed on its exact send time, so the reply cannot resolve without it.
CloakClientError and the error event
CloakClientError is the funnel for failures inside the SDK’s own plumbing: one of your handlers threw, a key pull died, an inbound frame could not be dispatched. It arrives on the error event.
Three things worth knowing:
- An
asynchandler that throws no longer disappears. The SDK wraps every listener at registration, so a rejected promise surfaces here instead of vanishing. Dispatch continues either way, and a throwing handler never stops the next frame. - Registering the listener changes no control flow. It only makes failures visible. With no
errorlistener the behavior is what it was before the event existed: silent, plus a debug warning. - Because listeners are wrapped,
client.listeners(event)returns the wrappers, not your original functions.off(),removeListener()andremoveAllListeners()all work normally. Pass the same function you registered.
CloakActionError, and a refused send fires sendRejected.
CloakEnvironmentError
CloakEnvironmentError says the host is wrong, not the bot. An unsupported Node version, an optional native the image cannot load, an executable the audio path expected on PATH. Nothing about your code, token or permissions is at fault, which is why it carries a remedy: one actionable paragraph about the machine.
string
One paragraph naming what to change on the host. The underlying failure also rides as
cause, and its text is quoted into message, because the two travel through different logs.login() raises one on the first connect only, and only for a failure it recognizes. The most common is an unsupported Node runtime: the SDK requires ^20.19.0 || >=22.12.0, and 21.x never qualifies. Node 18 imports fine, sometimes even logs in, then dies at the first key generation with crypto.getRandomValues must be defined.
Reconnects keep their normal backoff and log the remedy under debug: true. The audio path raises the same class from somewhere else entirely, for a missing ffmpeg or yt-dlp, and those surface from player.play() and ytdlpResource().
CloakRestError
CloakRestError covers the HTTP legs of the REST lane.
number
The HTTP status, or
0 when the request never left the host (DNS, TLS, timeout, no fetch).string
The request path with the query string stripped. Ids ride query strings, and an error object is a thing people paste into issues.
string | undefined
The server’s machine-readable
error_code when the body carried one, for example file_too_large or storage_exceeded.No credential material is ever quoted into this error. Neither the verification header nor the one-time mint secret appears in
message, in a field, or in toString().CloakActionError. A refused credential mint is an opcode failure and still rejects as CloakActionError with opcode 638. Everything after that is HTTP.
CloakEmbedError and CloakWebhookError
The two embed lanes have their own classes.sendCard() validates locally and throws CloakEmbedError before anything reaches the wire. The server never reads the encrypted embed payload and the renderer strips a bad card silently, so this class is the only place you can be told.
.reason is one of 'shape', 'content', 'cap', 'media-host', 'empty' or 'wire-cap'. .field names the exact offending path, for example 'embed.image.url', and is absent when the whole payload is at fault.
sendEmbed() and webhooks.post() reject with CloakWebhookError, which carries .status (or 0 for a transport failure), .detail (the ingress’ own body text) and .webhookId when known. The delivery url never appears in the error, because it embeds the token secret.
Failures that do not throw at all
Two login refusals stop the reconnect loop instead of rejecting. Both emitdisconnect:
-2, the token was revoked or regenerated.-12, this SDK is below the backend’s minimum wire version. Redeploy on a newer@cloak-software/bot-sdk.
-8 is a transient backend error and keeps retrying with the normal backoff. See Connection lifecycle.
describeActionError
(opcode, code) pair to the same message and optional permission a CloakActionError carries. Reach for it when you have raw numbers in hand, for example from a log line, and want the human-readable description without a live error object. Unknown codes get a generic message rather than throwing.
For normal handling you rarely need it: the error you caught already carries both fields.
Next
Errors reference
Every field on every error class.
Permissions
What decides whether an action is allowed.
Events
The full payload of
error and sendRejected.Sending messages
Why delivery has no acknowledgement.