Skip to main content
The Client is an event emitter. You subscribe with client.on(event, listener). The event map is fully typed, so your listener’s arguments are inferred.
Most events fire for your bot’s own actions too. When your bot deletes a message, your own messageDelete handler runs. The same is true of messageCreate, reactionUpdate, pinUpdate, the channel and group lifecycle events, typing (your own sendTyping() echoes back), and voiceJoin / voiceLeave.Filter with the actor id:

Overview

On an older Cloak server, the serverId and channelId on the message, reaction, pin, and lifecycle events can fall back to the bot’s currently selected server or channel. An up-to-date server carries them on every event.

Messages

messageCreate

Fires for every message your bot can see. Because delivery is a firehose, that covers every visible text channel across every server, the threads under those channels, and the posts inside every visible forum channel. It also fires for direct messages. A DM arrives on this same event with isDM: true and a null serverId, and its channelId holds the dm id. On a backend that honors wire version 3, your bot’s own DM sends echo back here too, with authorId === client.user.id, and a DM message carries createdAt, repliedTo, and card. See Direct messages. Forum posts arrive here too. A message inside a forum post has msg.postId set, msg.channelId equal to that post id, and msg.forumChannelId carrying the parent forum. The post’s first message never rides the firehose on its own: the SDK emits it from the post’s create event as a messageCreate with starter: true, once, right before the matching forumPostUpdate. See Forums.
Guard before anything server-scoped. Passing a null msg.serverId into client.can(), client.guild(), or client.send() is the single most common bug in Cloak bots. See Direct messages.
msg.reply() is a true reply. It renders a quote header and fires a “replied to you” notification that pierces mutes. For a plain answer, like a ping bot, use msg.channel.send(). See Mentions and replies.

systemMessage

Fires for system events rather than user messages. The type field is member_join, bot_add, or unknown. See SystemMessageEvent.

messageUpdate

Fires when a message is edited. content is the decrypted new text, best-effort. It is '' when the key is missing, never raw ciphertext.
The payload also carries card when the edit came through editCard(). The three states mean different things: serverId is null for a DM-lane edit, with channelId the dm id. The same is true of messageDelete and reactionUpdate. On a backend that honors wire version 3, a DM card that arrives after the message itself was already emitted also surfaces here, as a messageUpdate carrying card, so it is not lost.

messageDelete

Fires when a message is deleted.

sendRejected

Fires when the server denies a send(). For a plain send this is the only way a denial becomes visible: the promise already resolved when the frame went out. A send made with ack: true also rejects with a CloakActionError when the denial is pinned to it.
The payload is { code, message, permission?, serverId, channelId, attribution, frame }.
'exact' | 'ambiguous' | 'none'
How serverId and channelId were chosen. Every send carries a nonce, and a plan-136 backend echoes it on the deny, which names the send exactly no matter how many are outstanding. Without the echo the SDK falls back to its queue of sends still awaiting their firehose echo. 'exact': one send was outstanding, it supplied the route, and it was retired. 'ambiguous': several were, the oldest supplied the route as a hint, and nothing was retired. 'none': nothing was outstanding, and both ids are null. A bot that awaits each ack: true send before the next one always sees 'exact'.
This is not an acknowledgement channel. Silence does not mean delivered. Ask for a receipt with ack: true when the outcome matters. See Sending is fire-and-forget unless you ask for a receipt.
sendRejected does not also fire error. A backend denial is an expected outcome, not a plumbing failure. If you want one funnel, forward it yourself.

Reactions and pins

reactionUpdate

Fires when a reaction is added or removed on a visible message. count is the new total for that reaction. The wire does not say whether it was an add or a remove, so operation is inferred by comparing count with previousCount, the last total this session saw for the same message and reaction; it reads 'unknown' when there is none (the first time a reaction is seen after a start). userId is the reactor, so filter with userId === client.user?.id to ignore your bot’s own reactions. See Reactions.
The payload is { messageId, reactionId, count, userId, serverId, channelId, operation, previousCount, containerKind, parentChannelId }. On a plan-136 backend operation is authoritative (the hook carries an add/remove flag) and containerKind / parentChannelId name the thread or post the message lives in; on older backends operation is inferred and the two container fields are null.

