VoxaraDocs
Developer documentation

Build a Voxara bot

Everything you need to write a bot for Voxara: creating one in the portal, the JavaScript SDK, the raw WebSocket protocol for any other language, and webhooks for when you only need to post.

Chat bots

Answer commands, greet people, run games. Sign in over a WebSocket and react to events.

Moderation bots

Delete messages, time people out, kick, ban, lock threads, move people between voice channels.

Integrations

CI results, GitHub events, cron jobs. One HTTPS POST to a webhook, no code that stays running.

What a bot is

A bot is a Voxara account you drive with code. It signs in with a token instead of a password, joins spaces like a member, and then talks to the server through exactly the same API the Voxara app uses: JSON frames over a WebSocket. Anything a member can do in a space, a bot with the same roles can do too.

There are two ways to build one:

Bots carry a BOT tag everywhere they appear, take up a member slot in a space, and are subject to the space's moderation like anyone else.

Quick start

About five minutes, start to first reply.

1. Create the bot

Open the Developer Portal and sign in with your normal Voxara account. Click Create a bot, give it a display name and a username, and copy the token it shows you. It is shown once; you can reset it from the same page whenever you need to.

2. Add it to a space

Open the bot in the portal and use Add to a space. You can add it to any space you manage (you own it, or you hold a role with a management permission). A bot only sees the spaces it has been added to.

3. Get the SDK

The SDK is a single file with one dependency. In an empty folder:

# any Node.js 18 or newer
npm init -y
npm install ws
curl -O https://voxaraspace.com/developers/sdk/voxara-bot.js

Or download voxara-bot.js and drop it next to your script.

4. Write it

Save this as bot.js:

const { Client } = require('./voxara-bot');
const client = new Client();

client.on('ready', () => {
  console.log(`Signed in as ${client.user.displayName}`);
});

client.on('messageCreate', async (message) => {
  if (message.author.bot) return;          // never answer a bot, yourself included
  if (message.content === '!ping') await message.reply('pong');
});

client.login(process.env.VOXARA_BOT_TOKEN);

5. Run it

VOXARA_BOT_TOKEN=paste_your_token_here node bot.js

Type !ping in any channel of a space the bot is in. It replies pong. That is a working bot; everything below is what else it can do.

Tokens and safety

Concepts

TermMeaning
Space (guild in the API)A community with members, roles, categories and channels. The API keeps the older name guild in ids and op names.
ChannelLives inside a space. type is text, voice, forum (every post is a thread) or thread. Only text channels and threads carry messages a bot can post in.
ThreadA channel of type thread hanging off a message (text channel) or standing alone as a post (forum). Threads have parentChannelId, can be archived by their creator, and locked by moderators.
DMA private conversation. It has a channel id like anything else. A bot can open a DM with someone who shares a space with it; people cannot DM bots.
RoleA named bundle of permissions with a position. A bot's abilities come from the roles a space gives it, exactly like a member. Moderation only works on people whose highest role is below the bot's.
MessageText, attachments, embeds, reactions, a reply pointer, a thread pointer. See Object shapes.
OpOne request to the server, named like message:send. Every op gets exactly one reply.
EventSomething the server pushes without being asked: a new message, a member joining. Events have no request id.

The JavaScript SDK

One file, voxara-bot.js, depending only on the ws package. It is a thin, readable wrapper over the protocol: an EventEmitter with a request helper, a few classes, automatic reconnection. If it does not do something you need, the raw protocol is always reachable through client.request(op, data).

npm install ws
const { Client, Message, Channel, Embed } = require('./voxara-bot');

Client

const client = new Client({
  reconnect: true,             // retry with backoff when the socket drops
  rejectUnauthorized: false,   // true to insist on a valid certificate chain
});
await client.login(token);              // resolves when the bot is ready

Properties (after ready)

PropertyWhat it holds
client.userThe bot's own user object. client.user.id is what you compare against message.authorId.
client.guildsMap<id, guild> of every space the bot is in, with channels, roles, memberIds, memberRoles.
client.channelsMap<id, Channel> across all spaces. Each carries guildId, name, type, parentChannelId for threads.
client.usersMap<id, user> of everyone in the bot's spaces.
client.mediaTokenA read-only token for fetching attachments and pictures. See Attachments and media.
The caches are a snapshot from sign-in plus the events received since. Good for commands. For anything that must be exact, fetch again instead of trusting a long-lived cache.

