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.
string
Send into a thread of the channel. channelId is the thread’s parent text channel. See Threads.
string
Send into a post of a forum channel. channelId is the parent forum channel. Mutually exclusive with threadId. See Forums.

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. react, unreact, edit, editCard, and delete work there too on a backend that honors wire version 3; pin and unpin reject on a DM message. See Direct messages.

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, unless you ask for a receipt

There is no success acknowledgement for a message send on the wire. By default send() resolves when the frame is out, not when the server accepts it. A resolved promise means “sent”, never “delivered”.

Ask for a receipt

Pass ack: true and send() resolves with the persisted Message instead. The server echoes every message back on the firehose, the bot’s own included, and the SDK matches that echo to your call by its ciphertext. The message you get back carries the server’s messageId and createdAt, which is what edit(), delete(), editCard(), and replyTo need later.
A denial pinned to the send rejects with CloakActionError. No echo inside ackTimeoutMs (default 15 seconds), a drop of the socket, or a deny the SDK cannot pin to one send rejects with CloakSendTimeoutError, whose indeterminate flag says the message may still have been persisted, so retrying can duplicate it. The wait does not hold the send chain: other sends proceed in the meantime. msg.reply(), msg.channel.send(), sendDM(), sendCard(), and sendCommand() take the same option. A DM echo needs a backend that honors wire version 3.
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 come from the SDK’s queue of sends still awaiting their echo, and the payload’s attribution field says how much to trust them: 'exact' when one send was outstanding, 'ambiguous' when several were (the oldest is the hint), 'none' when none was (both null). Awaiting each ack: true send before the next keeps every deny exact.
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.