Skip to main content
Failures reach your bot through three different doors: a rejected promise, an event, or a local throw before anything hits the wire. Which door depends on what failed. This guide covers all of them.

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.
The stable surface for a bot is .message and .permission.
Never branch business logic on .code. Three success conventions coexist on the wire: the messaging handlers reply with negative codes, the moderation handlers use positive codes 2 through 9, and the webhook, DM and REST-mint handlers use 0 as success. A number that means “denied” for one action means “fine” for another.

The canonical pattern

Catch, check with instanceof, and re-throw anything else so you never swallow an unrelated bug.
The missing suffix is appended only when .permission is set, which keeps the line readable for denials that have no named permission.
That else { throw e; } branch is real. react, unreact, edit, delete, pin and unpin also reject with a plain Error, not a CloakActionError, in two cases: the message is a direct message (none of those six have a working DM path), and the message has no createdAt. Re-throwing from a hot messageCreate handler crashes the handler unless you have an error listener. Guard DMs first: if (msg.isDM || !msg.serverId) return;

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 with CloakActionError 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: fetchMessages when 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 raise CloakRestError instead.
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.
fetchMembers() is broken against every current backend. It awaits a frame the backend no longer sends, so it neither resolves nor rejects. A try/catch cannot save you and the await hangs until the transport gives up. Use fetchRoles(), which works, and treat fetchMembers() as unavailable until the SDK rebuilds it. See Members and roles.

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.
The payload is { code, message, permission?, serverId, channelId, frame }.
serverId and channelId are attributed best-effort from the most recent send inside a 30 second window, and are null otherwise. The deny frame carries no routing of its own. Treat them as a hint, never as a fact.
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:
  1. The bot holds no conversation key for that server yet.
  2. You passed replyTo with a target that has no createdAt. 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 async handler 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 error listener 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() and removeAllListeners() all work normally. Pass the same function you registered.
Backend denials never come through here. An awaited action rejects with 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().
It is deliberately not a 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.
The webhook lane is not end-to-end encrypted. Anything you post through sendEmbed() or webhooks.post() is stored server-side as plaintext. See Webhook embeds.

Failures that do not throw at all

Two login refusals stop the reconnect loop instead of rejecting. Both emit disconnect:
  • -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

Maps a raw (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.