Methods

MethodDoes
send(channelId, content, options?)Posts a message. Returns a Message. options: replyTo (message id), embed (an Embed or plain object). For an embed-only message pass the options object as the second argument: send(id, { embed }).
editMessage(channelId, messageId, content)Edits one of the bot's own messages.
deleteMessage(channelId, messageId)Deletes the bot's own message, or anyone's with manageMessages.
react(channelId, messageId, emoji)Toggles a reaction. emoji is a unicode glyph or a custom :name:. Calling it again removes it.
pinMessage(channelId, messageId, pinned = true)Pins or unpins. manageMessages in a space.
fetchMessages(channelId, { limit = 50, before })Reads history, newest last. Up to 100 per call; pass the oldest id as before to page back.
startTyping(channelId)Shows the typing indicator for a few seconds.
dm(userId, content, options?)Opens (or reuses) a DM and sends into it. See Direct messages.
kick(guildId, userId)kickMembers
ban(guildId, userId, reason?) / unban(guildId, userId)banMembers
timeout(guildId, userId, seconds)Mutes someone in the space for seconds; 0 lifts it. kickMembers
kickFromVoice(channelId, userId)Disconnects someone from a voice channel. moveMembers
moveToVoice(channelId, userId)Moves someone into another voice channel of the same space. moveMembers
lockThread(threadId, locked = true)Only moderators can post in a locked thread. manageMessages
setStatus(status)'online', 'idle', 'dnd' or 'invisible'.
setCustomStatus(text)The short line under the bot's name, up to 80 characters.
mediaUrl(path)Turns a /media/... path into a fetchable URL with the media token attached.
request(op, data)Any raw operation. Resolves with the reply's data, rejects with an error carrying .code.
destroy()Closes the socket and stops reconnecting.

Events

Subscribe with client.on(name, handler). Handlers may be async; the SDK does not wait for them, so catch your own errors.

EventHandler receivesWhen
readyclientSigned in and caches filled. Also fires again after every reconnect.
messageCreateMessageSomeone posted in a channel or DM the bot can see. Includes the bot's own messages.
messageUpdateMessageEdited, reactions changed, or an embed's images finished loading.
messageDelete{ id, channelId }Deleted by its author or a moderator.
messagePin{ channelId, messageId, pinned }Pinned or unpinned.
typingStart{ channelId, userId }Someone is typing.
memberAdd{ guildId, user }Someone joined a space the bot is in. Welcome bots live here.
memberRemove{ guildId, userId }Left, kicked or banned.
guildCreateguildThe bot was added to a new space.
channelDelete{ channelId, guildId }A channel is gone.
rateLimiterrorA request was refused with rate_limited. Slow down.
disconnect / reconnectingThe socket dropped / a retry is scheduled.
errorErrorA socket-level error. Always attach a handler.
raw{ op, data }Every server event the list above does not cover (threads, presence, voice state, and so on).

Message

What messageCreate, send() and fetchMessages() hand you.

PropertyTypeNotes
id, channelId, authorIdstringIds are strings. Never treat them as numbers.
contentstringThe text, with Markdown as typed. Empty for an embed-only or attachment-only message.
authoruserAlways an object. author.bot is true for bots and webhooks. author.uncached marks a stub for someone the cache has not seen.
channelChannel | nullNull for a DM (DMs are not in the channel cache) or a channel created after sign-in that no event has announced.
guildguild | nullThe space, or null in a DM.
mentionsstring[]User ids mentioned with @username. everyone is true for @everyone / @here.
attachmentsarray{ url, name, size, type }. See Attachments and media.
embedsarrayRich embeds (kind: 'rich'), link previews and shared Steam games share this array.
reactionsobject{ "👍": ["userId", ...] }
replyTo, threadId, pinnedAt, editedAt, createdAtTimestamps are milliseconds since the epoch.
rawobjectThe message exactly as the server sent it, for fields the class does not surface (polls, forwards).

Methods: reply(content, options?), edit(content), delete(), react(emoji), pin(), unpin().

Channel

