Skip to main content
The current version is 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 is sendCommand(), which now returns Promise<void | Message>.

Added

  • Send receipts. send(), sendDM(), sendCard(), sendCardDM(), sendCommand(), sendFile(), sendFiles(), msg.reply(), and msg.channel.send() take ack: true and then resolve with the persisted Message (the server’s messageId, createdAt, and route) once the bot’s own firehose echo lands, matched by ciphertext. A denial pinned to the send rejects with CloakActionError; an indeterminate outcome rejects with the new CloakSendTimeoutError (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. sendRejected gains attribution: '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), and sendTypingDM(userId) is new.
  • Reaction direction. reactionUpdate gains operation: 'add' | 'remove' | 'unknown' and previousCount, inferred from the last total the session saw.
  • Multi-file sends. sendFiles() and sendFilesDM() attach several files under one message, with rollback of every uploaded blob on any failure. sendFile() is now the one-file case. FileInput and SendFileOptions are exported.
  • Cancellation and paging. fetchMessages(), fetchDmMessages(), fetchThreads(), fetchForumPosts(), searchMembers(), sendFile(), sendFiles(), and downloadAttachment() accept an AbortSignal. 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? }) and guild().searchMembers(): a prefix search over username, display name, and nickname, at most 25 hits, working against every current backend. Returns MemberSearchResult[].
  • 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’s BotCapabilities map; a dropped socket resumes with the missed frames replayed (resumedLastLogin); every send carries a nonce, so a denial is attributed exactly and an ack: true receipt settles on the bot-only acceptance ack before the echo, with a Message built from the local text and the server’s id and time; reactionUpdate.operation is authoritative; messageDelete.actorId, the typing event’s serverId / channelId, and containerKind / parentChannelId on reactionUpdate, messageUpdate, messageDelete, typing, and Interaction are filled; thread frames carry their parent; fetchMessage() and resolveContainer() 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 repliedTo is always null in 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(), and postParent(). Message.postId, Message.forumChannelId, and Message.starter, plus the forumPostUpdate and forumPostDelete events and the ForumPost, ForumTag, and ForumMeta types. A post’s starter message arrives as a messageCreate with starter: true from the post’s own create event. Every location-taking call accepts postId next to threadId, 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, with embed optional when rows are present. Clicks arrive on the new interaction event as an Interaction, answered with exactly one of ack(), showModal(), or ephemeral(). 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 a ModalDefinition of up to five text inputs; the submit arrives as a second interaction with kind: 'modal', fields, and parentId. See Modals.
  • Card edits and reads. client.editCard() and Message.editCard() replace a message’s embed and rows in one edit. Message.card exposes an inbound card as a MessageCard, validated on read with the same caps and media rule as on write, and messageUpdate carries card when 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 on interaction with serverId: null and channelId equal 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 like sendDM(). fetchMessages(serverId: string | null, ...) accepts null plus the dm id for the same read on a DM this session already knows. Rows decrypt under the DM key, carry content, repliedTo, card, poll, createdAt, isDM: true, and serverId: null, include the bot’s own messages, and never replay command. No permission gate in a DM. Needs a wire version 3 backend. See Read a DM’s history.
  • DM slash commands. CommandDeclaration.dm?: boolean (default true) marks a command as available in DMs; false publishes a fourth flags element (bit 0, COMMAND_FLAG_NO_DM) that the DM picker filters on, and a DM invocation of it is dropped with a debug warning. New exports COMMAND_FLAG_NO_DM, MAX_COMMAND_FLAGS, declarationFlags(), and CommandRegistry.allowsDm(name). A structured invocation from the DM’s / picker dispatches through the same registry with CommandContext.serverId: null, dmId, channelId equal to the dm id, and userId, with no permission gate; ctx.reply() sends into the DM. CommandContext gains serverId, dmId, channelId, and userId on 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_VERSION 3 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 carries createdAt, repliedTo, a poll, and card. react(), unreact(), edit(), editCard(), and delete() work on such a DM message; pin() and unpin() still do not. editCard() takes serverId: string | null, where null plus the dm id is the DM edit. The messageUpdate, messageDelete, and reactionUpdate payloads carry serverId: 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, VoiceMediaSession gains the audio event (one per subscribed remote audio track, with a pull-driven frames iterable), the merged audio() iterable, setSubscribed(), and the receiveSampleRate and receiveChannels attach options, plus the pcmFrameBytes() helper and the PcmFrame, ParticipantAudio, and ParticipantFrame types. 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 new client.voiceConnectionReporter(), is how attach() sends that report, before the publish and around every engine reconnect. attach() then waits for the admission, polling the engine’s canPublish permission every 100 ms and re-sending the report every second, bounded by the new VoiceMediaOptions.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 under debug). An older single-phase backend still works either way. See Microphone admission.
  • Errors. CloakComponentError is new, CloakErrorSource gains 'interaction', and the errors reference carries the deny tables for the forum and interaction opcodes. The op-1 -17 deny 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.fields on mobile, in the same three-column grid as desktop with the same inline: false full-row rule. An older mobile build still drops the rows silently, so keep the substance in description until your members have updated. See Fields and older mobile builds.
  • Events. draining is documented: the lane a connection is on is being drained for a deploy, and the client reconnects on its own.

