# Build an Orb bot

An Orb app (people say "bot") is a program you write that lives in communities that chose to install it. It can post, read, answer
commands that members type, sit in a voice channel and drive the room's player. It is **not** a person: it has its own identity,
its own bearer token, and no wallet, session, login or signing key. Everything it does goes through one HTTP API.

This guide is the fast path. The reference is [`public/developers/openapi.json`](../public/developers/openapi.json) (also at
`/developers/openapi.json` on any Orb), the design record is [`orb-bots.md`](orb-bots.md), and a small client library lives in
[`sdk/orb-bot`](../sdk/orb-bot/README.md). English is the canonical language of the API, the command names and this guide; what an
app *says* to members is up to you.

## Two gates open every call

| Gate | Held by | Decides |
| --- | --- | --- |
| **Scopes** | you, the developer | which API calls the app is written to make |
| **Role** | a community admin (`manage_roles`) | which rights the app has in that community, and in which channels |

An app can do only what **both** allow. You cannot grant yourself a right in someone else's community, and an admin cannot
make an app do something it never asked for. Scopes: `messages:read`, `messages:write`, `voice`, `music`, `passport`.

## Five minutes to a working bot

1. **Register the app.** In a community you manage, open its settings, choose the **Apps** tab and then **Register an application**.
   Give it a name, a one-line description and the scopes it needs (`messages:read` and `messages:write` for a chat bot).
   The token appears **once**. Copy it into your environment as `ORB_BOT_TOKEN`; Orb keeps only a digest and cannot show it again.
   Lost it? Rotate it from the same panel; the old token stops working at once.
2. **Install it.** Still in the **Apps** tab, install the app into your community. This creates a role named `<app> · app` at member level
   with exactly the rights the scopes need (`send_messages` for `messages:write`, `connect` for `voice` or `music`, `speak` for
   `voice`). You can narrow the app to a list of channels.
3. **Say hello with curl.** Replace the host with your Orb:

   ```bash
   export ORB_URL=https://your-orb.example
   export ORB_BOT_TOKEN=orbbot_...

   # Who am I, and where was I accepted?
   curl -s "$ORB_URL/api/bot/v1/app" -H "authorization: Bearer $ORB_BOT_TOKEN"

   # Which channels can I use in a community?  (use an id from the call above)
   curl -s "$ORB_URL/api/bot/v1/app?server=COMMUNITY_ID" -H "authorization: Bearer $ORB_BOT_TOKEN"

   # Post.
   curl -s "$ORB_URL/api/bot/v1/messages" -X POST \
     -H "authorization: Bearer $ORB_BOT_TOKEN" -H "content-type: application/json" \
     -d '{"channel":"CHANNEL_ID","content":"Hello from my first Orb bot"}'
   ```

4. **Run a bot that listens.** With the SDK (Node 18 or newer, no dependencies):

   ```js
   import { OrbBot } from './sdk/orb-bot/index.mjs';   // or: npm i ./sdk/orb-bot (the files are also served at /developers/orb-bot/ on any Orb)

   const bot = new OrbBot();   // reads ORB_BOT_TOKEN and ORB_URL

   await bot.commands.sync([
     { name: 'echo', description: 'Say it back', options: [{ name: 'text', description: 'what to repeat' }] },
     { name: 'ping', description: 'Is the bot alive?' },
   ]);

   await bot.run({
     mention: ctx => ctx.reply(`You called, ${ctx.author.name}?`),
     commands: {
       echo: ctx => ctx.reply(ctx.options.join(' ') || 'Say something after /echo'),
       ping: ctx => ctx.reply('pong'),
     },
   }, { onError: error => console.error(error) });
   ```

   Mention the bot in a channel (`@YourBot#01`) or type `/ping` in a community that installed it. The same bot in plain
   `curl` and in Python is in [`examples/bots`](../examples/bots/README.md).

## Hearing what happens: two ways

Orb tells your app when a person **mentions** it in a channel it can read (`message.mention`) and when a member types one of its
**commands** (`application_command`). Which way the events reach you depends on one setting, the app's **endpoint URL**:

