# Orb applications (bots)

> Current status reviewed October 10, 2026 (listings, invite links, the directory, fetched events, the SDK and the on-chain registry were added since September 29). See [the release index](README.md) for shipped versus planned features, network, wallet signing and deployment boundaries. Older dated experiments below are retained as history.

Users build bots for their own communities. An application is a **service identity**: it has its own
member row in the communities that install it, its own bearer token, and no account, wallet or
session of any kind. It reads and writes what an admin accepted, and nothing else.

`docs/orb-bots-plan.md` is the design record; this file is what answers today. If you want to *write* an app, start with [the developer guide](orb-bot-developer-guide.md); the optional on-chain record is in [the registry document](orb-bot-registry.md).

## The two decisions every action needs

Neither side can grant the other's power alone:

| Gate | Held by | What it decides |
| --- | --- | --- |
| **scopes** | the developer who registered the app | which API calls the app is written to make |
| **role** | a community admin with `manage_roles` | which rights the app has in that community, and which channels |

Offered scopes (`lib/bot-scopes.ts` → `BOT_SCOPES`): `messages:read`, `messages:write`, `voice`,
`passport`, `music`. Installing creates a dedicated role named `<app> · app` at member level with exactly
`send_messages` (write) and `connect`+`speak` (voice) - never `mention_everyone`, never
`attach_files`, never anything moderation-shaped, and it appears in the role list so a moderator can
see what was accepted.

## Registering, installing, revoking

The panel is the **Apps** tab of the community's management dialog (admins only, since installing needs `manage_roles`). It does these things:

- **Register an application** - name, description, scopes. The token is returned **once**, in the
  response, and never again; only its SHA-256 digest is stored. `My applications` shows the list,
  the endpoint field, a switch, rotation and deletion.
- **Install** - any app you own can be installed into a community where you can manage roles.
  The install row records who accepted it, and the community can narrow it to specific channels.
- **Share** - each app has a listing: `private` (only its developer can install it), `unlisted` (anyone with its invite link, which opens the public page `/apps/<id>`) or `public` (also in the directory). A listing grants no power: the scopes and the role still decide what the app may do.
- **Add somebody else's app** - paste an invite link or pick from the directory. The admin sees who wrote the app, what it asks for and the rights the install would give it before accepting.
- **On Monad testnet** - optionally put the app on the Orb Bot Registry (see [the registry document](orb-bot-registry.md)), set a one-time price, update or retire the record. A priced app is installed only after the community's payment is on chain.
- **Test** - sends one signed event to your endpoint and shows what it answered.
- **Recent deliveries for the first app** - the last attempts, with the HTTP status or the reason it never arrived.

Deleting an application retires it: it can no longer authenticate, its installs and member rows go,
and **the messages it wrote stay, still badged as an application**. Deleting the author instead would
silently rewrite history into looking like a person said it.

## HTTP API

Versioned under `/api/bot/v1`, authenticated with `Authorization: Bearer <token>`. Errors are
`{ "error": { "message", "code" } }` with a matching HTTP status, and the messages are written for
the developer reading the curl, not for a member.

| Call | Needs | Answers |
| --- | --- | --- |
| `GET /app` | any valid token | the app, its member id, the communities that installed it; `?server=` adds the channels it can see |
| `POST /messages` `{channel, content, reply_to?}` | `messages:write` | posts as the app |
| `GET /messages?channel=&before=&limit=` | `messages:read` | up to 50 recent messages, oldest first |
| `POST /voice` `{action:"join"/"roster"/"heartbeat"/"leave"/"grant"}` | `voice` | a seat in a voice channel |
| `POST /music` `{channel, action, arg?}` · `GET /music?channel=` | `music` | drive, or read, the room's shared player |
| `GET /passport?server=&address=&kind=&minimum=` | `passport` | whether an address holds anchored evidence in that community |
| `POST /passport` `{action:"wallet", server, person}` | `passport` | the wallet a member holds their passport under |

What an app may not do, in the API rather than in good advice:

- **no `@everyone`**, ever - the message is refused before it is stored.
- **no onchain cards** - the trade, signal and shared-market prefixes are commitments a member
  verified with their own wallet, so an app posting one is refused.
- **no plaintext from encrypted conversations** - an end-to-end encrypted message is returned as
  `{encrypted: true, body: null}`. The keys belong to the people in the chat, and installing an app
  does not make it one of them.
- **no text in voice channels**, no writing anywhere it was not installed, and nothing in a channel
  the community left out of its allow-list.
- **no evidence of its own making** - an application reads the anchored record and never writes it.
  Its chatter is not participation, and it has no wallet for a claim to be about.
- rate limits per app, per channel and per purpose; a token is worth nothing without an install, and
  a revoked install stops working mid-sentence.

## Events, and how a handler knows they came from Orb

Set an endpoint and Orb POSTs an event when a person mentions the app inside a channel it can read:

```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": "eco, escuta Echo#01" }, "reason": "mentioned" } }
```

Verify it before acting:

