Developer platform

Clustr Bots

Build a bot once. Any Commz server can install it.

A bot is a global account owned by its developer, not by any one Clustr. You build it once, get one token, and Clustr admins install it into their own servers through a consent screen. The same bot can run in many Clustrs at once.

There are two sides to this:

Endpoints

Use these production endpoints for bots that connect to the live Commz service:

export API_URL="https://clustr-production.up.railway.app"
export GATEWAY_URL="wss://clustr-production.up.railway.app"

For local development against a local backend:

export API_URL="http://localhost:3001"
export GATEWAY_URL="ws://localhost:3001"

The REST API uses API_URL for every /api/bot/... route in this guide. The realtime gateway uses GATEWAY_URL and authenticates with the same bot token:

GATEWAY_URL?token=<url-encoded-bot-token>

Create A Bot

Open User Settings > Developer > New bot. Pick a display name, a username, an optional avatar image URL, and the permissions your bot will request when someone installs it.

You get a token immediately. Copy it: Clustr stores only a SHA-256 hash, a token hint, timestamps, and revocation state. Rotate or revoke tokens from the same screen.

Bots share the username namespace with people, so a bot cannot take a username that belongs to a user and vice versa. Bots never appear in user search, friend requests, or DMs.

Bot avatars are shown in messages, ephemeral command replies, the bot directory, install consent screens, and the server bot list. You can update or clear the avatar later from User Settings > Developer.

Install A Bot

Two ways in, both landing on the same consent screen:

Install link. Every bot has one, shown in the developer portal:

https://<your-clustr-host>/authorize?bot_id=<botId>&scopes=messages.write%20channels.read

Share it anywhere. An admin who opens it picks which Clustr to install to, reviews the permissions, unchecks anything they do not want, and authorizes.

The scopes parameter can only narrow the request. A link asking for a scope the bot never registered is ignored, so a crafted link cannot escalate a bot past what its developer declared.

Directory. Mark a bot public and it appears under Server Settings > Bots > Browse bots, where admins can install it without a link.

Bot Auth

Tokens are global to the bot — one token, every install:

Authorization: Bot clb.<botId>.<tokenId>.<secret>
Content-Type: application/json

Which Clustr a call acts on comes from the route, never from the token.

Scopes

A call succeeds only where all three of these grant the scope: the bot's registered scopes, the token's scopes, and the scopes that specific Clustr approved at install. Any one of them can revoke unilaterally — an admin removing moderation.write in their server does not affect the bot anywhere else, and a developer revoking a token kills it everywhere.

Widening a bot's registered scopes does not widen existing installs. Servers keep the permissions they actually approved until an admin re-authorizes through the consent screen.

Where Bots Appear

An installed bot shows up in the member list under its own Bots section, badged BOT. It is listed from the install record rather than a membership, so it cannot be kicked, banned, or given a role — removing a bot is an uninstall, done from Server Settings > Bots.

Bots are always shown as online. A bot's real availability is whether its gateway is connected or its webhook is answering, which is not something the roster should try to represent.

Channel Access

An admin can limit a bot to specific channels at install time or afterward. If no channels are selected, the bot can use every channel it is capable of in that Clustr, subject to scopes. This is per-install, so the same bot can be restricted in one server and unrestricted in another.

A bot can operate in text channels and voice channels. A voice channel's text chat lives on the same channel id, so there is nothing extra to address — post to the voice channel's id and the message lands in its chat. The channel list returns type so a bot that only cares about one kind can filter.

Forum, stage, and other channel kinds are not available to bots, and stream chat is separate (see Stream chat).

Runtime API

Read bot identity:

curl "$API_URL/api/bot/me" \
  -H "Authorization: Bot $BOT_TOKEN"

List the Clustrs this bot is installed in, with the scopes granted in each:

curl "$API_URL/api/bot/clustrs" \
  -H "Authorization: Bot $BOT_TOKEN"

List channels in one Clustr:

curl "$API_URL/api/bot/clustrs/$CLUSTR_ID/channels" \
  -H "Authorization: Bot $BOT_TOKEN"

Post a message. Channel ids are globally unique, so message routes take the channel directly and resolve the Clustr from it:

curl -X POST "$API_URL/api/bot/channels/$CHANNEL_ID/messages" \
  -H "Authorization: Bot $BOT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"content":"Build finished successfully."}'

Embeds

For anything structured — build results, search hits, status boards — send embeds instead of formatting text by hand. Up to 3 per message, alongside or instead of content:

curl -X POST "$API_URL/api/bot/channels/$CHANNEL_ID/messages" \
  -H "Authorization: Bot $BOT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "embeds": [{
      "title": "Build #1482 passed",
      "url": "https://ci.example.com/builds/1482",
      "description": "All 240 tests green.",
      "color": "#00e5c7",
      "fields": [
        { "name": "Branch", "value": "main", "inline": true },
        { "name": "Duration", "value": "3m 12s", "inline": true }
      ],
      "footer": "CI",
      "timestamp": 1786147200000
    }]
  }'

Fields: title (256), description (2048), url, color, imageUrl, thumbnailUrl, footer (200), timestamp, and up to 10 fields of { name, value, inline }.

color takes "#00e5c7", "00e5c7", or an integer like 0x00e5c7. All URLs must be https — http, data:, and javascript: are dropped silently, since embeds render in other people's clients. An embed with no title, description, image, or fields is discarded as empty.

Embeds also work on PATCH (omit embeds to leave them alone, pass [] to clear them) and on interaction responses, including ephemeral ones.

Components

Attach buttons and select menus to a message. Up to 5 rows, 5 controls per row; a select takes a whole row to itself.

curl -X POST "$API_URL/api/bot/channels/$CHANNEL_ID/messages" \
  -H "Authorization: Bot $BOT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "content": "Deploy build #1482 to production?",
    "components": [
      { "components": [
        { "customId": "deploy_yes", "label": "Deploy", "style": "success" },
        { "customId": "deploy_no",  "label": "Cancel", "style": "danger" },
        { "style": "link", "label": "View build", "url": "https://ci.example.com/1482" }
      ]},
      { "components": [
        { "type": "select", "customId": "env", "placeholder": "Target environment",
          "options": [
            { "label": "Production", "value": "prod", "description": "Live traffic" },
            { "label": "Staging",    "value": "stage" }
          ]}
      ]}
    ]
  }'

Button styles: primary, secondary, success, danger, link. Link buttons are pure navigation — they carry a url, take no customId, and never notify your bot. Every other control needs a customId unique within the message; duplicates are dropped rather than left to dispatch ambiguously. Link URLs must be https.

When someone uses a control you get a component.used event:

{
  "type": "component.used",
  "clustrId": "...",
  "data": {
    "interactionId": "...",
    "customId": "deploy_yes",
    "componentType": "button",
    "message": { "channelId": "...", "timestamp": 1786147200000 },
    "user": { "userId": "...", "username": "ada" }
  }
}

Selects also include values, and a select can only ever submit an option it offered — anything else is rejected before your bot sees it. The same holds for the control itself: a click is validated against the stored message, so a customId your bot never sent cannot be faked.

Respond to a component interaction with updateMessage: true to edit the message the control was on, rather than posting a new one — this is how you advance a menu in place or disable buttons after a choice:

curl -X POST "$API_URL/api/bot/interactions/$INTERACTION_ID/respond" \
  -H "Authorization: Bot $BOT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"updateMessage":true,"content":"Deploying to production...","components":[]}'

Deferring

If answering takes more than a moment, acknowledge first so the person does not think the bot ignored them:

curl -X POST "$API_URL/api/bot/interactions/$INTERACTION_ID/defer" \
  -H "Authorization: Bot $BOT_TOKEN"

That shows the invoker a thinking state, cleared automatically when your real response lands. Deferring buys presence, not extra time — the 15-minute window is unchanged, and deferring twice is a harmless no-op.

Interactions are stored durably and claimed with a conditional write, so responding is single-use even if two of your processes race the same interaction: exactly one gets a 200 and the other gets 409. They also survive a server restart.

For work outside the interaction flow, send a typing signal:

curl -X POST "$API_URL/api/bot/channels/$CHANNEL_ID/typing" \
  -H "Authorization: Bot $BOT_TOKEN"

Reply to a message by passing its timestamp. The quoted snippet is rebuilt from the stored message, so a bot cannot fabricate a quote:

curl -X POST "$API_URL/api/bot/channels/$CHANNEL_ID/messages" \
  -H "Authorization: Bot $BOT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"content":"On it.","replyTo":1786147200000}'

Read messages, either a page or one by timestamp:

curl "$API_URL/api/bot/channels/$CHANNEL_ID/messages?limit=50" \
  -H "Authorization: Bot $BOT_TOKEN"

curl "$API_URL/api/bot/channels/$CHANNEL_ID/messages/$TIMESTAMP" \
  -H "Authorization: Bot $BOT_TOKEN"

