Bot SDK reference
The bot SDK (bot-sdk/ in the Aleja repository) runs an end-to-end encrypted bot in Node.js. It
generates the bot's keys on your machine, keeps them in a local state file, decrypts incoming
messages, and encrypts everything the bot sends. See Bots for how this fits together and
what bots are allowed to do.
Requirements
- Node.js 22 or newer (for the built-in
WebSocket,fetchand Web Crypto). - A checkout of the Aleja repository with dependencies installed (
pnpm installat the root). The SDK uses the apps' own protocol code fromsrc/lib, so a bot and the apps can never disagree about the encryption format. - A bot token from the developer console, with the
gateway,e2eeand messaging scopes the bot uses.
Quick start
import { Bot, FileStore } from "./bot-sdk/src/index.js";
const bot = new Bot({
token: process.env.ALEJA_BOT_TOKEN,
server: "https://api.example.com",
store: new FileStore("./my-bot.state.json"),
});
bot.on("message", async (message) => {
if (message.text === "!ping") await message.reply("pong");
});
await bot.start();
The repository includes a runnable example:
cd bot-sdk
ALEJA_BOT_TOKEN=abot_... ALEJA_SERVER=http://localhost:8080 node examples/echo.js
On Windows PowerShell:
cd bot-sdk
$env:ALEJA_BOT_TOKEN = "abot_..."; $env:ALEJA_SERVER = "http://localhost:8080"; node examples/echo.js
Then add the bot to a nest under Nest settings โ Apps โ Integrations and send !ping, or type
/ and pick /ping or /roll from the bot's commands.
new Bot(options)
| Option | Default | Meaning |
|---|---|---|
token | required | The bot token. Starts with abot_ |
server | http://localhost:8080 | The API base URL. https:// also switches the socket to wss:// |
store | new MemoryStore() | Where keys and ratchet state live. Use a FileStore for any real bot |
logger | console | Anything with warn, error and log methods, or null for silence |
WebSocketImpl | globalThis.WebSocket | A WebSocket class, if you want a different one |
fetchImpl | globalThis.fetch | A fetch function, for proxies or tests |
Lifecycle
await bot.start()
Signs in, loads the bot's registered slash commands, loads or creates the bot's keys, publishes its public keys if the server doesn't have the current ones, collects any keys members sent while it was offline, and connects to the realtime socket. It rejects if the token is wrong, belongs to a person, or the state file belongs to a different bot.
await bot.stop()
Closes the socket, stops reconnecting, and waits until pending state has been written.
Reconnecting
If the connection drops, the bot reconnects with exponential backoff (1 to 30 seconds, with
jitter). Before each attempt it checks the token. If the token was reset or the bot was deleted,
it stops and emits error. After reconnecting it reloads its nests and collects any keys it
missed.
Events
bot.on(name, handler) follows Node's EventEmitter. Handlers can be async; an exception thrown
in one is emitted as error.
| Event | Payload | When |
|---|---|---|
ready | { user, nests } | The first time the socket is up. Not repeated on reconnects |
message | Message | Someone else posted in a channel the bot can see, or in a direct message with it |
command | Command | Someone ran one of the bot's slash commands |
messageUpdate | Message | A message was edited. text is the new text |
messageDelete | { id, channelId } | A message was deleted |
undecryptable | { id, channelId, authorId, reason } | A message couldn't be opened; see Limitations |
nestAdded | { id, name, channels } | The bot was added to a nest |
nestRemoved | { id, name, channels } | The bot was removed from a nest. Its keys for those channels are discarded |
disconnect | { code, reason, wasOpen } | The socket closed. A reconnect is already scheduled |
reconnect | The bot reconnected | |
error | Error | Something failed that the bot couldn't recover from on its own |
The bot never receives its own messages as message events.
When a message arrives before its sender's key, the bot asks for the key and holds the message for
up to two minutes. If the key arrives in time, the message is delivered as normal; otherwise it's
reported as undecryptable.
Message objects
| Field | Type | Meaning |
|---|---|---|
id | string | Message id |
channelId | string | Channel id |
nestId | string or null | The nest, or null for a direct message |
nest | string or null | The nest's name |
isDirect | boolean | True in a direct message |
author | { id, username, name, bot } | Who posted it. name is their nest nickname or display name. For webhook posts id and username are null |
webhook | { id, name } or null | Set when a webhook posted it |
text | string | Decrypted text |
files | array | Attachments: { id, filename, mime, size, key, sha256 } |
replyTo | object or null | The message this one replies to: { id, author_id, author, text } |
command | object or null | Set when the message runs a slash command: { bot_id, name, options }. For this bot's own commands you get a command event instead, with the options checked |
style | string | "me" for a /me action, otherwise empty |
tts | boolean | True for a /tts message. The server sets this after checking Send TTS |
createdAt | number | Milliseconds since the epoch |
editedAt | number or null | When it was last edited |
raw | object | The message exactly as the server sent it (content still encrypted) |
And three shortcuts:
await message.reply(text, options?)sendstextto the same channel as a reply to this message.await message.react(emoji)toggles the bot's reaction.await message.download(file)downloads and decrypts one ofmessage.files, resolving with aUint8Array.
Slash commands
Register commands with setCommands, then handle the command event:
await bot.setCommands([
{ name: "ping", description: "Checks that the bot is listening." },
{
name: "roll",
description: "Rolls a die.",
options: [
{ name: "sides", description: "How many sides", type: "integer", required: true },
{ name: "for", description: "Who it's for", type: "user" },
],
},
]);
bot.on("command", async (command) => {
if (command.name === "ping") await command.reply("pong");
if (command.name === "roll") {
const result = 1 + Math.floor(Math.random() * command.options.sides);
await command.reply(`You rolled ${result}`);
}
});
await bot.start();
The definition format and limits are in Bots. The server checks them and
setCommands rejects with an ApiError (400) that explains the first problem.
A command is an encrypted message that carries the invocation, so the bot needs the same access it
needs for messages: it must be in the channel, and anything it misses (for example, before it has
the sender's key) is reported as undecryptable.
The person's app builds the invocation, so the SDK checks it against your definitions first:
- Options you didn't define, values of the wrong type, and values that aren't one of the
choicesare dropped. - If the command isn't one of yours, or a required option is missing or invalid, there's no
commandevent. It arrives as amessagewhosetextis the readable form, like/roll sides:20.
await bot.setCommands(definitions)
Replaces all of the bot's commands and resolves with them as the server stored them. It works
before start(), and calling it with the same definitions again changes nothing. Pass [] to
remove every command.
Command objects
| Field | Type | Meaning |
|---|---|---|
name | string | The command's name |
options | object | Checked option values by name. user options are user ids. Optional options that weren't given are absent |
user | { id, username, name, bot } | Who ran it |
channelId | string | Where it was run |
nestId, nest | string or null | The nest and its name, or null in a direct message |
isDirect | boolean | True in a direct message |
message | Message | The message that carried the command |
And two shortcuts: await command.reply(text, options?) replies to the invocation, and
await command.react(emoji) reacts to it.
Methods
await bot.send(channelId, text, options?)
Encrypts and posts a message. Returns { id, channelId }.
| Option | Meaning |
|---|---|
replyTo | A Message to reply to. The quote travels inside the encrypted message |
files | Up to 5 attachments: { name, data, type? }, where data is a Uint8Array, Buffer or string. Each file is encrypted on your machine before upload, and its key travels inside the message |
await bot.send(channelId, "Nightly report attached", {
files: [{ name: "report.csv", data: csvText, type: "text/plain" }],
});
Uploads follow the server's file rules: allowed extensions and size limits apply as they do for members.
await bot.edit(channelId, messageId, text)
Replaces the text of one of the bot's messages. Attachments and reply quotes aren't kept.
await bot.delete(messageId)
Deletes a message. Bots can delete their own messages, and others' where their roles allow it.
await bot.react(messageId, emoji)
Toggles a reaction: the first call adds it, the second removes it.
bot.typing(channelId)
Shows the bot as typing in a channel for a few seconds. Returns false if the socket isn't connected.
await bot.download(file, message)
Same as message.download(file).
bot.listNests()
The nests the bot is in, each with { id, name, channels: [{ id, name, type }] }.
bot.fingerprint()
A digest of the bot's public identity key. It changes only if the bot gets new keys (for example, after its state file is lost), so it's useful to log at startup.
State storage
The store holds the bot's private keys and every ratchet. The SDK saves it before sending anything that used a fresh key, so a crash can't make the bot reuse one.
new FileStore(path)
Keeps state in one JSON file, created with permissions 0600 where the platform supports it. Writes
go to a temporary file that's then renamed over the old one, so the file is never left half-written.
new MemoryStore()
Keeps state in memory. The bot gets a new identity every time the process starts, and members' apps switch keys each time. Fine for tests, not for real bots.
Custom stores
Any object with these two methods works, for example one backed by a secrets manager:
const store = {
async load() { /* return the last saved object, or null */ },
async save(state) { /* persist the object; resolve once it's durable */ },
};
state is a plain JSON-serializable object. Encrypt it at rest if your storage isn't private.
Errors
HTTP failures reject with ApiError, which has status and body:
import { ApiError } from "./bot-sdk/src/index.js";
try {
await bot.send(channelId, "hello");
} catch (err) {
if (err instanceof ApiError && err.status === 403) console.log("No permission to post there");
else throw err;
}
Rate-limited requests (429) are retried automatically after the wait the server asks for. Failed
reads are retried on network errors; writes are not, so a message is never posted twice.
Lower-level pieces
Engine (the encryption state machine), MissingKeyError and checkInvocation (the command
check described under Slash commands) are exported for tests and custom
transports. Most bots only need Bot and a store.
