Overview
@frevvu/bot-sdk is a lightweight, event-driven wrapper over Frevvu's bot gateway. There's no persistent WebSocket connection on the bot side — every poll loop is a plain HTTP request, which keeps a bot runnable from any language runtime that can speak HTTP and JSON, and keeps this SDK's own surface small enough to audit against the backend's Rust source directly.
A bot account is a real member of every server it's invited to — it can send messages, attach rich embeds, add interactive buttons and select menus, open popup forms, register slash commands, join voice channels, and optionally end-to-end encrypt what it sends. FrevvuBotClient is the class you'll spend most of your time with; it wraps a lower-level RestClient and manages polling for you.
Installation
The SDK is not yet published to npm — package.json has no publishConfig, and it's intended for local/workspace consumption for now. Point your bot project at the package directory with a file: dependency:
{
"dependencies": {
"@frevvu/bot-sdk": "file:../path/to/frevvu/bot-sdk"
}
}
Then install as usual (npm install) and build the SDK once if its dist/ folder isn't already built:
# inside the bot-sdk package itself
npm run build
--env-file support if you follow the quickstart's env-var pattern, and a bundler/runtime that understands ESM ("type": "module") — the package ships only compiled .js + .d.ts under dist/.
Quickstart
Get a bot token from the Developer Portal (Bot tab → reveal token), then:
import { FrevvuBotClient } from "@frevvu/bot-sdk";
const bot = new FrevvuBotClient({
token: process.env.BOT_TOKEN,
baseUrl: "https://your-instance.example",
});
bot.on("error", (err) => console.error("[bot error]", err));
const me = await bot.start();
console.log(`Logged in as ${me.username}`);
// bot.on("message", ...) is never emitted — watch() is the real
// way to receive messages. Watch every text channel the bot can see.
for (const server of me.servers) {
for (const channel of server.channels) {
if (channel.kind !== "text") continue;
bot.channels.watch(channel.id, (msg) => {
const content = msg.content;
if (content.type === "text" && content.text === "!ping") {
bot.channels.send(channel.id, { type: "text", text: "Pong!" });
}
});
}
}
Run it with node --env-file=.env dist/index.js (or any dotenv loader of your choice) after compiling with tsc. That's a complete, working bot — everything past this point is building on the same three ideas: watch() to receive, send()/edit() to reply, and typed events for everything else.
FrevvuBotClient
The main entry point. Construct one, attach event handlers, call start().
Constructor options
| Option | Type | Default | Description |
|---|---|---|---|
token | string | required | The bot's token from the Developer Portal. |
baseUrl | string | required | Base URL of the Frevvu backend, e.g. https://your-instance.example. |
pollIntervalMs | number | 2000 | Shared interval for every internal poll loop (messages, interactions, voice commands, modal submissions). |
e2ee | boolean | KeyStorage | false | Enables device registration and decryption of old encrypted messages — true for the default file-based key store, or your own KeyStorage implementation. New messages can no longer be encrypted; see End-to-end encryption. |
deviceName | string | "bot-sdk" | Shown in the app's device list and E2EE registration payloads. |
Properties
| Property | Type | Description |
|---|---|---|
rest | RestClient | Low-level HTTP wrapper — every higher-level API is built on this. See RestClient. |
channels | ChannelsApi | Send, edit, and watch channel messages. |
commands | CommandsApi | Register slash commands. |
voice | VoiceApi | Join/leave voice channels, music controls. |
Methods
| Method | Returns | Description |
|---|---|---|
start() | Promise<BotIdentity> | Fetches identity, sets up E2EE if enabled, starts every poll loop. Resolves once ready has fired. Safe to call once — a second call returns the cached identity without restarting anything. |
stop() | void | Stops every poll loop (shared loop + any per-channel watch loops). |
Events
All events are plain EventEmitter emissions — bot.on("eventName", handler).
| Event | Payload | Fires when |
|---|---|---|
ready | BotIdentity | Once, right after start() finishes setup. |
interaction | InteractionOut | A user clicked a button or made a select-menu choice on a message this bot sent. |
modalSubmit | ModalSubmissionOut | A user submitted a modal this bot opened. |
voiceCommand | VoiceCommandOut | Play/pause/skip/previous clicked on this bot's music-controls card. |
error | unknown | Any poll loop threw — always attach a handler for this, since an unhandled error event crashes a Node process. |
message is declared in the event type union and appears in older examples, but it is never emitted anywhere in the SDK — it's dead code left over from an earlier design. Use bot.channels.watch(channelId, handler) to receive messages; there is no bot-wide "any message, any channel" event today.
BotIdentity shape
interface BotIdentity {
id: string;
username: string;
shown_name: string | null;
avatar: string | null;
servers: {
id: string;
name: string;
channels: { id: string; name: string; kind: string }[];
}[];
}
Messages & ChannelsApi
bot.channels is how a bot sends, edits, and receives messages.
| Method | Signature | Notes |
|---|---|---|
send | (channelId, content) => Promise<MessageOut> | Sends any valid message content — see content shapes. |
edit | (channelId, messageId, content) => Promise<MessageOut> | Updates a message this bot previously sent — the backend enforces author-bot-only, mirroring how a human can only edit their own messages. This is the primitive every interactive feature (pagination, live select state, modal confirmations) builds on. |
sendEncrypted | (channelId, content) => Promise<MessageOut> | Deprecated — always throws. Frevvu's server no longer accepts new end-to-end encrypted messages; use send(). See E2EE. |
watch | (channelId, handler) => void | Starts polling a channel for new messages; handler fires per message (auto-decrypted if it's an E2EE envelope and E2EE is enabled). Multiple handlers can watch the same channel. Calling this after start() takes effect on the very next poll tick — no restart needed. |
unwatch | (channelId) => void | Stops polling a channel and drops every handler on it. |
MessageOut shape
interface MessageOut {
id: string;
channel_id: string;
user_id: string;
content: unknown; // see "Message content shapes" below
timestamp: string;
}
content is typed as unknown on purpose — the SDK doesn't police what shape a message carries, it only transports it. Cast it yourself against the shapes documented next.
Message content shapes
Every shape below is what the backend actually validates server-side (see embeds.rs/components.rs) — a message that doesn't match gets rejected with a 400, so these limits are real, not aspirational.
Plain text
{ type: "text", text: string }
Embed (rich card)
{
type: "embed",
embed: {
title?: string,
description?: string,
url?: string, // makes the title a link
color?: string, // "#rrggbb"
author?: { name: string, url?: string, icon_url?: string },
thumbnail_url?: string,
image_url?: string,
footer?: { text: string, icon_url?: string },
timestamp?: string, // ISO 8601
fields?: { name: string, value: string, inline?: boolean }[],
}
}
| Field | Limit |
|---|---|
title | 256 characters |
description | 4096 characters |
author.name | 256 characters |
footer.text | 2048 characters |
fields | 25 entries max, up to 3 consecutive inline fields lay out side by side |
fields[].name | 256 characters |
fields[].value | 1024 characters |
| Combined total | title + description + every field's name+value + footer text + author name ≤ 6000 characters |
Any *_url field | Must be http:// or https:// — javascript:, data:, and relative URLs are rejected |
Components — buttons & select menus
components is a top-level sibling of type, not a content type of its own — a bot can attach interactive components to a plain-text message, an embed, or anything else:
{
type: "text", // or "embed", or anything — components ride alongside
text: "Pick one:",
components: [
{
buttons: [
{ label: "Confirm", style: "primary", custom_id: "confirm" },
{ label: "Cancel", style: "danger", custom_id: "cancel" },
{ label: "Docs", style: "link", url: "https://example.com" },
]
},
{
select_menu: {
custom_id: "color",
placeholder: "Pick a color…",
options: [
{ label: "Purple", value: "purple", description: "#8B5CF6" },
{ label: "Green", value: "green" },
],
min_values: 1,
max_values: 1,
}
}
]
}
A row is either buttons or select_menu, never both — mirroring Discord's own action-row constraint.
| Field | Limit / rule |
|---|---|
| Rows per message | 5 max |
| Buttons per row | 5 max |
button.style | primary · secondary · success · danger · link |
button.label | 80 characters |
button.custom_id | Required unless style: "link" — 100 characters max, echoed back on click |
button.url | Required only when style: "link", http(s) only — a link button just navigates, it never queues an interaction |
select_menu.options | 1–25 entries, each with a label (≤100 chars) and value (≤100 chars) |
select_menu.min_values / max_values | Default 1/1, max_values capped at 25 and can't exceed the option count |
See Buttons & select menus below for how clicks/selections reach your bot.
Slash command invocation (received)
When a user runs a registered slash command, it arrives through the exact same channel — watch() delivers it like any other message, just shaped differently:
{
type: "command",
command: string, // the command name
command_bot_id: string,
command_options?: Record<string, string>, // option name -> value
}
Attachments (received)
{
type: "attachments",
files: { url: string, name: string, size: number, content_type: string }[],
}
Buttons & select menus
Attach components to any message (see above), then listen for the interaction event to find out when someone uses them.
bot.on("interaction", (i) => {
if (i.custom_id === "confirm") {
bot.channels.send(i.channel_id, { type: "text", text: `Confirmed by @${i.username}` });
}
});
InteractionOut shape
interface InteractionOut {
id: string;
message_id: string;
channel_id: string;
user_id: string;
username: string; // see note below
shown_name: string | null;
custom_id: string;
values?: string[]; // present only for a select-menu interaction
}
// Narrows an InteractionOut to one with `values`:
function isSelectInteraction(i: InteractionOut): i is InteractionOut & { values: string[] };
@username text (not an id-based mention) — always build a mention as `@${i.username}`, never with i.user_id. username/shown_name are included on every interaction specifically so you never need a separate user lookup.
Use isSelectInteraction(i) to safely read i.values:
import { isSelectInteraction } from "@frevvu/bot-sdk";
bot.on("interaction", (i) => {
if (i.custom_id === "color" && isSelectInteraction(i)) {
bot.channels.send(i.channel_id, { type: "text", text: `@${i.username} picked ${i.values[0]}` });
}
});
edit()) lands as its own ordinary write and shows up for every connected client the normal way.
Pagination
There's no special "paginated embed" feature on the backend — Paginator is a small SDK convenience built entirely out of ordinary buttons plus channels.edit().
import { Paginator } from "@frevvu/bot-sdk";
const pages = [
{ type: "embed", embed: { title: "Page 1 of 3", description: "…" } },
{ type: "embed", embed: { title: "Page 2 of 3", description: "…" } },
{ type: "embed", embed: { title: "Page 3 of 3", description: "…" } },
];
const paginator = new Paginator(bot, { channelId, pages });
await paginator.send();
// later, once nobody should be able to page it anymore:
paginator.stop();
PaginatorOptions
| Field | Type | Description |
|---|---|---|
channelId | string | Where to send the paginated message. |
pages | unknown[] | One entry per page — each is a full message-content object (e.g. {"type":"embed","embed":{...}}). |
restrictToUserId | string (optional) | Only this user can page forward/back. Omitted → anyone can. |
labels | { prev?, next? } (optional) | Override the default "◀ Prev" / "Next ▶" button labels. |
Internally it generates reserved-looking custom_ids (__paginator:<nonce>:prev/:next), listens for matching interaction events, and calls channels.edit() on each click — nothing here is special-cased by the backend, so you could hand-roll the same pattern yourself with plain buttons if you needed something Paginator doesn't cover.
Modals
A modal is a popup form your bot can open in response to a button click or select-menu choice, collect a few text fields, and get the filled-out values back.
import { showModal } from "@frevvu/bot-sdk";
bot.on("interaction", (i) => {
if (i.custom_id === "feedback") {
showModal(bot, i.id, {
custom_id: "feedback-form",
title: "Send feedback",
components: [
{ text_input: { custom_id: "summary", label: "Summary", style: "short", required: true, max_length: 100 } },
{ text_input: { custom_id: "details", label: "Details", style: "paragraph", required: false, max_length: 1000 } },
],
});
}
});
bot.on("modalSubmit", (s) => {
if (s.modal_custom_id === "feedback-form") {
bot.channels.send(s.channel_id, {
type: "text",
text: `Thanks! Summary: ${s.fields.summary}`,
});
}
});
The triggering button just needs opens_modal: true so the client knows to wait briefly for a modal after the click:
{ label: "Feedback", style: "secondary", custom_id: "feedback", opens_modal: true }
ModalSpec / ComponentTextInput
interface ModalSpec {
custom_id: string;
title: string;
components: { text_input: ComponentTextInput }[];
}
interface ComponentTextInput {
custom_id: string;
label: string;
style?: "short" | "paragraph";
placeholder?: string;
required?: boolean; // default true
min_length?: number;
max_length?: number; // 4000 hard cap
value?: string; // pre-filled default
}
interface ModalSubmissionOut {
id: string;
channel_id: string;
user_id: string;
modal_custom_id: string;
fields: Record<string, string>; // text_input custom_id -> submitted value
}
| Limit | Value |
|---|---|
| Modal title | 100 characters |
| Text inputs per modal | 5 max |
| Field label | 45 characters |
| Field value | 4000 characters max |
showModal(). Call it as soon as you receive the interaction event — within about 10 seconds — or the client's short poll will give up and nothing will appear.
Slash commands
Register the commands your bot supports; Frevvu's composer autocompletes them for members of any server the bot is in.
await bot.commands.register([
{
name: "ping",
description: "Check if the bot is responsive",
},
{
name: "roll",
description: "Roll a die",
options: [
{ name: "sides", description: "Number of sides", required: false },
],
},
]);
register() is replace-all, not incremental — always call it with your bot's complete command list, typically once at startup, not with a diff of what changed. A command name must be lowercase letters, digits, -, and _ only.
A command invocation arrives exactly like any other message — see the "command" content shape above, delivered through whatever channel's watch() handler is listening.
Voice & music controls
A bot joins a voice channel exactly like a human does — same roster, same LiveKit room. The SDK only handles signaling; actually publishing audio is your job via any LiveKit client SDK, using the token this hands back.
const session = await bot.voice.join(channelId);
// session.token.{token, url} are what a LiveKit client SDK needs to connect
session.onCommand((command, userId) => {
// command is one of "play" | "pause" | "skip" | "previous"
console.log(`${userId} clicked ${command}`);
});
await session.setNowPlaying({
title: "Song Title",
artist: "Artist Name",
duration_secs: 180,
position_secs: 0,
playing: true,
});
// when the track ends:
await session.clearNowPlaying();
// when done entirely:
await session.leave();
setNowPlaying() populates the built-in music-controls card every other client sees on this bot's voice participant tile — that's what produces the play/pause/skip/previous clicks onCommand() receives.
| Method | Description |
|---|---|
bot.voice.join(channelId) | Joins, returns a VoiceSession. |
bot.voice.getUserVoiceChannel(userId) | Looks up which voice channel a user currently sits in (or null). |
session.onCommand(handler) | Registers a play/pause/skip/previous handler for this session. |
session.setNowPlaying(info) / clearNowPlaying() | Updates or clears the music-controls card. |
session.leave() | Leaves the channel and drops the session. |
End-to-end encryption
"e2ee" text message is refused with HTTP 400, from bots too. bot.channels.sendEncrypted() reflects this: it now always throws. Use bot.channels.send() instead. What still works is reading messages that were encrypted before the change — with e2ee enabled, watch() transparently decrypts them for your handler.
Enable it to read a channel's old encrypted history. A bot that doesn't opt in still works everywhere else, it just can't read those older messages:
const bot = new FrevvuBotClient({
token,
baseUrl,
e2ee: true, // default: FileKeyStorage at .frevvu-bot/{botId}.json
});
await bot.start(); // registers this device's E2EE identity on first run
await bot.channels.send(channelId, { type: "text", text: "sent as plain text" });
With e2ee: true, watch() auto-decrypts old E2EE envelopes before calling your handler — you never see raw ciphertext in content for a channel your bot holds the key for.
Key storage
| Class | Use case |
|---|---|
FileKeyStorage | Default — persists device identity + channel keys to a JSON file on disk (.frevvu-bot/<bot-id>.json, not encrypted at rest), so the bot's identity survives restarts. |
MemoryKeyStorage | Nothing persisted — a fresh device identity every run. Fine for short-lived/ephemeral bots (no persistent volume); every restart re-bootstraps channel keys, exactly like a human logging in on a brand-new device. |
What this does not cover
- Bots are never DM or call participants in the E2EE model — a bot can only ever hold a channel key, never a DM-thread key or a call key; the backend rejects those contexts outright for a bot.
- No forward secrecy within a key epoch — same accepted tradeoff as the human client. A compromised channel key exposes everything encrypted under it until the next member-driven rotation.
Low-level RestClient
Every higher-level API (channels, commands, voice) is a thin wrapper over bot.rest. You'll rarely need this directly, but it's the full, authoritative list of what the gateway exposes.
Messages
| Method | Endpoint |
|---|---|
sendMessage(channelId, content) | POST/bot-actions/messages/:channelId |
editMessage(channelId, messageId, content) | PATCH/bot-actions/messages/:channelId/:messageId |
pollEvents(channelId, since?) | GET/bot-events/:channelId |
Interactions & modals
| Method | Endpoint |
|---|---|
pollInteractions() | GET/bot-gateway/interactions |
openModal(interactionId, modal) | POST/bot-actions/interactions/:interactionId/modal |
pollModalSubmissions() | GET/bot-gateway/modal-submissions |
Identity & commands
| Method | Endpoint |
|---|---|
getMe() | GET/bot-gateway/me |
registerCommands(commands) | PUT/bot-gateway/commands |
Voice
| Method | Endpoint |
|---|---|
joinVoice(channelId) | POST/bot-actions/voice/:channelId/join |
leaveVoice(channelId) | POST/bot-actions/voice/:channelId/leave |
setNowPlaying(channelId, info | null) | POST/bot-actions/voice/:channelId/now-playing |
pollVoiceCommands() | GET/bot-gateway/voice-commands |
getUserVoiceChannel(userId) | GET/bot-gateway/voice-channel/:userId |
Devices / E2EE
| Method | Endpoint |
|---|---|
registerDevice(payload) | POST/bot-actions/devices/register |
getServerMemberDeviceKeys(serverId) | GET/devices/server/:serverId/keys |
getUserDeviceKeys(userId) | GET/devices/:userId/keys |
pollE2ee() | GET/bot-gateway/e2ee |
postE2ee(action) | POST/bot-actions/e2ee |
Every request carries Authorization: Bot <token>; a non-2xx response throws with the shape METHOD PATH -> STATUS: body text.
All exports
The complete public surface of @frevvu/bot-sdk:
Plus every type used above: BotIdentity, GatewayServer, GatewayChannel, MessageOut, InteractionOut, ComponentTextInput, ModalSpec, ModalSubmissionOut, CommandDefinition, CommandOption, VoiceCommandOut, VoiceTokenOut, NowPlayingIn, DeviceKeyOut, MemberDeviceKeys, KeyStorage, E2eeEnvelope, E2eeContext, E2eeKeyDistributionMessage, E2eeKeyRequestMessage, E2eeInboxMessage, RestClientOptions, FrevvuBotClientOptions, PaginatorOptions.
Limitations
Stated plainly, so you can design around them rather than discover them mid-build.
- Not on npm yet. Install via a
file:dependency (see Installation) — there is nonpm install @frevvu/bot-sdktoday. - The
messageevent doesn't exist. It's declared in the type union but never emitted. Usechannels.watch(). - Everything is polled, nothing is pushed. The default 2-second interval means every bot action — a reply, a button's visible effect, a voice command — has up to ~2 seconds of latency baked in. Modals add a bit more on top of that (see Modals).
- No bot-wide message feed. You must explicitly
watch()each channel a bot should react in — there's no single firehose event for "any message, anywhere." - Reactions aren't visible to bots. There's no poll surface or event for emoji reactions today — only button clicks and select-menu choices reach a bot via the
interactionevent. - E2EE key bootstrap needs a live member. See the callout in End-to-end encryption — always verify a real client can decrypt before depending on it.
- New encrypted messages are refused.
channels.sendEncrypted()always throws — Frevvu's server stopped accepting new"e2ee"text messages (voice/video calls are unaffected). E2EE support today is read-only: decrypting a channel's older history.