| | **Fetch events** (no endpoint URL) | **Webhook** (endpoint URL set) |
| --- | --- | --- |
| How | your code calls `GET /api/bot/v1/events` | Orb `POST`s each event to your URL |
| Needs a public address | no: runs from a laptop | yes, public `https`, default port |
| Latency | a long poll answers the moment an event arrives | one request per event |
| Missed events | kept for a day (at most 500) | not queued: an endpoint that is down misses them |
| Answer a command with | `POST /api/bot/v1/interactions/<event id>/respond` | `{"notice": "..."}` in the response |

**Fetching.** `GET /api/bot/v1/events?after=<cursor>&wait=25` returns `{ events, next, mode }`. Handle the events in order, then ask
again with `after=<next>`. `wait` holds the request open up to 25 seconds, so you are not polling in a tight loop. Asking with the same cursor repeats the same
events, which is what makes a crash harmless: save the cursor after you handle an event, not before.

**Webhooks.** Every request carries `X-Orb-Signature: t=<ms>,v1=<hex>` where

```
v1 = HMAC-SHA256( key = sha256(your token) as a lowercase hex string, message = "<t>.<raw request body>" )
```

Compute it over the **raw** body, compare in constant time and reject a `t` older than a few minutes. `verifySignature` in the SDK does all of
that. The URL must be a public `https` address: loopback, private ranges, link-local and cloud-metadata addresses, credentials in the URL,
non-default ports and redirects are refused, and responses are capped at 64 KiB. Orb tries twice and writes the outcome to the app's
**Recent deliveries** so you can see a handler that is down.

Two loops are closed on purpose: **an app is never notified about another app's message**, and the same message never notifies the same app twice.

An event looks like this (the same body in both modes; `cursor` is added when you fetch):

```json
{ "id": "mj8k2x-1f3a9c2b", "type": "message.mention", "sent_at": 1790640000000, "api": "orb-bots-v1",
  "app": { "id": "…", "name": "Echo" }, "server": "…", "channel": "…",
  "data": { "message": { "id": 412, "channel": "…", "author": { "id": "…", "name": "Ana" }, "body": "hey @Echo#01 listen" }, "reason": "mentioned" } }
```

```json
{ "id": "mj8k3q-77c01d2e", "type": "application_command", "sent_at": 1790640004000, "api": "orb-bots-v1",
  "app": { "id": "…", "name": "Echo" }, "server": "…", "channel": "…",
  "data": { "command": { "id": "…", "name": "echo", "options": ["hello", "there"] }, "member": { "id": "…", "name": "Ana" }, "invoked_at": 1790640004000 } }
```

## Commands

Register commands with the app's own token; they are what members see in the composer. `PUT /api/bot/v1/commands` makes the app's
global commands **exactly** the list you send (anything you left out is removed), so your code is the only place that says what the app can do:

```bash
curl -s "$ORB_URL/api/bot/v1/commands" -X PUT -H "authorization: Bearer $ORB_BOT_TOKEN" -H "content-type: application/json" \
  -d '{"commands":[{"name":"echo","description":"Say it back","options":[{"name":"text","description":"what to repeat"}]}]}'
```

- Names are lowercase letters, digits, `_` and `-`, up to 32 characters, **in English**: one spelling to learn, autocomplete that does not change with the interface language, documentation you can copy.
  Names that belong to the room player (`/play`, `/queue`, …) or to the community administration (`/kick`, `/ban`, …) are refused.
- Up to 50 commands and 6 options each. Orb does not type-check options: it splits what the member typed on spaces and hands you the pieces in `options`; your code parses its own sentence.
- `right` (optional) is the single channel right the person typing needs, for example `manage_messages` for a moderators-only command.
- Registering is not reaching: a command is typeable **only in a community that installed the app**, and running it still asks the channel.
- The member who typed it waits about six seconds. If you do not answer they are told so, plainly: silence from a bot looks like a broken bot.
  For anything that takes longer, answer quickly ("working on it") and post the result as a message.

