# sometimes bots API 1

API version 1 · REST, gateway events and individual WebRTC media.

This file is generated from the same source as https://sometimeschat.com/developers/bots.

## bot development

build third-party bots that users install in sometimes. API 1 provides authenticated REST endpoints, permission-filtered events and individual WebRTC call streams.

### availability

register a bot in the developer dashboard, configure its profile and requested permissions, then publish its install link. publication requires no admin approval. use a private bot and invite list for development; public bots also need HTTPS support and privacy-policy URLs. API requests use https://sometimeschat.com/api/bots/v1.

- [register a bot](https://sometimeschat.com/developers/dashboard/bots/new)

### applications and installations

developers host their own bot code. sometimes stores the application identity, publication configuration, user consent and installation scope. REST handles durable reads and writes, a WebSocket gateway delivers filtered events and command invocations, and WebRTC carries individual call media streams. the bot never receives a user’s password, session cookie or account bearer token.

### what bots can do

the 67 permissions cover chat, artwork, media, polls, call participation, profiles, settings, social actions and commands. actions can use the bot identity or the installing user’s identity where explicitly supported. a user can install account-wide or in one chat according to the developer’s configured policy. public and private bots publish without an admin approval step.

### excluded operations

even administrator cannot vote as a bot, join or play games, publish screen shares, inspect read receipts, mark messages read, or moderate anyone else’s messages. bots cannot export account data, view reports, change usernames or account credentials, alter testing roles or install other bots. starting a game never makes the bot a player. bot calls never impersonate a human participant.

### using this reference

start with registration and installation, then choose the smallest permission set for your bot. every resource chapter specifies its routes, permission requirements and object shapes. all runtime routes in the tables are relative to /api/bots/v1 unless a full control-plane path is shown. the downloadable reference contains the same content as these pages.

- [quickstart](https://sometimeschat.com/developers/bots/quickstart)
- [permission catalogue](https://sometimeschat.com/developers/bots/permissions)
- [running your bot](https://sometimeschat.com/developers/bots/delivery)

## quickstart

create an installer-only /hello command that replies privately. these examples use the public REST API and gateway directly.

### 1. register and configure

sign in with a verified sometimes account and create a bot in the developer dashboard. set a name, description, icon, support URL and privacy-policy URL. keep it private while testing and add test users by their stable account IDs. choose either account-wide or one-chat installation. configure commands and bot.messages.ephemeral as required; leave all other permissions disabled. this example needs no message-reading permission.

### choose your runtime

TypeScript is selected by default. the examples below use the same REST and gateway protocol in every language. install the listed dependencies and save each block under its indicated filename. Java commands use a colon between classpath entries on macOS/Linux; use a semicolon on Windows. publication and installation must be completed before the bot can receive commands.

| language | requirements | files and execution |
| --- | --- | --- |
| TypeScript | Node.js 24+; no package dependencies | concatenate the three blocks into bot.ts; run node bot.ts |
| JavaScript | Node.js 24+; no package dependencies | concatenate the three blocks into bot.mjs; run node bot.mjs |
| Python | Python 3.11+; python -m pip install websockets==16.0 | save client.py, register_command.py and bot.py; run python register_command.py once, then python bot.py |
| Java | Java 17+; Maven dependency com.google.code.gson:gson:2.14.0 | save BotClient.java, RegisterCommand.java and Bot.java. obtain the jar with mvn dependency:copy -Dartifact=com.google.code.gson:gson:2.14.0 -DoutputDirectory=.; compile with javac -cp gson-2.14.0.jar BotClient.java RegisterCommand.java Bot.java; run java -cp .:gson-2.14.0.jar RegisterCommand once, then java -cp .:gson-2.14.0.jar Bot |

### 2. keep the bot token on your server

create a token in the dashboard’s credentials tab and copy it before dismissing the one-time reveal. store it in the SOMETIMES_BOT_TOKEN environment variable on your server. never put it in frontend JavaScript, an install URL or a repository. choose a language in the code bar; your choice follows you between examples. no bot SDK is required.

#### typescript

```typescript
// bot.ts — Node.js 24+
const base = 'https://sometimeschat.com/api/bots/v1';
const token = process.env.SOMETIMES_BOT_TOKEN;
if (!token) throw new Error('set SOMETIMES_BOT_TOKEN');
type RequestBody = Record<string, unknown> & { installation?: string; key?: string };
async function request<T = unknown>(
  path: string, { installation, key, ...body }: RequestBody = {},
): Promise<T | null> {
  const r = await fetch(base + path, {
    method: 'POST',
    headers: {
      Authorization: `Bot ${token}`,
      'Content-Type': 'application/json',
      ...(installation ? { 'X-Sometimes-Installation': installation } : {}),
      ...(key ? { 'Idempotency-Key': key } : {}),
    },
    body: JSON.stringify(body),
  });
  if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`);
  return r.status === 204 ? null : r.json();
}
```

#### javascript

```javascript
const base = 'https://sometimeschat.com/api/bots/v1';
const token = process.env.SOMETIMES_BOT_TOKEN;
if (!token) throw new Error('set SOMETIMES_BOT_TOKEN');
async function request(path, { installation, key, ...body } = {}) {
  const r = await fetch(base + path, {
    method: 'POST',
    headers: {
      Authorization: `Bot ${token}`,
      'Content-Type': 'application/json',
      ...(installation ? { 'X-Sometimes-Installation': installation } : {}),
      ...(key ? { 'Idempotency-Key': key } : {}),
    },
    body: JSON.stringify(body),
  });
  if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`);
  return r.status === 204 ? null : r.json();
}
```

#### python

```python
# client.py — Python 3.11+
import asyncio
import json
import os
import urllib.error
import urllib.request

BASE = "https://sometimeschat.com/api/bots/v1"
TOKEN = os.environ["SOMETIMES_BOT_TOKEN"]

async def request(path, *, installation=None, key=None, **body):
    headers = {
        "Authorization": f"Bot {TOKEN}",
        "Content-Type": "application/json",
    }
    if installation:
        headers["X-Sometimes-Installation"] = installation
    if key:
        headers["Idempotency-Key"] = key

    def send():
        req = urllib.request.Request(
            BASE + path, data=json.dumps(body).encode(),
            headers=headers, method="POST",
        )
        try:
            with urllib.request.urlopen(req, timeout=15) as response:
                return None if response.status == 204 else json.load(response)
        except urllib.error.HTTPError as error:
            raise RuntimeError(
                f"{error.code}: {error.read().decode()}"
            ) from error

    return await asyncio.to_thread(send)
```

#### java

```java
// BotClient.java — Java 17+, Gson 2.14.0 on the classpath
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.concurrent.CompletableFuture;

public class BotClient {
    static final String BASE = "https://sometimeschat.com/api/bots/v1";
    static final String TOKEN = System.getenv("SOMETIMES_BOT_TOKEN");
    static final HttpClient HTTP = HttpClient.newBuilder()
        .connectTimeout(Duration.ofSeconds(15)).build();

    static JsonObject json(String text) {
        return JsonParser.parseString(text).getAsJsonObject();
    }

    static CompletableFuture<JsonObject> request(
        String path, String installation, String key, JsonObject body
    ) {
        if (TOKEN == null || TOKEN.isBlank())
            throw new IllegalStateException("set SOMETIMES_BOT_TOKEN");
        var builder = HttpRequest.newBuilder(URI.create(BASE + path))
            .timeout(Duration.ofSeconds(15))
            .header("Authorization", "Bot " + TOKEN)
            .header("Content-Type", "application/json");
        if (installation != null)
            builder.header("X-Sometimes-Installation", installation);
        if (key != null) builder.header("Idempotency-Key", key);
        return HTTP.sendAsync(
            builder.POST(HttpRequest.BodyPublishers.ofString(body.toString())).build(),
            HttpResponse.BodyHandlers.ofString()
        ).thenApply(response -> {
            if (response.statusCode() < 200 || response.statusCode() >= 300)
                throw new IllegalStateException(
                    response.statusCode() + ": " + response.body());
            return response.statusCode() == 204 ? null : json(response.body());
        });
    }
}
```

### 3. register a command

register /hello using the application token. command registration edits a draft command list; publish the configuration in the dashboard to make that command available to consenting installations. command names are scoped to the bot, so two bots can both provide /hello.

#### typescript

```typescript
await request('/applications/@me/commands', {
  key: 'register-hello-v1', name: 'hello',
  description: 'say hello privately', options: [],
});
```

#### javascript

```javascript
await request('/applications/@me/commands', {
  key: 'register-hello-v1', name: 'hello',
  description: 'say hello privately', options: [],
});
```

#### python

```python
# register_command.py
import asyncio
from client import request

async def main():
    await request(
        "/applications/@me/commands", key="register-hello-v1",
        name="hello", description="say hello privately", options=[],
    )

asyncio.run(main())
```

#### java

```java
// RegisterCommand.java
public class RegisterCommand extends BotClient {
    public static void main(String[] args) {
        request("/applications/@me/commands", null, "register-hello-v1", json("""
            {"name":"hello","description":"say hello privately","options":[]}
            """)).join();
    }
}
```

### 4. publish and install

publish the draft configuration without an admin review. the dashboard supplies https://sometimeschat.com/bots/APPLICATION_ID/install. open that link as a permitted user, choose a scope, inspect the permissions and authorize. permissions are loaded from the published dashboard configuration; adding permissions to the URL never changes the request. the owner must authorize their own installation too.

### 5. receive a command

obtain a short-lived gateway ticket, connect to the returned URL and identify in the first frame. acknowledge heartbeats. after receiving interaction.created, acknowledge the interaction promptly and create its private response. these single-connection examples exit on disconnection so a process supervisor can restart it; use the gateway chapter’s durable cursor, resumption and deduplication contract for production.

#### typescript

```typescript
type Ticket = { url: string; ticket: string };
type GatewayFrame = {
  op: string; nonce: string; type: string; id: string; cursor: string;
  installation_id: string; data: { id: string };
};
const ticket = await request<Ticket>('/gateway/tickets');
if (!ticket) throw new Error('missing gateway ticket');
const socket = new WebSocket(ticket.url);
let work = Promise.resolve();
const fail = (error: unknown) => {
  console.error(error);
  socket.close(1000, 'restart after error');
};
socket.addEventListener('open', () => socket.send(JSON.stringify({
  op: 'identify', ticket: ticket.ticket, subscriptions: ['interaction.created'],
})));
socket.addEventListener('message', ({ data }) => {
  try {
    const event = JSON.parse(String(data)) as GatewayFrame;
    if (event.op === 'heartbeat') {
      socket.send(JSON.stringify({ op: 'heartbeat_ack', nonce: event.nonce }));
      return;
    }
    if (event.op !== 'event') return;
    const interaction = event.type === 'interaction.created';
    const id = interaction ? encodeURIComponent(event.data.id) : '';
    const installation = event.installation_id;
    // Acknowledge commands immediately, but commit gateway cursors in order.
    const acknowledged = interaction
      ? request(`/interactions/${id}/ack`, {
          installation, key: `${event.id}:ack`, ephemeral: true,
        }).then(() => null, error => error)
      : Promise.resolve(null);
    work = work.then(async () => {
      const error = await acknowledged;
      if (error) throw error;
      if (interaction) await request(`/interactions/${id}/responses`, {
        installation, key: `${event.id}:reply`, content: 'hello!', ephemeral: true,
      });
      socket.send(JSON.stringify({ op: 'ack', cursor: event.cursor }));
    });
    void work.catch(fail);
  } catch (error) { fail(error); }
});
socket.addEventListener('close', () => process.exit(1));
```

#### javascript

```javascript
const ticket = await request('/gateway/tickets');
const socket = new WebSocket(ticket.url);
let work = Promise.resolve();
const fail = error => {
  console.error(error);
  socket.close(1000, 'restart after error');
};
socket.addEventListener('open', () => socket.send(JSON.stringify({
  op: 'identify', ticket: ticket.ticket, subscriptions: ['interaction.created'],
})));
socket.addEventListener('message', ({ data }) => {
  try {
    const event = JSON.parse(String(data));
    if (event.op === 'heartbeat') {
      socket.send(JSON.stringify({ op: 'heartbeat_ack', nonce: event.nonce }));
      return;
    }
    if (event.op !== 'event') return;
    const interaction = event.type === 'interaction.created';
    const id = interaction ? encodeURIComponent(event.data.id) : '';
    const installation = event.installation_id;
    // Acknowledge commands immediately, but commit gateway cursors in order.
    const acknowledged = interaction
      ? request(`/interactions/${id}/ack`, {
          installation, key: `${event.id}:ack`, ephemeral: true,
        }).then(() => null, error => error)
      : Promise.resolve(null);
    work = work.then(async () => {
      const error = await acknowledged;
      if (error) throw error;
      if (interaction) await request(`/interactions/${id}/responses`, {
        installation, key: `${event.id}:reply`, content: 'hello!', ephemeral: true,
      });
      socket.send(JSON.stringify({ op: 'ack', cursor: event.cursor }));
    });
    void work.catch(fail);
  } catch (error) { fail(error); }
});
socket.addEventListener('close', () => process.exit(1));
```

#### python

```python
# bot.py — install websockets: python -m pip install websockets==16.0
import asyncio
import json
from urllib.parse import quote
from websockets.asyncio.client import connect
from client import request

async def run():
    ticket = await request("/gateway/tickets")
    async with connect(ticket["url"]) as socket:
        await socket.send(json.dumps({
            "op": "identify", "ticket": ticket["ticket"],
            "subscriptions": ["interaction.created"],
        }))
        pending = asyncio.Queue()
        async with asyncio.TaskGroup() as tasks:
            async def commit():
                while True:
                    event, acknowledged = await pending.get()
                    if acknowledged is not None:
                        await acknowledged
                        interaction_id = quote(event["data"]["id"], safe="")
                        await request(
                            f"/interactions/{interaction_id}/responses",
                            installation=event["installation_id"],
                            key=f'{event["id"]}:reply',
                            content="hello!", ephemeral=True,
                        )
                    await socket.send(json.dumps({
                        "op": "ack", "cursor": event["cursor"],
                    }))

            tasks.create_task(commit())
            async for raw in socket:
                event = json.loads(raw)
                if event["op"] == "heartbeat":
                    await socket.send(json.dumps({
                        "op": "heartbeat_ack", "nonce": event["nonce"],
                    }))
                elif event["op"] == "event":
                    acknowledged = None
                    if event["type"] == "interaction.created":
                        interaction_id = quote(event["data"]["id"], safe="")
                        # Start ACK now; commit cursors in arrival order.
                        acknowledged = tasks.create_task(request(
                            f"/interactions/{interaction_id}/ack",
                            installation=event["installation_id"],
                            key=f'{event["id"]}:ack', ephemeral=True,
                        ))
                    pending.put_nowait((event, acknowledged))
            # Cancel pending work on disconnect; let the supervisor restart.
            raise ConnectionError("gateway disconnected")

if __name__ == "__main__":
    asyncio.run(run())
```

#### java

```java
// Bot.java
import com.google.gson.JsonObject;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.WebSocket;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;

public class Bot extends BotClient implements WebSocket.Listener {
    final StringBuilder fragments = new StringBuilder();
    final CompletableFuture<Void> ended = new CompletableFuture<>();
    CompletableFuture<Void> work = CompletableFuture.completedFuture(null);
    CompletableFuture<Void> writes = CompletableFuture.completedFuture(null);

    synchronized CompletableFuture<Void> send(WebSocket socket, JsonObject frame) {
        writes = writes.thenCompose(v ->
            socket.sendText(frame.toString(), true).thenApply(s -> null));
        return writes;
    }

    public void onOpen(WebSocket socket) { socket.request(1); }

    public CompletionStage<?> onText(WebSocket socket, CharSequence data, boolean last) {
        fragments.append(data);
        try {
            if (last) {
                JsonObject event = json(fragments.toString());
                fragments.setLength(0);
                String op = event.get("op").getAsString();
                if (op.equals("heartbeat")) {
                    var frame = json("{\"op\":\"heartbeat_ack\"}");
                    frame.add("nonce", event.get("nonce"));
                    send(socket, frame).exceptionally(error -> {
                        onError(socket, error);
                        return null;
                    });
                } else if (op.equals("event")) {
                    boolean interaction =
                        event.get("type").getAsString().equals("interaction.created");
                    String installation = event.get("installation_id").getAsString();
                    String eventId = event.get("id").getAsString();
                    String path = interaction ? "/interactions/" + URLEncoder.encode(
                        event.getAsJsonObject("data").get("id").getAsString(),
                        StandardCharsets.UTF_8).replace("+", "%20") : "";
                    // Start ACK now; commit cursors in arrival order.
                    CompletableFuture<JsonObject> ack = interaction
                        ? request(path + "/ack", installation, eventId + ":ack",
                            json("{\"ephemeral\":true}"))
                        : CompletableFuture.completedFuture(null);
                    work = work.thenCompose(v -> ack).thenCompose(v -> interaction
                        ? request(path + "/responses", installation, eventId + ":reply",
                            json("{\"content\":\"hello!\",\"ephemeral\":true}"))
                        : CompletableFuture.completedFuture(null)
                    ).thenCompose(v -> {
                        var frame = json("{\"op\":\"ack\"}");
                        frame.add("cursor", event.get("cursor"));
                        return send(socket, frame);
                    });
                    work.exceptionally(error -> { onError(socket, error); return null; });
                }
            }
        } catch (Exception error) { onError(socket, error); }
        socket.request(1);
        return null;
    }

    public CompletionStage<?> onClose(WebSocket socket, int status, String reason) {
        ended.completeExceptionally(new IllegalStateException("gateway disconnected"));
        return null;
    }

    public void onError(WebSocket socket, Throwable error) {
        ended.completeExceptionally(error);
        socket.abort();
    }

    public static void main(String[] args) {
        JsonObject ticket = request("/gateway/tickets", null, null, json("{}")).join();
        var listener = new Bot();
        WebSocket socket = HTTP.newWebSocketBuilder()
            .buildAsync(URI.create(ticket.get("url").getAsString()), listener).join();
        var frame = json("""
            {"op":"identify","subscriptions":["interaction.created"]}
            """);
        frame.add("ticket", ticket.get("ticket"));
        listener.send(socket, frame).join();
        listener.ended.join();
    }
}
```

### 6. verify the boundaries

invoke /hello from an authorized chat. only you should see the reply, with its faint background and “(only visible to you)” label. attempt to read chat history with the same installation: it must return permission_denied. uninstall the bot and confirm both new actions and replayed content stop. private invite removal and a group-owner exclusion must also deny access. these are launch acceptance requirements, not tests performed by this documentation.

## registration and publication

an application owns its bot identity, credentials, commands and published permission configuration. developers run the code on their own infrastructure.

### application fields

application IDs are opaque stable strings. the display name is not an account username and cannot claim the official system identity. names and descriptions use existing text-safety checks. developers retain user-authored capitalization. one verified human account owns each application in v1; team ownership and transfers are outside v1.

```json
{
  "id": "app_example",
  "name": "Helpful bot",
  "description": "A private helper.",
  "icon_asset_id": "asset_example",
  "owner_id": "user_developer",
  "support_url": "https://example.org/support",
  "privacy_url": "https://example.org/privacy",
  "visibility": "private",
  "status": "draft",
  "revision": "1",
  "published_revision": null,
  "install_policy": "either",
  "permissions": {
    "commands": "required",
    "bot.messages.ephemeral": "required"
  },
  "bot_user_id": "bot_example",
  "banner_asset_id": null,
  "enabled": true,
  "commands": [],
  "install_url": null,
  "created_at": "2026-10-20T07:00:00Z",
  "updated_at": "2026-10-20T07:00:00Z"
}
```

### configuration and validation

permissions accept only disabled, optional or required; omission means disabled. install_policy is account, either or chat. invalid keys, incompatible required dependencies, duplicate commands or missing publication fields reject the whole draft with field errors. avatar uploads use the existing image-validation rules. tokens are shown only when created. a bot token can register commands but cannot change permissions, visibility, invite lists or install policy; those changes require the owner’s dashboard session.

### publishing a revision

publish atomically freezes the name/icon metadata, commands, install policy and permission request at an immutable revision. publication runs validation but no admin approval, submission or review queue. the stable install link resolves the latest published revision on sometimes. the authorization screen binds its confirmation to that exact revision; concurrent publication returns configuration_changed and refreshes the screen before consent. uploaded executable packages are not part of this hosting model.

### drafts, unpublishing and disabling

saving a draft does not affect published installations. unpublish removes new installation access and keeps existing approved revisions running. disable stops all runtime operations, disconnects event/media sessions and hides commands immediately; enabling resumes only installations whose user, scope and private eligibility remain valid. delete is irreversible after dashboard confirmation, revokes credentials and installations and tombstones bot attribution on existing public content. suspension or deletion of the developer also disables the application. uninstall removes private bot state and credentials for that installation but preserves ordinary messages and immutable provenance; it never deletes user-authored chat content as a side effect.

### private bots

private installation requires the owner or an invite-list account ID at both prompt display and consent commit. resolve a typed handle to an account before saving so username changes do not transfer an invitation. removing an invite revokes that user’s installations immediately. switching public to private revokes installations for everyone outside the new invite list except the owner. eligibility loss uses the same revocation path as uninstall; adding someone back does not silently reinstall.

### developer dashboard routes

these dashboard routes use the verified owner’s existing same-origin session and CSRF protection, never the bot token. writes require If-Match for the current application revision except initial creation. lists use the pagination contract. secret creation returns the token once; later reads expose only token metadata. use the dashboard to publish permissions, manage invites and rotate credentials; the bot token cannot perform these owner actions.

| method and path | request | result |
| --- | --- | --- |
| GET /api/developers/bots | cursor, limit | owned Application list |
| POST /api/developers/bots | name, description | 201 Application draft |
| GET /api/developers/bots/{app} | none | Application + revisions + install_url |
| PUT /api/developers/bots/{app}/icon | validated image bytes up to 5 MiB; If-Match | draft icon_asset_id + new revision; owner session only |
| PATCH /api/developers/bots/{app} | editable application fields | Application draft with new revision |
| POST /api/developers/bots/{app}/publish | draft_revision | published_revision, install_url |
| POST /api/developers/bots/{app}/unpublish | none | Application |
| PATCH /api/developers/bots/{app}/enabled | enabled: boolean | Application |
| DELETE /api/developers/bots/{app} | confirmation: application ID | 204; revoke and tombstone |
| GET /api/developers/bots/{app}/invites | cursor, limit | invited account IDs + basic identity |
| PUT /api/developers/bots/{app}/invites/{user} | none | 204; owner-only eligibility addition |
| DELETE /api/developers/bots/{app}/invites/{user} | none | 204; revoke affected installations |
| GET /api/developers/bots/{app}/tokens | none | token metadata only |
| POST /api/developers/bots/{app}/tokens | label | 201 token, token_id; one-time secret |
| DELETE /api/developers/bots/{app}/tokens/{token} | none | 204; close sessions authenticated by token |

## installation and consent

an installation belongs to one human user. it authorizes one application at one configuration revision in a declared chat scope.

### authorization prompt

show bot name, blue bot label, developer identity, description, privacy/support links, install policy and requested permissions grouped exactly as the catalogue. disabled permissions are omitted. required permissions are checked and locked; optional permissions start on and can be switched off. explain account-wide access to present and future eligible chats. the final authorize button summarizes the selected scope. cancel creates nothing. show administrator as an explicit all-permissions choice with an expandable complete list; when selected, granular switches cannot imply exclusions.

### account and chat scopes

account means all current and future chats the installer can access, subject to group bot exclusions and privacy rules. chat means exactly one selected existing DM or group. either lets the installer choose. a single-chat installation can still use explicitly granted account capabilities such as profile, settings, friends and stickers. those account effects must be identified separately in the prompt. bot_dm adds a dedicated private bot DM as an explicit extra surface even for a single-chat installation; it never expands access to other chats.

### group-owner control

any current group member can install a bot for their own use. the group’s bot roster discloses every application with active access and which members authorized it; private account grants are not shown to peers. a human group owner can exclude an application from the group. exclusion immediately denies every installation of that application in the group, including account-wide installations, disconnects its call streams and cancels queued work there. the exclusion persists until the owner explicitly removes it. members cannot evade it by reinstalling; the bot API cannot alter this exclusion.

### multiple installations

allow one active installation per application, installer and scope target. installing the same target opens its management screen. an account-wide installation and distinct chat installations may coexist, but grants never combine: every operation names exactly one installation. command routing uses the installer’s active chat-specific installation if present, otherwise their account-wide installation. event fanout can produce one authorized event per installation, identified separately. developers must deduplicate intended shared effects rather than union permissions.

### configuration changes and permission renewal

a published revision never silently adds privileges. existing installations keep their approved permission configuration and command snapshot until the user accepts a newer revision. the developer must support those old grants or return a clear upgrade-needed response; new optional permissions remain unavailable until consent. scope expansion requires a new prompt. new code deployed by the developer may run immediately but still has only existing grants. users may revoke optional grants immediately; disabling a required grant pauses the installation until restored or reauthorized. uninstall, private eligibility, platform suspension and group exclusion always take effect immediately, regardless of revision pinning.

### history boundary

each author-read grant records an authorized_since timestamp when it is first enabled. without messages.history, reads and searches include only messages created at or after that grant’s timestamp. enabling a read grant later does not backfill earlier messages; edits to old messages do not turn them into new messages. scope additions likewise establish a new boundary for each newly authorized chat. loss and later regrant reset the corresponding boundary. bot-authored content has a separate narrow own-resource readback described in the resource chapters.

### user management routes

these same-origin routes require the human session and CSRF protection. they cannot be called with a bot token. consent POST includes a one-use prompt token bound to user, application, published revision, scope choices and expiry; tokens expire in 10 minutes and are consumed atomically. update prompts use the same flow. group exclusion writes require current owner status at commit.

| method and path | request | result |
| --- | --- | --- |
| GET /api/bot-installs/authorize/{app} | none | prompt token, published config, eligible scopes, expiry |
| POST /api/bot-installs | prompt_token, revision, scope:{type,chat_id?}, optional_permissions[] | 201 Installation; transactional consent record |
| GET /api/bot-installs | cursor, limit | your Installation list |
| GET /api/bot-installs/{id} | none | Installation + grant decisions + update availability |
| POST /api/bot-installs/{id}/reauthorize | fresh prompt_token, revision, scope, optional_permissions[] | Installation with renewed consent |
| PATCH /api/bot-installs/{id} | optional_permissions[], enabled; If-Match | Installation with new grant_revision |
| DELETE /api/bot-installs/{id} | none | 204; immediate revocation |
| GET /api/bot-installs/{id}/activity | cursor, limit | private action metadata, last 90 days |
| GET /api/conversations/{chat}/bots | none | member-visible bot roster and exclusions |
| PUT /api/conversations/{chat}/bot-exclusions/{app} | none | 204; owner excludes bot |
| DELETE /api/conversations/{chat}/bot-exclusions/{app} | none | 204; owner allows bot again |

## permission catalogue

these 67 stable permission keys are the v1 consent contract. numbers are reference labels, not bit positions or a JavaScript numeric bitmask.

### permission evaluation

evaluate application status and token, installation ownership/status, revision, private eligibility, resource scope, current installer access, group exclusion, blocks, audience, operation grant, field grant and resource ownership on every read, write, event delivery and media subscription. all conditions must pass. absent grants deny. administrator expands to the approved catalogue even if individually disabled. existing administrator approvals remain limited to keys 2–66; published configurations with permission_catalogue 67 include interactive displays only after renewed consent. scope and ownership checks still apply.

### combinations and dependencies

history, search, downloads and poll results never independently grant message content. actions on an existing message require the appropriate author read grant unless it is the installation’s own bot resource. creating a message-shaped sticker, GIF, attachment or poll uses its specific create/send permission without additionally requiring plain-text send; an optional caption uses the same grant. receiving/publishing media requires a joined bot session and the corresponding receive/publish grant. commands require commands plus bot.messages.send or bot.messages.ephemeral to reply. a writable operation’s receipt does not grant general read access.

### dependency validation

publication rejects a required permission if every way to satisfy its prerequisites is disabled. for example, required downloads need an enabled author-read grant; required media receive needs one of the join permissions enabled. if an optional prerequisite is declined, the prompt explains which features will be unavailable. a required feature cannot rely only on a declined optional prerequisite: authorization must reject that selection until the dependency is selected. administrator satisfies all v1 grant prerequisites when selected.

### master permission

grant names are exact API identifiers; display labels are shown during authorization.

| # | permission key and label | contract |
| --- | --- | --- |
| 1 | administrator<br>administrator | expands to the catalogue approved during authorization, including disabled permissions. existing approvals cover 2–66; renewed approvals can include 67. it never overrides scope, ownership, group exclusion, blocks or the prohibited operations. |

### utility

grant names are exact API identifiers; display labels are shown during authorization.

| # | permission key and label | contract |
| --- | --- | --- |
| 2 | messages.read_self<br>read your messages | reads messages authored by the installer, including delegated messages. history and search require their additional grants. |
| 3 | messages.read_others<br>read others messages | reads other authors in authorized chats, including bot authors. blocks and private-message audiences still filter results. |
| 4 | messages.history<br>read past messages | allows messages created before this installation's history boundary; requires the applicable author read grant. |
| 5 | messages.search<br>search for messages | searches only messages the installation can already read. older results also require messages.history. |
| 6 | scribbles.read<br>read scribbles | reads persisted chat strokes, cuts and placements and live drawing previews. it does not grant message bodies or private profile artwork. |
| 7 | bot_dm<br>message you 1-on-1 | creates or reopens the installer’s bot DM, with a blue bot tag. reading and sending in it still require their respective grants. |
| 8 | calls.audio.receive<br>listen to calls | subscribes to separate audio tracks identified by participant; requires an authorized joined bot call session. |
| 9 | calls.video.receive<br>receive video feeds | subscribes to participant camera tracks; does not include screen tracks or audio. |
| 10 | calls.screen.receive<br>receive screen share feeds | subscribes to screen video; screen audio additionally requires calls.audio.receive. never permits publishing a screen. |
| 11 | attachments.download<br>download sent attachments | downloads bytes only for messages currently readable by this installation, including the author/history/audience checks. |
| 12 | polls.results<br>view poll results | reads counts, outcomes and public voter identities for readable polls. anonymous polls never disclose voters. |
| 13 | presence.read<br>view online statuses | reads visible status for eligible users; respects their online-visibility preference and blocks. no device/session or last-seen telemetry. |
| 14 | typing.read<br>view typing indicators | receives transient typing activity in authorized chats, respecting typing privacy. never includes draft text. |
| 15 | groups.members.read<br>view group member lists | reads the roster and basic identity of members in authorized groups; full profiles require profile read grants. |
| 16 | friends.read<br>view your friend list | reads the installer’s accepted friends with basic identity, not their private profiles or messages. |
| 17 | friends.requests.read<br>view pending friend requests | reads the installer’s incoming and outgoing pending requests with basic identity. |
| 18 | profile.read_self<br>view your profile | reads the installer’s public profile fields and artwork, excluding credentials, recovery contacts and private account data. |
| 19 | profile.read_others<br>view others profiles | reads profiles the installer is eligible to view, subject to privacy and blocks. |
| 20 | profile.write<br>modify your profile | writes all editable Profile settings: display name, about me, pronouns, avatar, banner, gradient, profile/nameplate artwork, nameplate colors, online-indicator color and announcement pack. never username, roles, badges or points. |
| 21 | settings.write<br>modify your settings | reads and changes settings outside profile, testing, stickers and account. includes alerts, quiet hours, appearance, chat/media, call/audio preferences and privacy preferences; never exports, reports, credentials, sessions or permission grants. |
| 22 | chats.mute<br>mute and unmute chats | reads and changes only the installer’s mute state in authorized chats; does not change anyone else’s notifications. |
| 23 | commands<br>create and respond to commands | registers application commands and receives installer invocations. message responses require the appropriate bot send grant; command arguments are delivered without whole-chat read access. |
| 24 | notifications.send<br>send you notifications | sends the installer an app notification, subject to their blocks, mute/alert preferences and quotas. cannot broadcast to other users. |
| 25 | stickers.manage<br>create, view, and remove your stickers | manages the installer’s sticker library and consumes their normal storage/count quota. cannot delete another owner’s original asset. |

### outbound bot actions

grant names are exact API identifiers; display labels are shown during authorization.

| # | permission key and label | contract |
| --- | --- | --- |
| 26 | bot.messages.send<br>send messages (as a bot) | creates ordinary messages with the application’s bot identity. |
| 27 | bot.messages.ephemeral<br>send ephemeral messages (as a bot) | creates private messages for this installation’s user in an eligible chat; faint background and “(only visible to you)” label. |
| 28 | bot.messages.edit<br>edit messages (as a bot) | edits messages created by this application through this installation; cannot edit other installations’ messages. |
| 29 | bot.messages.delete<br>delete messages (as a bot) | deletes messages created by this application through this installation; no message moderation. |
| 30 | bot.scribbles.write<br>create and edit scribbles (as a bot) | creates and updates strokes owned by this bot installation; deletion and erasure require bot.scribbles.erase. |
| 31 | bot.scribbles.erase<br>erase scribbles (as a bot) | erases only strokes owned by this bot installation, by whole stroke or cut path. |
| 32 | bot.stickers.send<br>send stickers (as a bot) | sends any sticker in the installer’s library or uploads/sends an application-owned sticker. the library’s sendable picker is included; library mutation is not. |
| 33 | bot.gifs.send<br>send gifs (as a bot) | searches the supported GIF catalogue and sends a resolved GIF as the bot. |
| 34 | bot.placements.write<br>drag and remove stickers and gifs (as a bot) | moves/removes only this bot installation’s sticker/GIF placements. |
| 35 | bot.attachments.send<br>send attachments (as a bot) | uploads and sends supported files as the bot, with existing media validation and quotas. |
| 36 | bot.polls.create<br>create polls (as a bot) | creates a poll with bot authorship. does not permit bot voting. |
| 37 | bot.polls.close<br>close polls (as a bot) | closes only polls created by this bot installation. |
| 38 | bot.pins.write<br>pin and unpin messages (as a bot) | pins eligible readable messages as the bot; removes only pins attributed to this installation. |
| 39 | bot.reactions.write<br>add and remove reactions (as a bot) | adds/removes this bot installation’s reactions on eligible readable messages. |
| 40 | bot.games.start<br>start games (as a bot) | creates a game session/invitation but never adds the bot as a player. without an active call it starts with zero players. |
| 41 | bot.groups.create<br>create groups (as a bot) | creates a group attributed to the bot, with the installer as human owner. normal invitation eligibility applies. |
| 42 | bot.calls.start<br>start calls (as a bot) | starts or obtains a call in an authorized chat. does not itself join, publish or receive media. |
| 43 | bot.calls.speak<br>speak in calls (as a bot) | publishes a bot audio track in an authorized joined session; never uses the installer’s microphone. |
| 44 | bot.calls.join_with_user<br>join calls you are in (as a bot) | joins only while the installer is a participant; leaves when the installer leaves unless join_all is also granted. |
| 45 | bot.calls.join_all<br>join all calls (as a bot) | joins eligible calls in authorized chats even when the installer is absent; no discovery or admission outside scope. |
| 46 | bot.calls.video<br>display a video feed (as a bot) | publishes a bot camera/video track in an authorized joined session; never enables the installer’s camera. |
| 47 | bot.calls.invite<br>invite users to a call (as a bot) | proposes invitations using existing call voting, recipient acceptance, block and eligibility rules; cannot silently admit users. |

### outbound user actions

grant names are exact API identifiers; display labels are shown during authorization.

| # | permission key and label | contract |
| --- | --- | --- |
| 48 | user.messages.send<br>send messages (as you) | sends as the installer with visible “via [bot]” attribution and a private activity record. |
| 49 | user.messages.edit<br>edit messages (as you) | edits the installer’s ordinary messages, including ones they wrote manually, preserving bot edit attribution. |
| 50 | user.messages.delete<br>delete messages (as you) | deletes only the installer’s messages; group ownership does not authorize deleting another person’s message. |
| 51 | user.scribbles.write<br>create and edit scribbles (as you) | creates and edits the installer’s strokes, with delegated attribution. |
| 52 | user.scribbles.erase<br>erase scribbles (as you) | erases only the installer’s strokes, by whole stroke or cut path. |
| 53 | user.stickers.send<br>send stickers (as you) | sends only stickers uploaded by the installer and still in their library; adopted or bot-owned stickers are ineligible. |
| 54 | user.gifs.send<br>send gifs (as you) | searches and sends supported catalogue GIFs with delegated attribution. |
| 55 | user.placements.write<br>drag and remove stickers and gifs (as you) | moves/removes only the installer’s placements. |
| 56 | user.attachments.send<br>send attachments (as you) | uploads and sends files as the installer, using their normal quotas. |
| 57 | user.polls.create<br>create polls (as you) | creates a poll owned by the installer with delegated attribution. |
| 58 | user.polls.close<br>close polls (as you) | closes only polls owned by the installer. |
| 59 | user.polls.vote<br>vote in polls (as you) | casts, replaces or clears the installer’s vote; one user remains one voter. never accepts a bot actor. |
| 60 | user.pins.write<br>pin and unpin messages (as you) | pins eligible readable messages and removes pins attributed to the installer. |
| 61 | user.reactions.write<br>add and remove reactions (as you) | adds/removes only the installer’s reactions on eligible readable messages. |
| 62 | user.friends.request<br>send friend requests (as you) | sends or cancels the installer’s own outgoing requests, subject to existing recipient preferences and blocks. |
| 63 | user.friends.respond<br>accept and deny friend requests (as you) | accepts or denies incoming requests addressed to the installer; cannot manufacture a reciprocal acceptance. |
| 64 | user.blocks.write<br>block and unblock users (as you) | changes the installer’s blocks; no ability to remove another user’s block. |
| 65 | user.groups.write<br>modify groups you own (as you) | changes name, icon and member-invitation settings of an authorized group the installer still owns; no ownership transfer, group deletion or bot-exclusion management. |
| 66 | user.groups.members<br>modify members in groups you own (as you) | invites/removes human members in authorized groups the installer still owns, under normal membership limits. cannot remove the owner or alter group bot exclusions. |

### interactive displays

grant names are exact API identifiers; display labels are shown during authorization.

| # | permission key and label | contract |
| --- | --- | --- |
| 67 | bot.messages.components<br>create interactive displays | adds styled, interactive components to this installation’s bot messages. sending, editing, private replies, commands and audio still require their respective permissions. clicks disclose only the acting user and submitted values. |

### privacy and identity boundaries

basic identities attached to an authorized resource include only stable ID, display name, bot/user kind and avatar thumbnail. they do not grant full profile, presence, friends, contact details or message access. username is read-only wherever normally public; it is never writable. profile artwork asset reads require profile permission, and chat attachment bytes require attachments.download. message bodies, search snippets, embeds, captions, quote previews, polls, notifications and replay all use the same visibility rules. a single installer cannot grant access to someone else’s private DM or account.

## authentication and REST

the base URL is https://sometimeschat.com/api/bots/v1. this API uses application credentials and explicit installation context, independently of human account sessions.

### credentials and request context

send Authorization: Bot TOKEN over HTTPS. all installation-resource routes require X-Sometimes-Installation. the server checks that the installation belongs to the token’s application and resolves the human installer itself; clients never submit an arbitrary user ID to act as. application metadata, installation enumeration, gateway ticket and command-registration routes are application-level exceptions. bot credentials are rejected by existing human /api routes. browser CORS is disabled for bot-token endpoints.

#### typescript

```typescript
// Use request() from the quickstart.
await request('/chats/chat_example/messages', {
  installation: 'install_example', key: 'message-job-42',
  actor: 'bot', content: 'hello!',
});
```

#### javascript

```javascript
// Use request() from the quickstart.
await request('/chats/chat_example/messages', {
  installation: 'install_example', key: 'message-job-42',
  actor: 'bot', content: 'hello!',
});
```

#### python

```python
# send_message.py — uses client.py from the quickstart
import asyncio
from client import request

async def main():
    await request(
        "/chats/chat_example/messages",
        installation="install_example", key="message-job-42",
        actor="bot", content="hello!",
    )

asyncio.run(main())
```

#### java

```java
// SendMessage.java — uses BotClient.java from the quickstart
public class SendMessage extends BotClient {
    public static void main(String[] args) {
        request("/chats/chat_example/messages", "install_example", "message-job-42",
            json("""
                {"actor":"bot","content":"hello!"}
                """)).join();
    }
}
```

### token lifecycle

tokens contain at least 256 bits of random secret material, are stored as hashes and are redacted from logs. allow at most two active application tokens for explicit rotation overlap; the developer creates a replacement and revokes the old token. tokens have no automatic expiry in v1, but are revocable immediately. revocation closes sessions authenticated with that token. the token can enumerate only its application’s minimal installation metadata; possession never overrides user grants.

### common object conventions

IDs are opaque case-sensitive strings up to 128 characters; never parse their format. timestamps are RFC 3339 UTC strings. revisions and sequence counters are decimal strings, preventing integer precision loss. lists return {data, next_cursor}; cursor is an opaque signed token tied to application, installation, filters and authorization revision. limit defaults to 50 and ranges from 1 to 100. null next_cursor means complete. list order is documented per resource and stable by (created_at,id); message history uses sequence ascending with before/after cursors.

```json
{
  "id": "install_example",
  "application_id": "app_example",
  "user_id": "user_example",
  "configuration_revision": "3",
  "grant_revision": "1",
  "status": "active",
  "scope": {
    "type": "chat",
    "chat_id": "chat_example"
  },
  "permissions": [
    "commands",
    "bot.messages.ephemeral"
  ],
  "authorized_since": {},
  "created_at": "2026-10-20T07:00:00Z",
  "optional_permissions": []
}
```

### validation, concurrency and retries

JSON requests use UTF-8 and reject unknown fields rather than silently dropping them. PATCH changes only supplied fields; null clears only explicitly nullable fields. mutable-resource PATCH/DELETE and state replacements require If-Match with the last returned ETag; stale writes return 412. set-membership operations (pins, reactions, blocks), upload cancellation, outgoing-request cancellation and session cleanup are idempotent without an existing ETag except where a route explicitly requires one; whole settings/vote/group replacements require one. create/action POSTs require an Idempotency-Key of 1–128 ASCII characters, except gateway ticket issuance, call heartbeats and transient scribble previews. keys are scoped to application, installation, method and route for 24 hours; same key/body returns the stored receipt, different body returns 409. recheck authorization before returning cached receipts. resource and outbox event commits are atomic. GET is side-effect free and never marks read.

### errors and redaction

errors include a stable code, safe lowercase message, request_id and optional field details, missing_permissions or retry_after_ms. missing_permissions lists only known application grants. inaccessible resources return 404 to avoid existence leaks. reject expired/disabled credentials with 401; denied grants, paused installs and group exclusion use 403 when the installation is already known. do not retry permanent validation or authorization failures.

| status | codes | handling |
| --- | --- | --- |
| 400 | invalid_request, invalid_cursor, unsupported_actor, invalid_configuration | fix fields or start a fresh pagination query |
| 401 | invalid_token, revoked_token | replace credentials; never retry indefinitely |
| 403 | permission_denied, installation_paused, application_disabled, group_bot_excluded | inspect grants/status; request user action when appropriate |
| 404 | not_found | resource absent or not visible |
| 409 | configuration_changed, idempotency_conflict, already_exists, invalid_state | reload state or use the correct idempotency key |
| 410 | interaction_expired, cursor_expired, upload_expired | restart the relevant flow; do not replay stale actions |
| 412 | revision_mismatch | reload writable fields and explicitly merge |
| 413 / 415 | payload_too_large / unsupported_media | reduce size or use a supported media type |
| 429 | rate_limited, quota_exceeded | honor Retry-After and inspect quota metadata |
| 503 | temporarily_unavailable | bounded exponential retry with jitter and the same key |

```json
{
  "error": {
    "code": "permission_denied",
    "message": "this installation cannot send messages.",
    "request_id": "req_example",
    "missing_permissions": [
      "bot.messages.send"
    ]
  }
}
```

### identity and discovery

installation enumeration returns only ID, installer basic identity, approved revision, scope, grants and status. installation-scoped discovery filters every chat and permission by current access. chats.list is an intrinsic capability: it returns ID, kind and title needed to address grants, with no last-message preview, unread count or member roster.

| method and path | request | result |
| --- | --- | --- |
| GET /applications/@me | application token | public Application + enabled/status + published revision |
| GET /installations | cursor, limit; application token | application’s Installation list |
| GET /installations/@current | installation header | Installation + effective_permissions |
| GET /limits | installation header | limits, field schemas and granted quota usage |
| GET /chats | cursor, limit; installation header | eligible Chat{id,kind,title} list |
| GET /chats/{chat} | installation header | Chat{id,kind,title}; no previews or receipts |

## events and reconnection

the gateway delivers authorized changes without polling. subscriptions reduce traffic; they never add permissions.

### connection lifecycle

POST /gateway/tickets with the application token returns {url,ticket,expires_at}; tickets are single-use and expire in 60 seconds. connect to wss://sometimeschat.com/api/bots/v1/gateway with no credential in the URL. send identify within five seconds with ticket, subscriptions[] and optional resume_cursor. the server returns ready with session_id, cursor and heartbeat_interval_ms:30000. only one active gateway consumer is permitted per application in v1; a new consumer closes the old with 4009. distribute work inside your own service.

### frames and acknowledgements

server frames are ready, heartbeat, event, resumed and resync_required. client frames are identify, heartbeat_ack and ack. respond to heartbeat’s nonce within 10 seconds. acknowledge the highest contiguous cursor only after durably handling every earlier event. the cursor is opaque and ordered only within this application stream. never compare its string contents. transport ACKs do not replace command acknowledgements. invalid frames close with 4002, missing identify with 4001, denied authentication with 4003 and missed heartbeat with 4008.

```json
{
  "op": "event",
  "id": "evt_example",
  "cursor": "opaque_cursor",
  "type": "message.created",
  "installation_id": "install_example",
  "grant_revision": "2",
  "occurred_at": "2026-10-20T07:00:00Z",
  "resource_revision": "1",
  "origin": {
    "kind": "user",
    "application_id": null
  },
  "data": {
    "chat_id": "chat_example",
    "message": {
      "id": "msg_example",
      "content": "hello",
      "chat_id": "chat_example",
      "sequence": "1",
      "revision": "1",
      "kind": "text",
      "author": {
        "kind": "user",
        "id": "user_example",
        "display_name": "Avery",
        "avatar_url": null
      },
      "via_bot": null,
      "edited_via_bot": null,
      "created_at": "2026-10-20T07:00:00Z",
      "edited_at": null,
      "reply_to": null,
      "attachment_ids": null,
      "audience": "chat",
      "expires_at": null
    }
  }
}
```

### delivery and replay

durable changes use an outbox and at-least-once delivery. duplicate event IDs are expected. order is guaranteed per installation/resource, not across chats or applications. retain up to 10,000 events per installation for at most 24 hours, whichever is reached first. replay always rechecks current permissions, audience, scope and blocks; deleted content is never resurrected from an event payload. lost access emits only a safe invalidation without old content. bot self-events include origin for loop prevention.

### reconnect and recover

reconnect with a fresh ticket and the last durably acknowledged cursor, using exponential backoff with jitter from one second to 60 seconds. a valid cursor yields resumed then filtered replay. an expired cursor yields resync_required with scope:application, a reason and a new cursor; take authorized REST snapshots, reconcile resource revisions, then reconnect using that cursor so changes during snapshotting are replayed. keep a durable event-ID deduplication store for 24 hours. slow consumers receive resync_required then close 4010; maximum unacknowledged frames is 256.

### event catalogue

lifecycle events are always available to the owning application with minimal metadata. content events require the listed read grants, including history for pre-boundary content. basic own-resource mutation receipts do not turn into an unrestricted read stream. fields in parentheses are the payload shape; shared object definitions are in the relevant resource chapter.

| event | authorization | data |
| --- | --- | --- |
| installation.created / updated / revoked | own application; safe lifecycle metadata | Installation, or {id,reason,grant_revision} on revoke |
| application.updated / disabled | own application | {id,published_revision,status} |
| chat.access_changed | affected installation only | {chat_id,accessible,reason}; no hidden title/content |
| message.created / updated | applicable author read + history if needed | {chat_id,message:Message} |
| message.deleted | currently eligible author/category; previously visible ID | {chat_id,message_id,revision} |
| scribble.created / updated / erased | scribbles.read | {chat_id,stroke:Scribble} or {chat_id,ids,cuts,revision} |
| scribble.preview | scribbles.read; transient | {chat_id,actor,stroke,expires_at}; no replay |
| placement.created / updated / removed | scribbles.read or readable parent message | {chat_id,placement:Placement} or {chat_id,id,revision} |
| reaction.updated / pin.updated | readable target message | {chat_id,message_id,reactions} or {chat_id,message_id,pins} |
| poll.updated / closed | polls.results + readable poll | {chat_id,poll:Poll}; anonymous voters omitted |
| presence.updated | presence.read; eligible user; transient | {user_id,online,expires_at}; no replay |
| typing.started / stopped | typing.read; authorized chat; transient | {chat_id,user_id,expires_at}; no draft text |
| group.updated / members.updated | authorized group; roster only with groups.members.read | {chat_id,revision,group?} or {chat_id,members} |
| friends.updated / friend_request.updated | friends.read / friends.requests.read | {revision,refresh:true} or {request_id,revision,request} |
| profile.updated | matching profile read grant | {user_id,profile:Profile,revision} |
| settings.updated / stickers.updated | settings.write / stickers.manage | {target,patch,revision} / {sticker_id,operation,revision} |
| call.created / updated / ended | start or join grant; in-scope call | {chat_id,call:Call}; no raw SFU credentials |
| call.track.added / removed | joined session + matching media receive grant | {session_id,track:Track} or {session_id,track_id} |
| call.invitation.updated | bot.calls.invite; own proposal | {invitation_id,status,expires_at} |
| interaction.created | commands for private command invocations; bot.messages.components for component actions with the actual member actor_id | Interaction; explicit arguments only |
| game.started / ended | bot.games.start; own-started game | {chat_id,game_session_id,status}; no player game state |

### revocation during delivery

uninstall and invite removal atomically revoke grants and enqueue a minimal lifecycle notification. dispatch, replay, upload completion and media authorization must consult current revocation state. purge pending content and tear down streams; do not rely on eventual cache expiry for enforcement. if a resource became invisible after a client saw it, send only an invalidation for its known ID. downloaded data cannot be recalled; developers must honor deletion and retention commitments. webhooks are not a second event transport in v1, avoiding two delivery contracts.

## interactive displays

bots can send styled, interactive interfaces inside public or private chat messages. layouts and state update independently, including while users interact with them.

### enable displays

enable permission 67, bot.messages.components, in the dashboard, publish the configuration and obtain renewed consent. creating a public display also requires bot.messages.send; a private display requires bot.messages.ephemeral. updates require bot.messages.edit. deletion uses the normal message delete permission and route. displays always use bot authorship. existing administrator approvals cover only keys 2–66; permission_catalogue:67 is added by saving a new draft, and displays become available only after the installer approves it. displays confer no message-reading, account, command or media powers.

### send a display

POST /chats/{chat}/messages with actor:bot, a required plain-text content fallback and display. send the same display field to POST /chats/{chat}/ephemeral-messages or POST /interactions/{id}/responses. the receipt has has_display:true. the display is stored with its message, and deleting or expiring the message removes its display and retained image references. content remains the searchable, notification and older-client summary; display updates do not create messages, unread activity or repeated notifications.

```json
{
  "actor": "bot",
  "content": "music player · night drive",
  "display": {
    "version": 1,
    "state": {
      "title": "night drive",
      "artist": "example artist",
      "track": "track-1",
      "playback": {
        "position": 30,
        "duration": 240,
        "at": 1792526400000,
        "rate": 1
      },
      "queue": [
        "next song",
        "another song"
      ]
    },
    "styles": {
      "heading": {
        "fontSize": 20,
        "fontWeight": "bold"
      },
      "secondary": {
        "fontSize": 14,
        "color": "muted"
      }
    },
    "actions": {
      "play": {
        "type": "bot",
        "audience": "chat_members",
        "context": {
          "bind": "track"
        }
      },
      "pause": {
        "type": "bot",
        "audience": "chat_members",
        "context": {
          "bind": "track"
        }
      },
      "seek": {
        "type": "bot",
        "audience": "chat_members",
        "context": {
          "bind": "track"
        }
      }
    },
    "root": {
      "id": "player",
      "type": "column",
      "children": [
        {
          "id": "header",
          "type": "row",
          "children": [
            {
              "id": "cover-placeholder",
              "type": "icon",
              "props": {
                "name": "music",
                "label": "music"
              }
            },
            {
              "id": "metadata",
              "type": "column",
              "style": {
                "grow": 1,
                "gap": 4
              },
              "children": [
                {
                  "id": "title",
                  "type": "text",
                  "style_ref": "heading",
                  "props": {
                    "text": {
                      "bind": "title"
                    }
                  }
                },
                {
                  "id": "artist",
                  "type": "text",
                  "style_ref": "secondary",
                  "props": {
                    "text": {
                      "bind": "artist"
                    }
                  }
                }
              ]
            }
          ]
        },
        {
          "id": "seek",
          "type": "slider",
          "props": {
            "label": "playback position",
            "timeline": {
              "bind": "playback"
            },
            "action": "seek",
            "step": 1
          }
        },
        {
          "id": "controls",
          "type": "row",
          "children": [
            {
              "id": "play",
              "type": "button",
              "props": {
                "label": "play",
                "icon": "play",
                "action": "play"
              }
            },
            {
              "id": "pause",
              "type": "button",
              "props": {
                "label": "pause",
                "icon": "pause",
                "action": "pause"
              }
            }
          ]
        },
        {
          "id": "queue",
          "type": "details",
          "props": {
            "label": "up next"
          },
          "children": [
            {
              "id": "queue-list",
              "type": "list",
              "props": {
                "items": {
                  "bind": "queue"
                }
              }
            }
          ]
        }
      ]
    }
  }
}
```

### document and state

a version:1 document contains root, state and actions, with optional reusable styles. every node has a unique stable id and type, plus optional props, children, style, narrow_style and style_ref. identifiers contain 1–64 ASCII letters, digits, underscores or hyphens; reserved prototype keys are invalid. state is a flat map of strings, finite numbers, booleans, null, text lists, text tables or timelines. a property written as {bind:key} reads one state entry. bindings must exist and resolve to the property's expected type. bot actions may bind a scalar context such as the current track ID; the server rejects clicks against obsolete contexts. state is visible to everyone who can see the message, so keep credentials and private backend data on your server.

### available components

layout components can nest up to eight levels. labels are required for controls, tabs, details, icons and progress indicators. strings are rendered as text; markdown uses the existing safe message renderer. command and link actions are supported on buttons. forms submit only when the user explicitly submits them.

| type | properties and behavior |
| --- | --- |
| row, column, grid | children; flex rows wrap by default; grids support up to six columns |
| text, markdown, badge | text; safe formatted text uses markdown |
| image | asset_id, alt, optional fit:cover\|contain; an empty alt marks decorative artwork |
| icon, divider | icon requires name and label; divider has no properties |
| button | label, action, optional icon and disabled |
| select | label, options:text[], action, optional value and disabled |
| toggle | label, value:boolean, action, optional disabled |
| slider | label, action, value/min/max/step or timeline, optional disabled; sends the committed value after dragging or keyboard adjustment |
| input | label, action, optional placeholder and disabled; explicit submit sends up to 4,000 characters |
| progress, timeline | label and value/max or timeline; timeline requires a timeline object |
| list, table | list uses items:text[]; table uses headers:text[] and rows:text[][] with matching widths |
| details | label and children; expansion is local and survives state updates |
| tabs, tab | tabs requires label and 1–12 tab children; each tab requires label and may contain components; keyboard arrows, Home and End select tabs |

### layout and styling

style_ref selects a reusable entry in styles; inline style overrides it. narrow_style overrides both when the display is narrower than 420 pixels. allowed properties are gap/padding/radius (0–48px), fontSize (12–48px), borderWidth (0–4px), width/maxWidth (24–960px or fill), height (24–480px), grow (0–12), columns (up to six values such as 1fr 2fr or auto), align (start, center, end, stretch), justify (start, center, end, space-between, space-around), wrap (wrap, nowrap), textAlign (left, center, right) and fontWeight (normal, medium, bold). color, background, borderColor and gradientTo accept #rrggbb, #rrggbbaa or theme tokens surface, ink, muted, accent, line and transparent. gradientTo creates a diagonal gradient from background. use theme tokens for readable themes and maintain contrast with custom colors. displays stay within the chat width and preserve platform focus indicators. arbitrary CSS, HTML, JavaScript, remote fonts, iframes, browser storage and direct network fetches are not supported. available icon names are play, pause, skip, previous, plus, music, volume, refresh, check, x, heart and link.

### album covers and images

upload images through the existing bot upload flow using actor:bot, purpose:attachment, bot.attachments.send and the same installation and chat. after completion, set the returned asset ID on the image node, directly or through state. PNG, JPEG, WebP, GIF and AVIF are supported, up to 40 million decoded pixels within the normal upload limits. SVG and external image URLs are rejected. retained display images cannot be deleted through the asset API while referenced. display assets are reserved for displays; upload a separate copy to send the image as a normal attachment. image bytes use authenticated audience checks, including private-message expiration and current membership; users' browsers do not fetch arbitrary bot-supplied image URLs.

### partial and realtime updates

GET the display snapshot, then PATCH with If-Match quoting its revision and an Idempotency-Key. send either document for full replacement or operations for atomic patches. the server validates the complete result before committing anything. a conflict returns 412; refetch and recompute the change. snapshots contain document, revision, layout_revision, updated_at, server_time, active and installer. revision changes after every write; layout_revision changes only for structural/property changes or replacement. state patches preserve component IDs and local input, expansion, selected tab and focus. update the textual message separately when its searchable summary changes.

| method and route | result |
| --- | --- |
| GET /chats/{chat}/messages/{id}/display | public display snapshot; no ambient message-read grant required for the installation's own display |
| PATCH /chats/{chat}/messages/{id}/display | atomic public display update |
| GET /chats/{chat}/ephemeral-messages/{id}/display | private display snapshot for the owning installation |
| PATCH /chats/{chat}/ephemeral-messages/{id}/display | atomic private display update |

```json
{
  "operations": [
    {
      "op": "state",
      "values": {
        "title": "next track",
        "track": "track-2",
        "playback": {
          "position": 0,
          "duration": 212,
          "at": 1792526400000,
          "rate": 1
        }
      }
    },
    {
      "op": "props",
      "id": "play",
      "values": {
        "disabled": true
      }
    }
  ]
}
```

### patch operations

state merges values into the state map. props merges values into one node's properties. replace replaces the named node with node; remove deletes a non-root node; insert inserts node into the named parent's children at zero-based index. operations run in order and roll back together on failure. use full document replacement to remove state keys, style definitions, actions or properties. keep bindings valid throughout structural edits and preserve IDs when retaining a control.

```json
{
  "operations": [
    {
      "op": "state",
      "values": {
        "queue": [
          "another song"
        ]
      }
    },
    {
      "op": "insert",
      "id": "player",
      "index": 0,
      "node": {
        "id": "notice",
        "type": "badge",
        "props": {
          "text": "live"
        }
      }
    },
    {
      "op": "replace",
      "id": "notice",
      "node": {
        "id": "notice",
        "type": "text",
        "props": {
          "text": "updated"
        }
      }
    },
    {
      "op": "remove",
      "id": "notice"
    }
  ]
}
```

### realtime clocks and external data

a timeline is {position,duration,at,rate}. position and duration are seconds; at is a Unix timestamp in milliseconds; rate is 0–4, with zero meaning paused. the client interpolates and clamps the position locally from the server clock. update on play, pause, seek, track change or occasional drift correction, not every animation frame. bots consume their own external API, webhook or stream and submit state patches to Sometimes. the browser never runs bot code. updates are coalesced and sent to current viewers; missed or out-of-order revisions trigger a snapshot fetch. reconnect, visibility return and periodic reconciliation refresh access and state. hidden/offscreen displays stop their timers and refresh when visible.

### clicks and bot responses

actions map IDs to {type:bot,audience:installer|chat_members,context?}. a button or input refers to its action ID in props.action. chat_members permits current eligible human members to interact with this bot-owned display; installer restricts the control to the installing user. private bots still require the clicking member to be the owner or invited. the existing interaction.created gateway event has kind:component, actor_id for the actual clicker, installer_id for the installation owner, message_id, component_id, action_id, layout_revision, context and options:{value}. command_id is null. acknowledge within three seconds through POST /interactions/{id}/ack, then update the display or send ordinary interaction responses within 15 minutes. public ACKs/replies require bot.messages.send; private ones require bot.messages.ephemeral. commands is not required for component actions. an ephemeral response goes only to the clicker; its audience is interaction when that person differs from the installer. component clicks never grant access to that person's account, chat history or delegated user actions. bots must authorize the operation represented by each action and treat actor_id as the requesting identity. replay and responses recheck current eligibility, blocks, installation access and grants.

### insert commands and open links

a command action is {type:command,audience,command_id} and must refer to the installation's registered command snapshot. clicking inserts that bot's command into the clicking user's composer without submitting it. their existing draft is saved and can be restored; it returns after successful command submission. the user needs their own eligible installation with commands enabled. input arguments are completed and sent through the normal private command flow. a link action is {type:link,audience,url} with an HTTPS URL without embedded credentials; the link opens only after a click. controls cannot silently navigate, send chat messages or submit commands.

### limits, lifecycle and fallback

documents are at most 32 KiB of UTF-8 JSON with 128 nodes, depth 8, 32 actions and 32 patch operations per request. text values are at most 4,000 characters; labels/alt/placeholder are at most 200; lists/tables have at most 100 rows, 12 table columns and 500 characters per cell. updates are limited to five per second per installation/chat, in addition to normal REST limits. member interactions allow ten per five seconds per member/application. unknown fields, invalid types, unsupported versions and unresolved bindings reject the whole request. paused, revoked, excluded, disabled or inaccessible displays lose live controls. public historical layouts remain readable to eligible chat members; private displays disappear when access ends or they expire. fallback content is shown until a valid layout loads. controls expose keyboard labels, loading/errors and retry without losing draft input. data bound into a display is treated as message content by the normal text filter.

### Sometimes client protocol

these routes use a verified human session, not a bot credential. every request enforces membership and the display audience. POST interactions accepts a unique id, component_id, action_id, layout_revision, context and value; reusing an ID with changed input fails. bot_display_changed on the existing human chat socket contains conversation_id/id and either an invalidation or from_revision, revision, layout_revision, operations, updated_at and server_time. clients apply only contiguous revisions and refetch otherwise; these frames never include hidden private state.

| method and route | result |
| --- | --- |
| GET /api/conversations/{chat}/bot-displays/{id} | authorized snapshot |
| GET /api/conversations/{chat}/bot-displays/{id}/assets/{asset} | authorized image bytes |
| POST /api/conversations/{chat}/bot-displays/{id}/interactions | interaction receipt with id, ack_deadline and expires_at |

### run the music player example

download music-player.mjs and music-display.json into one directory and run with Node.js 24+. configure commands, bot.messages.components, bot.messages.send, bot.messages.edit and bot.messages.ephemeral, register a queue command with one required string option named song, then publish and install. set SOMETIMES_BOT_TOKEN, SOMETIMES_INSTALLATION, SOMETIMES_CHAT and SOMETIMES_QUEUE_COMMAND. for artwork, also authorize bot.attachments.send, then optionally set SOMETIMES_COVER_ASSET to a completed image upload for that chat. run node music-player.mjs. the example creates one player, handles play/pause/seek, queues songs through the command button, persists its message/cursor and reconnects. it models playback state; connect its action handler to your audio service or the existing bot call audio API to actually play music. display permission alone does not stream audio or bypass audio consent.

- [runnable JavaScript music player](https://sometimeschat.com/bot-api/v1/music-player.mjs)
- [music display JSON](https://sometimeschat.com/bot-api/v1/music-display.json)
- [display JSON Schema](https://sometimeschat.com/bot-api/v1/display.schema.json)
- [OpenAPI 3.1 REST schema](https://sometimeschat.com/bot-api/v1/openapi.json)

## commands and interactions

commands are available to their installing user in eligible chats. they do not expose ambient conversation history.

### command definitions

each application can publish up to 100 commands. name is 1–32 lowercase ASCII letters, digits, hyphens or underscores, description is 1–100 visible characters. each command has up to 25 named options of type string, integer, number, boolean, user, message or attachment, with required flags and optional choices. choices are limited to 25. strings are at most 4,000 characters. no arbitrary HTML, scripts, forms or executable components. the command picker shows bot identity to disambiguate names.

### using commands in chat

type / as the first character in the message bar to open the command picker. use the arrow keys and Tab to complete a command, then fill its named options. Escape closes the picker. the invocation and its arguments remain private to the person submitting it; a bot’s public replies appear in the shared chat. a single-chat installation takes precedence over the same user’s account-wide installation, including when commands were declined for that chat.

### invocation routing

only the installing user can invoke that installation’s commands. a different member needs their own installation; another user’s click never borrows the installer’s authority. include installer_id, installation_id, application_id, chat_id, command_id, configuration_revision and validated options in Interaction. user/message/attachment options contain IDs and basic labels only unless their normal read/download grants permit more. command input itself is explicit user-submitted data and needs no ambient message-read grant. the server verifies command existence and scope against the installation’s pinned revision.

### acknowledgement and replies

acknowledge within three seconds with ephemeral:true or false. a missed deadline expires the interaction and shows a user-facing failure. after ACK, one initial response and up to four follow-ups are allowed for 15 minutes from creation; the visibility choice is fixed. public responses need bot.messages.send, private responses need bot.messages.ephemeral, in addition to commands. all responses use bot identity. other actions, including delegated sends, still require their own grants. error messages sent by the platform are not a grant to send arbitrary content.

### command routes

command definition routes are application-level and update the draft; commands must be enabled in the draft configuration. response routes require the interaction’s exact installation header and still-active grants. only responses associated with that interaction can be edited or deleted through its response routes.

| method and path | request | result |
| --- | --- | --- |
| GET /applications/@me/commands | cursor, limit | draft Command list |
| POST /applications/@me/commands | name, description, options[] | 201 Command{id,revision,...} |
| PATCH /applications/@me/commands/{id} | definition patch; If-Match | Command |
| DELETE /applications/@me/commands/{id} | If-Match | 204; remove from draft |
| POST /interactions/{id}/ack | ephemeral: boolean | 204; fixed response visibility |
| POST /interactions/{id}/responses | content, ephemeral | 201 Message; initial then follow-ups |
| PATCH /interactions/{id}/responses/{message} | content; If-Match | MessageReceipt with changed content and revision; bot.messages.edit required |
| DELETE /interactions/{id}/responses/{message} | If-Match | 204; bot.messages.delete required |

### interaction payload

an interaction is not a user session or bearer credential. the application token, installation and live permission checks remain necessary.

```json
{
  "id": "interaction_example",
  "application_id": "app_example",
  "installation_id": "install_example",
  "installer_id": "user_example",
  "chat_id": "chat_example",
  "command_id": "command_hello",
  "configuration_revision": "3",
  "options": {},
  "created_at": "2026-10-20T07:00:00Z",
  "ack_deadline": "2026-10-20T07:00:03Z",
  "expires_at": "2026-10-20T07:15:00Z"
}
```

## messages and private replies

message access is filtered by author, installation scope, history boundary and audience. writing as a user always retains bot attribution.

### message fields

Message has id, chat_id, sequence, revision, kind, author, via_bot, content, created_at, edited_at, reply_to, attachment_ids, audience and expires_at. author is {kind:user|bot,id,display_name,avatar_url}; via_bot is null or {application_id,name}. kind is text, sticker, gif, scribble, attachment, poll, game_invitation or call_notice. unread state and read receipts never appear. optional typed payloads are returned only with their field permissions; use null or omitted fields as documented, not fake empty private data. content is at most 4,000 existing weighted characters. message mutations cannot alter author, audience, kind or chat.

### reading and search

messages.read_self and messages.read_others select the author sets. history permits earlier messages only within those sets; search applies the same filters before ranking or counting. no unfiltered totals, match highlights or quoted parent bodies may leak. a bot may GET a message it created through the same installation using the matching send/edit/delete grant for narrow readback, but this exception does not expose reactions, voters, human replies or attachments without their grants. exact user-authored message reads still require messages.read_self even when editing/deleting is allowed.

### message routes

actor is required for writes and accepts bot or user; the server chooses the corresponding permission and author. recipient_user_id is not accepted. send permissions are 26/48, edit 28/49 and delete 29/50. a supplied reply_to must identify a readable message in the same chat; public replies cannot reference private messages. message deletion also removes associated placements and attachments only when no other authorized reference retains them.

| method and path | request | result |
| --- | --- | --- |
| GET /chats/{chat}/messages | before or after cursor, limit | Message list; author/history-filtered |
| GET /chats/{chat}/messages/{id} | none | Message + ETag |
| GET /chats/{chat}/messages/search | q:1–200 chars, cursor, limit | Message list + permitted snippets; messages.search |
| POST /chats/{chat}/messages | actor, content, reply_to?:ID | 201 Message |
| PATCH /chats/{chat}/messages/{id} | actor, content; If-Match | MessageReceipt{id,content,revision,edited_at}; owner-only |
| DELETE /chats/{chat}/messages/{id} | actor in JSON; If-Match | 204; owner-only |
| POST /chats/{chat}/ephemeral-messages | content, expires_in?:60–86400 seconds | 201 private Message; permission 27 |
| PATCH /chats/{chat}/ephemeral-messages/{id} | actor:bot, content; If-Match; bot.messages.edit | private MessageReceipt with expires_at |
| DELETE /chats/{chat}/ephemeral-messages/{id} | actor:bot; If-Match; bot.messages.delete | 204; only this installation’s private message |
| POST /dm | none | 200 Chat; permission 7; idempotent reopen/create |
| GET /chats/{chat}/pins | cursor, limit | Pin list filtered to readable messages |
| PUT /chats/{chat}/pins/{message} | actor | Pin; permission 38/60 |
| DELETE /chats/{chat}/pins/{message} | actor in JSON; If-Match | 204; remove only actor’s pin |
| PUT /chats/{chat}/messages/{id}/reactions/{emoji} | actor | own Reaction receipt; permission 39/61 |
| DELETE /chats/{chat}/messages/{id}/reactions/{emoji} | actor in JSON | 204; remove only actor’s reaction |

### ephemeral audience and lifetime

ephemeral messages target only the installation owner, in a chat they can currently access within scope or their permitted bot DM. they use a separate audience-bound store with a default one-hour TTL and a maximum 24-hour TTL. they synchronize to that user’s signed-in devices until expiry or dismissal. show a faint background and the exact faint footer “(only visible to you)”, with accessible text contrast. they never appear in other users’ fanout, search, previews, unread counts, push payloads, exports or quoted messages. they cannot be pinned, reacted to, forwarded, polled or used as scribble anchors. v1 ephemeral content is text/Markdown only; no attachments or embedded remote previews. expiry is checked during reads even before physical cleanup.

### ownership and attribution

bot identity is stable per application, with a blue bot tag beside the display name wherever it appears. writes also store originating installation ID. bot edit/delete grants are limited to that installation’s resources. user edit/delete grants cover the installer’s own ordinary messages, including manually written ones. system notices and temporary special editing privileges are never exposed. delegated actions show “via [bot]” in message details or the relevant activity surface; edits retain author identity and show bot edit attribution. no API can erase this provenance.

### pins and reactions

Pin is {id,message_id,actor,installation_id?,created_at,revision}; actor contains kind and id. each actor may own a pin association; removing that association does not remove another actor’s pin. Reaction is {message_id,emoji,actor,active,revision}; one application identity has one visible reaction per emoji, with separate installation contributions. removing one contribution leaves the reaction visible while another contribution remains. aggregate reaction views filter identities through normal block rules.

## attachments, stickers and GIFs

upload permission, permission to send an asset and permission to download received content are separate checks.

### upload flow

create an upload with actor, purpose, filename, content_type and size. purpose is attachment, sticker, avatar, banner or group_icon. the server reserves quota, returns an upload ID and part count, and accepts raw bytes through authenticated PUT requests. parts are 10 MiB except the last. complete with ordered part numbers and their returned ETags. exact length, actual media signature, ownership, grant and scope are checked before committing. expiry is 15 minutes. cancellation and failure free concurrent upload slots and clean orphaned bytes; reserved bytes still count toward the rolling 24-hour upload budget. arbitrary URL ingestion is not supported.

### asset and content types

Asset is {id,purpose,owner_kind,content_type,size,filename,width,height,status,created_at}; status is uploading, ready or failed. attachments allow the app’s supported image/video types plus application/octet-stream, with a maximum 150 MiB. unknown file types download as attachments with nosniff and safe filenames. stickers allow PNG, JPEG, GIF and WebP up to 20 MiB. avatars, banners and group icons accept validated image assets; avatar/icon maximum 5 MiB, banner maximum 20 MiB, maximum decoded area 40 megapixels. profile writes reference ready assets of the correct purpose; assets never become bearer access to an entire library.

### upload and send routes

upload grants depend on purpose: attachment 35/56, bot sticker 32, user-library sticker 25, profile assets 20, group icon 41/65. chat-bound uploads include chat_id. attaching an asset requires the same installation and actor that uploaded it; user-owned uploaded stickers are selected through the sendable library instead.

| method and path | request | result |
| --- | --- | --- |
| POST /uploads | actor, purpose, chat_id?, filename, content_type, size | 201 {id,part_size,part_count,expires_at} |
| PUT /uploads/{id}/parts/{number} | raw bytes; Content-Length; installation context | {part_number,etag} |
| POST /uploads/{id}/complete | parts:[{part_number,etag}] | 201 Asset |
| DELETE /uploads/{id} | none | 204; cancel/reservation cleanup |
| POST /chats/{chat}/attachments | actor, asset_id, caption?:string | 201 Message; permission 35/56 |
| GET /chats/{chat}/attachments/{id}/content | optional single Range header | bytes; permission 11 + readable containing message |
| GET /stickers/sendable | actor, cursor, limit | eligible Sticker list; permission 32/53 |
| POST /chats/{chat}/stickers | actor, sticker_id or ready asset_id | 201 Message; permission 32/53 |
| GET /gifs/search | actor, q:1–100 chars, cursor, limit | resolved Gif list; permission 33/54 |
| POST /chats/{chat}/gifs | actor, gif_id | 201 Message; permission 33/54 |
| GET /me/stickers | cursor, limit | Sticker list; permission 25 |
| POST /me/stickers | ready sticker asset_id, name:1–40 chars | 201 Sticker; permission 25 |
| DELETE /me/stickers/{id} | If-Match | 204; permission 25; remove library association |

### sticker ownership

Sticker is {id,name,owner_id,uploaded,content_type,size,thumbnail_url,revision}; sendable views provide an authenticated thumbnail route restricted to that purpose. as a bot, the bot can send any sticker currently in the installer’s library, including adopted stickers, or upload its own application-owned sticker. as you, it can select only stickers uploaded by the installer and still in that library. sticker-library management consumes the user’s existing quota of 25 active uploaded stickers and cannot bypass uploaded ownership by adopting a bot asset. deleting a library association does not erase copies retained by existing messages or other users.

### download access and GIF sources

attachment content is streamed through an authenticated authorization check, including range requests; no permanent public signed URL bypasses revocation. thumbnail and profile-asset routes use equivalent audience-aware checks. Gif is {id,provider,width,height,preview_url,expires_at}; only server-resolved supported-provider IDs are accepted for sends, with their existing moderation rules. provider preview URLs are public catalogue media, never private attachment URLs. uploaded bot stickers count against an application quota of 100 assets and 200 MiB, separately from the installer’s quota.

## scribbles and placements

drawing reads and writes use the same chat scope as messages. ownership is checked per stroke or placement, never inferred from group ownership.

### stroke representation

Scribble contains id, chat_id, actor, installation_id for bot-owned work, anchor_sequence, canvas_width, tool, color, size, points, draw_order, erasures, created_at and revision. tool is pen, pencil or highlighter. points are finite [x,y] coordinates normalized by canvas width; x is 0–1, y is the vertical distance divided by canvas width. anchor_sequence identifies an eligible visible ordinary message or the chat’s zero anchor. use existing drawing bounds, brushes and validators; arbitrary paths or CSS are rejected.

```json
{
  "actor": "bot",
  "anchor_sequence": "0",
  "canvas_width": 800,
  "tool": "pen",
  "color": "#3e5bb0",
  "size": 3,
  "points": [
    [
      0.1,
      0.1
    ],
    [
      0.2,
      0.2
    ]
  ],
  "draw_order": 1
}
```

### drawing routes

GET requires scribbles.read. creation and replacement use 30/51. erase uses 31/52 and checks every referenced stroke before applying anything. updates preserve the author and target chat. batch erasure is atomic and rejects mixed ownership without partial changes. a bot cannot erase another application’s or human’s artwork.

| method and path | request | result |
| --- | --- | --- |
| GET /chats/{chat}/scribbles | cursor, limit | Scribble list |
| POST /chats/{chat}/scribbles | actor + stroke fields | 201 Scribble |
| PATCH /chats/{chat}/scribbles/{id} | actor, points?, color?, size?; If-Match | changed fields, id and revision; same owner |
| POST /chats/{chat}/scribbles/erase | actor, removed_ids[], cuts:[{stroke_id,size,points}], revisions | {removed_ids,cuts,revision} |
| POST /chats/{chat}/scribbles/preview | actor, preview_id, stroke, revision | 204; transient preview, five-second expiry |
| GET /chats/{chat}/placements | cursor, limit | Placement list; permission 6 or readable parent |
| PATCH /chats/{chat}/placements/{id} | actor, anchor_sequence?,canvas_width?,x?,y?,width?,height?,rotation?,order?; If-Match | Placement; permission 34/55 |
| DELETE /chats/{chat}/placements/{id} | actor in JSON; If-Match | 204; permission 34/55 |

### drawing limits

at most 1,200 points per stroke, 100 strokes/cuts per erasure request and 256 KiB JSON per drawing request. GET /limits exposes the brush, coordinate and color constraints. previews are limited to 10 per second per installation/chat and never persisted. deleting a message anchor uses the app’s normal relocation behavior. erasure returns an opaque batch revision; each affected stroke retains its own numeric revision for subsequent edits.

### placement representation

Placement has id, chat_id, message_id, kind, actor_id, anchor_sequence, canvas_width, x, y, width, height, rotation, order and revision. kind is sticker or gif. coordinates and dimensions are normalized by canvas width; width and height must also fit the 16–1,440 pixel bounds. rotation is degrees modulo 360. sending a sticker/GIF creates its placement through the normal app layout. dragging changes geometry; removal deletes the owned placed message. profile and nameplate artwork use profile endpoints.

## polls and game starts

bots can create and close their own polls and start games. voting is available only through an installing user’s explicit permission.

### poll contract

Poll has id, message_id, title, options, multiple, public_votes, closes_at and revision. title is 1–200 weighted characters; 2–20 options each have a string ID and 1–100 weighted characters of text. duration_seconds is 60–2,592,000. a result read adds closed, voters (the total count), winner_ids and each option’s count. option voters are basic identities only when public_votes is true. anonymous results omit individual selections. voting returns only the installer’s own_option_ids and the new revision.

### poll routes

creating a poll does not require plain-message send. closing requires matching author ownership. voting accepts option_ids as a full replacement, an empty list to clear, and at most one ID when multiple is false. repeated identical writes are idempotent. poll-result permission does not authorize voting, and bot actor is always rejected on the vote route.

| method and path | request | result |
| --- | --- | --- |
| POST /chats/{chat}/polls | actor,title,options:string[],multiple,public_votes,duration_seconds | 201 Poll + message_id; permission 36/57 |
| GET /chats/{chat}/polls/{id} | none | Poll/results; permission 12 + readable message |
| POST /chats/{chat}/polls/{id}/close | actor; If-Match | Poll close receipt; permission 37/58 |
| PUT /chats/{chat}/polls/{id}/vote | actor:user, option_ids[]; If-Match | {poll_id,own_option_ids,revision}; permission 59 |

### starting games without joining

GET /games lists currently playable published games and their public start-option schema for installations with bot.games.start. POST /chats/{chat}/games accepts game_id and validated options and returns {game_session_id,chat_id,game_id,status,message_id,player_count}. starting a game emits one attributed invitation and honors normal invite/mute rules. without an active call, create a lobby with zero players; the installer is not silently joined. with an active call, apply the same eligible-human autojoin policy as a normal game start. exclude all bot participants and enforce the game’s human capacity.

### game lifecycle

the first human to join a zero-player lobby becomes host under the normal game rules. preserve a pending lobby before invoking game rules that require a player; initialize those rules when the first eligible human joins. empty lobbies expire after 10 minutes and create no score/save entries. if a live game already exists, return 409 game_already_active with its ID only when visible; never replace it. the API offers no player join, input, signal, state projection, save, scoring or game-stop route for bots. a bot that launches games cannot inspect hidden game state or impersonate a human player through another permission.

## calls and individual media streams

bots appear as visible call participants with a blue bot tag. REST negotiates access and SDP; WebRTC carries separate participant media tracks.

### call admission

starting, joining, speaking, receiving audio, receiving camera video, receiving screen video and publishing video are distinct grants. start creates or returns a call but does not join. join_with_user requires the installer to be a current participant and disconnects when they leave, unless join_all remains granted. join_all still requires an in-scope call the installer is eligible to join. private call guest access never becomes chat access. one active call session per installation and one participant per application/installer/call are allowed; show “bot · installed by [name]” to make distinct installations visible.

### individual tracks

Track is {id,participant_id,source,kind,revision,active}; source is microphone, camera, screen or screen_audio, and kind is audio or video. audio.receive exposes each permitted microphone as a separate track and screen_audio only when screen.receive is also granted. camera and screen subscriptions require their own receive grants. track listings and events omit unauthorized types. a bot receives no mixed call recording, transcription or hidden channel. participants see the bot’s receiving/publishing indicators before tracks flow; joining a call with a bot displays the same notice. user mute and source-stop state remain effective.

### WebRTC negotiation

POST a session to join the call and receive {id,participant_id,ice_servers,capabilities,expires_at,revision}. capabilities advertises available audio/video codecs and transceiver limits; baseline audio is Opus and video negotiates VP8 or H.264 from the current service. the developer supplies a standards-compliant WebRTC implementation, not an assumed Node built-in media stack. the existing Sometimes SFU is the transport target, but provider app secrets and other participants’ provider session IDs stay behind the API. use a single serialized SDP operation queue per session.

### publishing and subscribing

for publish, create local transceivers and an SDP offer, wait for ICE gathering to complete, then submit {description:{type:offer,sdp},tracks:[{mid,source}]}; source may be microphone or camera only. the service returns {description:{type:answer,sdp},tracks,revision}; apply that answer. for subscriptions, submit remote track IDs; the service returns an SDP offer plus mappings of mid to track_id. apply it, create an answer, wait for ICE gathering and PUT that answer to /answer. received ontrack media is associated through mid, never by guessing stream order. no trickle-ICE endpoint is offered in v1; offers/answers carry gathered candidates. SDP is limited to 64 KiB. answer within 15 seconds or recreate the session.

### call routes

all routes carry the installation header. session IDs and track IDs are installation-bound. discovery includes only eligible calls. call invites use the existing participant proposal/vote/acceptance rules and never force someone into a call. session DELETE always permits cleanup by its creating installation even if a media grant was revoked; credentials must remain valid.

| method and path | request | result |
| --- | --- | --- |
| GET /calls | cursor, limit; start or join grant | eligible Call list, including authorized guest calls |
| GET /chats/{chat}/call | none; start or join grant | Call{id,chat_id,status,started_at,participants} or null |
| POST /chats/{chat}/calls | video?:boolean; permission 42 | 201 Call, or 200 for an existing call; no implicit join |
| POST /calls/{call}/sessions | {}; permission 44 or 45 | 201 Session + ICE/capabilities |
| POST /calls/{call}/sessions/{session}/heartbeat | none | {expires_at}; renew 75-second lease |
| GET /calls/{call}/sessions/{session}/tracks | none | authorized Track list |
| POST /calls/{call}/sessions/{session}/publish | description + tracks:[{mid,source}]; If-Match; permission 43/46 | SDP answer + Track list |
| POST /calls/{call}/sessions/{session}/subscriptions | track_ids[]; If-Match; permissions 8/9/10 | SDP offer + {mid,track_id} mappings |
| PUT /calls/{call}/sessions/{session}/answer | description:{type:answer,sdp}; If-Match | 204; completes pending negotiation |
| DELETE /calls/{call}/sessions/{session}/tracks/{track} | If-Match | 200 Negotiation with revision; apply any returned offer and answer it |
| DELETE /calls/{call}/sessions/{session} | none | 204; leave and release SFU resources |
| POST /calls/{call}/invitations | user_ids[]; permission 47 | Invitation{id,status,expires_at} |
| GET /calls/{call}/invitations/{id} | none; permission 47; own proposal | Invitation |

### leases, revocation and recovery

send call heartbeat every 20 seconds; a stale session expires after 75 seconds independently of gateway health. call end, membership loss, block, group exclusion, uninstall or private invite removal force-close applicable tracks and participant sessions. permission loss closes only now-forbidden tracks unless join authority is also lost. revalidate at negotiation commit and never accept client-supplied foreign SFU IDs. ICE failure recreates the session with a new ID and authorized subscriptions; duplicate starts with the same idempotency key return the original receipt rather than new participants. a bot cannot end a human call, mute/kick another participant or publish screen tracks.

## profiles and settings

profile changes cover the complete Profile page except username. settings changes exclude profile, testing, stickers and account.

### profile fields

GET /me/profile needs profile.read_self; GET /profiles/{user} needs the matching self/others read grant and ordinary profile visibility. PATCH /me/profile needs profile.write and returns changed fields plus id and revision. a write-only bot can get its concurrency revision from /me/profile/revision without reading the profile. writable fields are display_name, about, pronouns, avatar_asset_id, banner_asset_id, background_gradient, profile_art, nameplate_name_color, nameplate_background_color, online_indicator_color, nameplate_art and announcement_pack. null clears optional photo/banner/art/color fields. username, email, phone, badges, roles, points and counters are rejected even under administrator.

### profile validation and artwork

display_name follows the current 20-character weighted limit; about is at most 200 weighted characters and six lines; pronouns are at most 40 weighted characters. background_gradient is {top,bottom} with validated #rrggbb colors. nameplate and indicator colors use the existing palette IDs or theme default. announcement_pack is an available catalogue ID (the empty string selects the default pack). artwork uses the existing ProfileArt v2 schema: strokes plus positioned emoji/sticker items; profile canvas is 428×549 within a 440×561 card, nameplate canvas is 280×64. profile limits are 1,500 strokes/40 items/180,000 total points/1,500,000 encoded characters; nameplate limits are 400 strokes/8 emoji-only items/24,000 total points/240,000 characters. profile writes allow 1,756,384 JSON bytes; other JSON requests retain their normal cap. preserve current palette, geometry, safety and ownership validation. modifying a profile never downloads private sticker bytes without its corresponding access.

### allowed settings

GET /me/settings and PATCH /me/settings require settings.write. expose only allowlisted setting fields; do not return reports, contact details, sessions, export data or account/security controls. account settings include online/typing visibility, incoming friend/shared-group DM preferences, quiet-hour schedule and timed pause. appearance includes theme and current account appearance preferences. instance settings include push/in-app/category alerts and previews, ringtone, text size/spacing, motion, autoplay/load-media/subtle-drawing preferences, enter-to-send, quick reaction/emoji preference where supported, audio mode, device choices, join-muted, feedback sounds, volume and own-camera-preview preference. GET /limits lists every supported account and instance field with its constraints.

### device-specific settings

instance-only changes require an explicit opaque target_id obtained from GET /me/settings/targets. targets expose a device label and known device_choices, never session tokens, IP addresses or raw hardware IDs. only already-known device choices can be selected; bots cannot trigger microphone/camera capture or OS permission dialogs. account writes do not overwrite every device implicitly. removed targets return 404; changing theme does not change profile artwork. blocks require user.blocks.write, chat mutes require chats.mute and friend-request actions need their own grants.

### profile and settings routes

profile and settings patches are atomic, use If-Match, retain existing cooldowns and return field errors without partial saves. GET /limits provides supported field enums, numerical bounds and current remaining quota, so a developer can validate before making a write.

| method and path | request | result |
| --- | --- | --- |
| GET /me/profile | none; permission 18 | Profile + ETag |
| GET /profiles/{user} | none; permission 18/19 | visible Profile + ETag |
| PATCH /me/profile | allowlisted Profile patch; If-Match; permission 20 | changed fields + id + revision |
| GET /me/profile/revision | permission 20 | {id,revision}; no profile contents |
| GET /profiles/{user}/assets/{asset} | profile access grant | authorized image/art bytes |
| GET /announcement-packs | permission 20 | {id,name} catalogue; no private account data |
| GET /me/settings | target_id?; permission 21 | allowed settings + ETag |
| GET /me/settings/targets | cursor, limit; permission 21 | opaque settings target IDs + labels + permitted values |
| PATCH /me/settings | target_id? and allowlisted patch; If-Match; permission 21 | changed settings + revision |
| GET /chats/{chat}/mute | permission 22 | {muted,revision} |
| PUT /chats/{chat}/mute | muted:boolean; If-Match; permission 22 | {muted,revision} |

## groups, friends and presence

social actions use the installer’s relationships and authority. group ownership never expands access to other members’ private accounts.

### group operations

bot.groups.create creates a group attributed to the bot while assigning human ownership to the installer. include the installer automatically; invited members must satisfy existing friendship, blocking and capacity rules. a single-chat installation does not gain access to the new group automatically: return its creation receipt, then the user can authorize a new installation there. existing group edits and membership changes require both the matching grant and current installer ownership at commit. ownership transfer, group destruction and group bot exclusions are human-only in v1.

### group and relationship routes

FriendRequest is {id,sender,recipient,direction,created_at,revision}; basic identities contain no contact details. accepting/denying requires a request actually addressed to the installer. cancellation applies only to the installer’s outgoing request. block changes immediately affect message/event/media authorization; the response is a mutation receipt, not a full block-list export.

| method and path | request | result |
| --- | --- | --- |
| POST /groups | name:1–48 chars, member_ids[], icon_asset_id?; permission 41 | 201 Group{id,name,owner_id,created_by_bot,revision} |
| GET /groups/{group}/members | cursor, limit; permission 15 | basic member identities + owner flag |
| PATCH /groups/{group} | name?,icon_asset_id?,members_locked?; If-Match; permission 65 | Group mutation receipt |
| PUT /groups/{group}/members/{user} | If-Match; permission 66 | 204; normal invitation constraints |
| DELETE /groups/{group}/members/{user} | If-Match; permission 66 | 204; cannot remove owner |
| GET /me/friends | cursor,limit; permission 16 | accepted friend basic identities |
| GET /me/friend-requests | direction:incoming\|outgoing\|both, cursor,limit; permission 17 | FriendRequest list |
| POST /me/friend-requests | user_id; permission 62 | 201 FriendRequest receipt |
| DELETE /me/friend-requests/{id} | permission 62; outgoing only | 204; cancel own request |
| POST /me/friend-requests/{id}/respond | decision:accept\|deny; permission 63 | request_id,status,revision |
| PUT /me/blocks/{user} | permission 64 | 204; block as installer |
| DELETE /me/blocks/{user} | permission 64 | 204; unblock as installer |
| GET /presence | user_ids:up to 100; permission 13 | eligible {user_id,online,expires_at} only |

### addressing users

bots use stable IDs obtained from granted group/friend/request/profile surfaces or explicit command user selections. GET /profiles/resolve?username=HANDLE requires profile.read_others and returns a basic identity only if the installer could normally view that account; it is not a global account directory. human developer invite-list editing uses the dashboard’s own user picker. invalid or hidden identities return not_found without differentiating the reason.

### presence and typing privacy

presence reads are limited to eligible friends, members of authorized chats or permitted bot DM peers. privacy opt-outs yield no online state; do not report a hidden user as offline. only online boolean and lease expiry are exposed, never device count, activity timestamps or last-seen history. typing is delivered only as transient gateway events and expires in five seconds. bots cannot publish human typing events, extend human presence, suppress human notifications by sending activity heartbeats or mark a conversation as read.

### notifications to the installer

POST /notifications requires notifications.send and accepts {title,body,chat_id?,message_id?}. title is at most 80 characters, body at most 240; destination must be a currently authorized Sometimes resource. no arbitrary URL, recipient ID or push token is accepted. return {id,accepted:true} without delivery/read status. obey quiet hours, per-device categories, preview privacy and chat mutes before queueing and again before delivery. notifications containing message data require that message to remain readable. mute and notification grants cannot bypass OS permissions or spam limits.

## field schemas and examples

these examples define the writable data shapes. unknown fields reject the request; server-owned identity, provenance and grant fields cannot be supplied by the bot.

### settings patch shape

PATCH /me/settings accepts {target_id?,patch}. without target_id, patch may contain only account fields. with target_id, it may contain only instance fields. both GET and PATCH return {target_id,values,revision}; GET returns the full permitted projection, PATCH only changed values. omission leaves a setting unchanged. account fields are shareOnline, shareTyping, allowFriendRequests, allowGroupDMs, quietEnabled, quietStart, quietEnd, quietDays, quietTimezone, quietAllowCalls, pausedUntil, theme, emojiSkinTone and doubleClickEmoji. instance fields are timezone, push, inApp, ringtone, messages, reactions, friendRequests, calls, preview, audioMode, microphone, camera, output, joinMuted, callSounds, gameSounds, soundVolume, followSystemTheme, textSize, spacing, motion, autoplay, loadMedia, subtleDrawings, enterSends and showMyCameraPreview. changing a privacy preference does not add another bot grant.

```json
{
  "target_id": "settings_target_example",
  "patch": {
    "soundVolume": 60,
    "audioMode": "studio",
    "joinMuted": true
  }
}
```

### setting values

boolean controls accept true/false only. quietStart/quietEnd use HH:mm; quietDays contains unique integers 0–6; scheduled quiet hours use the account’s quietTimezone; pausedUntil is null or an RFC 3339 timestamp at most 30 days ahead. timezone is an IANA name. textSize is an integer 12–22 and soundVolume 0–100. preview is full|sender|hidden; audioMode noise-cancelling|studio; spacing comfortable|compact; motion system|reduce|full; enterSends auto|send|newline. microphone/camera/output are known opaque device-choice IDs or the empty default. theme, emojiSkinTone and announcement-pack IDs come from limits/catalogues; doubleClickEmoji is one supported emoji grapheme. server-only account security fields and unlisted settings are invalid_request. profile artwork, library actions and blocks remain separate endpoints.

### profile patch shape

the v1 profile API maps about to the app’s bio field and maps palette IDs to existing stored color values. palette IDs are red, orange, yellow, lime, green, teal, blue, purple, pink and gray; null uses the theme default. profile_art and nameplate_art replace their respective complete validated artwork values, guarded by If-Match. profile reads include normal visible fields only and never reveal private account or testing administration data.

```json
{
  "display_name": "Avery",
  "about": "making things",
  "pronouns": "they/them",
  "avatar_asset_id": "asset_avatar",
  "banner_asset_id": null,
  "background_gradient": {
    "top": "#ccd9cc",
    "bottom": "#e8dfce"
  },
  "nameplate_name_color": "blue",
  "nameplate_background_color": null,
  "online_indicator_color": "green",
  "announcement_pack": ""
}
```

### profile artwork shape

artwork is {version:2,strokes,items}. stroke fields are id,anchor_seq,canvas_width,tool,color,size,points,draw_order and optional erasures. artwork stroke IDs and item IDs are UUIDs, unlike opaque resource IDs elsewhere. anchor_seq must be zero. size is 1–24 reference-canvas pixels; point coordinates are normalized by canvas width. item fields are id,kind,value,x,y,width,height,rotation,order, with positions and sizes in canvas pixels. kind is emoji or sticker; nameplate items allow only emoji. nameplate stroke colors use canonical six-digit palette values or opaque #rrggbbff for fixed sampled colors. erasures contain {id,size,points}; all counts, bounds and retained asset ownership use the existing ProfileArt v2 validator.

```json
{
  "nameplate_art": {
    "version": 2,
    "strokes": [
      {
        "id": "00000000-0000-4000-8000-000000000001",
        "anchor_seq": 0,
        "canvas_width": 280,
        "tool": "pen",
        "color": "#3e5bb0",
        "size": 3,
        "points": [
          [
            0.1,
            0.1
          ],
          [
            0.2,
            0.15
          ]
        ],
        "draw_order": 1
      }
    ],
    "items": []
  }
}
```

### limits response

GET /limits returns {api_version,catalogue_version,limits,fields,quotas}. limits contains the named numeric buckets from the limits chapter; fields contains allowed enums and min/max bounds for settings, profile, artwork, media, group capacity and text; quotas contains only the current installer/application’s relevant counters. it is discovery metadata, not authority to perform an action. the example below shows selected fields from the complete response. quota entries appear only for granted capabilities; cancelled and failed uploads still consume the daily byte budget.

```json
{
  "api_version": 1,
  "catalogue_version": 1,
  "limits": {
    "rest_application": {
      "capacity": 100,
      "refill": 50,
      "period_ms": 1000
    },
    "uploads": {
      "concurrent": 3,
      "daily_bytes": 524288000,
      "part_bytes": 10485760,
      "lifetime_ms": 900000
    }
  },
  "fields": {
    "profile": {
      "display_name": {
        "type": "string",
        "x-weighted-max-length": 20,
        "x-weighted-min-length": 1,
        "description": "length uses sometimes weighted textLength; UTF-16 maxLength is not equivalent."
      },
      "about": {
        "type": "string",
        "x-weighted-max-length": 200,
        "x-weighted-min-length": 0,
        "description": "length uses sometimes weighted textLength; UTF-16 maxLength is not equivalent.",
        "x-max-lines": 6
      }
    },
    "instance_settings": {
      "textSize": {
        "type": "integer",
        "minimum": 12,
        "maximum": 22
      }
    }
  },
  "quotas": {
    "user_stickers": {
      "used": 3,
      "limit": 25,
      "remaining": 22
    }
  }
}
```

### machine-readable schemas

download the OpenAPI 3.1 document for REST request and response schemas, and the gateway JSON Schema for client frames, server frames and event payloads. both reuse the field bounds returned by /limits and are checked against actual Worker requests and responses. media signaling uses the REST SessionDescription, Track and Negotiation schemas; WebRTC transports the media. unknown request fields and unsupported actors are rejected.

- [OpenAPI 3.1 REST schema](https://sometimeschat.com/bot-api/v1/openapi.json)
- [gateway JSON Schema](https://sometimeschat.com/bot-api/v1/gateway.schema.json)
- [permission catalogue JSON](https://sometimeschat.com/bot-api/v1/permissions.json)

### additional resource routes

these routes complement the detailed resource chapters. use the OpenAPI document for complete response shapes, headers and field constraints.

| method and path | request | result |
| --- | --- | --- |
| POST /gateway/tickets | application token; {} | {url,ticket,expires_at}; no Idempotency-Key required |
| GET /stickers/{id}/content | actor:bot\|user; matching sticker send grant | authenticated bytes for a sendable sticker |
| GET /me/stickers/{id}/content | stickers.manage | authenticated bytes from your uploaded library |
| GET /games | cursor,limit; bot.games.start | Game catalogue with start_options_schema |
| POST /chats/{chat}/games | game_id, options:{}; bot.games.start | 201 GameStarted receipt; no player membership |
| GET /profiles/resolve | username; profile.read_others | visible basic identity |
| GET /identities/{user}/avatar | installation context; accessible identity | avatar thumbnail without full profile access |
| GET /groups/{group} | authorized group scope | Group including revision; no member roster |
| POST /notifications | title,body,chat_id?,message_id?; notifications.send | 202 {id,accepted:true}; no delivery receipt |

### dashboard and app integration routes

these additional routes use human authentication and the app’s CSRF protection. bot tokens cannot use them. dashboard changes require the application If-Match revision; image reads apply profile visibility rules. the normal dashboard, installation page and message bar call these routes for you.

| method and path | request | result |
| --- | --- | --- |
| PUT /api/developers/bots/{app}/banner | image bytes up to 20 MiB; If-Match | updated draft Application |
| GET /api/developers/bots/{app}/resolve | username | eligible invite account identity |
| GET /api/developers/bots/{app}/commands | cursor,limit | draft Command list |
| POST /api/developers/bots/{app}/commands | name,description,options; If-Match | 201 Command |
| PATCH /api/developers/bots/{app}/commands/{id} | definition patch; If-Match | Command |
| DELETE /api/developers/bots/{app}/commands/{id} | If-Match | 204; remove draft command |
| GET /api/bot-installs/authorize/{app}/chats | cursor,limit | eligible chat choices |
| GET /api/conversations/{chat}/bot-commands | none | commands from the user’s preferred installations |
| POST /api/conversations/{chat}/bot-interactions | id,installation_id,command_id,configuration_revision,options | 201 invocation receipt, or 200 for an identical retry |
| GET /api/bot-interactions/{id} | none; invoking user | pending, acknowledged, responded or expired status |
| GET /api/conversations/{chat}/bot-ephemeral-messages | before?,limit | installer-only private replies |
| DELETE /api/conversations/{chat}/bot-ephemeral-messages/{id} | none | 204; dismiss your private reply |
| GET /api/bot-images/{app}/{asset} | human profile visibility | avatar or banner bytes |

## limits and reliability

these limits apply to API 1. inspect GET /limits and response headers to adapt to your installation’s current allowance.

### rate limits

limits apply simultaneously at application, installation, user and resource levels. token rotation and multiple installations cannot bypass an application or user quota. return RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset and RateLimit-Bucket headers; 429 additionally includes Retry-After in seconds and retry_after_ms in the error. use returned values rather than relying on wall-clock alignment. GET /limits returns current limits, permitted field enums and remaining storage quotas for the current installation without unrelated account data.

| bucket | initial limit | scope |
| --- | --- | --- |
| REST requests | 50/second, burst 100 | application |
| REST requests | 10/second, burst 20 | installation |
| message-like writes | 5 per 5 seconds, burst 5 | application + installer + chat; includes media/polls/private replies |
| notifications | 5/hour and 20/day | application + installer |
| friend requests / group invitations | 10/hour | application + installer, plus existing account limits |
| call starts / invitations | 5 per 10 minutes | application + installer + chat |
| drawing previews | 10/second | installation + chat |
| JSON request | 256 KiB; profile writes 1,756,384 bytes; SDP 64 KiB | request |
| gateway inbound frame | 64 KiB; 120/minute except heartbeat ACK and event ACK | application connection |
| simultaneous uploads | 3 | installation; reservation-aware |
| uploaded bytes | 500 MiB/day | application + installer, plus asset/user quotas |
| active call sessions | 1 per installation; 20 per application | v1 self-service default |
| bot applications / private invites | 10 per developer / 1,000 per application | owner/application |
| installations | 100 active per user; 10,000 per application | v1 self-service default |

### storage and event budgets

replay stores at most 10,000 events per installation and 100,000 per application for 24 hours, with a 64 KiB event payload cap. oversized changes emit {resource_id,revision,fetch_required:true} instead of the body; fetching still requires current grants. private message bodies expire at their TTL, independent of replay. idempotency receipts last 24 hours; never store secrets or unredacted private content in request logs. activity metadata lasts 90 days and contains action, actor, application, resource IDs, timestamp, result and changed field names, not old/new message or profile bodies.

### retry and conflict handling

retry network failures, 429 and 503 with bounded exponential backoff and jitter, honoring Retry-After. retain the original idempotency key for uncertain POST outcomes. do not automatically merge a 412 by overwriting a user’s more recent edit; reread and reconcile only intended fields. installation revocation is permanent until a new human authorization. transient gateway interruption does not grant permission to repeat non-idempotent actions.

### abuse handling

publication has no approval queue, but existing platform content rules, account restrictions and abuse controls still apply. users can block, disable or uninstall bots; group owners can exclude them; platform staff can disable abusive applications after publication. grant checks are enforced server-side even when clients are old or malicious. rate-limit reservations and upload quotas apply across concurrent tokens. developers cannot buy or declare their way around consent in an install URL.

### versioning and compatibility

API version 1 is selected by /api/bots/v1 and the gateway ready frame. nonbreaking additive fields may appear; clients ignore unknown response fields and event types. requests reject unknown fields. removing fields, changing grant semantics or expanding administrator requires a new API/catalogue version and migration plan. announce breaking retirement at least 90 days ahead and keep old consent snapshots interpretable. the four quickstart languages call this public contract directly; no official bot SDK is required.

## running your bot

deploy your bot on your own server, protect its credentials and keep its approved installations working as you publish changes.

### credentials and deployment

keep the bot token in your hosting provider’s secret store. the dashboard reveals it once and lists its prefix and last use afterward. create a replacement, deploy it, reconnect the gateway and revoke the old token. rotating a token does not require users to reinstall. run one gateway consumer per application and distribute its work inside your service. use process supervision to restart after a closed connection; keep durable cursors and idempotency keys for recovery.

### publishing configuration changes

save profile, permission, scope and command changes in the developer dashboard, then publish. name, profile picture, banner and about-me changes update existing installations immediately. grants, commands and scope remain pinned to each user’s approved revision. users review the new configuration from settings → bots; old approvals keep working until renewed consent. unpublish prevents new installations while preserving existing ones. disable stops runtime access and hides commands; delete revokes credentials and installations.

### testing with a private bot

keep a development bot private and add test accounts through the invite picker. test both optional-grant choices, account and single-chat scope, a group owner’s exclusion, revoked invites and token replacement. submit commands with the slash picker and verify that public replies reach the chat while private replies reach only their installer. test call receive, publish and leave paths with separate participants; close media sessions in your shutdown handler. removing a private invite immediately revokes that account’s installations.

### handling interrupted work

use the same Idempotency-Key when a write’s outcome is uncertain. handle 412 by rereading the resource revision and reconciling the intended change. persist gateway cursors only after processing preceding events. after resync_required, rebuild authorized snapshots and reconnect with the supplied cursor. retain no private content after access is revoked. keep call negotiation serialized; close and recreate a session after ICE failure or an unanswered SDP offer.

### troubleshooting

record the response request_id, status, error code and timing when diagnosing failures. keep tokens, message bodies, private command arguments and SDP out of logs. the installer can review delegated actions in settings → bots → activity.

| symptom | check |
| --- | --- |
| 401 invalid_token | use Authorization: Bot followed by a current token; browser requests with Origin are not accepted |
| 403 permission_denied | inspect this installation’s effective_permissions; publishing a grant does not authorize existing users automatically |
| 404 not_found | check the exact installation, chat scope, resource owner, blocks and group exclusions; hidden resources also return 404 |
| 409 configuration_changed | refresh the consent prompt or command list; a newer permission or scope choice needs human authorization |
| 412 revision_mismatch | read the latest ETag, then decide whether your edit still applies |
| 429 rate_limited | honor Retry-After and reduce concurrency; tokens and installations share application/account quotas |
| command absent | publish the draft command, authorize commands for the selected installation, and start the composer with / |
| command expired | ACK within three seconds; send the reply within 15 minutes and keep its acknowledged visibility |
| missing media track | check the receive grant, source state and participant access; screen audio needs both audio and screen grants |
| gateway 4009 / 4010 | another consumer replaced yours / authorized REST snapshots are required before resuming |

### downloadable schemas

use the reference and machine-readable schemas when implementing a client. unknown response fields may be added within API 1. the four-language quickstart is intentionally small; a production bot should also implement durable recovery and application-specific error handling.

- [OpenAPI 3.1 REST schema](https://sometimeschat.com/bot-api/v1/openapi.json)
- [gateway JSON Schema](https://sometimeschat.com/bot-api/v1/gateway.schema.json)
- [permission catalogue](https://sometimeschat.com/bot-api/v1/permissions.json)
- [complete Markdown reference](https://sometimeschat.com/bot-api/v1/api-reference.md)