```
X-Orb-Signature: t=<sent_at>,v1=<hex>
v1 = HMAC-SHA256(key = sha256(your token), message = `${t}.${rawBody}`)
```

The key is the digest Orb stores, so a handler that holds its token can prove the caller knew the
secret and not merely the format. `X-Orb-Event` identifies the event, `X-Orb-Delivery` the attempt
(one retry, then the failure is logged for the developer to see). Delivery is **not** a queue: an
endpoint that is down misses the event, and `Recent deliveries for the first app` says so.

Two guards keep the loop from eating a community:

- **bots are never notified about other bots.** Only a person's message can wake an application, so
  an app answering its own mention cannot chain into the next one.
- the URL has to be a public https endpoint. Loopback, RFC1918, link-local and cloud-metadata
  addresses, credentials in the URL, non-default ports and redirects are all refused - Orb is the
  caller here, and an unchecked URL is a request into the private network made on a stranger's
  behalf. Every resolved address is checked; the connection is pinned to a checked IP,
  preserving Host and the TLS server name so DNS cannot change the destination between validation
  and connection. Responses are capped at 64 KiB; redirects are not followed.
  Plain `http://`/loopback is accepted only with `ORB_ALLOW_LOCAL_BOT_ENDPOINTS=1`, a local file
  database **and** a loopback `ORBITA_PUBLIC_ORIGIN`. A production SQLite deployment does not
  qualify. The default is disabled.

## Voice

A bot joins with `{"action":"join","channel"}` and gets an ordinary seat: the channel's own capacity
limit, one call per member, the `connect` right, and the same ninety-second heartbeat that drops a
closed browser tab - a forgotten process cannot keep a chair. `heartbeat` reports the roster, which
marks which participants are applications; `speaking: true` sets the mic, and a moderator's mute wins
over what the app asked for. `leave` gives the seat back, and uninstalling takes it away.

`{"action":"grant"}` returns a short-lived (30 s), room-scoped LiveKit token **only where this Orb
runs a voice server**. That token is what lets the developer's own worker publish audio into the one
room it is seated in. Without an SFU configured the answer is `{available:false, reason}` and the
truth is that an application can be present in a channel but not heard in it - a deployment without configured SFU credentials has this limit. The published Orb
uses the configured media service; applications still need their own audio-publishing
worker and an authorized source.

## Reading the passport

```
GET /api/bot/v1/passport?server=<community>&address=0x…&kind=room-join&minimum=3
{ "member": true, "count": 4, "holds": true, "confirmed": true, "kind": "room-join", "minimum": 3 }
```

The count is read back from the registry, not out of Orb's table, so an app gating a command on it
is gating on the same fact a moderator sees and a stranger can verify on Monad. `holds` is
deliberately three-valued: `null` means the chain could not be consulted - an app that treats that
as `false` would be locking members out because a remote node had a bad minute, so treat `null` as
"try again", never as "no". Only addresses bound by a member **of that community** are answered; an
app is not handed a way to probe arbitrary wallets across Orb.

## Verification

```
npm run bots:test              # scripts/test-orb-bots.mjs, scripts/test-orb-bot-voice.mjs
npm run bots:test:ui           # scripts/test-orb-bots-ui.mjs - the same journey through the panel
node scripts/test-bot-platform-v2.mjs   # listings, fetched events, interactions, commands (against the HTTP server)
node scripts/test-bot-sdk.mjs           # the JavaScript SDK against that API
npm run bots:registry:test     # the registry contract, its reader and the server's side, on a local EVM
npm run bots:registry:test:ui  # the apps panel and the public pages in a browser, with real transactions on a local EVM
```

The first three run against the local preview server (`node work/focus-preview.mjs`) and a local file
database, and the first starts an HTTP receiver on `127.0.0.1` to stand in for the developer's
server - it recomputes the signature from the app's own token, so the suite proves the contract
rather than the shape. No chain is written and no real transaction is signed.

The UI suite exists because an API suite cannot see a broken render: it drives the sidebar item, the
register form, the install and the channel that has to carry the `BOT` badge, and it fails the run on
any page error. It needs `ORBITA_PLAYWRIGHT_PATH` to point at a Playwright checkout.

## Not built yet

A review of listed apps (a listing is the developer's word, and a registry record says who signed it, not that the app is safe), per-channel overrides an
application can read about itself, a payment path other than the one-time price on the registry, and any path that would let an application write evidence - that
one needs a kind in the registry first, so the claim says what it is.

## Commands a member types

An app's row in the panel has **Commands members can type**: a name, a description,
argument names, the channel right the sender needs, and whether it is offered everywhere the app is
installed or in one community. Registering is not reaching - the command appears only where the app
was installed, and typing it still asks the channel.

Orb then pushes an `application_command` event to the endpoint and waits, once, for four seconds:
whatever the handler answers as `{"notice": "..."}` is what the member reads. An app that does not
answer says it did not answer, and the attempt is in the delivery log like any other - a silent
robot looks identical to a broken one, so the difference is stated out loud.

Names already taken by the room player or by the administration commands are refused at registration,
because two answers to one string is how a member loses trust in the composer.
