0.5.0. Check what you have with npm ls @cloak-software/bot-sdk.
0.5.0
No breaking changes. Everything in this release is additive, and a 0.4.0 bot runs unmodified. The one type widening issendCommand(), which now returns Promise<void | Message>.
Added
- Send receipts.
send(),sendDM(),sendCard(),sendCardDM(),sendCommand(),sendFile(),sendFiles(),msg.reply(), andmsg.channel.send()takeack: trueand then resolve with the persistedMessage(the server’smessageId,createdAt, and route) once the bot’s own firehose echo lands, matched by ciphertext. A denial pinned to the send rejects withCloakActionError; an indeterminate outcome rejects with the newCloakSendTimeoutError(indeterminate: true).ackTimeoutMs(default 15 s) bounds the wait, which does not hold the send chain. See Sending is fire-and-forget unless you ask for a receipt. - Deny attribution.
sendRejectedgainsattribution: 'exact' | 'ambiguous' | 'none'. The route now comes from the queue of sends still awaiting their echo, instead of the last send inside 30 seconds. - Typing in threads, posts, and DMs.
sendTyping()’s fourth argument accepts{ groupId?, threadId?, postId? }(a plain group id string still works), andsendTypingDM(userId)is new. - Reaction direction.
reactionUpdategainsoperation: 'add' | 'remove' | 'unknown'andpreviousCount, inferred from the last total the session saw. - Multi-file sends.
sendFiles()andsendFilesDM()attach several files under one message, with rollback of every uploaded blob on any failure.sendFile()is now the one-file case.FileInputandSendFileOptionsare exported. - Cancellation and paging.
fetchMessages(),fetchDmMessages(),fetchThreads(),fetchForumPosts(),searchMembers(),sendFile(),sendFiles(), anddownloadAttachment()accept anAbortSignal. History reads run each wire page as its own serialized action, so a long read no longer holds sends and reactions behind it. - Member search.
searchMembers(serverId, query, { channelId?, limit?, signal? })andguild().searchMembers(): a prefix search over username, display name, and nickname, at most 25 hits, working against every current backend. ReturnsMemberSearchResult[]. - Wire version 4 and the plan-136 backend. The SDK declares wire version 4. Against a backend carrying the bot consumer-gap work:
capabilities()returns the login’sBotCapabilitiesmap; a dropped socket resumes with the missed frames replayed (resumedLastLogin); every send carries a nonce, so a denial is attributed exactly and anack: truereceipt settles on the bot-only acceptance ack before the echo, with aMessagebuilt from the local text and the server’s id and time;reactionUpdate.operationis authoritative;messageDelete.actorId, thetypingevent’sserverId/channelId, andcontainerKind/parentChannelIdonreactionUpdate,messageUpdate,messageDelete,typing, andInteractionare filled; thread frames carry their parent;fetchMessage()andresolveContainer()are new;reactToStarter()is awaited. Every one of these degrades to the 0.4.0 behavior on an older backend. See What the server must support.
Changed
fetchMembers()is documented accurately: it rejects with the transport’s 8 second timeout rather than hanging forever.- The known-limitations page no longer claims commands are never dispatched from DMs, and the mentions guide no longer claims
repliedTois alwaysnullin a DM; both defer to the direct-message guide, which was right.
0.4.0
No breaking changes. Everything in this release is additive, and a 0.3.0 bot runs unmodified.Added
- Forums.
createForumPost(), sending with{ postId },fetchMessages()with{ postId },fetchForumPosts(),fetchForumMeta(),editForumPost(),setForumPostState(),followForumPost(),reactToStarter(), andpostParent().Message.postId,Message.forumChannelId, andMessage.starter, plus theforumPostUpdateandforumPostDeleteevents and theForumPost,ForumTag, andForumMetatypes. A post’s starter message arrives as amessageCreatewithstarter: truefrom the post’s own create event. Every location-taking call acceptspostIdnext tothreadId, and the two are mutually exclusive. The firehose now includes every post in every forum the bot can view. See Forums. - Buttons and selects.
CardMessage.components: up to five rows of buttons and string selects under a card, withembedoptional when rows are present. Clicks arrive on the newinteractionevent as anInteraction, answered with exactly one ofack(),showModal(), orephemeral(). A card with rows rides a v2 envelope that clients from before this release drop entirely, showing only the message text; a card without rows keeps the v1 shape and renders everywhere. See Buttons and selects. - Modals.
Interaction.showModal()with aModalDefinitionof up to five text inputs; the submit arrives as a secondinteractionwithkind: 'modal',fields, andparentId. See Modals. - Card edits and reads.
client.editCard()andMessage.editCard()replace a message’s embed and rows in one edit.Message.cardexposes an inbound card as aMessageCard, validated on read with the same caps and media rule as on write, andmessageUpdatecarriescardwhen an edit replaced one. See editCard. - DM cards.
client.sendCardDM(userId, card)sends the same card, v1 or v2 envelope, into a 1:1 DM under the DM key. Clicks arrive oninteractionwithserverId: nullandchannelIdequal to the dm id. See Cards in a DM. - DM history.
fetchDmMessages(userId, { before?, after?, around?, limit? })reads a 1:1 DM by the peer’s user id, resolving the dm likesendDM().fetchMessages(serverId: string | null, ...)acceptsnullplus the dm id for the same read on a DM this session already knows. Rows decrypt under the DM key, carrycontent,repliedTo,card,poll,createdAt,isDM: true, andserverId: null, include the bot’s own messages, and never replaycommand. No permission gate in a DM. Needs a wire version 3 backend. See Read a DM’s history. - DM slash commands.
CommandDeclaration.dm?: boolean(defaulttrue) marks a command as available in DMs;falsepublishes a fourthflagselement (bit 0,COMMAND_FLAG_NO_DM) that the DM picker filters on, and a DM invocation of it is dropped with adebugwarning. New exportsCOMMAND_FLAG_NO_DM,MAX_COMMAND_FLAGS,declarationFlags(), andCommandRegistry.allowsDm(name). A structured invocation from the DM’s/picker dispatches through the same registry withCommandContext.serverId: null,dmId,channelIdequal to the dm id, anduserId, with no permission gate;ctx.reply()sends into the DM.CommandContextgainsserverId,dmId,channelId, anduserIdon every invocation. The text lane (/play ...typed by hand) runs in DMs too, under the same rules, and works on any backend; a message dispatches at most once across the two DM frames. Only the/picker’s structured lane needs a wire version 3 backend. See Commands in direct messages. - Wire version 3. The SDK declares
CLIENT_WIRE_VERSION3 at login. A backend that honors it delivers the DM lane’s message broadcast to the bot, its own DM sends echoed back included, so a DM message carriescreatedAt,repliedTo, a poll, andcard.react(),unreact(),edit(),editCard(), anddelete()work on such a DM message;pin()andunpin()still do not.editCard()takesserverId: string | null, wherenullplus the dm id is the DM edit. ThemessageUpdate,messageDelete, andreactionUpdatepayloads carryserverId: string | null. On an older backend nothing changes, and a DM action rejects with a message saying the frame carried no timestamp. See What a wire version 3 backend adds. - Voice receive. In room scope,
VoiceMediaSessiongains theaudioevent (one per subscribed remote audio track, with a pull-drivenframesiterable), the mergedaudio()iterable,setSubscribed(), and thereceiveSampleRateandreceiveChannelsattach options, plus thepcmFrameBytes()helper and thePcmFrame,ParticipantAudio, andParticipantFrametypes. Every receive entry point throws in participant scope. There is no recorder, no file sink, and no transcription helper. See Receiving audio. - Microphone admission. A current backend withholds the microphone from a server-voice credential until the bot reports its media connection up.
VoiceMediaOptions.connectionReporter?: (state: 0 | 1) => void, supplied by the newclient.voiceConnectionReporter(), is howattach()sends that report, before the publish and around every engine reconnect.attach()then waits for the admission, polling the engine’scanPublishpermission every 100 ms and re-sending the report every second, bounded by the newVoiceMediaOptions.admissionTimeoutMs(default 5000); on timeout it publishes once anyway and fails closed, naming the admission, if that publish rejects. Pass the reporter on every attach: without it there is no wait and a bot on a current backend publishes silence with no error (a warning underdebug). An older single-phase backend still works either way. See Microphone admission. - Errors.
CloakComponentErroris new,CloakErrorSourcegains'interaction', and the errors reference carries the deny tables for the forum and interaction opcodes. The op-1-17deny now reads “cannot send into a forum channel directly (send into a post with the postId option)”. - Mobile renders card fields. The client release that ships with 0.4.0 renders
embed.fieldson mobile, in the same three-column grid as desktop with the sameinline: falsefull-row rule. An older mobile build still drops the rows silently, so keep the substance indescriptionuntil your members have updated. See Fields and older mobile builds. - Events.
drainingis documented: the lane a connection is on is being drained for a deploy, and the client reconnects on its own.
Changed
- The
threadDeletepayload is documented as{ serverId, parentChannelId, threadId }, which is what the SDK has always emitted. - Forums add no permission names. There is no
create_postsormanage_posts, and there never will be: creating a post and sending inside one needmessage_send(resolved against the forum channel), and moderating other members’ posts needsmessage_manage.
0.3.0
No breaking changes. Everything in this release is additive, and a 0.2.0 bot runs unmodified.Added
- File attachments.
sendFile(),sendFileDM(),downloadAttachment(), andMessage.attachments. End-to-end encrypted, conversation-wrapped key grants, limits billed to the bot owner’s plan, transfers metered by a rolling bandwidth window. Rides the REST lane, so it needs the server operator to have enabled bot REST credentials. See File attachments. - Threads.
createThread(), sending with{ threadId },Message.threadId/Message.threadParentId,fetchThreads(),joinThread()/leaveThread(),fetchThreadMembers(), and thethreadUpdate/threadDeleteevents. Reactions, edits, pins, polls, and attachments all work inside threads. See Threads. - Polls. Create with
SendOptions.poll, read withMessage.poll, plusfetchPollState(),closePoll(),fetchPollVoters(), and thepollUpdateevent. Bots never vote: the server refuses a bot vote before reading a single row, and the SDK ships no vote API. See Polls. - Permissions 31 through 42. The declared-permission table now covers the full backend range, including
soundboard_use,member_list,send_tts,send_voice_message, the four operations codes, and the four thread codes (create_public_thread,create_private_thread,send_in_thread,manage_threads). - Distribution. Not an SDK surface, but new alongside this release: the developer portal and bot invite links. See Distribute your bot.
Changed
CloakRestErrornow carries the response body verbatim, so metering verdicts (bot_bandwidth_exceededwithretry_after_seconds,file_too_large,storage_exceeded) surface unmodified. The SDK never auto-retries a 429.
0.2.0
Breaking: send() takes an options object
The fourth argument used to be a positional groupId. It is now a SendOptions object, and the positional form is gone.
SendOptions carries three fields, two of them new:
string
The channel’s group, for the case where the SDK has not already cached it from an inbound frame. This is the value that used to be positional.
MentionTarget[]
Who to notify. New in 0.2.0. Mention targets ride the wire in plaintext, because the server routes notification fanout without decrypting the body. See Mentions and replies.
ReplyTarget
Makes the message a true reply. New in 0.2.0.
string is not assignable to SendOptions. Run tsc --noEmit and fix everything it flags.
Sending is fire-and-forget in every version. A resolved promise means the frame is on the wire, not that the server accepted it. A server-side denial arrives later on the
sendRejected event. If your 0.1.x bot never listened for it, add a listener while you are in here.Breaking: msg.reply() is now a true reply
In 0.1.x, msg.reply() was a plain send into the same channel. In 0.2.0 it posts a real reply: the client renders a quote header, and the author gets a “replied to you” notification that pierces their mute settings.
For a bot that is simply answering, that notification is usually wrong. Use msg.channel.send(), which is the old behavior under a new name.
msg.reply() for the cases where a quote header genuinely helps, such as an answer that arrives long after the question, or a moderation notice aimed at one person. ctx.reply() inside a command handler follows the same rule.
Breaking: Message.serverId is string | null
A direct message has no server, so Message.serverId (and MessageLocation.serverId) are now nullable. Inbound DMs arrive on the same messageCreate event as channel messages, which means any handler you already have will start seeing them.
Guard before you pass msg.serverId to anything server-scoped:
strictNullChecks, TypeScript flags these too. msg.reply() and msg.channel.send() need no guard: they route themselves at the DM when the message is one. In 0.2.0 six actions rejected on a DM message: react, unreact, edit, delete, pin, and unpin. Since 0.4.0 only pin and unpin do, on a backend that honors wire version 3. See Direct messages.
Migration checklist
1
Run the type checker first
tsc --noEmit finds both the positional groupId calls and the serverId nullability. Start from its output rather than reading every file.2
Rewrite positional groupId calls
send(s, c, text, groupId) becomes send(s, c, text, { groupId }). Same for guild().send().3
Decide reply or channel.send at every call site
Ask whether the author should get a mute-piercing notification. If not, switch to
msg.channel.send().4
Add a DM guard to every messageCreate handler
if (msg.isDM || !msg.serverId) return; at the top, unless the handler is meant to answer DMs.5
Remove any call to fetchMembers()
It cannot work against a current backend. See Known broken below.
6
Check your Node version
^20.19.0 || >=22.12.0. Node 21.x does not qualify. See Install and requirements.Added
- Direct messages.
client.sendDM(),Message.isDM,Message.dmId, and inbound DMs on the unifiedmessageCreate. 1:1 only. See Direct messages. - Mentions and replies.
SendOptions.mentionsandSendOptions.replyTo, theuserMention(),roleMention(),EVERYONE,HERE,mentionPairs()andparseMentions()helpers, andMessage.mentions/Message.mentionsMe. See Mentions and replies. - Slash commands. A typed command registry declared before
login(), dispatched through one handler whether the invocation arrives as an encrypted sidecar or as parsed message text. See Slash commands. - Rich cards.
sendCard(), the encrypted embed lane. Same conversation key and epoch as a normal message, posted under the bot’s own identity. See Rich cards. - Webhook embeds.
sendEmbed(),client.webhooks, andpostToWebhook(). This lane is not end-to-end encrypted, which is why the payload type is namedUnencryptedWebhookPost. See Webhook embeds. - Voice control plane.
joinVoice()andleaveVoice(), the roster and its events, andclient.voiceKeys. See Voice overview. - Voice audio. The opt-in media layer:
VoiceMediaSession,VoicePlayer,ffmpegResource(), and theytdlpResource()/resolveMediaUrl()extractor. See Playing audio. - Typing and presence.
sendTyping(),setStatus(), and thetypingevent. See Typing, presence, and profile. - REST surfaces.
client.rest, off by default and gated server-side. See The REST API. - Environment doctor.
generateDependencyReport()and friends, which report what your host actually resolved. See Troubleshooting. - HPKE key wrap. The key-wrap format cut over to RFC 9180 HPKE. Both ends of a key handover must speak it, so a human handing your bot a server key needs a current client build. The SDK names the offending peer in a warning if it receives an older wrap.
Known broken
Status
The SDK is validated live end to end against a real Cloak stack. A bot logged in, published its identity, received and unwrapped a server key wrapped by a real desktop client, decrypted an incoming message, and sent an encrypted reply the client displayed. Both crypto directions are wire-compatible with the shipped clients. Auto-reconnect is validated by bouncing the service under a live bot. The default WebSocket lane is live-verified too, through an integration harness that drives a realClient over ws:// against the whole stack in Docker.
A green harness run is not evidence that voice media works. The harness publishes no audio, subscribes to none, and decrypts no frame.
@livekit/rtc-node is deliberately absent from its image, so frame-key interop between the SDK and the shipped clients is not covered by it.Not implemented
- Group DMs. 1:1 DMs ship in 0.2.0. The group-DM lane uses a different, versioned key model and is not implemented.
- DM history, reactions, edits, and pins. The backend implements them. None of their hooks reach a bot, so the SDK does not pretend to.
Next
Sending messages
The current send surface, options object and all.
Direct messages
Why
serverId went nullable, and what a DM message can and cannot do.Mentions and replies
Pills, pings, and when a true reply is the right call.
Install and requirements
The supported Node range and why it is what it is.