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.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 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. 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 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, unless you ask for a receipt
There is no success acknowledgement for a message send on the wire. By defaultsend() resolves when the frame is out, not when the server accepts it. A resolved promise means “sent”, never “delivered”.
Ask for a receipt
Passack: 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.
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.
Register a listener once, at startup.
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.
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.