The signature
SendOptions object is accepted by msg.reply(), msg.channel.send(), guild().send(), and client.sendDM().
SendOptions
string
The target channel’s group. The SDK resolves it from its channel cache for any channel the bot has already seen traffic from, so you usually leave it out. Pass it when sending into a channel with no prior traffic.
MentionTarget[]
Who to notify:
{ userId }, { roleId }, 'everyone', or 'here'. This array is the ping. The pill that renders in the message is separate literal text you put in text yourself. See Mentions and replies.This array rides the wire in plaintext. The server routes notifications without decrypting the body, so mention targets are visible server-side even though the message text is not.ReplyTarget
Make this a true reply to another message: it renders the quote header and fires the mute-piercing “replied to you” notification. A received
Message satisfies the type, so { replyTo: msg } works. The target must live in the channel you are sending to and must carry a createdAt.Answer a message you are handling
A receivedMessage carries its own location, so you pass only the text. You have two choices, and they are not interchangeable.
msg.channel.send(text, opts?)
A plain message in the same channel. Nothing is quoted and no reply notification fires. This is what a bot answering a user should send.
msg.reply(text, opts?)
A true reply. It renders the quote header on the original message and fires the “replied to you” notification, which pierces the recipient’s mutes.
msg.reply() was a plain send before 0.2.0. If you are upgrading a bot that answered commands with reply(), switch those calls to msg.channel.send() unless you really want every answer to ping the user through their mutes.channel.send().
keystorePath is not optional in practice.
Use reply() when the quote genuinely helps, such as a moderation notice attached to the offending message.
client.send(), which is why you do not have to branch. They are also the only two message actions that work in a DM: react, unreact, edit, delete, pin, and unpin all reject on a DM message.
Send to a channel you name
Reach forclient.send() when the target is not where a message arrived. Greeting a new member in a welcome channel is the common case, and systemMessage hands you every id you need, including the group.
string
required
The server that holds the target channel.
string
required
The channel to send into.
string
required
The message content. The SDK encrypts it before it leaves your process.
SendOptions
{ groupId?, mentions?, replyTo? }, described above. Optional.Guard serverId before you pass it
Message.serverId is string | null. It is null for a direct message, and direct messages arrive on the same messageCreate event as server messages. Anywhere you hand msg.serverId to a server-scoped call, guard first.
can() returns false for a null server, so the bot goes quiet with nothing to read in the logs. See Direct messages for the rest of the DM story.
When to pass groupId
For any channel the bot has already seen traffic from, the SDK resolves the group from its channel cache and you can leavegroupId out. Pass it when the bot has no prior traffic from that channel, such as a member-join greeting in a channel nobody has spoken in. The systemMessage event carries evt.groupId for exactly that reason.
A wrong or unknown group makes the server refuse the channel selection, and the send rejects with a plain Error naming the channel and the group it tried.
Send through a server handle
client.guild(serverId) returns a handle with the server already bound, which reads better when a block of code works against one server.
guild().send(channelId, text, opts?) takes the same options object as client.send(). See The guild() handle.
Sends never race
Every action the client performs, sends included, runs on one serialized chain. A send and its channel selection cannot be interleaved by another call. You do not manage ordering: fire messages in the order you want them delivered.Sends are fire-and-forget
There is no success acknowledgement for a message send.send() resolves when the frame is on the wire, not when the server accepts it. A resolved promise means “sent”, never “delivered”.
Register a listener once, at startup.
serverId and channelId are attributed best-effort from the most recent send inside a 30 second window, and are null otherwise. Treat them as a hint, not a fact.
The deny codes a send can report
The deny codes a send can report
Older Cloak servers delivered only
-6 and dropped the rest. Current servers deliver all of them. Even so, this is not an acknowledgement channel: silence does not prove delivery.sendRejected does not also fire error. A denial is an expected outcome, not a plumbing failure. Forward it yourself if you want a single funnel.
When a send rejects locally
Two failures happen inside your process, before anything reaches the wire, and both reject the promise.1
No conversation key for the server
The rejection is a plain
Error reading No conversation key for server <id>; not added / key not delivered yet. This happens right after a first join, before the key handoff completes. The SDK keeps retrying key acquisition on its own, so a later send succeeds once a key-holding member has been online. Treat it as “not yet”, not as fatal.2
replyTo with no createdAt
The rejection reads
[cloak-sdk] replyTo has no createdAt; the reply slot needs the original message's server send time. The server re-reads the quoted row by its send time, so a reply target without one cannot resolve. Check before you pass it.msg.reply() never throws for the second reason. On the rare message with no createdAt it degrades to a plain send rather than blowing up inside your handler.
Next
Mentions and replies
Pill text, the plaintext ping array, and true replies.
Direct messages
Why
serverId is nullable and what changes in a DM.Message delivery
How the firehose decides what reaches your bot.
Handling errors
Which failures throw, which reject, and which arrive as events.