Edit and delete. A bot may only edit or delete its own messages — moderating other people's messages is a separate power that messages.write does not grant:

curl -X PATCH "$API_URL/api/bot/channels/$CHANNEL_ID/messages/$TIMESTAMP" \
  -H "Authorization: Bot $BOT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"content":"Build finished (updated)."}'

curl -X DELETE "$API_URL/api/bot/channels/$CHANNEL_ID/messages/$TIMESTAMP" \
  -H "Authorization: Bot $BOT_TOKEN"

React to any message. Reactions follow the same one-per-user rule as people, so adding a second emoji replaces the first:

curl -X PUT "$API_URL/api/bot/channels/$CHANNEL_ID/messages/$TIMESTAMP/reactions/%F0%9F%91%8D" \
  -H "Authorization: Bot $BOT_TOKEN"

curl -X DELETE "$API_URL/api/bot/channels/$CHANNEL_ID/messages/$TIMESTAMP/reactions/%F0%9F%91%8D" \
  -H "Authorization: Bot $BOT_TOKEN"

Register commands. Commands are global to the bot, so this needs no Clustr:

curl -X PUT "$API_URL/api/bot/commands" \
  -H "Authorization: Bot $BOT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"commands":[{"name":"status","description":"Show current service status"}]}'

Moderation is always scoped to one Clustr:

curl -X POST "$API_URL/api/bot/clustrs/$CLUSTR_ID/members/$USER_ID/text-mute" \
  -H "Authorization: Bot $BOT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"muted":true,"durationMs":600000,"reason":"Spam"}'

curl -X POST "$API_URL/api/bot/clustrs/$CLUSTR_ID/bans" \
  -H "Authorization: Bot $BOT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"userId":"user-id","reason":"Raid account"}'

Streams

With streams.read, a bot can see what is live in the Clustrs it is installed in:

curl "$API_URL/api/bot/clustrs/$CLUSTR_ID/streams" \
  -H "Authorization: Bot $BOT_TOKEN"

curl "$API_URL/api/bot/clustrs/$CLUSTR_ID/streams/$STREAM_ID" \
  -H "Authorization: Bot $BOT_TOKEN"

curl "$API_URL/api/bot/clustrs/$CLUSTR_ID/stream-recaps?limit=20" \
  -H "Authorization: Bot $BOT_TOKEN"

You get title, category, streamer, playback URL, viewer and peak viewer counts, hype count, chat message count, and clip count. Ingest credentials are never exposed — no stream key, no ingest endpoint, no channel ARN. streams.read lets a bot observe a stream, never broadcast as one.

Fetching a single stream returns { live: true, stream } while it is running and { live: false, recap } for a short window after it ends, so a bot posting a wrap-up does not lose the race against the stream going away.

Three events pair with this:

Stream chat

Stream chat is a normal channel whose id is stream_<streamId>, so all the usual message endpoints work on it — post, read, react, delete, typing. No separate API.

Access needs two grants, from two different people. A Clustr admin gives your bot streams.chat at install, and then each streamer separately enables it for their own stream in Go Live. Neither is sufficient alone. This is deliberate: a server admin should not be able to put a bot in someone's chat, and a streamer should not be able to grant a bot a capability their server never approved.

A streamer enabling your bot also chooses, per bot, whether it can moderate. Without that, your bot can read and post but not remove anything.

Events for stream chat are renamed so you can tell them from a normal channel without inspecting the id:

These go only to bots that streamer enabled, never to every bot in the Clustr.

With moderation granted:

# Delete anyone's message in that stream's chat
curl -X DELETE "$API_URL/api/bot/channels/stream_$STREAM_ID/messages/$TIMESTAMP" \
  -H "Authorization: Bot $BOT_TOKEN"

# Time a viewer out. Omit durationMs (or pass 0) to clear an existing timeout.
curl -X POST "$API_URL/api/bot/channels/stream_$STREAM_ID/stream-timeouts" \
  -H "Authorization: Bot $BOT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"userId":"user-id","durationMs":600000}'

Timeouts are scoped to that stream and vanish when it ends — a stream is ephemeral, so a timeout outliving it would be a server ban by another name. They cap at 24 hours, and the streamer can never be timed out.

Note that posting still needs messages.write as well: streams.chat says *where* your bot may operate, messages.write says *what* it may do. Deleting other people's messages is the exception — that comes from the streamer's moderation grant, not from any server scope, so a chat-moderation bot needs no power over the server itself.

