Your bots

Bot API

A bot is a member you drive with code. It reads and posts where its roles let it, answers direct messages, hears events live, and can be woken by a webhook — and it can never do more than a person with the same roles could.

Getting started

  1. Open Settings → Developer and create a bot. Copy its token — it is shown once.
  2. In a space, open Members and add the bot by its @handle. Give it a role if it needs more than a regular member can do.
  3. Connect to the event stream and start answering — see the example at the bottom.

Authentication

Every request carries the token in a header:

Authorization: Bot vn_bot_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Every response is JSON in the same envelope: { "success": true, "data": … } or { "success": false, "error": { "code", "message" } }. A token that is wrong or has been replaced gets 401. Only a hash of the token is stored, so a lost token cannot be recovered — make a new one.

Endpoints

Bots use the same API as the app. Permissions are checked exactly as for a person, so a 403 means the bot's roles do not allow it.

You

GET/api/meThe bot's own account (id, name, handle).
GET/api/workspacesSpaces the bot has been added to.
GET/api/presence?ids=a,bOnline / idle / do-not-disturb of people who share a space.

Spaces & members

GET/api/workspaces/:idOne space, with its boards.
GET/api/workspaces/:id/membersWho is in it.
POST/api/workspaces/:id/membersAdd someone: { handle } or { email }, role EDITOR | VIEWER. Needs permission.
GET/api/workspaces/:id/permissionsWhat the bot itself may do, per channel.
GET/api/workspaces/:id/rolesRoles.

Channels & messages

GET/api/workspaces/:id/channelsChannels the bot can see.
POST/api/workspaces/:id/channelsCreate one: { name, type: text | voice | announcement | media, topic? }.
GET/api/channels/:id/messages?before=&limit=Newest messages first page; page back with before=<messageId>.
POST/api/channels/:id/messagesPost: { content } — up to 4000 characters.
DELETE/api/channels/:id/messages?messageId=Delete a message (its own, or any with Manage Messages).

Direct messages

GET/api/conversationsThe bot's DMs.
POST/api/conversationsOpen a DM: { userId } — anyone who shares a space with the bot.
GET/api/conversations/:id/messagesMessages in a DM.
POST/api/conversations/:id/messagesSend: { content }.

Boards & files

GET/api/boards?workspaceId=Boards.
POST/api/boardsCreate: { title, workspaceId }.
PATCH/api/boards/:idRename or describe a board.
POST/api/uploadUpload a file (multipart: file, purpose=chat). Returns a URL to post in a message.
GET/api/search?query=Search boards and spaces the bot can see.

Live events

GET /api/events with the bot's token is a Server-Sent Events stream. Each event is a line data: {…json…} followed by a blank line; lines starting with : are keep-alives. If it drops, reconnect.

message.created

{ workspaceId, channelId, messageId, message } — in a channel the bot can view.

message.deleted

{ workspaceId, channelId, messageId }

dm.created

{ conversationId, messageId, message } — someone messaged the bot directly.

member.joined

{ workspaceId, userId }

hello

{ connectionId, userId } — first event on every (re)connection.

A bot never receives its own messages, and only hears channels it is allowed to view.

Webhooks

For bots that sleep between events (serverless functions), set a webhook URL in Settings → Developer. Every event is POSTed there as JSON with these headers:

X-Velsi-Event:      message.created
X-Velsi-Timestamp:  1731430000
X-Velsi-Signature:  sha256=<HMAC-SHA256 of "<timestamp>.<body>" with your signing secret>

Always verify the signature before trusting the body:

import { createHmac, timingSafeEqual } from "node:crypto";

// In your webhook handler, with the RAW request body:
function verify(rawBody, headers, secret) {
  const timestamp = headers["x-velsi-timestamp"];
  const given = headers["x-velsi-signature"]?.replace("sha256=", "") ?? "";
  // Refuse anything older than five minutes: stops replays.
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
  return given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}

Webhook URLs must be public https:// addresses; private networks, raw IP addresses and localhost are refused. Delivery is best-effort with a 5-second timeout and no retries — use the event stream if you cannot miss anything.

Limits & rules

Example bot

Replies to !echo hello with hello, in any channel it can see. Run it with VELSI_BOT_TOKEN=… node echo-bot.mjs.

// echo-bot.mjs — Node 18+. No dependencies.
const BASE = "https://velsi.amplaying.com";
const TOKEN = process.env.VELSI_BOT_TOKEN;
const auth = { Authorization: `Bot ${TOKEN}` };

async function listen() {
  const res = await fetch(`${BASE}/api/events`, { headers: auth });
  const reader = res.body.getReader();
  const decoder = new TextDecoder();
  let buffer = "";
  for (;;) {
    const { value, done } = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, { stream: true });
    let i;
    while ((i = buffer.indexOf("\n\n")) >= 0) {
      const chunk = buffer.slice(0, i);
      buffer = buffer.slice(i + 2);
      const line = chunk.split("\n").find((l) => l.startsWith("data: "));
      if (line) await handle(JSON.parse(line.slice(6)));
    }
  }
  setTimeout(listen, 3000); // reconnect
}

async function handle(event) {
  if (event.type !== "message.created") return;
  if (!event.message.content.startsWith("!echo ")) return;
  await fetch(`${BASE}/api/channels/${event.channelId}/messages`, {
    method: "POST",
    headers: { ...auth, "Content-Type": "application/json" },
    body: JSON.stringify({ content: event.message.content.slice(6) }),
  });
}

listen();