Changed

  • The threadDelete payload is documented as { serverId, parentChannelId, threadId }, which is what the SDK has always emitted.
  • Forums add no permission names. There is no create_posts or manage_posts, and there never will be: creating a post and sending inside one need message_send (resolved against the forum channel), and moderating other members’ posts needs message_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(), and Message.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 the threadUpdate / threadDelete events. Reactions, edits, pins, polls, and attachments all work inside threads. See Threads.
  • Polls. Create with SendOptions.poll, read with Message.poll, plus fetchPollState(), closePoll(), fetchPollVoters(), and the pollUpdate event. 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

  • CloakRestError now carries the response body verbatim, so metering verdicts (bot_bandwidth_exceeded with retry_after_seconds, file_too_large, storage_exceeded) surface unmodified. The SDK never auto-retries a 429.

0.2.0

Three breaking changes land in this release. A bot written against 0.1.x either fails to compile or misbehaves at runtime until you work through the migration checklist.

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.
Every send surface moved together: 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.
TypeScript catches the old call sites for you: a 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.
Keep 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:
Under 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 unified messageCreate. 1:1 only. See Direct messages.
  • Mentions and replies. SendOptions.mentions and SendOptions.replyTo, the userMention(), roleMention(), EVERYONE, HERE, mentionPairs() and parseMentions() helpers, and Message.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, and postToWebhook(). This lane is not end-to-end encrypted, which is why the payload type is named UnencryptedWebhookPost. See Webhook embeds.
  • Voice control plane. joinVoice() and leaveVoice(), the roster and its events, and client.voiceKeys. See Voice overview.
  • Voice audio. The opt-in media layer: VoiceMediaSession, VoicePlayer, ffmpegResource(), and the ytdlpResource() / resolveMediaUrl() extractor. See Playing audio.
  • Typing and presence. sendTyping(), setStatus(), and the typing event. 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

fetchMembers() and guild().fetchMembers() do not work against any current backend. They await op-30, which a backend change made DM-only: a server-scoped request produces no reply frame at all, not even an error code. The promise does not resolve. It stalls until the SDK’s transport timeout and then fails with a generic timeout, never with a permission error, so a try/catch tells you nothing useful and the wait blocks the client’s action queue while it runs. There is no workaround in this SDK. Server rosters moved to opcodes it does not yet speak.fetchRoles() is unaffected and works normally. The fetchMembers() signature will survive the fix, so code written against it stays valid. It just cannot run today.

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 real Client 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.