## What an app may not do

Enforced by the API, not by good advice:

- no `@everyone`, ever; the message is refused before it is stored;
- no onchain trade, signal or market cards: those are commitments a member verified with their own wallet;
- no plaintext from end-to-end encrypted conversations (they come back as `{ "encrypted": true, "body": null }`);
- no text in voice channels, nothing in a read-only channel, nothing in a channel the community left out of the app's list;
- no evidence of its own making: an app reads the anchored record and never writes it;
- no deleting other people's messages: an app can take back only what it wrote (`DELETE /api/bot/v1/messages/<id>`);
- no wallet, no signing, no transfer, no role grants, no moderation.

## Limits and errors

Errors are `{ "error": { "message": "…", "code": 400 } }` with the same HTTP status, written for the developer reading the curl.
A throttled call answers **429** with `Retry-After`; the SDK waits and retries a few times.

| Call | Limit |
| --- | --- |
| `POST /messages` | 30 a minute, 12 a minute per channel |
| `GET /messages` | 120 a minute |
| `GET /events` | 240 a minute (a long poll counts once) |
| `PUT`/`DELETE /commands` | 20 a minute |
| `POST /interactions/…/respond` | 60 a minute |
| `DELETE /messages/<id>` | 30 a minute |
| failed sign-ins | 60 a minute per caller address |

| Status | Usually means |
| --- | --- |
| 401 | missing or wrong token |
| 403 | a scope the app lacks, or the community's role/channel list does not allow it |
| 404 | not installed there, no such channel, no such event |
| 409 / 410 | a command already answered / the member stopped waiting |

## Keep the token safe

- Put it in an environment variable or a secret store. Never in the repository, a message, a screenshot or client-side code.
- Verify every webhook signature before acting on it.
- Rotate it from the **Apps** tab if it may have leaked: the old token stops working immediately. Switching an app off, or deleting it, takes its power away
  in every community at once; messages it wrote stay, still badged as an app.
- Ask for the scopes you use and nothing more. Admins read that list before they accept you.

## Share your app

By default only you can install your app. In the **Apps** tab you can change who may add it to a community, and copy its invite link:

| Listing | Who can add it | Where it shows |
| --- | --- | --- |
| **Private** | only you | nowhere |
| **Unlisted** | anyone who holds your invite link (`/apps/<app id>` on your Orb) | nowhere |
| **Public** | anyone | the directory in the **Apps** tab, and on [the developer page](/developers) |

A listing never grants a power. The invite link opens a public page (`/apps/<app id>`) that shows who wrote the app, what it asks for, the rights the install would give it and, if it is on the registry, its price. An admin pastes that link under **Add somebody else's app** in their own community's **Apps** tab, reads the same things, and has to accept.
Going back to private stops new installs; communities that already accepted it keep it until they remove it.

## Verifiable authorship on Monad (optional)

An app can be registered on the **Orb Bot Registry** on Monad testnet, signed by your own wallet, with a hash of its manifest (name, scopes, commands).
Communities can record the install on chain with the scopes they accepted, and installs can carry a one-time price that pays you directly (Orb's
fee on that price is fixed in the contract and disclosed before anyone signs). When an app has a price, Orb installs it into a community only after that
community's payment is on chain, and an app you retire there can no longer be installed. You register, update, retire and set the price from the
**Apps** tab with your own Orb Wallet. It is testnet only, uses test tokens, and nothing about it is needed to run a bot. See [`orb-bot-registry.md`](orb-bot-registry.md).

## Reference

- OpenAPI: [`public/developers/openapi.json`](../public/developers/openapi.json) · design record: [`orb-bots.md`](orb-bots.md)
- Client library: [`sdk/orb-bot`](../sdk/orb-bot/README.md) · runnable examples: [`examples/bots`](../examples/bots/README.md)
- Test it: `npm run bots:test` (the platform), `node scripts/test-bot-platform-v2.mjs` (events, commands, listings) and `node scripts/test-bot-sdk.mjs` (the client)