Every channel the bot can see, keyed by id in client.channels. The raw channel fields (id, guildId, name, type, topic, categoryId, slowmode, parentChannelId, locked, archived) are on the object directly, plus send(content, options?), fetchMessages(options?) and startTyping().

const general = [...client.channels.values()]
  .find((c) => c.guildId === guildId && c.type === 'text' && c.name === 'general');
await general.send('Good morning.');

Embeds

A message can carry one rich embed: a card with a title, description, side colour, up to ten fields, an image, a thumbnail, an author line, a footer and a timestamp.

const { Embed } = require('./voxara-bot');

const embed = new Embed()
  .setTitle('Deploy finished')
  .setURL('https://ci.example.com/build/482')
  .setDescription('Build **482** passed on `main`.')
  .setColor('#35c96a')
  .addField('Branch', 'main', true)
  .addField('Duration', '48s', true)
  .setThumbnail('https://example.com/logo.png')
  .setFooter('GitHub Actions')
  .setTimestamp();

await client.send(channelId, { embed });               // embed only
await client.send(channelId, 'Done.', { embed });       // text and embed
PartLimit
title256 characters
description2048 characters, Markdown allowed
fields10, name 100 / value 500 characters, inline puts short ones side by side
author.name256 characters
footer.text200 characters
color#rrggbb only
url, author.urlhttp(s) only
image, thumbnailAn http(s) image URL. The server downloads it and re-hosts a copy under its own /media/, so viewers never fetch from your URL. The pictures arrive a moment after the message as a messageUpdate.

Values over a limit are truncated, not rejected. A message with no text, no attachment and an embed with nothing in it fails with empty_message. Editing an embed is not supported yet: delete and post again.

What your bot says about itself

In the Developer Portal, give the bot a description and tick the scopes it uses (reads messages, posts, sends DMs, moderates, voice, sends data outside Voxara). They appear on the bot's profile card, so a space owner knows what they are adding. They are a promise, not a permission: what the bot can actually do is decided by the roles the space gives it.

Slash commands

Declare commands once and people get them as autocomplete after typing / in any space the bot is in. A use never becomes a message; it arrives at the bot as a command event, and the bot answers however it likes.

client.on('ready', () => client.setCommands([
  { name: 'roll', description: 'Roll some dice', options: [{ name: 'sides', type: 'integer', description: 'How many sides (default 6)' }] },
  { name: 'poll', description: 'Start a quick poll', options: [{ name: 'question', required: true }] },
]));

client.on('command', async (cmd) => {
  if (cmd.name === 'roll') {
    const sides = cmd.options.sides || 6;
    await cmd.reply(`${cmd.user.displayName} rolled a ${1 + Math.floor(Math.random() * sides)}`);
  }
});
On the interactionMeaning
name, args, argvThe command, the raw text after it, and that text split on spaces (quotes group words).
optionsargv matched to your declared options in order and converted by type (string, number, integer, boolean, user).
user, channel, guildWho ran it and where.
reply(content, options?)Posts into that channel, same as send() (embeds welcome).

Limits: 25 commands per bot, 10 options each, names 1 to 32 characters (letters, digits, -, _). If the bot is offline the person is told so and nothing is sent. Raw protocol: bot:commands to declare, command:invoke event to receive.

Direct messages

client.on('memberAdd', async ({ guildId, user }) => {
  if (user.bot) return;
  const guild = client.guilds.get(guildId);
  try {
    await client.dm(user.id, `Welcome to ${guild.name}, ${user.displayName}!`);
  } catch (err) {
    if (err.code !== 'cannot_dm') throw err;
    // their privacy settings say no: greet them in a channel instead
  }
});

Rules: a bot can DM someone only if they share a space with it and the person's DM privacy allows it. Otherwise dm() rejects with cannot_dm; treat that as a normal outcome, not a failure. People cannot start a DM with a bot. A DM is a channel like any other once open: its messages come through messageCreate with message.guild === null.

Moderation bots

Give the bot a role in the space with the permissions it needs (space settings, Roles). The server enforces the same rules it applies to people:

const BANNED = ['buy followers', 'free nitro'];

client.on('messageCreate', async (message) => {
  if (message.author.bot || !message.guild) return;
  const text = message.content.toLowerCase();
  if (!BANNED.some((w) => text.includes(w))) return;
  await message.delete();
  await client.timeout(message.guild.id, message.authorId, 600);  // ten minutes
  await message.channel.send(`${message.author.displayName}, that is not allowed here.`);
});

