Skip to main content
Most chat SDKs make you subscribe to channels one at a time. Cloak does not. When your bot logs in, it starts receiving the live messages of every channel it is permitted to view, across every server it belongs to. It also receives its own 1:1 direct messages. This is called the firehose.

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.
A single !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.
msg.channel.send() is a plain send. msg.reply() is a true reply: it renders the quote header and fires a “replied to you” notification that pierces mutes. For a bot that is just answering, channel.send() is the right call.

Direct messages arrive here too

Inbound 1:1 DMs come through the same messageCreate event. There is no second event and no separate listener to register. A DM message differs in three ways:
  • isDM is true.
  • serverId is null. A direct message has no server at all.
  • channelId equals dmId. This is deliberate, so msg.reply() and msg.channel.send() keep working on a DM without your code branching.
Message.serverId is string | null. Guard it before anything server-scoped. client.can('message_send', msg.serverId) does not type-check without a guard, and client.send(msg.serverId, ...) would post into nowhere.
Outbound DMs are client.sendDM(userId, text, opts?), which opens the DM and exchanges its key on first contact. Eligibility is the server’s decision and is deny-by-default. See Direct messages.

Every message knows where it belongs

A messageCreate Message carries its source location on the payload: 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:
send(), reply(), and channel.send() take an options object as their last argument. The old positional groupId was removed in 0.2.0. Write send(serverId, channelId, text, { groupId }), never send(serverId, channelId, text, groupId).

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. This has one practical consequence worth remembering:
A quiet channel can mean the bot is not permitted to view it, not just that nobody is talking. Silence is not proof that the bot is listening.
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

Delivery out has a failure path that delivery in does not. send(), reply(), channel.send(), and sendCard() resolve as soon as the frame goes out. They are not acknowledgement channels. 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. serverId and channelId are attributed best-effort from the most recent send within 30 seconds and are null otherwise, so treat them as a hint rather than a fact. 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.

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:
The full signature is 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, and channel, group, or server lifecycle changes all arrive as events. Those carry serverId and channelId the same way a message does. Three exceptions are worth knowing before you build on them.
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.
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.
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 own messageDelete 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 sets msg.command and dispatches your handler. If your messageCreate handler also does its own text parsing, filter them out:
See Slash commands.

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.

Connection lifecycle

Reconnects, and what carries across them.