npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 via fetchMessages(), moderation (kick/ban/timeout), channel/group management, and a guild() 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.ts declarations.

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-sdk

Getting 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. messageCreate fires 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." Use client.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() throws No 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 keystorePath for 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 0600 permissions) and never commit it. Add *.cloak-keystore.json to your .gitignore.
  • Keystore writes are atomic (written to a .tmp sibling 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, emit guildCreate for existing servers, then emit ready.
  • 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). groupId is 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. id is a normalized hex id you can compare against msg.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 a Date or a Message — Cloak message ids are random UUIDv4 and not time-sortable, so there is no id cursor. Default (no cursor) returns the newest limit (default 50); before/after page strictly past the cursor (after keeps the limit closest to the cursor); around is a single ~50-row window and is not auto-paged. Returned messages are fully actionable (reply/react/edit/delete/pin) and carry pinned.
  • messageLocation(messageId): MessageLocation | undefined: where a previously seen message lives (bounded LRU; best-effort).

Moderation (server-scoped)

  • kick(serverId, userId): requires member_kick and a rank above the target.
  • ban(serverId, userId, reason?): requires member_ban; reason ≤ 256 chars.
  • unban(serverId, userId): requires member_ban + owner/admin standing.
  • timeout(serverId, userId, durationSeconds) / removeTimeout(serverId, userId): requires member_timeout. A timed-out member cannot send or react. Takes SECONDS; 0 lifts. 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) — undefined when 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 what react() accepts.

Permissions (advisory)

  • permissions(serverId): PermissionSet | undefined: the bot's cached resolved capability set for a server (named booleans + effectiveRank), or undefined if no verdict has arrived yet.
  • can(permission, serverId): boolean: advisory check of whether the bot holds permission in serverId per the last verdict (administrator implies 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, and timeout() 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 by base64(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).