pinUpdate

Fires when a message is pinned or unpinned. pinned is a boolean.

Polls and threads

New in 0.3.0. See Polls and Threads.

pollUpdate

Fires when a visible, non-hidden poll’s tally changes, and when a poll is closed and its final numbers broadcast. counts is null for a hidden poll that has not revealed yet, and counts are always absolute server totals, never deltas.

threadUpdate

Fires with a decoded thread row (title decrypted best-effort) on thread creation, activity, flag changes, and membership moves.

threadDelete

Fires when a thread is deleted. For a private thread, being removed from its member list also arrives as a threadDelete, because the thread stops existing from the bot’s point of view. The payload is { serverId, parentChannelId, threadId }.

Forums

New in 0.4.0. See Forums.

forumPostUpdate

Fires with a decoded ForumPost when a post is created, when a reply bumps its activity, when its archived, locked, pinned, or answered state flips, or when its tags are edited. It reaches every member who can view the parent forum, your bot included, and it fires for your bot’s own posts too. The title is decrypted best-effort and is '' when the key is missing. starterContent is the decrypted first message when the row carried it (the create event and fetchForumPosts() pages do; a later activity bump does not). following is the bot’s own follow state where the row said, and false otherwise.
On the create path, the starter message arrives first as a messageCreate with starter: true, then this event fires. A redelivered create event never emits the starter twice.

forumPostDelete

Payload { serverId, forumChannelId, postId }. Fires when a post is deleted.

Interactions

New in 0.4.0. See Components and Modals.

interaction

Fires when someone clicks a button, picks from a select, or submits a modal on a card your bot posted. The payload is an Interaction. It is addressed to your bot alone: it never rides the firehose, and a click on another bot’s card in the same channel never reaches your handler.
Answer every interaction with exactly one of ack(), showModal(), or ephemeral(). The clicker’s control shows a pending state for up to 3 seconds while it waits. A second call rejects locally with a CloakClientError whose source is 'interaction', and nothing goes on the wire. An interaction the SDK cannot decrypt (no key for that server and epoch, or for that DM peer) emits error and is dropped.
This event never fires for your bot’s own actions. Bots do not click, and the server refuses a click from a bot session before reading it.

Typing

typing

Fires when someone starts or stops typing. A firehose across every channel your bot can see, exactly like messageCreate.
serverId and channelId on this event are always null. That is a wire fact, not a decode gap: the relay frame carries no routing tail, so there is nothing to read. They are typed nullable so filling them in later stays additive.Never substitute your bot’s own selection cursor for them. It points wherever the bot last sent, which has nothing to do with who is typing.
On a plan-136 backend the relay carries routing: serverId (null in a DM) and channelId (the thread or post id when typing inside one) are set, and containerKind / parentChannelId name the container. On an older backend all four are null. The event is raw and un-debounced by design, with no aggregation and no expiry timer. A bot that wants “who is typing right now” should hold its own map and expire an entry about ten seconds after its last true, which is what the shipped Cloak clients do. The outbound side is not limited the same way: sendTyping() targets threads and posts, and sendTypingDM() targets a DM. See Typing, presence, and profile.

Channels, groups, and servers

These fire as channels, groups, and the server itself change, and they fire for your bot’s own mutations too. See Channels and groups.

channelCreate

Payload { channelId, name, groupId, type, serverId }.

channelUpdate

Payload { channelId, name, serverId }. Fires when a channel is renamed.

channelDelete

Payload { channelId, serverId }.

groupCreate

Payload { groupId, name, serverId }.

groupUpdate

Payload { groupId, name, serverId }. Fires when a group is renamed.

groupDelete

Payload { groupId, serverId }.

serverUpdate

Payload { serverId, name }. Fires when the server is renamed by its owner. There is no method to rename a server, so this is how you learn about it.