Bots still cannot control a stream (title, category, ending it) or access the video.

Events

Events reach your bot one of two ways. The gateway is tried first, and the webhook is only used when no gateway connection is open — so a bot never receives the same event twice, and running a local gateway session takes delivery over from a deployed webhook while you develop.

Open a WebSocket to the API host with your token as bot_token:

wss://<your-api-host>/?bot_token=clb.<botId>.<tokenId>.<secret>

Because your bot dials out, it needs no public URL, no TLS certificate, and no inbound firewall rule. It runs fine on a laptop behind NAT.

On connect you get a ready frame with your identity and current installs:

{
  "type": "ready",
  "payload": {
    "bot": { "botId": "...", "username": "..." },
    "heartbeatIntervalMs": 30000,
    "clustrs": [
      { "clustrId": "...", "name": "...", "scopes": ["messages.write"], "allowedChannelIds": [] }
    ]
  }
}

Every event then arrives as:

{ "type": "event", "payload": { "id": "...", "type": "message.created", "clustrId": "...", "data": {} } }

The server sends WebSocket pings every 30 seconds and closes connections that stop responding. Most clients answer pongs automatically. If yours cannot, send {"type":"heartbeat"} and you will get a heartbeat:ack back.

The gateway is receive-only, same as Discord: events come down the socket, actions go back over the REST API with the same token.

Webhook

Configure one event webhook URL and select event types in User Settings > Developer. The webhook belongs to the bot, so it receives events from every Clustr that granted events.receive. Route on the clustrId in the payload.

Retries. A failed delivery is retried up to 3 more times with growing backoff (1s, 5s, 25s). Only failures worth repeating are retried — timeouts, network errors, 5xx, and 429. Any other 4xx is treated as a deliberate refusal and dropped immediately, so returning 400 to an event you do not care about is a valid way to decline it cheaply.

Retries carry the same X-Clustr-Event-Id, so deduplicate on that rather than assuming each POST is a distinct event.

Auto-pause. After 10 consecutive failures across all events, delivery to that endpoint stops entirely and the developer portal shows why. This stops one dead endpoint from burning requests forever. Any successful delivery clears the streak, and changing the webhook URL resumes automatically — otherwise use Resume delivery in the portal.

The portal shows recent failure counts and the last error, so a half-broken endpoint is visible before it gets paused.

Supported events:

Each delivery is a POST with JSON:

{
  "id": "event-id",
  "type": "message.created",
  "clustrId": "server-id",
  "botId": "bot-id",
  "createdAt": 1786147200000,
  "data": {}
}

Validate the signature:

X-Clustr-Signature-256: sha256=<hmac>

The HMAC is HMAC-SHA256(eventSecret, rawRequestBody). Keep the raw body bytes for verification before parsing JSON.

Commands

Register commands with PUT /api/bot/commands and they appear in the message composer's slash menu for every Clustr your bot is installed in, alongside the built-in commands.

Built-ins win on a name collision — registering ban or kick will not shadow the real one, and your version simply never runs.

Arguments are positional and map onto your declared options in order. The last option absorbs the remaining words, so a trailing reason or message stays intact.

Option types

Declare an option as string, number, boolean, user, or channel. The server coerces values before your bot sees them and rejects anything that does not fit, so you never parse raw text or guess who @ada meant.

user and channel arrive resolved to records:

/warn @ada posting spam in general
{
  "user": { "userId": "u_123", "username": "ada", "displayName": "Ada" },
  "reason": "posting spam in general"
}

The composer shows the expected shape in the slash menu (<@target>, [#room], [count:number]), and a missing required option is caught before anything reaches your bot. Coercion runs server-side regardless, so a hand-rolled API call cannot bypass it — invalid values come back as 400 with a message naming each bad option.

Responding

A command.invoked event carries an interactionId and a respondBefore timestamp. Answer it within 15 minutes:

curl -X POST "$API_URL/api/bot/interactions/$INTERACTION_ID/respond" \
  -H "Authorization: Bot $BOT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"content":"All systems green."}'

Set "ephemeral": true to send the reply only to the person who ran the command. Ephemeral replies are never stored in channel history; Commz delivers them over the user's realtime session, briefly queues them during reconnects, and removes them when the channel is reloaded. That makes them the right choice for errors, confirmations, and anything private.

An interaction is single-use and bound to your bot. A second response returns 409, another bot using the id gets 403, and a late one gets 410.

You can also just post a normal message instead of responding, if the command does not need a direct reply.

Production Notes

Limits