Voice: client.on('raw', ...) receives voice:state events ({ channelId, users }) whenever someone joins or leaves a voice channel, which is how a bot knows who is where before calling moveToVoice or kickFromVoice.

Attachments and media

Attachment and picture URLs in the API are paths like /media/attachment-....png. They are private: fetching one needs a token in the query string. Use the bot's media token, which can read media and nothing else:

for (const file of message.attachments) {
  const res = await fetch(client.mediaUrl(file.url));   // https://host/media/...?t=<mediaToken>
  const bytes = Buffer.from(await res.arrayBuffer());
}

Uploading is upload:attachment on the raw protocol: { name, type, data } with data base64, which returns an attachment object you pass in send()'s options.attachments. Up to 4 per message.

Errors and rate limits

Every rejected request is an Error with a .code and a human-readable .message. Branch on the code, show the message.

try {
  await message.reply('hi');
} catch (err) {
  switch (err.code) {
    case 'rate_limited': /* back off */ break;
    case 'slowmode':     /* the channel's slow mode applies to bots without manageMessages */ break;
    case 'forbidden':    /* missing permission or role rank */ break;
    default: console.error(err.code, err.message);
  }
}
CodeMeaning
bad_tokenThe bot token is wrong or was reset.
unauthorizedYou sent a request before signing in.
forbiddenNot allowed: not in the space, missing permission, or the target outranks the bot.
not_foundThe channel, message, user or space does not exist (or the bot cannot see it).
rate_limitedToo many requests. The SDK also emits rateLimit.
slowmodeThe channel's slow mode says wait. Moderators are exempt.
lockedThe thread is locked.
timed_outThe bot itself is timed out in that space.
cannot_dmThat person cannot be DM'd by this bot.
empty_message / message_too_longNothing to send, or over 4000 characters.
blocked_wordThe space's automod refused the text.
too_many, too_many_reactions, too_many_pins, too_many_attachmentsA ceiling was hit. See Limits.
invalid_*A field was malformed: invalid_emoji, invalid_status, invalid_name, and so on.
internalThe server failed. Retry later.

Guides for using the app

Not building a bot? These cover the app itself: creating a space, channels, roles and permissions, invite links and posting GitHub events into a channel.

The protocol, for any language

The SDK is optional. A bot is any WebSocket client that speaks the same JSON the app does. Python, Go, Rust, a shell script with websocat: all fine.

Connect and sign in

  1. Open a WebSocket to wss://voxaraspace.com. Send the header Origin: app://pulse, or no Origin at all. Any other Origin is refused.
  2. Send { "id": 1, "op": "auth:bot", "data": { "token": "YOUR_TOKEN" } }.
  3. The reply is the ready payload: { "id": 1, "ok": true, "data": { user, guilds, users, mediaToken, ... } }. user.id is the bot's own id. guilds[] holds every space with its channels[], roles[] and memberIds[]; users is a map of everyone in those spaces.
  4. From now on the server pushes events, and you send requests whenever you like.
Before sign-in, frames over 64 KB close the socket, and there are 20 sign-in attempts per minute per address. Sign in first, then do everything else.

Frames

// request: you choose the id, any string or number, unique per request in flight
{ "id": 7, "op": "message:send", "data": { "channelId": "1788…", "content": "hello" } }

// reply: same id, exactly once
{ "id": 7, "ok": true,  "data": { "message": { … } } }
{ "id": 7, "ok": false, "error": { "code": "forbidden", "message": "You cannot send messages in this channel." } }

// event: pushed by the server, no id
{ "op": "message:new", "data": { "message": { … } } }

Send { "op": "ping" } if you want a heartbeat; it is the one op that does not count toward the per-connection frame ceiling. Idle sockets are fine either way.

Operations a bot uses

