Preloader
Frevvu for developers

Bot SDK Reference

Everything @frevvu/bot-sdk can do — the official Node.js/TypeScript client for building Frevvu bots: messages, rich embeds, buttons, select menus, modals, pagination, slash commands, voice, and end-to-end encryption.

v0.1.0 ESM / TypeScript Poll-based gateway

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.

Architecture in one sentence: your bot process polls the backend on a timer (2 seconds by default) for new messages, button/select clicks, modal submissions, and voice commands — nothing is pushed to it. That single fact explains almost every latency characteristic and design decision described below.

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
Requires Node.js 20.6+ for --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

OptionTypeDefaultDescription
tokenstringrequiredThe bot's token from the Developer Portal.
baseUrlstringrequiredBase URL of the Frevvu backend, e.g. https://your-instance.example.
pollIntervalMsnumber2000Shared interval for every internal poll loop (messages, interactions, voice commands, modal submissions).
e2eeboolean | KeyStoragefalseEnables 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.
deviceNamestring"bot-sdk"Shown in the app's device list and E2EE registration payloads.

Properties

PropertyTypeDescription
restRestClientLow-level HTTP wrapper — every higher-level API is built on this. See RestClient.
channelsChannelsApiSend, edit, and watch channel messages.
commandsCommandsApiRegister slash commands.
voiceVoiceApiJoin/leave voice channels, music controls.

Methods

MethodReturnsDescription
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()voidStops every poll loop (shared loop + any per-channel watch loops).

Events

All events are plain EventEmitter emissions — bot.on("eventName", handler).

EventPayloadFires when
readyBotIdentityOnce, right after start() finishes setup.
interactionInteractionOutA user clicked a button or made a select-menu choice on a message this bot sent.
modalSubmitModalSubmissionOutA user submitted a modal this bot opened.
voiceCommandVoiceCommandOutPlay/pause/skip/previous clicked on this bot's music-controls card.
errorunknownAny poll loop threw — always attach a handler for this, since an unhandled error event crashes a Node process.
Not real: 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.

MethodSignatureNotes
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) => voidStarts 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) => voidStops 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 }[],
  }
}
FieldLimit
title256 characters
description4096 characters
author.name256 characters
footer.text2048 characters
fields25 entries max, up to 3 consecutive inline fields lay out side by side
fields[].name256 characters
fields[].value1024 characters
Combined totaltitle + description + every field's name+value + footer text + author name ≤ 6000 characters
Any *_url fieldMust 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.

FieldLimit / rule
Rows per message5 max
Buttons per row5 max
button.styleprimary · secondary · success · danger · link
button.label80 characters
button.custom_idRequired unless style: "link" — 100 characters max, echoed back on click
button.urlRequired only when style: "link", http(s) only — a link button just navigates, it never queues an interaction
select_menu.options1–25 entries, each with a label (≤100 chars) and value (≤100 chars)
select_menu.min_values / max_valuesDefault 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[] };
Mentioning the clicking user: Frevvu's mention syntax is plain @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]}` });
  }
});
Fire-and-forget by design: a click is queued the instant it happens, and your bot picks it up on its own next poll tick — there's no synchronous round-trip. Whatever your handler does (send a message, call 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

FieldTypeDescription
channelIdstringWhere to send the paginated message.
pagesunknown[]One entry per page — each is a full message-content object (e.g. {"type":"embed","embed":{...}}).
restrictToUserIdstring (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
}
LimitValue
Modal title100 characters
Text inputs per modal5 max
Field label45 characters
Field value4000 characters max
Latency, honestly stated: this platform has no gateway-push channel to a single specific client — only server-wide broadcasts. So opening a modal isn't instant the way it might feel on a push-based platform: after the triggering click, the user's client short-polls for a few seconds waiting to see if your bot responds with 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.

MethodDescription
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

Status: new encrypted messages are no longer accepted. Frevvu stopped encrypting channel and DM text server-side — voice and video calls are still end-to-end encrypted, but a new "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

ClassUse case
FileKeyStorageDefault — 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.
MemoryKeyStorageNothing 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.
Verify decryption in your actual target channel before relying on this. Bootstrapping a channel key depends on an existing member (a human client already in that channel) answering the bot's key request in time; if nobody does, the bot originates a brand-new key and distributes it going forward. That's the normal, supported path — but it means a bot dropped into a channel with no active members to answer its first request may take a moment (or a retry) before decryption actually works. Send a test message and confirm a real client can read it before shipping.

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

MethodEndpoint
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

MethodEndpoint
pollInteractions()GET/bot-gateway/interactions
openModal(interactionId, modal)POST/bot-actions/interactions/:interactionId/modal
pollModalSubmissions()GET/bot-gateway/modal-submissions

Identity & commands

MethodEndpoint
getMe()GET/bot-gateway/me
registerCommands(commands)PUT/bot-gateway/commands

Voice

MethodEndpoint
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

MethodEndpoint
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:

FrevvuBotClient RestClient ChannelsApi CommandsApi VoiceApi VoiceSession Paginator showModal isSelectInteraction isE2eeEnvelope FileKeyStorage MemoryKeyStorage

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 no npm install @frevvu/bot-sdk today.
  • The message event doesn't exist. It's declared in the type union but never emitted. Use channels.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 interaction event.
  • 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.