Skip to main content
This bot does two things. If you give it a user id, it opens a direct message with that user at startup. Then it echoes every DM it receives back as a true reply. It is the shortest demonstration of the two rules every DM-handling bot needs: direct messages arrive on the same messageCreate event as server traffic, and Message.serverId is null for every one of them.

The full bot

dm-bot.ts
The SDK is ESM-only, so your project is an ES module ("type": "module" in package.json) and top-level await is available. Use client.login().catch(...) instead if you prefer to keep the failure handling inline.

Run it

From a checkout of the SDK, put CLOAK_TOKEN in a .env file and run:
The source file is examples/dm-smoke.ts. To run your own copy:

How it works, piece by piece

The server decides, and it decides deny-by-default. A human may DM a bot 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 a CloakActionError from the create step, before anything is encrypted or sent, which is why the sendDM call above sits in a try.Group DMs are not supported. A bot holds 1:1 conversations only.
There is no separate DM event. A direct message fires messageCreate with msg.isDM === true and msg.serverId === null, because a DM has no server at all.msg.dmId and msg.channelId both carry the dm id. That is one deliberate convention: channelId is always “the thing this message lives in”, so msg.reply() and msg.channel.send() work without branching.This bot inverts the usual guard. It returns on if (!msg.isDM) return; because server traffic is not its business. A bot that does server-scoped work needs the opposite line, if (msg.isDM || !msg.serverId) return;, before it touches client.guild(), client.can(), or anything else that takes a server id.
Message.serverId became nullable in 0.2.0. Bots written against 0.1.x that assume a non-null serverId will pass null into server-scoped calls the first time somebody DMs them. TypeScript points at every one of these sites.
The first send to a peer creates the DM and mints a conversation key wrapped to that peer’s identity. Both the dm id and the key are written to the keystore, so this happens once per peer per keystore, not once per restart.Losing the DM half of a keystore is mild: the peer-to-dm map is re-derivable (the create call is idempotent for an existing pair and returns the same id), and a lost DM key means the bot establishes a new one while the peer’s older messages stay unreadable.Losing the identity is not mild, which is why keystorePath is set above. See Keys and the keystore.
Both work in a DM, and neither needs a location: the Message already knows where it lives.msg.reply(text) is a true reply. It renders the quote header and fires a “replied to you” notification that pierces a mute. This bot uses it because echoing a specific message is exactly what a reply is for.msg.channel.send(text) is a plain message into the same conversation, with no quote header and no reply notification. Use that when the bot is simply answering rather than pointing at one message.Both take the same options object: msg.reply(text, { mentions, replyTo, groupId }). In a DM, replyTo works, groupId is ignored, and only direct user mentions notify (role, @everyone, and @here pairs are dropped server-side).
react(), unreact(), edit(), delete(), pin(), and unpin() all reject on a DM message. The backend implements those operations, but their result hooks never reach a bot, so the SDK would be issuing writes it can never observe. reply() and channel.send() work normally.msg.repliedTo is always null on a DM. The frame a bot receives carries no reply array, so there is nothing to decode.Slash commands are not dispatched from DMs. The structured command lane does not exist on the DM 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.
A resolved sendDM() or reply() promise means the frame reached the wire. It does not mean the server accepted it.A server-side denial arrives on the sendRejected event, never as a rejected promise. Bots are rate-limited server-side, and a throttled send shows up there as code -9. In a DM the deny carries no routing, so serverId is null and channelId is a hint rather than a guarantee.The error event is separate again. It surfaces internal failures the SDK would otherwise swallow, including a key exchange that stalls. Registering a listener changes no control flow.

Next

Direct messages

Eligibility, the unified messageCreate, and DM-specific send semantics.

Keys and the keystore

Why a bot with no keystore is bricked on its second run.

Sending messages

send(), reply(), SendOptions, and sendRejected.

Events reference

Every event this bot listens for.