OpDataReply data
auth:bot{ token }ready payload
message:send{ channelId, content?, replyTo?, embed?, attachments? }{ message }
message:edit{ channelId, messageId, content }{ message }
message:delete{ channelId, messageId }{}
messages:fetch{ channelId, limit?, before? }{ messages, hasMore }
messages:pinned{ channelId }{ messages }
message:pin{ channelId, messageId, pinned }{ message }
reaction:toggle{ channelId, messageId, emoji }{}
typing:start{ channelId }{}
upload:attachment{ name, type, data } (base64)attachment
dm:open{ userId }{ dm }, then send to dm.id
thread:create{ channelId, name, messageId?, content?, tags? }{ thread }
thread:list{ channelId }{ threads }
thread:archive{ threadId, archived }{ thread }
thread:lock{ threadId, locked }{ thread }
guild:kick{ guildId, userId }{}
guild:ban / guild:unban{ guildId, userId, reason? }{}
guild:bans{ guildId }{ bans }
member:timeout{ guildId, userId, seconds }{}
voice:kick{ channelId, userId }{ ok }
voice:move{ channelId, userId }{ ok }
me:update{ status?, customStatus?, displayName?, bio? }{ user }
bot:commands{ commands: [{ name, description, options }] }{ commands }
guild:leave{ guildId }{}

Permissions on these are the same as for the SDK methods above. Ops the app uses for accounts, friends, billing and space administration exist too but are outside what a bot needs; the server refuses what a bot cannot do.

Server events

OpData
message:new{ message }
message:update{ message } (edit, reaction change, embed images ready)
message:delete{ channelId, messageId }
message:pinned{ channelId, messageId, pinned }
command:invoke{ id, channelId, guildId, name, args, argv, user } (a slash command aimed at this bot)
typing{ channelId, userId }
member:add{ guildId, user }
member:remove{ guildId, userId }
guild:new{ guild } (the bot was added to a space)
guild:update{ guild }
channel:new / channel:update / channel:delete{ channel } / { channelId, guildId }
thread:new / thread:update{ thread }
presence{ userId, status, customStatus }
voice:state{ channelId, users } (everyone currently in that voice channel)
voice:kicked / voice:movedOnly ever sent to the person it happened to.
error{ message } for a frame the server could not parse

Object shapes

user

{ "id", "username", "displayName", "bot", "status", "customStatus", "bio",
  "avatarColor", "avatarUrl", "bannerUrl", "badges", "steamLinked", … }

message

{ "id", "channelId", "authorId", "content", "createdAt", "editedAt",
  "attachments": [{ "url", "name", "size", "type" }],
  "embeds": [{ "kind": "rich" | "link" | "steamgame", … }],
  "reactions": { "👍": ["userId"] },
  "mentions": ["userId"], "everyone": false,
  "replyTo": "messageId" | null, "threadId": null, "pinnedAt": null,
  "poll"?: { … }, "forwardedFrom"?: { … } }

guild (a space)

{ "id", "name", "description", "ownerId", "iconUrl", "iconColor",
  "channels": [{ "id", "name", "type", "topic", "categoryId", "slowmode" }],
  "categories": [{ "id", "name" }],
  "roles": [{ "id", "name", "color", "position", "permissions": { "manageMessages": true, … } }],
  "memberIds": ["userId"], "memberRoles": { "userId": ["roleId"] },
  "timeouts": { "userId": untilMs }, "emojis": [{ "name", "url" }] }

Python example

The same ping bot with the websockets package (pip install websockets), no SDK:

import asyncio, json, os, itertools
import websockets

SERVER = "wss://voxaraspace.com"
TOKEN = os.environ["VOXARA_BOT_TOKEN"]
ids = itertools.count(1)

async def main():
    async with websockets.connect(SERVER, origin="app://pulse") as ws:
        await ws.send(json.dumps({"id": next(ids), "op": "auth:bot", "data": {"token": TOKEN}}))
        ready = json.loads(await ws.recv())
        assert ready["ok"], ready
        me = ready["data"]["user"]["id"]
        print("signed in as", ready["data"]["user"]["displayName"])

        async for raw in ws:
            frame = json.loads(raw)
            if frame.get("op") != "message:new":
                continue
            m = frame["data"]["message"]
            if m["authorId"] == me or m["content"] != "!ping":
                continue
            await ws.send(json.dumps({"id": next(ids), "op": "message:send",
                "data": {"channelId": m["channelId"], "content": "pong", "replyTo": m["id"]}}))

asyncio.run(main())

