You do not subscribe to channels
Once your bot is running,messageCreate fires for messages in any channel it can see. You do not register interest in a channel first. Each event carries where it came from, so you always know how to respond.
!ping handler answers in every channel of every server the bot is in, and in DMs. No channel needs to be selected ahead of time.
Direct messages arrive here too
Inbound 1:1 DMs come through the samemessageCreate event. There is no second event and no separate listener to register.
A DM message differs in three ways:
isDMistrue.serverIdisnull. A direct message has no server at all.channelIdequalsdmId. This is deliberate, somsg.reply()andmsg.channel.send()keep working on a DM without your code branching.
client.sendDM(userId, text, opts?) and client.sendCardDM(userId, card), which open the DM and exchange its key on first contact. Eligibility is the server’s decision and is deny-by-default. On a backend that honors wire version 3, the bot’s own DM sends echo back on messageCreate like its server sends do. See Direct messages.
Every message knows where it belongs
AmessageCreate Message carries its source location on the payload:
A container message uses the same convention a DM does:
channelId is always “the thing this message lives in”. So a thread reply has channelId === threadId, and a forum post reply has channelId === postId. The difference between the two is that a post frame carries its parent on the wire, so forumChannelId is always known, while threadParentId is resolved from the SDK’s thread index. See Threads and Forums.
The SDK tracks the channel’s group internally, so msg.reply() and msg.channel.send() already know where to go. You only reach for client.send() when you want to send somewhere other than where a message arrived.
A systemMessage additionally carries a groupId, since a member join can happen in a channel the bot has not seen traffic from yet. Pass it through SendOptions so the reply lands in the right place:
Delivery is view-gated
The server decides which channels your bot may view, based on the permissions it was granted.messageCreate fires only for channels the bot is allowed to see. The SDK does not filter the firehose on your side. The server gates it before it reaches you.
Threads and forum posts are gated on their parent. A thread reply reaches your bot when it can view the thread’s parent text channel, and a post reply reaches it when it can view the parent forum channel. There are no private forum posts: following a post is a notification preference, not an access list, so a firehose bot hears every post in every forum it can view whether it follows the post or not. Private threads are the one exception, and those are gated on membership. See Threads.
This has one practical consequence worth remembering:
To reason about which channels your bot should be hearing from, ask it:
visibleChannelIds() returns an empty array when no permission verdict has arrived for that server yet. See visibleChannelIds().
Sending is fire-and-forget unless you ask for a receipt
Delivery out has a failure path that delivery in does not. By defaultsend(), reply(), channel.send(), and sendCard() resolve as soon as the frame goes out. The wire has no acknowledgement for a send. A server-side denial does not reject the promise. It arrives later on the sendRejected event.
permission names the permission the bot was missing, when the code maps to one. The deny frame carries no routing, so the SDK keeps a queue of sends still waiting for their echo and attributes the deny from it: attribution is 'exact' when one send was outstanding, 'ambiguous' when several were (the oldest supplies serverId and channelId as a hint), and 'none' when none was (both null).
Ask for a receipt
Passack: true and the promise resolves with the persisted Message instead of void. The server echoes every message back on the firehose, the bot’s own included, and the SDK matches that echo to the send by its ciphertext, which is unique per encryption. A resolved promise then means the server wrote the row, and the message carries the server’s messageId, createdAt, and route.
- Resolved with a
Message: persisted. - Rejected with
CloakActionError: the server denied it, and the denial was pinned to this send (it was the only one outstanding).sendRejectedfires too, withattribution: 'exact'. - Rejected with
CloakSendTimeoutError: no echo and no deny insideackTimeoutMs(default 15 seconds), the socket dropped while waiting, or the deny was ambiguous. The outcome is indeterminate: the server may have persisted the message.indeterminateistrueon the error. A retry can duplicate; read history if you must know.
sendDM() only receives its echo on a backend that honors wire version 3; on an older one an acknowledged DM send times out. sendCard(), sendCardDM(), sendCommand(), sendFile(), and sendFiles() take the same option.
Awaited actions behave differently. react(), delete(), kick(), and the rest reject with a CloakActionError when the server denies them. Only the send lane is fire-and-forget, and only by default.
Pre-selecting a channel is optional
You almost never need to select a channel, because the firehose already delivers everything. One method exists for the rare case where you want to warm a specific channel ahead of time, for example to prepare its key before your first send:watch(serverId, channelId, groupId?). The group is auto-resolved from the server’s channel list, so pass it only to skip that resolution round trip.
watch() is remembered and re-established automatically after a reconnect. What is remembered is the value you passed, so an auto-resolved group is re-resolved from a fresh cache rather than pinned.
A
watch() target is only a preference. If the watched channel was deleted or is no longer selectable while the bot was offline, the SDK drops the watch and continues on the firehose rather than failing the reconnect. A working bot never gets stranded over an optional pre-selection.On a fresh login(), the SDK also reconciles the watch against the server list. If the watched server is gone (the bot was kicked while it was down), it fires guildDelete and clears the target.More than messages
The firehose delivers more than new messages. Message edits and deletions, reactions, pins, thread and forum post lifecycle (threadUpdate, forumPostUpdate, and their delete twins), and channel, group, or server lifecycle changes all arrive as events. Those carry serverId and channelId the same way a message does.
One event is deliberately not a firehose. A click on a card your bot posted arrives as an interaction, and it is addressed to your bot alone: the clicker encrypts it for the conversation, the server pushes it to the addressed bot’s sessions only, and a click on another bot’s card in the same channel never reaches you. See Components.
Three exceptions are worth knowing before you build on them.
typing carries no location
typing carries no location
The
typing event has serverId and channelId always null today. That is a wire fact, not a decoding gap: the source channel travels in the server’s fanout envelope and never reaches the frame. Do not substitute the bot’s own selection cursor, which points wherever the bot last sent and has nothing to do with who is typing.typing is also raw and un-debounced, with no expiry timer, and it fires for the bot’s own sendTyping() calls. Hold your own map and expire an entry roughly 10 seconds after its last true if you want a “who is typing right now” view.Voice roster events are not firehose
Voice roster events are not firehose
voiceJoin, voiceLeave, voiceStateUpdate, and voiceConnectionState are cursor-gated. A bot sees them only for the server it currently has selected or is in voice in. Do not build on them as a firehose. refreshVoiceRoster(serverId) forces a fresh snapshot.Lifecycle events on non-selected servers need a current backend
Lifecycle events on non-selected servers need a current backend
Routing channel, group, and server lifecycle hooks to a bot for a server it does not have selected requires a Cloak backend carrying the membership-routed server hooks. Against an older backend those events fall back to the bot’s currently selected server.
Your bot’s own actions come back
These events fire for the bot’s own actions as well. When your bot deletes a message, its ownmessageDelete handler runs. When it reacts, its own reactionUpdate handler runs. Filter by author or actor id where that matters, the same way you skip your bot’s own messages in messageCreate.
Content that cannot be decrypted comes back empty
messageUpdate.content and msg.repliedTo.content decrypt best-effort. When the key is missing or stale, they are ''. They are never raw ciphertext, so a blank string means “could not read”, not “the user sent nothing”.
Slash-command invocations ride the same event
When an inbound message is a slash-command invocation your registry recognises, the SDK setsmsg.command and dispatches your handler. If your messageCreate handler also does its own text parsing, filter them out:
Next
Permissions
What decides which channels your bot can view.
Events
Every event the firehose can deliver, with payloads.
Direct messages
The DM lane, and what behaves differently there.
Forums
How post messages look, and how the starter arrives.