{
  "openapi": "3.1.0",
  "info": {
    "title": "Orb bot API",
    "version": "orb-bots-v1",
    "summary": "What an app can do in the communities that installed it.",
    "description": "An Orb app is a service identity: its own member row in each community that installed it, a bearer token, and no wallet, session or login. Two gates open every call: the scopes the developer asked for, and the role the community's admin accepted. Errors are `{ \"error\": { \"message\", \"code\" } }` with a matching HTTP status. Throttled calls answer 429 with `Retry-After`.",
    "license": { "name": "MIT", "url": "https://opensource.org/license/mit" }
  },
  "servers": [{ "url": "/", "description": "The Orb you are reading this from. Calls go to /api/bot/v1." }],
  "security": [{ "bot": [] }],
  "tags": [
    { "name": "App", "description": "Who the app is and where it was accepted." },
    { "name": "Messages", "description": "Speak and read as the app. Scopes: messages:write, messages:read." },
    { "name": "Events", "description": "Fetch events with a cursor when the app has no endpoint URL." },
    { "name": "Commands", "description": "Commands members type, registered with the app's own token." },
    { "name": "Voice", "description": "A seat in a voice channel. Scope: voice." },
    { "name": "Music", "description": "Drive the room's shared player. Scope: music." },
    { "name": "Passport", "description": "Ask whether a member holds anchored proof. Scope: passport." },
    { "name": "Directory", "description": "The apps their developers listed publicly, and what the Orb Bot Registry says about them. No token needed." }
  ],
  "paths": {
    "/api/orb/directory": {
      "get": {
        "tags": ["Directory"],
        "operationId": "getDirectory",
        "summary": "The public directory of apps",
        "description": "Every app whose developer chose the public listing, newest and most installed first (up to 60), with what it asks for, its price on the Orb Bot Registry and the manifest that record commits to. Hash `manifest.json` with keccak256 and compare it with the `manifest` on the chain record to check a listing yourself. Unlisted and private apps never appear. No authentication; throttled per caller address.",
        "security": [],
        "responses": {
          "200": {
            "description": "The listed apps and the registry's public facts.",
            "content": { "application/json": { "schema": {
              "type": "object",
              "properties": {
                "apps": { "type": "array", "items": { "$ref": "#/components/schemas/ListedApp" } },
                "registry": { "type": "object", "properties": {
                  "chainId": { "type": "integer", "example": 10143 },
                  "contract": { "type": ["string", "null"], "description": "The registry contract on Monad testnet." },
                  "asset": { "type": "string", "description": "The test token a price is paid in." },
                  "feeRecipient": { "type": "string" },
                  "feeBps": { "type": "integer", "example": 500, "description": "The share of a price that goes to Orb, in basis points." },
                  "apps": { "type": "integer", "description": "How many apps were ever registered on the chain." },
                  "fees": { "type": "string", "description": "Fees waiting for the Orb fee recipient, in wei." } } }
              }
            } } }
          },
          "429": { "description": "Too many requests.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
        }
      }
    },
    "/api/bot/v1/app": {
      "get": {
        "tags": ["App"], "operationId": "getApp", "summary": "Who am I, and where was I accepted?",
        "description": "Without `server`: the app, its member id and the communities that installed it. With `server`: the channels the app may use in that community, exactly as its role sees them.",
        "parameters": [{ "name": "server", "in": "query", "schema": { "type": "string" }, "description": "A community id." }],
        "responses": { "200": { "description": "The app.", "content": { "application/json": { "schema": { "type": "object" } } } }, "401": { "$ref": "#/components/responses/Unauthorized" } }
      }
    },
    "/api/bot/v1/messages": {
      "post": {
        "tags": ["Messages"], "operationId": "sendMessage", "summary": "Post as the app.",
        "description": "Refused: `@everyone`, onchain trade/signal/market cards, text in a voice channel, a read-only channel, a channel the community left out of the app's allow-list. Limits: 30 a minute, 12 a minute per channel.",
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["channel", "content"], "properties": {
          "channel": { "type": "string", "description": "A text channel id." },
          "content": { "type": "string", "maxLength": 2000 },
          "reply_to": { "type": "integer", "description": "A message id in the same channel." } } } } } },
        "responses": { "200": { "description": "The stored message.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PostedMessage" } } } }, "400": { "$ref": "#/components/responses/Bad" }, "403": { "$ref": "#/components/responses/Forbidden" }, "429": { "$ref": "#/components/responses/Throttled" } }
      },
      "get": {
        "tags": ["Messages"], "operationId": "readMessages", "summary": "Read a channel.",
        "description": "Up to 50 recent messages, oldest first. End-to-end encrypted messages come back as `{ encrypted: true, body: null }`.",
        "parameters": [
          { "name": "channel", "in": "query", "required": true, "schema": { "type": "string" } },
          { "name": "before", "in": "query", "schema": { "type": "integer" }, "description": "Return messages older than this id." },
          { "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 50, "default": 50 } }],
        "responses": { "200": { "description": "A page of messages.", "content": { "application/json": { "schema": { "type": "object", "properties": { "messages": { "type": "array", "items": { "$ref": "#/components/schemas/Message" } }, "hasOlder": { "type": "boolean" } } } } } }, "403": { "$ref": "#/components/responses/Forbidden" } }
      }
    },
    "/api/bot/v1/messages/{id}": {
      "delete": {
        "tags": ["Messages"], "operationId": "deleteMessage", "summary": "Take back a message the app wrote.",
        "description": "Only the app's own messages, in a community that still has it installed. Moderating people's messages is not something an app is given.",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }],
        "responses": { "200": { "description": "Deleted.", "content": { "application/json": { "schema": { "type": "object", "properties": { "ok": { "type": "boolean" }, "id": { "type": "integer" } } } } } }, "403": { "$ref": "#/components/responses/Forbidden" }, "404": { "$ref": "#/components/responses/NotFound" } }
      }
    },
    "/api/bot/v1/events": {
      "get": {
        "tags": ["Events"], "operationId": "fetchEvents", "summary": "The events waiting for this app.",
        "description": "For apps without an endpoint URL. Events wait for a day, at most 500. `wait` holds the request open up to 25 seconds, so a bot can run from a laptop with no public address. Ask again with `after` set to the `next` you were given; asking with the same cursor repeats the same events. An app that has an endpoint URL receives its events there and this list stays empty (`mode: webhook`).",
        "parameters": [
          { "name": "after", "in": "query", "schema": { "type": "integer", "default": 0 }, "description": "The cursor of the last event you handled." },
          { "name": "wait", "in": "query", "schema": { "type": "integer", "minimum": 0, "maximum": 25, "default": 0 } },
          { "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 50 } }],
        "responses": { "200": { "description": "Events, oldest first.", "content": { "application/json": { "schema": { "type": "object", "properties": {
          "events": { "type": "array", "items": { "$ref": "#/components/schemas/Event" } }, "next": { "type": "integer" }, "mode": { "type": "string", "enum": ["events", "webhook"] } } } } } }, "400": { "$ref": "#/components/responses/Bad" } }
      }
    },
    "/api/bot/v1/commands": {
      "get": { "tags": ["Commands"], "operationId": "listCommands", "summary": "The commands this app registered.", "responses": { "200": { "description": "Commands.", "content": { "application/json": { "schema": { "type": "object", "properties": { "commands": { "type": "array", "items": { "$ref": "#/components/schemas/Command" } } } } } } } } },
      "put": {
        "tags": ["Commands"], "operationId": "syncCommands", "summary": "Make the app's global commands exactly this list.",
        "description": "Whatever was registered and is not in the list is removed, so your code is the one place that says what the app can do. Up to 50. Names that belong to the room player or to the administration commands are refused. Registering is not reaching: a command is typeable only in a community that installed the app.",
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["commands"], "properties": { "commands": { "type": "array", "maxItems": 50, "items": { "$ref": "#/components/schemas/CommandInput" } } } } } } },
        "responses": { "200": { "description": "The registered commands." }, "400": { "$ref": "#/components/responses/Bad" }, "429": { "$ref": "#/components/responses/Throttled" } }
      },
      "delete": {
        "tags": ["Commands"], "operationId": "removeCommand", "summary": "Remove one global command.",
        "parameters": [{ "name": "name", "in": "query", "required": true, "schema": { "type": "string" } }],
        "responses": { "200": { "description": "Removed." }, "404": { "$ref": "#/components/responses/NotFound" } }
      }
    },
    "/api/bot/v1/interactions/{event}/respond": {
      "post": {
        "tags": ["Commands"], "operationId": "respondToCommand", "summary": "Answer a typed command.",
        "description": "For apps that fetch their events. The member who typed the command waits about six seconds; the answer is shown to them only. 409 if already answered, 410 if the member stopped waiting (post a message instead), 400 if the event is not a command.",
        "parameters": [{ "name": "event", "in": "path", "required": true, "schema": { "type": "string" }, "description": "The `id` of the application_command event." }],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["content"], "properties": { "content": { "type": "string", "maxLength": 1800 } } } } } },
        "responses": { "200": { "description": "Delivered to the member." }, "409": { "$ref": "#/components/responses/Bad" }, "410": { "$ref": "#/components/responses/Bad" } }
      }
    },
    "/api/bot/v1/voice": {
      "post": {
        "tags": ["Voice"], "operationId": "voice", "summary": "A seat in a voice channel.",
        "description": "`join` and `roster` take `channel`; `heartbeat` (every minute or so, `speaking`), `leave` and `grant` take `call`. A bot takes an ordinary seat with the same capacity and the same ninety-second heartbeat as a browser tab. `grant` returns a 30-second LiveKit token only where this Orb runs a voice server.",
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["action"], "properties": { "action": { "type": "string", "enum": ["join", "roster", "heartbeat", "leave", "grant"] }, "channel": { "type": "string" }, "call": { "type": "string" }, "speaking": { "type": "boolean" } } } } } },
        "responses": { "200": { "description": "The result of the action." }, "409": { "$ref": "#/components/responses/Bad" }, "410": { "$ref": "#/components/responses/Bad" } }
      }
    },
    "/api/bot/v1/music": {
      "post": {
        "tags": ["Music"], "operationId": "music", "summary": "Drive the room's shared player.",
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["channel", "action"], "properties": { "channel": { "type": "string" }, "action": { "type": "string", "enum": ["add", "play", "pause", "next", "back", "jump", "remove", "seek", "volume", "loop", "shuffle", "clear", "state"] }, "arg": {} } } } } },
        "responses": { "200": { "description": "The player." } }
      },
      "get": {
        "tags": ["Music"], "operationId": "musicState", "summary": "What the room has queued.",
        "parameters": [{ "name": "channel", "in": "query", "required": true, "schema": { "type": "string" } }],
        "responses": { "200": { "description": "The player." } }
      }
    },
    "/api/bot/v1/passport": {
      "get": {
        "tags": ["Passport"], "operationId": "passportCheck", "summary": "May this member do the thing?",
        "description": "Read back from the registry, not from Orb's table. `holds` is three-valued: `null` means the chain could not be consulted, and an app that treats that as `false` would lock members out because a node had a bad minute.",
        "parameters": [
          { "name": "server", "in": "query", "required": true, "schema": { "type": "string" } },
          { "name": "address", "in": "query", "required": true, "schema": { "type": "string", "pattern": "^0x[0-9a-fA-F]{40}$" } },
          { "name": "kind", "in": "query", "required": true, "schema": { "type": "string" } },
          { "name": "minimum", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 999, "default": 1 } }],
        "responses": { "200": { "description": "The evidence count and whether it holds." } }
      }
    }
  },
  "webhooks": {
    "event": {
      "post": {
        "summary": "An event pushed to the app's endpoint URL.",
        "description": "Verify before you act: `X-Orb-Signature: t=<ms>,v1=<hex>`, where `v1 = HMAC-SHA256(key = sha256(token) as a lowercase hex string, message = \"<t>.<raw body>\")`. `X-Orb-Event` identifies the event and `X-Orb-Delivery` the attempt. Orb tries twice and records the result in the app's delivery log. For a typed command, answer with `{ \"notice\": \"...\" }` within four seconds.",
        "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Event" } } } },
        "responses": { "200": { "description": "Any 2xx counts as delivered.", "content": { "application/json": { "schema": { "type": "object", "properties": { "notice": { "type": "string", "description": "Only for application_command." } } } } } } }
      }
    }
  },
  "components": {
    "securitySchemes": { "bot": { "type": "http", "scheme": "bearer", "description": "The app token, shown once when the app is created or its token rotated." } },
    "responses": {
      "Unauthorized": { "description": "Missing or invalid token.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
      "Forbidden": { "description": "A scope or the community's role does not allow this.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
      "NotFound": { "description": "No such thing for this app.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
      "Bad": { "description": "The request is not valid.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
      "Throttled": { "description": "Too many requests. Wait `Retry-After` seconds.", "headers": { "Retry-After": { "schema": { "type": "integer" } } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
    },
    "schemas": {
    "ListedApp": {
      "type": "object",
      "properties": {
        "id": { "type": "string" },
        "name": { "type": "string" },
        "blurb": { "type": "string" },
        "scopes": { "type": "array", "items": { "type": "string", "enum": ["messages:read", "messages:write", "voice", "music", "passport"] } },
        "rights": { "type": "array", "items": { "type": "string" }, "description": "The channel rights an install would give the app's role." },
        "homepage": { "type": "string" },
        "developer": { "type": "string" },
        "installs": { "type": "integer" },
        "chain": { "type": ["object", "null"], "description": "Null when the app is not on the Orb Bot Registry.", "properties": {
          "app": { "type": "string", "description": "The app's id on the registry (bytes32)." },
          "price": { "type": "string", "description": "The one-time price in wei of the test token; \"0\" is free." },
          "retired": { "type": "boolean" } } },
        "manifest": { "type": "object", "properties": {
          "json": { "type": "string", "description": "The exact JSON the record commits to." },
          "hash": { "type": "string", "description": "keccak256 of the UTF-8 bytes of json." } } }
      }
    },
      "Error": { "type": "object", "properties": { "error": { "type": "object", "properties": { "message": { "type": "string" }, "code": { "type": "integer" } } } } },
      "PostedMessage": { "type": "object", "properties": { "id": { "type": "integer" }, "channel": { "type": "string" }, "server": { "type": "string" }, "created": { "type": "integer" }, "author": { "type": "object", "properties": { "id": { "type": "string" }, "name": { "type": "string" }, "bot": { "type": "boolean" } } } } },
      "Message": { "type": "object", "properties": { "id": { "type": "integer" }, "channel": { "type": "string" }, "author": { "type": "object", "properties": { "id": { "type": "string" }, "name": { "type": "string" }, "bot": { "type": "boolean" } } }, "created": { "type": "integer" }, "replyTo": { "type": ["integer", "null"] }, "encrypted": { "type": "boolean" }, "body": { "type": ["string", "null"] }, "mentions": { "type": "array", "items": { "type": "string" } } } },
      "Event": { "type": "object", "properties": {
        "cursor": { "type": "integer", "description": "Only on events fetched with GET /events." },
        "id": { "type": "string" }, "type": { "type": "string", "enum": ["message.mention", "application_command", "app.test"] },
        "sent_at": { "type": "integer" }, "api": { "const": "orb-bots-v1" },
        "app": { "type": "object", "properties": { "id": { "type": "string" }, "name": { "type": "string" } } },
        "server": { "type": ["string", "null"] }, "channel": { "type": ["string", "null"] },
        "data": { "type": "object", "description": "message.mention: { message: { id, channel, author, body }, reason }. application_command: { command: { id, name, options }, member: { id, name }, invoked_at }." } } },
      "Command": { "type": "object", "properties": { "id": { "type": "string" }, "name": { "type": "string" }, "description": { "type": "string" }, "args": { "type": "array", "items": { "type": "object" } }, "right": { "type": "string" }, "community": { "type": ["string", "null"] } } },
      "CommandInput": { "type": "object", "required": ["name"], "properties": {
        "name": { "type": "string", "pattern": "^[a-z0-9_-]{1,32}$" }, "description": { "type": "string", "maxLength": 140 },
        "right": { "type": "string", "enum": ["send_messages", "attach_files", "manage_messages", "connect", "speak", "stream", "move_members"], "description": "The channel right the person running it needs." },
        "options": { "type": "array", "maxItems": 6, "items": { "type": "object", "required": ["name"], "properties": { "name": { "type": "string" }, "description": { "type": "string" }, "optional": { "type": "boolean" } } } } } }
    }
  }
}