serverDelete

Payload { serverId }. Fires when the server is deleted by its owner.

Lifecycle

ready

Fires once, after login() finishes. Permission grants are already populated, so can() works inside this handler.

guildCreate

Fires when your bot is added to a server, and once per existing server during login. Payload is a Guild.

guildDelete

Fires when your bot is removed from a server. The SDK drops that server’s cached conversation key, its voice key, and its permission state.

permissionsUpdate

Fires when a server resolves or changes your bot’s grant. The payload is the serverId plus the full PermissionSet, spread flat.

Commands

commandError

Fires when a slash command could not be run.
reason: 'parse' means the readable text matched a registered command but did not fit its declaration, for example a missing required option or an uncoercible number. The handler is not invoked, and the SDK does not auto-reply, because it never speaks unprompted. reason: 'handler' means your own handler threw or rejected. An unregistered command emits nothing at all. See Slash commands.

Voice

voiceJoin, voiceLeave, voiceStateUpdate, and voiceConnectionState are cursor-gated, not firehose. Your bot receives them only for the server it currently has selected or is in voice in. Do not build on them as a firehose. The same applies to the roster snapshot behind voiceRoster().

voiceKeyUpdate

Fires when the bot’s voice key for a server is provisioned again: a new key version, a new scope, or both.
A live media session must re-pin on either change. A scope change matters as much as a version change: a bot that just gained voice_listen can now hear everyone, and one that lost it cannot. Never poll for this. See Voice keys.

voiceJoin

Fires when someone joins a voice channel, including your own bot. Filter with p.userId === client.user?.id. The payload is the live roster entry, so later updates mutate it in place. Copy it if you need a snapshot.

voiceLeave

Payload { channelId, userId }. Fires for your bot’s own leave too.

voiceStateUpdate

Payload { channelId, userId, state, value }. Fires when a member’s mute, deafen, webcam, or stream flag changes. state is a VoiceStateFlag.
The VoiceStateFlag union still lists 'p2pWebcam' and 'p2pStream'. Peer-to-peer video is deleted from the Cloak platform, so those two values can no longer arrive. Ignore them.

voiceConnectionState

Payload { channelId, userId, reconnecting }. Fires when a member’s voice session enters or leaves the backend’s 60 second reconnect grace window.

voiceDisconnect

Payload { channelId, reason: 'moved' | 'forced', toChannelId? }. Fires when a moderator moves or disconnects your bot. toChannelId is set only for a move. voiceConnection() is already cleared when this fires, and the SDK does not auto-rejoin. Rejoin yourself if that is what you want.

Connection

These let you observe the automatic reconnect. You do not usually need to act on them. See Connection lifecycle.

disconnect

Two login failures are terminal and stop the reconnect loop, and both arrive here rather than throwing: code -2 (the token was revoked or regenerated) and code -12 (this SDK is below the backend’s minimum wire version). If your bot goes quiet and no reconnecting follows a disconnect, that is why. Code -8 is transient and keeps retrying.

reconnecting

reconnected

draining

Payload { delayMs }. Fires when the server lane your connection is on is being drained for a deploy. The client reconnects on its own after delayMs, so you do not need to act on it.

Diagnostics

error

Fires when the SDK catches a failure that would otherwise have been silent: one of your handlers threw or returned a rejecting promise, a key pull failed, or an inbound frame could not be dispatched. The payload is a CloakClientError.
Registering this listener changes nothing about control flow. It only makes failures visible. Dispatch continues either way, and a throwing handler never stops the next frame.
Server denials do not come through here. An awaited action rejects with CloakActionError, and a denied send fires sendRejected.

raw

Every inbound frame, verbatim, including the ones the SDK consumes itself. A frame is a positional array, typed as unknown[].
This is a firehose and it is emitted only when something is listening, so it costs nothing if you never subscribe. It is the read side of client.raw.

Next

Types

The shape of each event payload.

Client

The methods you call in response.

Errors

Every error class, and what reaches which channel.

Direct messages

Why messageCreate needs a guard.