@cloak-software/bot-sdk
v0.1.2
Published
Official TypeScript SDK for building Cloak bots — developer-hosted, full E2EE members. Discord.js-flavored API.
Readme
@cloak-software/bot-sdk
The official TypeScript SDK for building Cloak bots: a discord.js-flavored API over Cloak's end-to-end encrypted messaging protocol.
A Cloak bot is a developer-hosted external process and a full E2EE member of the servers it joins. Your bot holds its own libsignal identity, receives each server's encryption key through Cloak's normal key-exchange flow, and does all encryption and decryption inside your process. The Cloak service never sees plaintext or unwrapped keys.
The API intentionally mirrors discord.js so porting a Discord bot feels mechanical, but note it is not wire-compatible with Discord.
import { Client } from '@cloak-software/bot-sdk';
// url defaults to Cloak's hosted service; just pass your bot token.
const client = new Client({ token: process.env.CLOAK_TOKEN! });
client.on('ready', () => console.log(`Logged in as ${client.user?.id}`));
client.on('messageCreate', (msg) => {
if (msg.authorId === client.user?.id) return; // ignore our own messages
if (msg.content.trim() === '!ping') msg.reply('pong');
});
await client.login();Features
- End-to-end encrypted. AES-256-GCM message content plus libsignal key wrapping, wire-compatible with the Cloak client. The service relays ciphertext only.
- discord.js-shaped.
Client, events (messageCreate,reactionUpdate,guildCreate, and more),msg.reply()/msg.react()/msg.edit()/msg.delete()/msg.pin(), history viafetchMessages(), moderation (kick/ban/timeout), channel/group management, and aguild()handle. Familiar if you have written a Discord bot. - Firehose delivery. A bot receives messages from every channel it is permitted to view, across every server it belongs to, with no per-channel subscription required. Delivery is view-gated: channels the bot cannot see are not delivered.
- Resilient. Automatic reconnect with exponential backoff and jitter; identity and conversation keys persist across restarts and reconnects.
- Typed. Fully typed event map and options; ships
.d.tsdeclarations.
Requirements
- Node.js
^20.19.0 || >=22.12.0(the transport uses WebTransport over QUIC/HTTP-3, which requires a recent Node). - A Cloak account and a bot token created in the Cloak app. Your bot connects to Cloak's hosted service over WebTransport; there is no server for you to run or host.
Install
npm install @cloak-software/bot-sdkGetting started
1. Create a bot and copy its token
In the Cloak client, open Settings > My Bots, create a bot, and copy the
token. It is shown once. The token has the shape botid.tokenid.secret and
grants full E2EE membership, so treat it like a password (store it in an env var
or secret manager, and never commit it).
2. Add the bot to a server
In the server you want the bot in, open Server Settings > Invites > Add a Bot (requires Manage Server). Adding the bot shares that server's encryption key with it, so the bot can decrypt and send messages there.
3. Declare and receive permissions
A bot declares the permissions it needs next to its code, and a human approves a subset per-server when adding it. Declaration is not a grant: the SDK sends your declared manifest to the server on login, but the server only grants what the human approved on the Cloak app's add-a-bot consent screen.
const client = new Client({
token: process.env.CLOAK_TOKEN!,
requiredPermissions: ['message_send', 'view_text', 'reaction_add'],
});The server then pushes the bot its resolved grant per server. The SDK caches it and surfaces it three ways:
client.on('permissionsUpdate', ({ serverId, effectiveRank, ...perms }) => {
console.log(`grant for ${serverId}: send=${perms.message_send} rank=${effectiveRank}`);
});
// Advisory pre-gate: check before firing an action the server would reject.
if (client.can('message_send', msg.serverId)) {
await msg.reply('pong');
}
client.permissions(serverId); // the full PermissionSet, or undefined
client.visibleChannelIds(serverId); // channels the bot may see (see below)can(), permissions(), and visibleChannelIds() are advisory — the
server remains authoritative and re-checks every action. They exist so a bot can
pre-gate its own commands instead of firing actions that will bounce. Permission
names are the strings in src/permissions.ts (e.g.
'message_send', 'member_ban', 'manage_channels', 'administrator');
administrator implies every other permission.
The firehose is view-gated.
messageCreatefires only for channels the bot is permitted to view. A silent channel can therefore mean "the bot isn't permitted to see it" — not just "no key yet." Useclient.visibleChannelIds(serverId)to reason about which channels the bot should be hearing from.
First-join key handoff: the first time a bot joins a server, a human member who already holds that server's key must be online to wrap and hand the key to the bot. Until that happens the bot can connect but cannot decrypt or send in that server (
send()throwsNo conversation key for server ...). The SDK retries the key pull automatically.
4. Run your bot
import { Client } from '@cloak-software/bot-sdk';
const client = new Client({
token: process.env.CLOAK_TOKEN!, // botid.tokenid.secret
keystorePath: './bot.cloak-keystore.json', // persist identity + keys (recommended)
// url is optional; defaults to Cloak's hosted service.
});
client.on('ready', () => {
console.log(`Online as ${client.user?.id}`);
});
client.on('messageCreate', (msg) => {
if (msg.authorId === client.user?.id) return; // ignore our own (echoed) messages
if (msg.content.trim() === '!ping') {
msg.reply('pong').catch(console.error);
}
});
await client.login();Because delivery is via firehose, this bot answers !ping in any channel of any
server it is in; no channel needs to be pre-selected.
Example: a welcome bot
Greets each new member and replies to !ping everywhere:
import { Client } from '@cloak-software/bot-sdk';
const client = new Client({
token: process.env.CLOAK_TOKEN!,
keystorePath: './welcome-bot.cloak-keystore.json',
});
client.on('ready', () => console.log(`Online as ${client.user?.id}`));
// A bot is a first-class server member: react to being added or removed.
client.on('guildCreate', (guild) => console.log(`added to server ${guild.id}`));
client.on('guildDelete', (guild) => console.log(`removed from server ${guild.id}`));
// System events (member joins, bot adds).
client.on('systemMessage', (evt) => {
if (evt.type === 'member_join') {
client
.send(evt.serverId, evt.channelId, `Welcome, ${evt.actorName || 'friend'}!`, evt.groupId)
.catch(console.error);
}
});
client.on('messageCreate', (msg) => {
if (msg.authorId === client.user?.id) return;
if (msg.content.trim() === '!ping') msg.reply('pong').catch(console.error);
});
// Connection lifecycle.
client.on('reconnecting', ({ attempt, delayMs }) =>
console.log(`reconnecting (attempt ${attempt}) in ${delayMs}ms...`),
);
client.on('reconnected', () => console.log('reconnected'));
await client.login();A runnable version lives in
examples/welcome-bot.ts (run it with
npm run example:welcome, which reads a local .env; see
.env.example).
A moderation/utility bot exercising the full action surface (!purge,
!timeout, !roles, !history, react-to-confirm) lives in
examples/mod-bot.ts (npm run example:mod).
Connecting to Cloak
By default the SDK connects to Cloak's hosted service: you do not set url at
all, and there are no certificates to configure. Just pass your token. The
hosted service uses a standard CA-signed TLS certificate.
The serverCertificateHashes option exists only for pointing the SDK at a Cloak
stack you run yourself with a self-signed certificate (for example, SDK
contributors running the service locally in Docker). WebTransport will not trust
a self-signed cert through a CA, so you pin it by its DER SHA-256 hash. Per the
WebTransport spec, a pinned certificate must be valid for 14 days or less. Normal
bots against the hosted Cloak service never need this.
import { readFileSync } from 'node:fs';
import { createHash, X509Certificate } from 'node:crypto';
import { Client } from '@cloak-software/bot-sdk';
function certHash(pemPath: string): { algorithm: 'sha-256'; value: Uint8Array } {
const der = new X509Certificate(readFileSync(pemPath)).raw; // DER bytes
return { algorithm: 'sha-256', value: new Uint8Array(createHash('sha256').update(der).digest()) };
}
const client = new Client({
token: process.env.CLOAK_TOKEN!,
url: 'https://localhost:2000/wt',
serverCertificateHashes: [certHash('./certs/cert.pem')],
});Core concepts
Firehose delivery
A bot session receives the live messages of every channel it is permitted to
view, across all servers it is a member of. Each messageCreate and
systemMessage carries its own serverId, channelId, and (where known)
groupId, so you always know where a message came from and can reply into the
right place. You do not need to subscribe to channels.
Delivery is view-gated by the server: messageCreate fires only for
channels the bot may view (per its resolved permissions). A quiet channel can
mean "not permitted to view," not just "no key yet." Inspect
client.visibleChannelIds(serverId) to see which channels the bot should hear
from. The SDK does not client-side-filter the firehose; the server gates it.
client.watch(serverId, channelId, groupId?) is optional. Use it to pre-select a
channel (for example, to warm its key and channel-to-group mapping ahead of the
first send). groupId is optional and auto-resolved from the server's channel
list — pass it only to skip that resolution round trip. It is remembered and
re-established automatically after a reconnect.
Keys and the keystore
The bot's libsignal identity is published write-once on the server. The SDK
persists that identity plus every acquired conversation key to a keystore file
(keystorePath) and reuses them across restarts and reconnects.
- Always set
keystorePathfor a hosted bot and keep the file. Deleting it after the identity is published breaks decryption ("Bad MAC") until the bot's server-side identity is reset. - The keystore holds live conversation keys. Protect it (the SDK writes it with
0600permissions) and never commit it. Add*.cloak-keystore.jsonto your.gitignore. - Keystore writes are atomic (written to a
.tmpsibling and renamed into place), so an interrupted save can never truncate it. - If the keystore file exists but is corrupt or unreadable, the client throws at construction rather than starting with a fresh identity. Regenerating the identity would permanently break decryption (publication is write-once), so restore the file from a backup instead of deleting it — delete it only if you also reset the bot's identity server-side.
Reconnection
On an unexpected disconnect the client recreates the transport, re-authenticates,
and re-establishes its last watch() target, using exponential backoff with
jitter (capped at 30s). Persisted keys and identity carry across reconnects.
If the watch can no longer be established (for example the channel was deleted
while the bot was offline), the client drops it and continues on the firehose
rather than failing the reconnect. Observe it via the reconnecting,
reconnected, and disconnect events. Calling destroy() shuts the client
down without reconnecting.
Server removal
If the bot is kicked or banned, the SDK drops that server's cached key and emits
guildDelete. This does not tear down reconnect or your current watch(); a bot
left with nothing to do can call destroy() from its own guildDelete handler.
API reference
new Client(options)
| Option | Type | Required | Description |
|---|---|---|---|
| token | string | yes | Bot token botid.tokenid.secret from Settings > My Bots. |
| url | string | no | WebTransport endpoint. Defaults to Cloak's hosted service; set only for a self-hosted or local dev stack. |
| serverCertificateHashes | { algorithm: 'sha-256'; value: Uint8Array }[] | no | Only for a self-hosted or local dev stack with a self-signed cert (see Connecting to Cloak). Not needed for the hosted service. |
| keystorePath | string | no | Path to persist identity and conversation keys. Strongly recommended for hosted bots. |
| requiredPermissions | PermissionName[] | no | The permissions the bot declares it needs (its manifest). Sent to the server on login; a human approves a subset per-server. See Declare and receive permissions. |
| deviceId | string | no | Stable device id for clean reconnect takeover. Defaults to one derived from the token. |
| debug | boolean | no | Log every frame (bot token is redacted). |
Methods
Lifecycle & messaging
login(): Promise<void>: connect, authenticate, publish identity if needed, emitguildCreatefor existing servers, then emitready.send(serverId, channelId, text, groupId?): Promise<void>: send an encrypted message. Sends are serialized so they do not race the server's selection cursor. Throws if the server's conversation key is not held yet.watch(serverId, channelId, groupId?): Promise<void>: optionally pre-select a channel (see firehose delivery).groupIdis auto-resolved from the server's channel list when omitted.destroy(): Promise<void>: close the connection and stop reconnecting.user: { id: string; username: string } | null: the bot's identity after login.idis a normalized hex id you can compare againstmsg.authorId.
History (requires message_read_history)
fetchMessages(serverId, channelId, opts?): Promise<Message[]>: message history, returned oldest → newest, auto-paginated.opts:{ before?, after?, around?: Date | Message; limit?: number; groupId?: string }. Cursors are aDateor aMessage— Cloak message ids are random UUIDv4 and not time-sortable, so there is no id cursor. Default (no cursor) returns the newestlimit(default 50);before/afterpage strictly past the cursor (afterkeeps thelimitclosest to the cursor);aroundis a single ~50-row window and is not auto-paged. Returned messages are fully actionable (reply/react/edit/delete/pin) and carrypinned.messageLocation(messageId): MessageLocation | undefined: where a previously seen message lives (bounded LRU; best-effort).
Moderation (server-scoped)
kick(serverId, userId): requiresmember_kickand a rank above the target.ban(serverId, userId, reason?): requiresmember_ban; reason ≤ 256 chars.unban(serverId, userId): requiresmember_ban+ owner/admin standing.timeout(serverId, userId, durationSeconds)/removeTimeout(serverId, userId): requiresmember_timeout. A timed-out member cannot send or react. Takes SECONDS;0lifts. Requires a server running cloak-backend plan 036+.fetchBans(serverId): Promise<BanInfo[]>: the ban list (member_ban+ owner/admin).
Channel & group management (server-scoped, require manage_channels)
createChannel(serverId, name, groupId, type?): Promise<string>: returns the new channel id.editChannel(serverId, channelId, groupId, newName)/deleteChannel(serverId, channelId, groupId)moveChannel(serverId, channelId, fromGroupId, toGroupId, order): order is clamped to 0..126; a same-place move is rejected by the server as a no-op.createGroup(serverId, name): Promise<string | undefined>: the ack carries no id, so the id is captured best-effort from the create broadcast (name-matched, 4s window) —undefinedwhen it can't be captured.editGroup(serverId, groupId, newName)/deleteGroup(serverId, groupId)
Server edit/delete are deliberately NOT exposed as methods (destructive,
owner-level); they surface as the serverUpdate / serverDelete events only.
Reads
fetchMembers(serverId): Promise<Member[]>: all members with username, online status, icon,isBot, and assigned role. On-demand, no cache.fetchRoles(serverId): Promise<Role[]>: the server's roles with color, position, and member count.fetchEmojis(): Promise<{ emojis, category }>: the global emoji catalog; its ids are whatreact()accepts.
Permissions (advisory)
permissions(serverId): PermissionSet | undefined: the bot's cached resolved capability set for a server (named booleans +effectiveRank), orundefinedif no verdict has arrived yet.can(permission, serverId): boolean: advisory check of whether the bot holdspermissioninserverIdper the last verdict (administratorimplies all). The server remains authoritative.visibleChannelIds(serverId): string[]: the channel ids the bot may view in a server (mirrors the view-gated firehose).
guild(serverId) — a thin handle that binds serverId onto every
server-scoped method above (no cache, no state; pure sugar over the flat
methods):
const g = client.guild(msg.serverId);
await g.timeout(msg.authorId, 300);
const members = await g.fetchMembers();
const history = await g.fetchMessages(msg.channelId, { limit: 100 });Errors: CloakActionError
Every action rejects with a typed CloakActionError when the server denies it:
import { CloakActionError } from '@cloak-software/bot-sdk';
try {
await msg.delete();
} catch (e) {
if (e instanceof CloakActionError) {
console.log(e.opcode, e.code, e.message);
if (e.permission) console.log(`missing permission: ${e.permission}`);
}
}permission is set when the failure maps to a named permission (e.g. deleting
another user's message without message_manage), so a bot can uniformly report
what it lacks.
Events
| Event | Payload | Fires when |
|---|---|---|
| ready | none | Login completed. |
| messageCreate | Message | A user message arrives (from any channel). |
| systemMessage | SystemMessageEvent | A system event (member_join, bot_add). |
| messageUpdate | { messageId, content, serverId, channelId } | A message was edited. content is decrypted best-effort ('', never ciphertext, when no key). |
| messageDelete | { messageId, serverId, channelId } | A message was deleted. |
| reactionUpdate | { messageId, reactionId, count, userId, serverId, channelId } | A reaction was added or removed (count is the new total). |
| pinUpdate | { messageId, pinned, serverId, channelId } | A message was pinned or unpinned. |
| channelCreate / channelUpdate / channelDelete | { channelId, serverId, ... } | Channel lifecycle. |
| groupCreate / groupUpdate / groupDelete | { groupId, serverId, ... } | Group lifecycle. |
| serverUpdate / serverDelete | { serverId, ... } | The server was renamed / deleted by its owner. |
| guildCreate | { id: string } | The bot is added to or already in a server. |
| guildDelete | { id: string } | The bot is removed from a server. |
| disconnect | unknown | The transport dropped. |
| reconnecting | { attempt: number; delayMs: number } | A reconnect is scheduled. |
| reconnected | none | A reconnect succeeded. |
| permissionsUpdate | { serverId: string } & PermissionSet | The server pushed the bot's resolved permission grant for a server. |
The message/reaction/pin and lifecycle events fire for the bot's OWN actions
too — filter by author/actor where that matters, exactly like messageCreate.
Server runtime dependencies. Routing these events to a bot for non-selected servers, plus the reaction/pin success acks, need a Cloak backend running plan 034/035;
timeout()needs plan 036. Against an older backend the SDK still works, but hook-derived events fall back to the bot's currently selected server for routing,react()/pin()cannot confirm success, andtimeout()times out.
Message
interface Message {
content: string; // decrypted plaintext ('' if the key is missing or decrypt failed)
authorId: string; // normalized hex; compare with client.user?.id
authorName: string;
messageId: string;
serverId: string;
channelId: string;
createdAt: Date | null; // server send time, or null if the payload omitted it
repliedTo: { // the message this one replies to, or null for a non-reply
messageId: string;
authorId: string; // normalized hex; compare with client.user?.id for "replied to me"
content: string; // decrypted quote ('' if undecryptable — never raw ciphertext)
createdAt: Date | null;
} | null;
reply(text: string): Promise<void>; // reply into the same channel
channel: { send(text: string): Promise<void> };
// Actions. All reject with CloakActionError on a server deny, and all except
// react/unreact need createdAt (rows are keyed by send time server-side).
react(reactionId: string): Promise<void>; // requires reaction_add
unreact(reactionId: string): Promise<void>;
edit(newContent: string): Promise<void>; // author-only; re-encrypts like send()
delete(): Promise<void>; // author, or message_manage
pin(): Promise<void>; // author, or the channel pin permission
unpin(): Promise<void>;
pinned?: boolean; // when known (history / pinUpdate)
}repliedTo.content is decrypted with the same server key as the outer message
(selected by the quote's own epoch) and is '' when it can't be decrypted — a
missing/stale key never leaks raw ciphertext, and the quote path never triggers
the key self-heal.
SystemMessageEvent
interface SystemMessageEvent {
type: 'member_join' | 'bot_add' | 'unknown';
actorId: string;
actorName: string;
serverId: string;
channelId: string;
groupId?: string; // pass to send()/reply() so the channel-select has a group
}How it works
- Transport (
src/transport.ts): WebTransport over QUIC/HTTP-3 via@fails-components/webtransport, with newline-delimited JSON-array framing. (The service speaks QUIC only; a plain TCP WebSocket client cannot connect.) - Crypto (
src/crypto/): AES-256-GCM for message content (16-byte IV,base64(iv)followed bybase64(ciphertext+tag)on the wire) and libsignal for wrapping each server's conversation key between devices. Pinned to the exact libsignal revision the Cloak client uses for wire compatibility. - Client (
src/client.ts): a discord.js-shaped event emitter over the opcode protocol; serializes all sends through a single queue (sends share one server-side selection cursor per connection, so they cannot safely interleave across channels).
Develop this SDK
npm install
npm run typecheck # tsc --noEmit
npm test # vitest (crypto self-consistency, transport framing, keystore, routing)
npm run build # emit dist/ (also runs automatically on npm pack / npm publish)Status
Validated live end-to-end against a real Cloak stack (2026-07-06): a bot running
this SDK connected over WebTransport, logged in, published its identity, received
and unwrapped a server key wrapped by a real Electron client, then decrypted an
incoming !ping and sent an encrypted pong the client displayed. Both crypto
directions (AES-GCM messages and libsignal key wrap) are wire-compatible with the
real client. Auto-reconnect is validated by bouncing the service under a live
bot.
Roadmap
- Bot DMs (user to bot): lands with Cloak DM support plus this SDK's DM layer.
License
Proprietary. See LICENSE. Use of this package is also subject to
the Bot Developer Terms published with the Cloak Bot SDK docs
(legal/developer-terms).
