Skip to main content
Your bot hands text to the SDK, which encrypts it in your process and puts ciphertext on the wire. There are three entry points: reply to a message you are handling, send into the channel it arrived in, or send to a channel you name. This page covers all three, plus the two behaviors that catch people out. The fourth argument is an options object, and a send the server refuses does not reject.

The signature

Breaking in 0.2.0. The fourth argument used to be a positional groupId. It is now a SendOptions object. Rewrite send(s, c, text, groupId) as send(s, c, text, { groupId }). In TypeScript the old call is a compile error. In plain JavaScript it fails quietly: reading .groupId off a string yields undefined, so the message goes out with no group, which is the exact failure that argument exists to prevent.
The same 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 received Message 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.
A ping bot answers, so it uses channel.send().
See Keys and the keystore for why keystorePath is not optional in practice. Use reply() when the quote genuinely helps, such as a moderation notice attached to the offending message.
Both methods work on a direct message. There they route into the DM lane instead of 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 for client.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.
Without the guard, TypeScript rejects the call and plain JavaScript degrades silently: 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 leave groupId 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”.
A server-side denial does not reject the promise. It arrives later on the sendRejected event. Wrapping send() in try/catch will not catch a missing message_send permission, a rate limit, or a timeout.
Register a listener once, at startup.
The deny frame carries no routing of its own, so 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.
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.