messageCreate event as server messages, and a DM message has no server, so msg.serverId is null.
Who may DM whom
The server decides, and it decides deny-by-default. A human and a bot may DM each other only if that human owns the bot, or the two share at least one server. The SDK never pre-computes this. An ineligible target rejects with aCloakActionError from the create step, before anything is encrypted or sent.
Sending a DM
client.sendDM(userId, text, opts?) sends a direct message to a user, creating the DM on first contact.
string
required
The user to DM. A bot cannot DM itself, and that rejects locally.
string
required
The message body. Encrypted before it leaves the process.
SendOptions
{ groupId?, mentions?, replyTo? }, with reduced DM semantics. See below.SendOptions is the same object you pass to send(), with three differences inside a DM:
replyToworks. The DM branch parses the same reply slot.mentionsonly notifies for direct user mentions. Role,@everyone, and@herepairs are dropped server-side inside a DM.groupIdis meaningless in a DM and is ignored.
Receiving a DM
DMs come in onmessageCreate, with isDM set.
boolean
true for a direct message.string | null
null for a DM. A direct message has no server at all.string | null
The dm id for a DM,
null otherwise. Always equal to channelId when set.string
For a DM this holds the dm id. One convention throughout the SDK:
channelId is always “the thing this message lives in”, so msg.reply() and msg.channel.send() work without branching.null
Always
null on a DM. The notify frame a bot receives carries no reply array, so there is nothing to decode. This is a wire fact, not a gap.Guard before anything server-scoped
What does not work on a DM message
react(), unreact(), edit(), delete(), pin(), and unpin() all reject on a DM message. The rejection is a plain Error naming the method, not a CloakActionError.
The backend does implement those operations for DMs, but their result hooks never reach a bot, so the SDK would be issuing writes it can never observe. It refuses rather than writing blind.
reply() and channel.send() work normally.
Slash commands are not dispatched from DMs. The structured command lane does not exist on the DM notify frame, and the menu a client’s / picker renders is published per server. messageCreate still fires, so parse msg.content yourself if you want DM commands.
There is also no DM history read. fetchMessages is server-channel only, so a bot cannot page backward through a DM. It sees what arrives while it is connected.
The keystore is not optional here
DM state lives in the same keystore as your identity and server keys: both the peer-to-dm map and the conversation key are written there. Without a keystore, every restart re-runs the key exchange, and, far worse, a regenerated identity can never be re-published. See Keys and the keystore for why a bot with no keystore is bricked on its second run. Losing only the DM half is mild by comparison. The peer-to-dm map is re-derivable, because the create call is idempotent for an existing pair and returns the same id. A lost DM key means the bot establishes a new one, and the peer’s older messages stay unreadable. If an inbound DM arrives that the bot cannot decrypt,content is the empty string '' and the SDK quietly re-runs the key exchange in the background, throttled per peer. Handle '' rather than assuming every DM is readable on arrival.
A complete DM echo bot
This isexamples/dm-smoke.ts. Run it with npm run example:dm.
Next
Sending messages
send(), SendOptions, and why sends are fire-and-forget.
Keys and the keystore
Why a bot without a keystore cannot be restarted.
Mentions and replies
The pill, the ping, and what rides in plaintext.
DM echo bot
The full runnable example.