A real bot would match replies to requests by id instead of ignoring them, reconnect when the socket closes, and send a WebSocket ping every 45 seconds or so to keep a quiet connection open. The SDK source is a good reference for all three.

Webhooks

A webhook is one URL that posts into one text channel. No socket, no sign-in, no library. Use it for anything that only needs to announce: CI results, deploys, alerts, forms, cron jobs.

Create one

In the Voxara app, right-click a text channel and choose Webhooks (or Edit channel, then Webhooks). Create one, give it a name, and copy the URL. Like a bot token, the URL is shown once and can be reset. Anyone managing the channel can create up to 10.

https://voxaraspace.com/wh/<id>/<token>

Post to it

curl https://voxaraspace.com/wh/<id>/<token> \
  -H 'content-type: application/json' \
  -d '{"content": "Deploy finished: v1.40.1 is live."}'
// reply
{ "ok": true, "message": { "id": "1788…", "createdAt": 1788874382726 } }
{ "ok": false, "error": { "code": "rate_limited", "message": "Too many requests." } }

The body is JSON with content (Markdown, up to 4000 characters) and/or an embed in the same shape as above:

{
  "embed": {
    "title": "Build 482 passed",
    "url": "https://ci.example.com/build/482",
    "description": "main · 48s",
    "color": "#35c96a",
    "fields": [{ "name": "Author", "value": "sam", "inline": true }],
    "footer": { "text": "GitHub Actions" },
    "timestamp": 1788874382726
  }
}

From GitHub Actions

- name: Tell Voxara
  run: |
    curl -sS "$VOXARA_WEBHOOK" -H 'content-type: application/json' \
      -d "{\"content\": \"${{ github.repository }}: ${{ job.status }} on ${{ github.ref_name }}\"}"
  env:
    VOXARA_WEBHOOK: ${{ secrets.VOXARA_WEBHOOK }}

From Python

import requests
requests.post(WEBHOOK_URL, json={"content": "Backup finished, 2.1 GB."}, timeout=10)

Ready-made templates

Point a service's own webhook at the URL with a suffix and Voxara turns its payload into a card; nothing to configure on their side.

URLPaste it intoWhat posts
…/wh/<id>/<token>/githubGitHub repo → Settings → Webhooks (content type JSON)Pushes (with commit list), pull requests opened/merged/closed, issues, releases, workflow results, stars.
…/wh/<id>/<token>/gitlabGitLab project → Settings → WebhooksPushes, merge requests, issues, pipeline results.
…/wh/<id>/<token>/uptimeUptime Kuma (webhook notification) or any monitor sending { "name", "status", "message" }Up and down notices.

Events the template has no card for (pings, labels, comments) are accepted and ignored, so nothing spams the channel.

Rules

Limits

WhatLimit
Messages sent25 per 10 seconds per account
Other writes (reactions, edits, pins, moderation, uploads)90 per minute per account, uploads 20 per minute
Frames per connection300 per 10 seconds, whatever the op
Message length4000 characters
Attachments4 per message, 525 MB each
History per fetch100 messages
Reactions20 different emoji per message
Pins50 per channel
Embed1 per message; see the field limits
Bots owned25 per account
Webhooks10 per channel; 30/min per webhook, 60/min per address, 16 KB body
Slow modeApplies to bots without manageMessages, and inside every thread of a channel that has it

Hitting a limit is never fatal: the request is refused with a clear code and the bot stays connected.

Good practice

Troubleshooting

SymptomUsually
bad_token at sign-inToken pasted with a space, or it was reset in the portal. Copy it again.
Signed in, but no messages arriveThe bot is not in any space yet. Add it from the portal; client.guilds.size tells you.
forbidden on a moderation callThe bot's role lacks the permission, or the target's highest role is at or above the bot's.
cannot_dmThe person does not share a space with the bot, or their privacy setting blocks DMs. Fall back to a channel.
Connection refused with "Origin not allowed"Send Origin: app://pulse or no Origin header at all.
Attachment download returns 401Add the media token: client.mediaUrl(path), or ?t=<mediaToken> from the ready payload.
The bot answers itself in a loopYou forgot to ignore message.author.bot.

Something missing here? The SDK source, voxara-bot.js, is 400 readable lines and shows every frame it sends. A complete example bot is at ping-bot.js, and the same thing without the SDK at raw-ping-bot.js.