pocolobby 0.12.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +35 -0
- package/dist/activity.js +1 -0
- package/dist/adapters/claude.js +7 -0
- package/dist/attachments.js +2 -0
- package/dist/auth.js +4 -0
- package/dist/bin.js +4 -0
- package/dist/board-caption.js +19 -0
- package/dist/board-github.js +1 -0
- package/dist/board-model.js +1 -0
- package/dist/board-page.js +418 -0
- package/dist/board-summary.js +22 -0
- package/dist/board.js +1 -0
- package/dist/catalog.js +1 -0
- package/dist/cli.js +123 -0
- package/dist/client.js +1 -0
- package/dist/code-stats.js +3 -0
- package/dist/config.js +3 -0
- package/dist/console-page.js +467 -0
- package/dist/console.js +1 -0
- package/dist/exchange-rates.js +1 -0
- package/dist/hook.js +1 -0
- package/dist/html.js +1 -0
- package/dist/icons.js +4 -0
- package/dist/launch.js +2 -0
- package/dist/local-files.js +1 -0
- package/dist/lock.js +1 -0
- package/dist/mail.js +4 -0
- package/dist/mascots.js +6 -0
- package/dist/memory-curate.js +59 -0
- package/dist/memory.js +5 -0
- package/dist/money.js +1 -0
- package/dist/node-version.js +1 -0
- package/dist/pace.js +1 -0
- package/dist/png.js +1 -0
- package/dist/presentation.js +1 -0
- package/dist/prices.js +1 -0
- package/dist/protocol.js +1 -0
- package/dist/recurrence.js +1 -0
- package/dist/secrets.js +1 -0
- package/dist/server.js +8 -0
- package/dist/statusline.js +7 -0
- package/dist/store.js +303 -0
- package/dist/transcripts.js +2 -0
- package/docs/AGENT-SETUP-PROMPT.md +72 -0
- package/docs/API.md +1097 -0
- package/docs/ARCHITECTURE.md +410 -0
- package/docs/CLAUDE-SETUP.md +166 -0
- package/docs/INTEGRATION-PLAN.md +122 -0
- package/docs/LOCAL-SETUP.md +356 -0
- package/docs/MEMORY.md +142 -0
- package/docs/OPERATIONS.md +320 -0
- package/docs/QUICKSTART.md +111 -0
- package/docs/RELEASING.md +40 -0
- package/docs/SELF-HOSTING.md +236 -0
- package/docs/UPGRADES-WITHOUT-AGENT-RESTARTS.md +112 -0
- package/examples/CLAUDE.md +50 -0
- package/examples/PACING.md +33 -0
- package/examples/POCOLOBBY.md +69 -0
- package/examples/fly.toml +42 -0
- package/package.json +68 -0
- package/site/agents/gonk-server.webp +0 -0
- package/site/agents/luke-sleep.webp +0 -0
- package/site/agents/mouse-empty.webp +0 -0
- package/site/agents/mouse-envelope.webp +0 -0
- package/site/agents/poco-bell.webp +0 -0
- package/site/agents/vader-asleep.webp +0 -0
- package/site/agents/vader-crate.webp +0 -0
- package/site/agents/vader-deckchair.webp +0 -0
- package/site/agents/vader-point.webp +0 -0
- package/site/agents/vader-thumbs.webp +0 -0
- package/site/icons/apple-touch-icon.png +0 -0
- package/site/icons/favicon-32.png +0 -0
- package/site/icons/favicon.ico +0 -0
- package/site/icons/favicon.svg +12 -0
- package/site/index.html +204 -0
package/docs/API.md
ADDED
|
@@ -0,0 +1,1097 @@
|
|
|
1
|
+
# PocoLobby HTTP and WebSocket API
|
|
2
|
+
|
|
3
|
+
This reference describes the implemented `/v1` broker protocol. The HTTP/WS protocol is independent of MCP. The Claude adapter translates it into tools and Channel notifications; it takes its feature tools from the broker's [tool catalog](#tool-catalog-and-dispatch), so new broker features reach running agents without a package update. The broker and the bridge of one release go together: the broker refuses a bridge older than its [event version](#event-version-and-envelope). Besides messaging, the reference covers [message retention](#message-retention), [attachments](#attachments), the [push size](#push-size), the [tool guide](#the-tool-guide-and-stale-host-schemas) for hosts that keep an older tool schema, [room member changes and room deletion](#changing-and-deleting-rooms), each lobby's [dashboard](#dashboards) and its [Settings](#settings) at the lobby's own address, the [hosts and addresses](#hosts-and-addresses) hosted and local, [reserved lobby IDs](#reserved-lobby-ids), email-code and local [sign-in](#sign-in), [archived rooms](#archived-rooms) and the operator's [settings routes](#owner-routes). Copyable agent instructions and the private-lobby setup are in [Claude setup](CLAUDE-SETUP.md).
|
|
4
|
+
|
|
5
|
+
## Connection and principals
|
|
6
|
+
|
|
7
|
+
Configure an origin such as `https://pocolobby.com`, with no path, query, fragment, or embedded credentials. The bundled client rejects other URL forms. Loopback HTTP is permitted for local testing. Remote listeners require TLS termination; the broker's explicit `--allow-remote` option allows it to listen behind that terminator.
|
|
8
|
+
|
|
9
|
+
Every route except `GET /healthz`, `GET /` and the browser routes of [Settings](#settings) and the [dashboards](#dashboards) requires:
|
|
10
|
+
|
|
11
|
+
```http
|
|
12
|
+
Authorization: Bearer <pocolobby-token>
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Use the header for WebSocket upgrades too; tokens in URL query parameters are rejected. Requests with a browser `Origin` header are currently rejected. This is a native-client API, not a browser CORS interface. Mutation bodies use `Content-Type: application/json`; send `{}` for mutations without fields. JSON objects use strict schemas, so unexpected fields fail validation.
|
|
16
|
+
|
|
17
|
+
The **owner** (the server operator) uses the broker's separately configured administrative token. An **agent** uses its provisioned agent token. The server derives the sender from that credential; sending a `from` field is invalid. The reserved identity `owner` cannot be provisioned as an agent. Provisioning and rotation return a token once; subsequent reads do not return token material. The owner CLI saves these responses in protected files without printing the token.
|
|
18
|
+
|
|
19
|
+
Agent, lobby and room IDs match `[a-z][a-z0-9_-]{0,47}`. A new lobby's ID must also be a DNS label (letters, digits and inner hyphens, no underscore), may not take a [reserved ID](#reserved-lobby-ids), and may not take the ID of a deleted lobby. Agent and room names are local to a lobby; separate lobbies may use the same short names. Tokens contain 32–512 URL-safe characters. No Anthropic or other provider credential is accepted or required by this protocol.
|
|
20
|
+
|
|
21
|
+
## Private lobbies and shared rooms
|
|
22
|
+
|
|
23
|
+
The server binds each agent credential to one private lobby. Agent calls cannot supply `lobby` or `lobby_id` query selectors; attempts return `scope_selection_forbidden`. A config file holds only the origin and token, so changing local text cannot join another lobby. Discovery, direct messages, broadcasts, room membership, message history and receipts are scoped by that authenticated identity.
|
|
24
|
+
|
|
25
|
+
The owner can select a lobby with `?lobby=my-team` on scoped administrative routes and owner sends/discovery. There is no default lobby: a request that names none acts in the broker's only lobby, and a broker with several lobbies, or none, refuses it with `400 lobby_required`. A new broker has no lobby until the owner creates one (`POST /v1/admin/lobbies`, `pocolobby lobby create NAME`). Owner provisioning may supply `lobby_id` in its body; if an explicit query selector is also present, they must match. Only the owner (the operator) admits agents and issues credentials. A lobby owner signed in to [Settings](#settings) may also create lobbies it owns and change their rooms and settings; it never reaches the API below or another lobby.
|
|
26
|
+
|
|
27
|
+
Rooms such as `app`, `social` and `web` are recipient groups within one lobby. `manage_rooms` permits an existing agent to create and delete rooms and to change their membership using already-admitted same-lobby agents. It grants neither owner administration nor cross-lobby access. A coordinator can inspect/manage all rooms in its lobby but must join a room to send/receive its messages. Ordinary agents discover only their joined rooms. Room creation starts with no members.
|
|
28
|
+
|
|
29
|
+
## Owner routes
|
|
30
|
+
|
|
31
|
+
All paths in this table require the owner token. Ordinary successes return 200; lobby creation, room creation and agent provisioning return 201. Scoped routes use the lobby selector described above.
|
|
32
|
+
|
|
33
|
+
| Method and path | Request / result |
|
|
34
|
+
| ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
35
|
+
| `GET /v1/admin/lobbies` | `{ lobbies: [{ lobby_id, display_name, agent_count }, ...] }` |
|
|
36
|
+
| `POST /v1/admin/lobbies` | `{ lobby_id }` → `{ lobby_id }` |
|
|
37
|
+
| `PATCH /v1/admin/lobbies/:id` | `{ display_name }` → `{ lobby_id, display_name }`: how the dashboard and Settings show the lobby, such as `My Team`; at most 40 characters, no control characters; empty or `null` shows its ID again; `pocolobby lobby display-name NAME ["Display Name"]` |
|
|
38
|
+
| `GET /v1/admin/rooms` | `{ rooms: [{ lobby_id, room_id, members }, ...] }` for the selected lobby |
|
|
39
|
+
| `POST /v1/admin/rooms` | `{ room_id }` → empty room |
|
|
40
|
+
| `PUT /v1/admin/rooms/:room/members` | `{ members: ["agent-id", ...] }` → room with the full replacement membership |
|
|
41
|
+
| `PATCH /v1/admin/rooms/:room/members` | `{ add?: [...], remove?: [...] }` → room with only those members changed |
|
|
42
|
+
| `DELETE /v1/admin/rooms/:room` | → deletion result; see [Changing and deleting rooms](#changing-and-deleting-rooms) |
|
|
43
|
+
| `GET /v1/admin/agents` | `{ agents: [...] }`, every identity including disabled ones, with role, permissions, runtime, receipt/notification state, pending count, `last_message_at`, `owner_requests`, `connected`, `bridge_version` and `activity` |
|
|
44
|
+
| `POST /v1/admin/agents` | Provisioning body below → `{ agent, token }` |
|
|
45
|
+
| `PATCH /v1/admin/agents/:id` | Any subset of `role`, `can_broadcast`, `manage_rooms`, `allowed_recipients`, `enabled`, `paused` → updated agent |
|
|
46
|
+
| `POST /v1/admin/agents/:id/rotate` | `{}` → `{ token }`; invalidates the previous credential and runtime binding |
|
|
47
|
+
| `POST /v1/admin/agents/:id/reset-runtime` | `{}` → `{ ok: true }`; retires the current runtime so a different conversation can claim |
|
|
48
|
+
| `POST /v1/admin/agents/:id/retry` | `{}` → `{ ok: true }`; explicitly resets bootstrap and unresolved-inbox attempt budgets |
|
|
49
|
+
| `GET /v1/admin/agents/:id/messages` | Same paging parameters and result as agent message reads |
|
|
50
|
+
| `GET /v1/admin/agents/:id/context` | Role, checkpoint and recovery context for that agent |
|
|
51
|
+
| `GET /v1/admin/agents/:id/schedules` | That agent's active schedules and undelivered scheduled messages; `:id=owner` lists the owner's own scheduled sends |
|
|
52
|
+
| `POST /v1/admin/agents/:id/schedules/cancel` | `{ id }` → cancels one of them, as below; for example a schedule left by a retired agent |
|
|
53
|
+
| `GET /v1/admin/attachments` | `{ items, next_cursor }`: the selected lobby's attachment metadata with uploader and referencing messages and schedules; `cursor` and `limit` (1–100) page it |
|
|
54
|
+
| `GET /v1/admin/attachments/usage` | The selected lobby's attachment storage: `used_bytes`, `quota_bytes`, counts, limits and free disk. The owner never downloads attachment content |
|
|
55
|
+
| `GET /v1/admin/dashboard` | The selected lobby's dashboard status: `dashboard_url`, visibility, viewers and open sessions, summary model and effort, agents' emoji, sources with commit and errors, last summary and attempt, usage and cost |
|
|
56
|
+
| `PUT /v1/admin/dashboard/sources/:room` | `{ repo, ref?, notes }` → dashboard settings: read that room's notes from `repo` at `ref` (default `main`), and approve `repo` for the lobby; see [Dashboards](#dashboards) |
|
|
57
|
+
| `DELETE /v1/admin/dashboard/sources/:room` | → dashboard settings without that source; its snapshot goes too |
|
|
58
|
+
| `PUT /v1/admin/dashboard/icon` | `{ png }` → `{ icon: { sha256, bytes } }`: gives the selected lobby's dashboard its own icon, checked as in [Settings](#settings-pages-and-actions); `pocolobby dashboard icon set LOBBY ICON.png` |
|
|
59
|
+
| `DELETE /v1/admin/dashboard/icon` | → `{ icon: null }`: the dashboard shows PocoLobby's icon again; `pocolobby dashboard icon remove LOBBY` |
|
|
60
|
+
| `PUT /v1/admin/dashboard/agents` | `{ agents: { "<id>": { emoji?, name?, hidden?, support? } } }` → dashboard settings; replaces how agents look on the dashboard. `hidden: true` leaves an agent off it; see [Dashboards](#dashboards) |
|
|
61
|
+
| `GET /v1/admin/settings/accounts` | `{ accounts: [{ account_id, email, may_create_lobbies, created_at, disabled_at, owns, views }] }`; `pocolobby accounts list` |
|
|
62
|
+
| `POST /v1/admin/settings/accounts` | `{ email, enabled?, may_create_lobbies? }` → that account, created when new. `enabled: false` disables it and ends its account-area and dashboard sessions, hand-offs and outstanding code; `pocolobby accounts allow`, `enable` and `disable` |
|
|
63
|
+
| `PUT /v1/admin/settings/owner` | `{ email }` → the account (created when new) now owns the selected lobby; `409 lobby_owned` when another account does; `pocolobby accounts grant EMAIL LOBBY` |
|
|
64
|
+
| `POST /v1/admin/settings/owner/revoke` | `{ email }` → that account no longer owns the selected lobby; `404 owner_not_found` when it did not; `pocolobby accounts revoke EMAIL LOBBY` |
|
|
65
|
+
| `GET /v1/admin/settings/repos` | `{ lobby_id, repos }`: the repositories the selected lobby's owner may read notes from; `pocolobby repos LOBBY list` |
|
|
66
|
+
| `POST /v1/admin/settings/repos` | `{ repo }` → approves `owner/name` (stored lowercased) for the selected lobby |
|
|
67
|
+
| `DELETE /v1/admin/settings/repos/:owner/:name` | → withdraws the approval; `409 repo_in_use`, with `error.rooms`, while a room's source (archived rooms included) still reads it |
|
|
68
|
+
| `POST /v1/admin/settings/ticket` | `{}` → `{ ticket, expires_in_seconds: 60 }`: a one-time [local sign-in](#sign-in) ticket for a broker without a dashboard domain; `404 not_local` on one with a domain; `pocolobby settings` |
|
|
69
|
+
|
|
70
|
+
Example provisioning body:
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{
|
|
74
|
+
"agent_id": "backend",
|
|
75
|
+
"lobby_id": "my-team",
|
|
76
|
+
"manage_rooms": false,
|
|
77
|
+
"role": "Maintain the backend and report results to coordinator.",
|
|
78
|
+
"can_broadcast": false,
|
|
79
|
+
"allowed_recipients": ["coordinator", "frontend"]
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`lobby_id` is optional: without it, and without `?lobby=`, the agent is admitted to the broker's only lobby, and with several lobbies or none the request is refused with `400 lobby_required`. The lobby must exist. `role` defaults to an empty string. `work_description` (at most 300 characters), `machine` (at most 40) and `screen` default to empty and false: what the agent does, the machine it runs on and whether it has that machine's screen for live checks, which every agent in its lobby reads in discovery and the dashboard shows under its name. They are owner-written directory data, not instructions or permissions; `role` stays the agent's private instructions. `can_broadcast` and `manage_rooms` default to false. `allowed_recipients: null`, the default, allows addressing other enabled agents in the same lobby; an explicit array restricts those short names, and an empty array permits none. It also filters room and whole-lobby fanout. The maximum allowlist length is 100.
|
|
84
|
+
|
|
85
|
+
Disabling an agent invalidates its runtime and active access. Pausing holds notifications, including bootstrap, while retaining inbox state. Updating its role requires fresh bootstrap; a new `work_description`, `machine` or `screen` shows at the next read without one. Neither pausing nor retiring a runtime stops shell commands or edits already running outside the broker.
|
|
86
|
+
|
|
87
|
+
The owner may also use `GET /v1/agents` and `POST /v1/messages`; owner messages have `from: "owner"`. Agent-only routes cannot be used with the owner credential. Backup and restore are offline CLI operations, not HTTP endpoints.
|
|
88
|
+
|
|
89
|
+
## Reserved lobby IDs
|
|
90
|
+
|
|
91
|
+
Every `<label>.<dashboard domain>` names a lobby's dashboard, and a local broker serves each lobby under `/<lobby>`, so these names can never be lobby IDs: `admin`, `agents`, `api`, `app`, `assets`, `board`, `console`, `dashboard`, `healthz`, `knock`, `lobby`, `mail`, `open`, `pocolobby`, `settings`, `sign-in`, `static`, `status`, `v1` and `www`. On a local broker `settings`, `v1`, `healthz` and `agents` (PocoLobby's pictures) are the paths beside the lobbies. Creating such a lobby answers `400 invalid_request`, from the admin API, Settings and the CLI. A new lobby's ID must also be a DNS label, and the ID of a deleted lobby is never used again (`409 lobby_exists`).
|
|
92
|
+
|
|
93
|
+
## Hosts and addresses
|
|
94
|
+
|
|
95
|
+
The broker routes by the `Host` header. With the dashboard domain `pocolobby.com` (`POCOLOBBY_DASHBOARD_DOMAIN`):
|
|
96
|
+
|
|
97
|
+
| Host | Serves |
|
|
98
|
+
| --------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
|
99
|
+
| `lobby.pocolobby.com`, the settings origin's host | The API, and the account area at `/`: Your lobbies, with `/sign-in`, `/open/<label>` and `/api/…` beside it |
|
|
100
|
+
| `pocolobby.com`, `<your-app>.fly.dev` and every host outside the domain | The API, with the landing page at `GET /` and no account area |
|
|
101
|
+
| Every other reserved label, such as `www.pocolobby.com` and `knock.pocolobby.com` | `404` for every request, with no forward |
|
|
102
|
+
| `<label>.pocolobby.com`, every other label | Lobby `<label>`'s dashboard at `/` and its [Settings](#settings) at `/settings`, and nothing else |
|
|
103
|
+
|
|
104
|
+
The account area answers only on the host of the settings origin, `POCOLOBBY_SETTINGS_ORIGIN` (default `https://<dashboard domain>`): on any other host every request for it is `404`, with no forward. A settings origin without a dashboard domain stops the broker at start, since the hand-off needs the lobbies' hosts. Its paths are dispatched before the API's checks, which go on refusing every request with an `Origin` header. An empty dashboard domain turns lobby hosts off. Every response to a request that reached the broker over HTTPS through a proxy (`X-Forwarded-Proto: https`, as Fly's edge sends) carries `Strict-Transport-Security: max-age=31536000`; browsers ignore it over plain HTTP, so a local broker is unaffected.
|
|
105
|
+
|
|
106
|
+
**A local broker.** Without a dashboard domain, a broker listening on loopback (`127.0.0.1`, the default, `::1` or `localhost`) serves its lobbies under its own address, the same pages as a hosted one:
|
|
107
|
+
|
|
108
|
+
| Path | Serves |
|
|
109
|
+
| ---------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
|
110
|
+
| `GET /` | The operator's [sign-in](#sign-in), then every lobby, and Create lobby |
|
|
111
|
+
| `/<lobby>` | That lobby's dashboard, with `/<lobby>/dashboard.json` and `/<lobby>/icon.png` beside it |
|
|
112
|
+
| `/<lobby>/settings[/…]` | Its [Settings](#settings) |
|
|
113
|
+
| `/v1/…`, `/healthz`, `/agents/…` and the icons | The API and PocoLobby's pictures and icons, as on any host |
|
|
114
|
+
|
|
115
|
+
These pages answer only when the `Host` header names the broker's own loopback address and port exactly, such as `localhost:8787`, `127.0.0.1:8787` or `[::1]:8787` (with no port on port 80). Any other host, such as a name a hostile site points at 127.0.0.1, gets `421`, so a page from elsewhere cannot reach them through DNS rebinding. The API is unaffected. A broker listening elsewhere (`--allow-remote`) without a domain has neither; its `GET /` serves the landing page.
|
|
116
|
+
|
|
117
|
+
The landing page is the static `site/index.html` shipped with the package: `text/html; charset=utf-8`, `Cache-Control: public, max-age=300`, `X-Content-Type-Options: nosniff`, and a Content-Security-Policy that allows inline styles and Google Fonts only.
|
|
118
|
+
|
|
119
|
+
**Icons.** Every host, API and lobby alike, serves PocoLobby's icon from the package's `site/icons`: `/favicon.svg`, `/favicon.ico`, `/favicon-32.png` and `/apple-touch-icon.png`, with `Cache-Control: public, max-age=86400`, and every page links them: the landing page, Settings and the dashboards. A lobby's owner may give its dashboard its own icon instead ([Settings](#settings-pages-and-actions) or `PUT /v1/admin/dashboard/icon`); its dashboard then links `icon.png?v=<version>` beside it, see [Routes on a lobby's host](#dashboards).
|
|
120
|
+
|
|
121
|
+
## Dashboards
|
|
122
|
+
|
|
123
|
+
A dashboard shows one lobby to its human viewers on a phone: what the agents say they wait on from the owner, and one row per enabled agent with its state, its last 24 hours of work, and what it does now and next. It is a projection of live agent facts, the lobby's messages and its notes; it never stores or assigns work. See [Architecture](ARCHITECTURE.md#dashboards-a-projection-of-the-notes) for its data flow and trust boundary and [Operations](OPERATIONS.md#dashboards) for its setup.
|
|
124
|
+
|
|
125
|
+
**Who opens a dashboard.** Each lobby's dashboard is `private`, the default, or `public`. A private dashboard opens for the lobby's owner and the viewers it lists, after [email-code sign-in](#sign-in) in the account area, which hands the browser to the lobby's host; on a local broker it opens for the operator signed in with `pocolobby settings`. A public dashboard opens for anyone with its address. Visibility, viewers and owner are checked on every request, so removing a viewer, disabling an account or making a dashboard private again closes it on the next page view.
|
|
126
|
+
|
|
127
|
+
**Routes on a lobby's host.** Only these exist, beside the lobby's [Settings](#settings) under `/settings`. Every other path, including every `/v1` route with any credential, answers `404`, and so does a WebSocket upgrade. `Authorization` headers are never read here, and no API route reads cookies.
|
|
128
|
+
|
|
129
|
+
| Method and path | Without access | With access (public, or a live session that may still view it) |
|
|
130
|
+
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
|
|
131
|
+
| `GET /` | `303` to `/sign-in` on this host | The dashboard page, complete without JavaScript |
|
|
132
|
+
| `GET /sign-in?to=…` | Begins sign-in: sets the hand-off's verifier cookie and answers `303` to `<settings origin>/open/<label>?to=…&state=…` | (same) |
|
|
133
|
+
| `GET /dashboard.json` | `401 { error }` | `{ generated_at, activity, page, html }`: the dashboard's refreshable fragment |
|
|
134
|
+
| `GET /handoff?token=…&to=…` | A valid hand-off, in the browser that began the sign-in: `303` to `to` (`/` or one of the lobby's settings pages) with the session cookie; otherwise `400` and a notice | (same) |
|
|
135
|
+
| `POST /sign-out` | Ends the session, clears the cookie, and shows a notice with a sign-in link | (same) |
|
|
136
|
+
| `GET /healthz` | The API's health check | (same) |
|
|
137
|
+
| `GET /icon.png` | `404` | The lobby's own icon, or `404` without one |
|
|
138
|
+
|
|
139
|
+
Every label gets the same redirect, whether or not such a lobby exists or is private, so subdomains reveal no lobby names; only a public dashboard answers differently. PocoLobby's [icons](#hosts-and-addresses) answer on every label alike. A dashboard with its own icon links `icon.png?v=<the first 16 hex digits of its SHA-256>`, which is kept a year (`private`, or `public` on a public dashboard, `max-age=31536000, immutable`); without that version the icon is `private, no-cache`. It is a PNG served as `image/png` with `nosniff` and `default-src 'none'`. `POST /sign-out` must carry the lobby host's own full `Origin` (`https://<host>` behind TLS); a missing, `null` or foreign origin is refused with `403`. On a local broker the same dashboard routes sit under `/<lobby>`, without `/handoff` and `/sign-out` (the operator signs out in Settings), and a private dashboard without the operator's session sends the browser to `/`, its sign-in.
|
|
140
|
+
|
|
141
|
+
**The header.** Every dashboard and settings page has one header, as wide as the page below it: PocoLobby's logo and name, which lead to your lobbies, the lobby's name, which leads to the top of the dashboard or to the Overview of its settings, and at the right Lobbies, Dashboard and Settings as plain links, the current one underlined. Lobbies, your lobbies at the account area's root, `<settings origin>/`, shows to whoever is signed in, and alone on the account area's own pages. Settings shows only for the lobby's owner and the local operator; on a public dashboard, a visitor who is not signed in gets Sign in in its place, so its owner reaches Settings from it too. Who is signed in, and Sign out, sit at the foot of each page. The theme (System, Light or Dark) is chosen under Settings › General and kept in that browser for the lobby's address; elsewhere pages follow the device.
|
|
142
|
+
|
|
143
|
+
**The hand-off.** Sign-in to a lobby begins on its own host: `GET /sign-in` there sets `__Host-pocolobby_handoff=<verifier>; Path=/; Secure; HttpOnly; SameSite=Lax`, a random 256-bit verifier kept while the browser runs, which a sign-in in another tab reuses, and sends the browser to `<settings origin>/open/<label>?to=…&state=<challenge>`, where the challenge is the verifier's SHA-256 in hex: the verifier itself never leaves the lobby's host. The account area mints a hand-off only with a challenge; without one it sends the browser to the lobby's `/sign-in` first, and a browser not signed in there signs in with the challenge carried along. For a signed-in account that owns that lobby or views its dashboard, whatever its visibility, it stores, with the lobby, the account and an expiry one minute later, the SHA-256 of a random 256-bit token bound to the challenge, and answers `303` to `https://<label>.<dashboard domain>/handoff?token=…`, with `&to=` naming the page when it was opened for one of the lobby's settings pages. The lobby's host computes the key from the token and its own verifier cookie and redeems it in one transaction: it deletes the hand-off, checks that it belongs to this lobby and has not expired, checks again that the account may view the dashboard, and stores a dashboard session. So a hand-off works once, on its own host, within a minute, and only in the browser that began the sign-in: a link opened in another browser finds no hand-off and consumes nothing. The redemption answers `303` to `to` when it is `/` or one of the lobby's settings pages, and to `/` otherwise, with `Cache-Control: no-store` and `Referrer-Policy: same-origin`, so the token stays in no page. A signed-in account that may not view a dashboard, and a label that is no lobby, get the same `404` page, except that a public dashboard opens directly.
|
|
144
|
+
|
|
145
|
+
**Sessions.** A dashboard session stores the SHA-256 of a random 256-bit token with its lobby and account, and sets `__Host-pocolobby_board=<token>; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=2592000` (the cookie keeps its name): host-only, so it never reaches the account area, the API or another lobby. It lasts 30 days from the hand-off, and a session in use is extended to 30 days from then at most once a day, with its cookie set again, on a dashboard or a settings page alike, so a dashboard kept open stays signed in. The same session opens the lobby's Settings when its account owns the lobby. An expired session's `/` sends the browser to the account area, which hands it back at once while its own session lasts. A hand-off ends the browser's earlier session on that lobby's host in the same step, so opening the dashboard again does not count as another device. Disabling an account deletes its sessions; removing a viewer deletes that viewer's sessions of that dashboard. Recovery mode clears every browser session and disables every account.
|
|
146
|
+
|
|
147
|
+
**Headers on dashboard and settings responses.** `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: same-origin`, and on pages `Content-Security-Policy: default-src 'none'; style-src 'nonce-…' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; script-src 'nonce-…'; connect-src 'self'; img-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'`, with a fresh nonce per response. `no-referrer` is not used: under it browsers send `Origin: null` with the sign-out form.
|
|
148
|
+
|
|
149
|
+
**The page.** It is headed by the lobby's display name, or its ID without one; the sign-in notice of a private dashboard shows only the address's label. Its sections, all live:
|
|
150
|
+
|
|
151
|
+
- **Waiting for you** lists each enabled agent that has [owner requests](#owner-requests) set, longest waiting first: its name, "N requests", how long ago it set them, and its text. With none it says "Nothing waits on you." A hidden agent's requests show here too.
|
|
152
|
+
- **Accounts** shows each Claude account the shown agents' status lines report, with its [weekly pace](#weekly-pace), as a small card, up to three a row as the width allows: the email, a bar of the week, and "Week 63% used target 57% resets Wed 21 Oct, 07:00" with its agents ("backend, frontend") below, the target being how much of the week it should have used by now to use all of it exactly by its reset (the line in `list_agents`). The bar runs from 0 to 100% with a mark at the target: green for what was used up to the mark, orange for what was used beyond it (red when more than 20 points ahead), blue for the room left before it, and all green within 5 points of the mark, the few points still to use before it in blue like any room left. It is inline SVG, whose sizes are attributes, since the page's style policy allows no inline styles. Accounts are grouped by email, each with its freshest reading; hidden agents are left out, and so is a reading without an email, older than 24 hours or past its weekly reset. A public dashboard names them "Account 1", "Account 2" and so on, in the order of their emails. Without any account the section is left out.
|
|
153
|
+
- **Tokens, last 4 weeks** appears once agents report [token counts](#token-counts): a bar per UTC day, as tall as what that day's tokens would cost at API list prices, with the 4 weeks' total and today's above. Clicking or tapping a day, or hovering over it with a mouse, shows each model's cost and its input, cache writes, cache reads and output, and a click elsewhere closes it; a refresh waits while a day's details are open. It is on public dashboards too. It comes between Accounts and Agents.
|
|
154
|
+
- **Agents** has one row per enabled agent the owner has not hidden, coordinators first on a tinted row: its state chip, "bridge offline" when the broker has no connection from its bridge, with Not responding how many messages it has not acknowledged and since when, its "now" line, its "Next" items, its [plan usage](#plan-usage) when its status line reports it, and a strip of the last 24 hours. The "now" line is the summary's, or "No current task in the notes" without one. "Next" shows at most 4 of the summary's items and how many more; "nothing queued" when the summary says nothing is queued; and nothing at all when it cannot tell.
|
|
155
|
+
|
|
156
|
+
**Hidden agents.** The owner can hide an agent from the dashboard with `hidden: true` in `PUT /v1/admin/dashboard/agents` (`pocolobby dashboard agents LOBBY --hide ID`). It then has no row and the summarizer is not asked about it, but its owner requests still show under Waiting for you, so nothing waiting on the owner is missed. The agent stays in the lobby and works as before; the Agents page in Settings marks it "not on the dashboard".
|
|
157
|
+
|
|
158
|
+
**Support agents.** `support: true` (`pocolobby dashboard agents LOBBY --support ID`) marks an agent that supports the lobby from outside the team's work, such as one that develops the lobby's tools and answers the agents' feedback. Its row comes last, on the coordinator's tint, and like the coordinator's it shows one sentence from the summarizer on what it did today instead of now and next; the summarizer gets `support: true` for it.
|
|
159
|
+
|
|
160
|
+
The strip has 96 bars, one per quarter hour, oldest first, the last one still open. A bar is green and as tall as the minutes the agent worked of that quarter hour, adding up its [turns](#turn-activity), from short for one minute to the strip's full height for all fifteen, and its tooltip gives the quarter hour in UTC and the minutes worked; a quarter hour with less than a minute of work, or no turn seen, is left empty. A figure at the end of the name line gives its working time in the last 24 hours, such as "19 h 20 min active", or "no work". A turn counts from the prompt or message that starts it until Claude finishes, waiting on a tool included, and a turn still running counts until now, or for an offline bridge until it was last seen. The axis labels local 00, 06, 12 and 18 o'clock in the viewer's browser, and reads "24 h ago", "12 h ago" and "now" without the script. A footer says what is live, when and by which model now and next were written, and which commit each notes source was read at.
|
|
161
|
+
|
|
162
|
+
Nothing refreshes a dashboard on a timer on the server: opening it, or the page's own refresh of `dashboard.json`, brings the lobby up to date in the background (notes checked at most once per notes interval, a new summary at most once per summary interval and only when its input changed) and answers at once with the live facts and the last summary. While visible, the page fetches `dashboard.json` every 10 seconds, and every 4 seconds while `activity` reports `checking` or `summarizing`, which it shows under the lobby's name; a hidden page stops asking and asks again at once when it is shown. The theme switch (System, Light, Dark) is remembered in the browser. Every string from a summary, a note, an agent's owner requests or the `Host` header is HTML-escaped on the server.
|
|
163
|
+
|
|
164
|
+
**State chips**, decided on the server from live facts in this order: **Not responding** when the agent has a message written to its session since its latest progress and unacknowledged for 15 minutes or more (its [`unseen_notified_count` and `oldest_unseen_notified_at`](#runtime-ownership-and-agent-routes)); **Waiting on you** (orange, as Claude Code shows a session waiting for its user) when it has owner requests set; **Away**, in grey as in Slack or Teams, when its bridge is offline; then from its [turns](#turn-activity): **Working** while one runs, the coordinator too, whose tinted first row still marks it; **Turn failed 3 min ago** (red) since one ended on an API error; and otherwise **Idle 12 min** (a hollow dot) since its last one ended, or plain **Idle** while no turn has been seen since its session started. Messages decide no chip, and neither does the summary. Agents report no work state of their own: the hooks are Claude Code's, not the model's word.
|
|
165
|
+
|
|
166
|
+
**Notes sources.** One per room: `{ repo, ref, notes }`, where `notes` lists 1–20 paths, each a directory (every `*.md` directly inside it) or a file such as `PLAN.md`. The room must exist when the source is set; a lobby has at most 10 sources. A lobby owner may name only a repository the operator approved for the lobby (`403 repo_not_approved`); the operator's own `dashboard source set` approves its repository. An archived room's source is kept but not read or summarized. Each source is read with `GITHUB_TOKEN` at its own commit: the ref is resolved with a conditional request (`If-None-Match`), then every path is read at that commit, and only blobs with a new SHA are downloaded. A file is cut after 128 KiB and a source after 200 KiB, visibly on the page and in the summarizer's input. A path that does not exist contributes nothing; a source with no matching files shows "no notes found". A failed fetch keeps that source's last snapshot and shows its error while the other sources render.
|
|
167
|
+
|
|
168
|
+
**Summaries.** With `ANTHROPIC_API_KEY`, the broker asks the lobby's model, Claude Haiku 5.5 (`claude-haiku-5-5`, the default) or Claude Sonnet 5.5 (`claude-sonnet-5-5`), at its effort, `low` (the default), `medium` or `high`, with structured output and thinking off (`disabled` for Haiku, `between_tools` for Sonnet), to write only each agent's "now", what it is working on and why in about 120 characters or empty when the sources do not say, and its "next", the tasks queued after it, each in about 80 characters: `[]` when the sources say nothing is queued, `null` when they do not say enough to tell. The lengths are targets: the model is told to keep each entry whole rather than cut it off, and the broker shows what it wrote in full, with no clipping. It is also told to leave out the notes' internal codes ("F02 M3", "Q15 milestone 2") and say what the work is about. The request carries every source's last snapshot, the lobby's newest 40 report and assignment messages, whatever their age, since messages are [kept a day](#message-retention) (600-character excerpts, 16,000 characters in all), and the ID and name of each enabled agent the owner has not hidden, after a system prompt sent without prompt caching. It runs only when that input changed (a note's content, such a message, the agents, the model or the effort) and at most once per `POCOLOBBY_DASHBOARD_SUMMARY_SECONDS` (600). An input that only lost messages, which leave it only by expiring, counts as unchanged, compared with the last answer this broker process stored, a summary or a failure the model answered. So does an input on which the model already answered with a failure. A paid attempt whose record cannot be stored, not even as a failure (as on a full disk), stops further paid attempts until it is stored, and the page says "New summaries are paused: the cost of the last one could not be recorded". An input found unchanged is not built again for a notes interval (`POCOLOBBY_DASHBOARD_NOTES_SECONDS`, 60). A lobby's summaries may cost at most $5 in any 24 hours: at that ceiling no new summary runs, and the page says "New summaries are paused" until older runs leave the window. The notes are cut beyond 200,000 characters in all, marking each cut file, so a request stays under Haiku 5.5's 100K-token price step. The test run in Settings summarizes at once, whether or not anything changed, at most once per lobby per 5 minutes after any attempt, never while one runs and never at the daily ceiling (`429 summary_test_wait`, `409 summary_running`, `429 summary_budget`, `503 summary_unavailable` without a key). Sonnet 5.5 is priced at $2 / $10 per million input and output tokens at any prompt length, Haiku 5.5 at $0.10 / $0.50 up to 100K prompt tokens and $0.50 / $2.50 above. Every attempt is stored with its request hash, sources and commits, model, token use and cost, and either its summary or its error; a failure keeps the last good summary on the page with the failure stated.
|
|
169
|
+
|
|
170
|
+
**The caption.** When no agent waits on the owner, a private dashboard's top panel is "Right now": the coordinator's answer to "How is work going today?" in a speech bubble beside a picture of the coordinator that fits, such as the coordinator in a deck chair on a calm stretch. With `ANTHROPIC_API_KEY` the broker asks Claude Opus 5.5 (`claude-opus-5-5`) at low effort, with structured output, for a `caption` of about 120 characters, in the first person as a calm project manager speaks, and a `picture` of the coordinator, one of five: in a deck chair, giving a thumbs-up, pointing ahead, on a crate addressing the team, or asleep at the desk. The request carries only what the dashboard shows: how many shown agents are working, idle and offline, each one's name, role, state and now line from the latest summary, and today's commits and lines added and removed when the room's [code statistics](#code-statistics) come in. The prompt has it say how the day goes in the owner's words, naming parts of the product rather than code terms, prefer the team's day to one agent's task, build on one real fact, keep work in progress apart from finished work, claim no progress from tokens, commits or lines, and let the joke land on the day, never on an agent's mistake, a correction or a comparison of agents; speaking in the coordinator's voice, it makes no promise, decision, date or verdict. After the text, the bubble shows when it was written, muted in brackets, in the viewer's time. It runs after each summary newer than the last attempt, judged from the stored attempts so a restart writes none for the same summary, and never on a public dashboard. Every attempt is stored (`board_captions`, schema 17, 30 days, the newest good caption kept) with its token use and cost, about $0.01, which counts toward the lobby's $5 a day with the summaries, as do its runs in the summary usage in Settings. A failed attempt keeps the last good caption; a paid one that cannot be stored stops paid captions until it is. When an agent has owner requests, "Waiting for you" takes the panel back. Without a key or a caption yet, the panel says "Nothing waits on you".
|
|
171
|
+
|
|
172
|
+
## Settings
|
|
173
|
+
|
|
174
|
+
Settings is where a lobby's owner manages it in a browser. Its addresses are lobby first and match hosted and local:
|
|
175
|
+
|
|
176
|
+
| Hosted, with a dashboard domain | Local, without one |
|
|
177
|
+
| ------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
|
|
178
|
+
| `<settings origin>/`: sign-in, your lobbies, Create lobby | `localhost:8787/`: the operator's sign-in, every lobby, Create lobby |
|
|
179
|
+
| `<lobby>.<domain>/settings[/rooms\|summaries\|access\|agents\|tokens\|memory\|activity\|general]` | `localhost:8787/<lobby>/settings[/…]` |
|
|
180
|
+
|
|
181
|
+
The settings origin is `POCOLOBBY_SETTINGS_ORIGIN`, by default `https://<dashboard domain>`; this deployment uses `https://lobby.pocolobby.com`. Settings are a separate surface: they read only their own cookies and never `Authorization`, the API reads only `Authorization` and never cookies, and a signed-in account is never the operator. A lobby's settings sit beside its dashboard on its own host, so they use the dashboard's session, and they are for the lobby's owner only: a viewer who opens them gets a page that says so. See [Architecture](ARCHITECTURE.md#identities-and-settings) for the identities and [Operations](OPERATIONS.md#settings) for its setup.
|
|
182
|
+
|
|
183
|
+
**Accounts.** An account is a person, keyed by email address trimmed and lowercased. It may own lobbies (each lobby has at most one owner), view private dashboards, and, when the operator allows it, create up to 3 lobbies of its own. The operator manages accounts through the [owner routes](#owner-routes) or `pocolobby accounts`. A disabled account signs in nowhere. A local broker's operator needs no account: it signs in with a one-time link, below.
|
|
184
|
+
|
|
185
|
+
### Costs in the lobby's currency
|
|
186
|
+
|
|
187
|
+
A lobby's owner chooses under Settings › General the currency its costs show in. Costs are kept in US dollars, the list prices' currency, in the store, the API and the summaries' daily limit, which stays USD 5; only the pages convert them: the dashboard's Tokens chart, its totals, tooltips and gridlines (scaled in the chosen currency, so they fall on round amounts of it), and in Settings the Tokens history, the summaries' cost and the memory curation costs. Each says which rate it used: the European Central Bank's euro reference rate of a date, as the chosen currency's units per dollar. Without a rate for the chosen currency, or before the first fetch, costs show in US dollars and General says why.
|
|
188
|
+
|
|
189
|
+
The broker fetches the rates only with `POCOLOBBY_EXCHANGE_RATES=ecb`: `https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml`, about 30 currencies against the euro, published around 16:00 CET on the ECB's working days. It asks at start and from its one-minute sweep, at most once a day counted from its last attempt, whether that worked or not, so a weekend, a holiday or an outage never makes it ask again and again; it sends nothing about any lobby. Only a whole table with a dollar rate and every rate a positive number replaces the stored one, in the metadata table (`exchange_rates`, with each lobby's choice as `currency:<lobby>`); a failure keeps the last. Without the setting the broker fetches nothing, and a stored table stays usable. This is the one outside call besides the operator's notes and summaries integrations.
|
|
190
|
+
|
|
191
|
+
### Sign-in
|
|
192
|
+
|
|
193
|
+
A six-digit code is sent by email through Postmark (`POSTMARK_TOKEN`, from `POCOLOBBY_MAIL_FROM`, message stream `outbound`, opens and links not tracked, the code in the subject, a 15-second timeout). Only an enabled account that may create lobbies, owns one or views a dashboard gets a code; every other address gets the same answer and no mail.
|
|
194
|
+
|
|
195
|
+
| Method and path | Body | Result |
|
|
196
|
+
| -------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
197
|
+
| `POST /api/sign-in/code` | `{ email }` | `200 { ok, message }` for every address that may or may not sign in; `429` within a minute of the last code for that address; `502 mail_failed` with Postmark's reason when the mail could not be sent; `503 sign_in_unavailable` without mail settings |
|
|
198
|
+
| `POST /api/sign-in/verify` | `{ email, code, lobby?, to?, state? }` | `200 { ok, redirect }` and the session cookie, the redirect leading on to the lobby's hand-off when a lobby was named; `400 code_invalid` for a wrong, expired or spent code alike, or `429 too_many_wrong_codes` |
|
|
199
|
+
| `POST /api/sign-out` | `{}` | `200 { ok, redirect }`, the session ended and the cookie cleared |
|
|
200
|
+
|
|
201
|
+
- **Codes.** A code is stored only as an HMAC-SHA256, bound to the address, under a key derived (HKDF) from the broker's admin token; rotating that token voids outstanding codes. It works once, for 10 minutes. A new code replaces the old one. Five wrong tries spend a code. Wrong codes also count per address, across new codes: after 10 in an hour every check for that address answers `429` until the hour has passed. A right code is consumed and the session created in one transaction.
|
|
202
|
+
- **Sending.** One code per address per minute and five an hour, counted for every address alike, under a key that is an HMAC of the address, so neither the answer nor the `429` tells known and unknown addresses apart, and timing does not either while Postmark answers within a second: a request that is not refused takes at least one second from the end of its body, whether or not a mail went out. A failed Postmark send is the one answer only a known address can get. At most 300 codes an hour are mailed in all. Each client may ask for 10 codes and try 30 codes per 15 minutes. The client, for these limits and the API's request rate, is the socket address, or the first address in `POCOLOBBY_CLIENT_IP_HEADER`; an IPv6 client is counted by its /64, which one household or server holds whole, and an IPv4-mapped IPv6 address as its IPv4 address.
|
|
203
|
+
- **Sessions.** An account-area session stores the SHA-256 of a random 256-bit token and sets `__Host-pocolobby_console=<token>; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=2592000`, host-only. It lasts 30 days from its last use, extended at most once a day. Expired codes, sessions, hand-offs and rate rows are removed by the broker's one-minute sweep.
|
|
204
|
+
- **Every settings POST** must carry the exact `Origin` of the page's own host: the settings origin for the account area, the lobby's host for its settings (`403 origin_not_allowed` otherwise, another lobby's host included), `Content-Type: application/json` (`415`), at most 4 KB (`413`; 512 KB for an icon) and only the fields its route names (`400`). Other methods on the account area's `/api/` and a lobby's `/settings/api/` answer `405`.
|
|
205
|
+
|
|
206
|
+
**Local sign-in.** A broker without a dashboard domain sends no mail. Its operator runs `pocolobby settings --config owner.json`, which asks `POST /v1/admin/settings/ticket` with the owner token for a one-time ticket, prints the link `<broker address>/#ticket=…`, such as `http://127.0.0.1:8787/#ticket=…`, and opens it in the browser. The ticket rides in the URL fragment, which the browser never sends to a server. The page's script posts it to `POST /api/sign-in/ticket` (`{ ticket }`) from the page's own origin and takes it out of the address bar. A ticket works once, within a minute (`400 ticket_invalid` otherwise). The session it starts sets `pocolobby_operator=<token>; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000`, without `__Host-` and `Secure`, which plain http on loopback cannot carry, and lasts 30 days. Tickets and sessions are kept only in the broker's memory, so a restart signs the operator out; `pocolobby settings` signs in again. The session opens the broker's pages and never `/v1`, and the pages answer only on the broker's own loopback address and port (see [Hosts and addresses](#hosts-and-addresses)). `POST /api/sign-out`, or Sign out on any lobby's settings, ends it. A local broker has no email viewers: Dashboard access offers none.
|
|
207
|
+
|
|
208
|
+
### Settings pages and actions
|
|
209
|
+
|
|
210
|
+
Pages are server-rendered with the dashboard's headers and Content-Security-Policy; their buttons send JSON with a small script. Every page and action of a lobby's settings checks that the session on that host belongs to the lobby's owner, or is the local operator's, and the store checks ownership again inside each change, after the body has arrived; the lobby always comes from the address, never from a field. Without a session a page answers `303` to sign-in (`/sign-in?to=<page>` on the lobby's host hosted, `/` local) and an action `401 sign_in_required`. A signed-in viewer who is not the owner gets `403` on a page and `404 lobby_not_found` on an action; on a local broker, a lobby that does not exist is `404`.
|
|
211
|
+
|
|
212
|
+
The account area's pages, at the root of `<settings origin>` hosted and of the broker's own address local:
|
|
213
|
+
|
|
214
|
+
| Page | Shows |
|
|
215
|
+
| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
216
|
+
| `GET /` | Sign-in, or Your lobbies: those you own, dashboards shared with you, and Create lobby when allowed; on a local broker every lobby and Create lobby. Open leads to a lobby's dashboard, and Create lobby to the new lobby's settings |
|
|
217
|
+
| `GET /sign-in` | Sign-in, or Your lobbies when signed in |
|
|
218
|
+
| `GET /open/<label>?to=<page>&state=<challenge>` | The [hand-off](#dashboards) to that lobby's dashboard, or with `to` to one of its settings pages, or sign-in first; without a challenge, `303` to the lobby's `/sign-in` (hosted) |
|
|
219
|
+
|
|
220
|
+
A lobby's pages, under its address (`<lobby>.<domain>` hosted, `localhost:8787/<lobby>` local):
|
|
221
|
+
|
|
222
|
+
| Page | Shows |
|
|
223
|
+
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
224
|
+
| `GET /settings` | Overview: repositories, summaries, dashboard |
|
|
225
|
+
| `GET /settings/rooms` | Rooms and repos: members, each room's source and its files at the last commit, archived rooms |
|
|
226
|
+
| `GET /settings/summaries` | AI summaries: provider, whether the server has a key (never any of it), model, effort, the last 24 hours |
|
|
227
|
+
| `GET /settings/access` | Dashboard access: address with Copy and Open, visibility, the dashboard's icon, viewers and open sessions; no viewers on a local broker |
|
|
228
|
+
| `GET /settings/agents` | Agents, read-only: emoji, rooms, last message time, whether the bridge is connected, "not on the dashboard" for a hidden agent, and Runs headless for a curator that never connects a bridge, listed last; admission stays in the CLI |
|
|
229
|
+
| `GET /settings/tokens` | Tokens: the lobby's whole [token](#token-counts) history, newest first, by day, ISO week or month (`?by=day`, `week` or `month`): each period's cost at API list prices and each model's input, cache writes, cache reads, output and calls; and Delete old totals |
|
|
230
|
+
| `GET /settings/memory` | Memory: each room's [shared memory](#shared-memory), its curator, and erasing an entry or a fragment |
|
|
231
|
+
| `GET /settings/activity` | Activity: the lobby's newest 100 audit events, by the owner (Settings), agents (API) and the operator (CLI) |
|
|
232
|
+
| `GET /settings/general` | General: name and address, display name, the [currency](#costs-in-the-lobbys-currency) its costs show in, Delete lobby |
|
|
233
|
+
| `GET /settings/icon.png` | The dashboard's own icon, for the Dashboard access page's preview |
|
|
234
|
+
|
|
235
|
+
The account area's actions sit under `/api/` at its root: `sign-in/code`, `sign-in/verify`, `sign-in/ticket` (local), `sign-out` and `lobbies`. A lobby's actions sit under its own `/settings/api/`, on its address, so the lobby is never in the path or the body.
|
|
236
|
+
|
|
237
|
+
| `POST …/settings/api/…` | Body | Effect |
|
|
238
|
+
| --------------------------------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
239
|
+
| `lobbies` (account area, `POST /api/lobbies`) | `{ lobby_id, first_room? }` | Creates a lobby owned by the account (DNS label, not reserved, never used; at most 3 owned; `403 lobby_creation_forbidden` unless allowed, `409 lobby_allowance`) and leads to its settings; a local operator creates lobbies without that limit |
|
|
240
|
+
| `rooms` | `{ room_id }` | Creates an empty room |
|
|
241
|
+
| `rooms/<room>/members` | `{ add }` or `{ remove }` | Adds or removes one member, keeping the others; a removed member's unseen room messages are held |
|
|
242
|
+
| `rooms/<room>/source` | `{ repo, ref?, notes }` | Sets the room's notes source to an approved repository; `notes` is a comma-separated list |
|
|
243
|
+
| `rooms/<room>/source/remove` | `{}` | Stops reading the room's notes |
|
|
244
|
+
| `rooms/<room>/archive` | `{}` | [Archives](#archived-rooms) the room |
|
|
245
|
+
| `rooms/<room>/restore` | `{}` | Restores an archived room, empty |
|
|
246
|
+
| `summaries` | `{ model, effort }` | `claude-haiku-5-5` or `claude-sonnet-5-5`; `low`, `medium` or `high` |
|
|
247
|
+
| `summaries/test` | `{}` | Starts a test summary; see [Summaries](#dashboards) |
|
|
248
|
+
| `access/visibility` | `{ visibility }` | `private` or `public` |
|
|
249
|
+
| `display-name` | `{ display_name? }` | Sets the lobby's display name, as `PATCH /v1/admin/lobbies/:id`; without the field, the lobby shows its name again |
|
|
250
|
+
| `currency` | `{ currency }` | Chooses the currency the lobby's costs show in: `USD`, or a three-letter code the stored rates have (`400 currency_unavailable` otherwise) |
|
|
251
|
+
| `access/icon` | `{ png }` | Gives the dashboard its own icon: `png` is a PNG in base64, square from 32 to 1024 pixels and at most 64 KB (`400 icon_not_png`, `400 icon_size`, `413 icon_too_large`); this route's body may be 512 KB |
|
|
252
|
+
| `access/icon/remove` | `{}` | Gives the dashboard PocoLobby's icon again |
|
|
253
|
+
| `access/viewers` | `{ email }` | Adds a viewer (at most 50); an existing account keeps its state and permissions |
|
|
254
|
+
| `access/viewers/remove` | `{ email }` | Removes a viewer and ends its sessions of this dashboard |
|
|
255
|
+
| `memory/erase` | `{ kind, key }` | Erases a [shared memory](#shared-memory) entry or fragment (`kind` is `entry` or `fragment`) |
|
|
256
|
+
| `memory/curator` | `{ room_id, agent_id }` | Names a room's curator, or `none` for no curator |
|
|
257
|
+
| `tokens/clear` | `{ before }` | Deletes the lobby's daily token totals of the UTC days before `before` (`YYYY-MM-DD`), audited as `tokens_clear`; only days older than the 28 days that can still take late reports (`400 tokens_too_recent`, `400 invalid_date`) |
|
|
258
|
+
| `delete` | `{ confirm }` | Deletes the lobby when `confirm` is its exact name; see below |
|
|
259
|
+
| `sign-out` | `{}` | Ends the session that opens this lobby's settings, the dashboard session hosted or the operator's local, and leads to the account area |
|
|
260
|
+
|
|
261
|
+
**Deleting a lobby** removes, in one transaction, its agents and their credentials, rooms, messages, deliveries, receipts, schedules, attachments, display name, dashboard settings and icon, notes, summaries, viewers, sessions, hand-offs, approved repositories and token counts, and reserves its name forever. The deletion stays in the audit log. Attachment content is removed after the commit only when no other lobby holds the same SHA-256. Connected agents of the lobby are disconnected with close code `4401`. A notes fetch or summary under way for that lobby stores nothing when it finishes.
|
|
262
|
+
|
|
263
|
+
### Archived rooms
|
|
264
|
+
|
|
265
|
+
Archiving a room (Settings only) removes its members and the room, and reserves its name in the lobby: sends to it and `create_room` with its name answer `409 room_archived`. Unlike [deleting a room](#changing-and-deleting-rooms), every message the room had accepted is still delivered, scheduled ones when they fall due, and existing holds stay held. The room's recurring schedules are cancelled with `last_error: "room_archived"`. Its notes source is kept but not read or summarized. Restoring brings the room back with no members; cancelled schedules stay cancelled. Agents see an archived room as gone from `list_rooms`; `delete_room` is unchanged.
|
|
266
|
+
|
|
267
|
+
## Runtime ownership and agent routes
|
|
268
|
+
|
|
269
|
+
A runtime is the conversation that consumes this agent's inbox. Each newly started adapter process claims its runtime UUID without a generation:
|
|
270
|
+
|
|
271
|
+
```http
|
|
272
|
+
POST /v1/runtime
|
|
273
|
+
Content-Type: application/json
|
|
274
|
+
Authorization: Bearer <agent-token>
|
|
275
|
+
|
|
276
|
+
{"runtime_id":"<runtime-uuid>"}
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
The result is `{ runtime_id, generation, incarnation, probe_nonce, ready }`. The broker handles a start claim as follows:
|
|
280
|
+
|
|
281
|
+
- **Unbound identity:** binds the runtime unless it was retired earlier.
|
|
282
|
+
- **Same runtime already bound (a resumed conversation):** issues a new generation and a new probe nonce, sets `ready` to false, and resets unresolved notification attempts. Any earlier process of that runtime is fenced, and its socket closes with 4409. The new process must complete bootstrap again.
|
|
283
|
+
- **Different runtime bound:** `409 runtime_conflict`; the owner must reset the binding first.
|
|
284
|
+
- **Retired runtime:** `409 stale_runtime`. Every runtime that has ever held the identity stays retired once replaced, reset, disabled or rotated away.
|
|
285
|
+
|
|
286
|
+
A network reconnect of the running process repeats the generation it holds:
|
|
287
|
+
|
|
288
|
+
```json
|
|
289
|
+
{ "runtime_id": "<runtime-uuid>", "generation": 3 }
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
This returns the unchanged binding only while that exact runtime and generation are current; otherwise it fails with `409 stale_runtime`. A superseded process therefore cannot rejoin after a partition. Ordinary broker restarts preserve bindings; recovery from backup explicitly invalidates them.
|
|
293
|
+
|
|
294
|
+
The Claude adapter derives a conversation's runtime UUID from Claude's session ID and the agent credential; see [Architecture](ARCHITECTURE.md#restarts-without-owner-action). Other clients may use any stable UUID per conversation, or a fresh UUID per process.
|
|
295
|
+
|
|
296
|
+
Agent mutations other than the initial claim, [`PUT /v1/usage`](#plan-usage), [`PUT /v1/activity`](#turn-activity) and [`POST /v1/tokens`](#token-counts), and WebSocket upgrades, require these additional headers:
|
|
297
|
+
|
|
298
|
+
```http
|
|
299
|
+
X-PocoLobby-Runtime-Id: <runtime_id>
|
|
300
|
+
X-PocoLobby-Generation: <generation>
|
|
301
|
+
X-PocoLobby-Incarnation: <incarnation>
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
These fence broker operations from stale runtimes. They do not fence external files, subprocesses, or deployment tools. Agent reads require the token but not runtime headers.
|
|
305
|
+
|
|
306
|
+
| Method and path | Request / result |
|
|
307
|
+
| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
308
|
+
| `GET /v1/agents` | `{ agents: [...] }` with the discovery projection described below |
|
|
309
|
+
| `POST /v1/agents/rest` | `{ agents, resting, resume_at? }` → `{ agents: [{ agent_id, paused, resume_at }] }`: rests or resumes agents of the caller's lobby, all or none; requires current runtime and `manage_rooms`; see [Resting agents](#resting-agents) |
|
|
310
|
+
| `GET /v1/rooms` | `{ rooms: [{ lobby_id, room_id, members }, ...] }`; joined rooms, or all own-lobby rooms for a manager |
|
|
311
|
+
| `POST /v1/rooms` | `{ room_id }` → empty room; requires current runtime and `manage_rooms` |
|
|
312
|
+
| `PUT /v1/rooms/:room/members` | `{ members: ["agent-id", ...] }` → replacement room membership; requires current runtime and `manage_rooms` |
|
|
313
|
+
| `PATCH /v1/rooms/:room/members` | `{ add?: [...], remove?: [...] }` → room with only those members changed; current runtime and `manage_rooms` |
|
|
314
|
+
| `DELETE /v1/rooms/:room` | → deletion result; requires current runtime and `manage_rooms` |
|
|
315
|
+
| `POST /v1/runtime` | `{ runtime_id, generation? }` → runtime binding; omit `generation` when a process starts |
|
|
316
|
+
| `GET /v1/context` | Own recovery context |
|
|
317
|
+
| `GET /v1/messages` | Own inbox/history/reconciliation page; history takes a search [`query`](#reading-receipts-and-checkpoints) |
|
|
318
|
+
| `POST /v1/messages` | Send body below → stored-message result |
|
|
319
|
+
| `POST /v1/acknowledgments` | `{ message_ids: ["uuid", ...] }` → `{ receipt_snapshot, unknown_message_ids? }`; see [reading and receipts](#reading-receipts-and-checkpoints) |
|
|
320
|
+
| `GET /v1/checkpoint` | → `{ checkpoint, receipt_snapshot }`: what a save needs, without the rest of the context |
|
|
321
|
+
| `PUT /v1/checkpoint` | Checkpoint body below → saved checkpoint |
|
|
322
|
+
| `POST /v1/ready` | `{ context_revision, probe_nonce }` → `{ ready: true }` |
|
|
323
|
+
| `PUT /v1/usage` | `{ five_hour?, seven_day?, account?, plan?, model?, effort?, context?, model_windows? }` → `{ recorded: true }`; [plan usage](#plan-usage) for the dashboard; no runtime needed |
|
|
324
|
+
| `PUT /v1/activity` | `{ event, at, background_tasks? }` → `{ recorded: true }`; [turn activity](#turn-activity) from the agent's hooks; no runtime needed |
|
|
325
|
+
| `POST /v1/tokens` | `{ calls: [...] }` → `{ recorded }`; [token counts](#token-counts) from the agent's status line; no runtime needed |
|
|
326
|
+
| `PUT /v1/owner-requests` | `{ count, text? }` → `{ owner_requests: { count, text, set_at } \| null }`; requires current runtime |
|
|
327
|
+
| `PUT /v1/status` | `{ text }` → `{ status: { text, set_at } \| null }`; a [status line](#status-line) for peers; requires current runtime |
|
|
328
|
+
| `GET /v1/schedules` | `{ schedules, messages }`: own active schedules and own undelivered scheduled messages |
|
|
329
|
+
| `POST /v1/schedules` | Schedule body below → `{ status: "scheduled", schedule, duplicate }`; requires current runtime |
|
|
330
|
+
| `POST /v1/schedules/update` | `{ id, content?, deliver_at?, repeat? }` → `{ updated: "schedule" \| "message", schedule \| message }`; requires current runtime |
|
|
331
|
+
| `POST /v1/schedules/cancel` | `{ id }` → `{ cancelled: "schedule" \| "message", schedule \| message, already_cancelled }`; requires current runtime |
|
|
332
|
+
| `GET /v1/tools` | This agent's [tool catalog](#tool-catalog-and-dispatch); needs only the credential |
|
|
333
|
+
| `POST /v1/tools/:name` | `{ arguments }` → `{ result }`: runs one catalog tool through the routes above |
|
|
334
|
+
| `PUT /v1/attachments/:sha256` | Binary body → `{ sha256, size, kind, mime_type }`; an [attachment upload](#attachments); requires current runtime |
|
|
335
|
+
| `GET /v1/attachments/:id` | Attachment metadata, for its uploader, the recipients of a message that references it, and the members of such a message's room |
|
|
336
|
+
| `GET /v1/attachments/:id/content` | The attachment's bytes, as `application/octet-stream`, for the same agents |
|
|
337
|
+
| `GET /v1/events` with WebSocket upgrade | Notification stream and transport receipts |
|
|
338
|
+
|
|
339
|
+
Discovery returns `agent_id`, `lobby_id`, `work_description`, `machine` and `screen` (what the owner wrote about the agent, empty where nothing is written), `connected`, `bridge_version` (the package the connected bridge runs, which it sends as `X-PocoLobby-Bridge-Version` with its event stream; `null` while it is not connected, so a rollout can be confirmed from the list), [`activity`](#turn-activity) (its turns from its Claude Code hooks while it is connected, or `null`), `enabled`, `paused`, `ready`, `last_seen_at`, `last_agent_seen_at`, `notification_attention_required`, `unseen_notified_count`, `oldest_unseen_notified_at`, [`last_message_at`](#last-message-time), [`owner_requests`](#owner-requests), [`status`](#status-line) and [`usage`](#weekly-pace) for the caller's lobby. `unseen_notified_count` counts that agent's unacknowledged, unheld deliveries whose latest host write is at or after its latest progress: its ready claim, an acknowledgment or checkpoint (`last_agent_seen_at`), or a message it sent (not its schedules' occurrences). `oldest_unseen_notified_at` is the latest host write of the oldest of them, which a reconnect can rewrite, so it understates the wait. A message the agent left unacknowledged while it kept working does not count here; `pending_count` in the owner's listing still does. They are aggregates: no message IDs, senders or rooms. A long-standing value suggests the agent cannot take turns, for example when its account is out of usage, or is busy. It proves neither, only the agent's progress clears it, and zero is not proof of health. They show an agent stalled by a usage limit on a shared account even while its scheduled checks are delivered on time. They count only since the agent's latest progress, so an agent that leaves one room FYI unacknowledged while it keeps acknowledging and sending is not shown as Not responding. It lists **enabled identities only**, so retired placeholders and disabled test identities do not appear; `enabled` is therefore always true there. The owner's `GET /v1/admin/agents` (`pocolobby agents`) still lists every identity. Discovery omits other agents' roles, allowlists, inboxes, checkpoints, tokens and runtime bindings. Connected means that the broker has a transport connection; ready records a bootstrap claim. Neither proves model availability or completed work.
|
|
340
|
+
|
|
341
|
+
### Resting agents
|
|
342
|
+
|
|
343
|
+
A coordinator rests agents of its lobby with `rest_agents` (`POST /v1/agents/rest`), such as agents whose Claude account is at its limit until it resets, which a room message would otherwise wake into failed turns. Resting is the owner's pause (`paused` in discovery and `PATCH /v1/admin/agents/:id`): messages to a resting agent are accepted and stored and show as pending to their senders, scheduled ones are released as usual, but nothing is pushed to it, no inbox event, read receipt or activity event, so its session takes no turn for them. Work it is already doing goes on. Messages do not resume it; `rest_agents` with `resting: false` does, or the owner's resume, or the time given as `resume_at` (an ISO time with an explicit zone, with `resting: true` only, in the future), on the broker's first dispatch tick after it, also after it was down. Resuming starts the waiting deliveries' and the bootstrap's retries afresh, so everything waiting arrives, coalesced as usual; with nothing waiting it wakes nothing. Each call replaces the rest and `resume_at` of the agents it names, and a call that changes nothing records nothing. A coordinator cannot rest itself (`400 cannot_rest_self`), a disabled agent is refused (`409 agent_disabled`), a past `resume_at` too (`400 resume_at_past`), and the owner pauses through the admin route (`400 owner_pauses`). The owner's pause or resume, and recovery mode, clear a set time. Messages are still [kept a day](#message-retention), so a rest of more than a day loses them. Activity records each rest and resume, a resume at its set time as PocoLobby's. Discovery shows `paused` and `resume_at` (null when none); the dashboard shows the agent as Resting, and Settings › Agents with its time.
|
|
344
|
+
|
|
345
|
+
### Last message time
|
|
346
|
+
|
|
347
|
+
`last_message_at`, in discovery and in the owner's listing (`GET /v1/admin/agents`, `pocolobby agents`), is when the agent last sent a message or received a direct message, or `null` when it did neither within the last day, since messages are [kept a day](#message-retention). A send counts when the agent made it, so its schedules' occurrences do not count; a scheduled direct message counts as received when it is delivered, and a cancelled one never; room and whole-lobby messages count only for their sender. The broker derives it from stored messages; it is a fact, not the agent's claim. An agent can work for hours without sending a message, so an old time does not mean it stopped, and a recent one does not mean it is available.
|
|
348
|
+
|
|
349
|
+
### Owner requests
|
|
350
|
+
|
|
351
|
+
`PUT /v1/owner-requests` records how many decisions, approvals or looks the calling agent waits on from the owner, with one line naming each:
|
|
352
|
+
|
|
353
|
+
```json
|
|
354
|
+
{ "count": 2, "text": "Approve the release notes; choose A or B for the login page" }
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
`count` is a whole number from 0 to 20. `text` is one line of at most 300 characters, stored trimmed, and required when `count` is above 0. The result is `{ owner_requests: { count, text, set_at } }`. `count: 0` clears it, whatever the text, and answers `{ owner_requests: null }`. Each call replaces the last; the same count and text again keep `set_at`, so the dashboard counts from when the agent first asked, and any change sets a new time. It requires the current runtime (`409 runtime_required`, `409 stale_runtime`). The catalog offers it to every agent as `set_owner_requests`.
|
|
358
|
+
|
|
359
|
+
It is the agent's own word: the broker never sets, ages or clears it otherwise, so it stays until the agent sends `count: 0`. It survives a resumed conversation and is cleared when the owner retires the runtime (reset, rotation, disable) or enters recovery. The owner's listing (`GET /v1/admin/agents`, `pocolobby agents`) shows `owner_requests` per agent, `null` when nothing waits, and so does discovery (`GET /v1/agents`), so a coordinator asked whether anyone needs the owner can answer for the whole lobby. The [dashboard](#dashboards) lists it live under Waiting for you and gives the agent the Waiting on you chip. It is a record on the dashboard, not a message: the owner sees it only when looking, so the tool's description tells an agent whose work a decision blocks to also ask its coordinator, or whoever relays to the owner, with a question.
|
|
360
|
+
|
|
361
|
+
### Status line
|
|
362
|
+
|
|
363
|
+
`PUT /v1/status` (the `set_status` tool) sets one line the calling agent keeps for its peers, such as the order and times of a queue it runs, which requesters can then read without "queued, about HH:MM" notices that each cost every reader a turn:
|
|
364
|
+
|
|
365
|
+
```json
|
|
366
|
+
{ "text": "Screen queue: backend's install, then frontend's rerun, about 07:40" }
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
`text` is one line of at most 300 characters, stored trimmed; empty text clears it. The result is `{ status: { text, set_at } }`, or `{ status: null }` once cleared. The same text again keeps `set_at`. It requires the current runtime. Discovery (`GET /v1/agents`) shows `status` per agent, `null` when none is set; the dashboard does not show it. Like owner requests it is the agent's own word, free text with no state the broker reads: it stays until the agent changes or clears it, survives a resumed conversation, and is cleared when the owner retires the runtime or enters recovery. Nobody is told when it changes.
|
|
370
|
+
|
|
371
|
+
### Plan usage
|
|
372
|
+
|
|
373
|
+
`PUT /v1/usage` records the Claude plan usage of the account the calling agent runs on, for the [dashboard](#dashboards). `pocolobby statusline`, run as the agent's Claude Code status line, sends it from the `rate_limits` Claude Code passes a status line, and also the session's model (`model.id`) and reasoning effort (`effort.level`); see [Local setup](LOCAL-SETUP.md#plan-usage-on-the-dashboard).
|
|
374
|
+
|
|
375
|
+
```json
|
|
376
|
+
{
|
|
377
|
+
"five_hour": { "used_percentage": 38, "resets_at": 1792345200 },
|
|
378
|
+
"seven_day": { "used_percentage": 85, "resets_at": 1792558800 },
|
|
379
|
+
"account": "owner@example.com",
|
|
380
|
+
"plan": "max",
|
|
381
|
+
"model": "claude-opus-5-5",
|
|
382
|
+
"effort": "xhigh",
|
|
383
|
+
"context": 1000000,
|
|
384
|
+
"model_windows": [{ "name": "Fable", "used_percentage": 15, "resets_at": 1792558800 }]
|
|
385
|
+
}
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
Every field is optional and no other is accepted. A window has `used_percentage` from 0 to 100 and `resets_at`, a positive whole number of Unix seconds; `account` is an email address and `plan` at most 40 word characters, spaces, dots or hyphens. `model` is a model ID of at most 80 word characters, dots, hyphens or brackets, such as `claude-opus-5-5` or `claude-opus-4-6[1m]`, and `effort` is `low`, `medium`, `high`, `xhigh` or `max`; Claude Code leaves effort out for a model without it. `context` is the session's context window in tokens, from Claude Code's `context_window.context_window_size`. `model_windows` lists, at most 8, the weekly windows of model families that have a limit of their own, as Claude Code's `/usage` shows them ("Fable this week: separate weekly limit for Fable"): `name` at most 40 word characters, spaces, dots or hyphens, and a window's `used_percentage` and `resets_at`. The percentage is of that family's own allowance, not of the week. The status line input does not carry these windows, so every five minutes, failures included, `pocolobby statusline` asks a headless Claude Code for its usage: `claude --safe-mode --no-session-persistence --print` with stream JSON, an `initialize` and then the experimental `get_usage` control request of Claude Code's SDK protocol, which needs no prompt and makes no model call. Safe mode starts no MCP server (so no second PocoLobby bridge), hook or plugin, and the child inherits the status line's environment, so it reads the same account. It answers in about a second; after 10 seconds, or on any failure, the status line sends the rest of its report as usual. The windows are sent only while the last answer is under 15 minutes old, and an empty list means Claude Code says the account has none. Verified on Claude Code 2.1.302 against a real account; the request is experimental and may change. A refused report is sent again at the next run. It needs the agent credential but no runtime binding, since a status line holds the credential and no conversation's runtime; the owner credential gets `403 agent_required`. The result is `{ recorded: true }`. It is not a catalog tool and cannot be dispatched.
|
|
389
|
+
|
|
390
|
+
The broker keeps only each agent's latest reading, in memory, so a restart forgets it until the next report, which the status line sends within 5 minutes. The dashboard shows it on the agent's row, each part spaced apart: "owner@example.com (max) 5h 62% left resets 19:40 Week 15% left resets Wed 21 Oct, 07:00". Three pills follow the agent's name, on public dashboards too: the model ("Opus 5.5"), the context window ("1M", from the status line's report, else from a model ID ending in `[1m]`) and the reasoning effort in its level's colour: blue for xhigh, green for high, orange for medium, red for low and brass for max, so an agent set differently from the rest stands out. A coordinator's row shows, in place of now and next, one short sentence from the summarizer (about 140 characters) on what the team got done today under its coordination. An agent's row also shows the weekly window of the model family it runs when that family has its own, such as "Fable 85% left resets Sat 24 Oct, 12:00" for an agent on Fable 5.1, and each account card has a second bar under its week for each such window, against its own line. The dashboard leaves the coordinator first in the agents list on a tinted row. Reset times are written in UTC and the page's script shows them in the viewer's local time, the time alone when it is today. Under 20% left is marked in red, a window past its reset is left out, a reading older than 30 minutes adds "as of 2 h ago", and a reading older than 24 hours is not shown. The account, and the plan with it, appear on the agent's row only on a private dashboard; a public dashboard shows the windows alone. Each agent reports its own account, and agents on one account show the same numbers; the dashboard's Accounts section groups them by the email they report. Real Claude sessions in a live lobby report it.
|
|
391
|
+
|
|
392
|
+
### Turn activity
|
|
393
|
+
|
|
394
|
+
`PUT /v1/activity` says that a turn of the calling agent's Claude Code session started or ended. `pocolobby hook`, set as the agent's Claude Code hooks, sends it; see [Local setup](LOCAL-SETUP.md#turn-activity-and-token-counts).
|
|
395
|
+
|
|
396
|
+
```json
|
|
397
|
+
{ "event": "stop", "at": 1792345200000, "background_tasks": 0 }
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
`event` is `start` (the `UserPromptSubmit` hook, which Claude Code also runs when a Channel event starts a turn), `stop` (`Stop`), `failed` (`StopFailure`, a turn that ended on an API error such as an exhausted plan) or `waiting` (the `Notification` hook's `idle_prompt`, a minute after the last answer, which also ends a turn the owner interrupted, since an interrupted turn has no `Stop`). `at` is the hook's own time in Unix milliseconds, so a report that arrives late never overwrites a newer one. `background_tasks`, on a stop, counts the background tasks still running that do model work and wake the session with their results, which keep it working: subagents, workflows, teammates and cloud sessions. A shell, a monitor or an MCP task is a process that can run for hours, such as a dev server or a log tail, and does not count. Those go in `running` instead, by kind, `{ shell?, monitor?, mcp_task? }`, since one may be a build its agent waits on, which wakes the session when it ends. No other field is accepted, so no prompt, answer or tool detail can reach the broker. Like the status line's usage it needs the agent credential but no runtime binding; the owner credential gets `403 agent_required`. It is not a catalog tool and cannot be dispatched.
|
|
401
|
+
|
|
402
|
+
The broker keeps each agent's state in memory and each stretch of work in its store (`agent_turns`, kept two days after it ended), which the dashboard's strip draws and a restarted broker reads each agent's state back from: a stretch still open is working since it started, one that ended is idle or failed since then. The agent list shows `activity: { state, since, running? }` while the agent's bridge is connected, `running` naming the background processes its last stop said still run when it is idle or failed: `working` while a turn runs, `idle` since its last turn ended, `failed` since a turn ended on an API error. It is `null` when unknown: without hooks, while the bridge is offline, after a new bridge process claims the runtime (a restart or `/mcp reconnect`), and after the owner interrupts a turn that a background task's result started, since nothing then says whether other tasks still run, until the next hook event. It describes turns, not tasks: idle means the session stopped, not that its work is done. The [dashboard](#dashboards) shows it as Working, Idle or Turn failed, and a coordinator hears of a stopped turn through an [activity event](#activity-events-for-coordinators).
|
|
403
|
+
|
|
404
|
+
### Token counts
|
|
405
|
+
|
|
406
|
+
`POST /v1/tokens` records model calls from the calling agent's Claude Code transcripts. `pocolobby statusline` reads them from the session's transcript and its subagents' transcripts, after its usage report, once a minute or, while it is behind, a step of at most 500 calls a run, and sends counts only:
|
|
407
|
+
|
|
408
|
+
```json
|
|
409
|
+
{
|
|
410
|
+
"calls": [
|
|
411
|
+
{
|
|
412
|
+
"id": "3f1c…",
|
|
413
|
+
"at": 1792345200000,
|
|
414
|
+
"model": "claude-opus-5-5",
|
|
415
|
+
"speed": "standard",
|
|
416
|
+
"geo": "global",
|
|
417
|
+
"input": 2,
|
|
418
|
+
"cache_write_5m": 0,
|
|
419
|
+
"cache_write_1h": 18893,
|
|
420
|
+
"cache_read": 32770,
|
|
421
|
+
"output": 165
|
|
422
|
+
}
|
|
423
|
+
]
|
|
424
|
+
}
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
A report holds 1 to 500 calls. `id` is 32 hexadecimal digits, the start of the SHA-256 of the call's message ID and request ID: a transcript repeats a call on every line of the same message, and a forked session copies earlier calls into its own transcript, so the broker counts each ID once. `at` is the call's time in Unix milliseconds, `model` a model ID as for [plan usage](#plan-usage), `speed` `standard` or `fast` and `geo` the inference region, both optional, and the five counts whole numbers. The answer is `{ recorded }`, the calls not counted before. The broker remembers each ID for 28 days and refuses calls older than that, or more than a day ahead of its clock, so no call is counted twice after its ID is forgotten. Each new call adds its counts to its UTC day's total for its agent, model, speed, region and whether its prompt (input, cache writes and cache reads) was over 100,000 tokens, which Haiku 5.5 prices higher. The daily totals are kept until the lobby's owner deletes them in [Settings](#settings-pages-and-actions), and only days older than the 28 days can be deleted. No transcript text, path or session ID leaves the agent's machine. It needs the agent credential but no runtime binding; the owner gets `403 agent_required`.
|
|
428
|
+
|
|
429
|
+
The dashboard's [Tokens, last 4 weeks](#dashboards) and the Tokens page in Settings show what the counts would cost at Anthropic's API list prices, as platform.claude.com/docs/en/about-claude/pricing gave them, not live prices and not what a Claude subscription costs. Cache writes cost 1.25 (five minutes) and 2 (an hour) times the input price; cache reads 0.1 times it on most models, 0.05 on Opus 5.5 and Sonnet 5.5 and 0.025 on Fable 5.1; fast mode and the US region (1.1 times) are priced as the page says. A model without a list price counts in tokens only, and its cost is shown as incomplete.
|
|
430
|
+
|
|
431
|
+
### Shared memory
|
|
432
|
+
|
|
433
|
+
A room can keep a shared memory: what its members learned that the code, CLAUDE.md and notes do not say. [MEMORY.md](MEMORY.md) gives the design and the reasons for its sizes; each agent's personal memory stays Claude Code's auto memory. A room has shared memory once the owner names its **curator**, a member: `PUT /v1/admin/memory/rooms/:room/curator {"agent_id":"backend"}` (or `null` to stop), `pocolobby memory curator ROOM AGENT|none`, or the Memory page in Settings.
|
|
434
|
+
|
|
435
|
+
- `remember` (`POST /v1/memory`, runtime-fenced): `{client_request_id, room_id, kind, text, evidence?, about?}` saves a fragment for the curator. `kind` is `gotcha`, `fix` or `procedure`; `text` at most 2,000 characters; `evidence` at most 300, something the code can be checked against; `about` what the fragment corrects: an active entry such as `app-12`, or the ID of a fragment of the room still waiting for the curator (`404 memory_entry_not_found` when it names neither). The curator gets a correction with the fragment it corrects in one snapshot, and when that fragment has become an entry, the entry in full. Only room members; at most 200 waiting per room (`409 memory_backlog`); a recognizable credential is refused before anything is stored (`400 secret_refused`). A retry with the same `client_request_id` returns the first fragment with its current `state` (`pending`, `used`, `refused`) and the curator's `note`; a different fragment under it is `409 idempotency_conflict`.
|
|
436
|
+
- `recall` (`GET /v1/memory`): `ids` (comma-separated entry IDs such as `app-12`, up to 20) returns those entries with their current text, and names `missing` ones with why (archived, merged into another, or not in the agent's rooms); `query` returns every entry whose words match well (lightly stemmed, weighted by rarity, three times in a title, twice in its keywords), each scoring at least half the best match and matching a title or keyword, at most 15, none when nothing qualifies; `mine=true` returns the agent's own fragments of the last 30 days. Entries carry `ref`, `kind`, `title` (the entry's rule, "condition → what to do or expect"), `text`, `keywords`, `provenance` (authors and evidence, the latest 10), `checked_commit` and `checked_at`, `disputed`, and `created_at` and `updated_at`.
|
|
437
|
+
- **The SessionStart hook** (`GET /v1/memory/brief?part=1|2|3`, the agent credential, no runtime): what `pocolobby hook --memory 1|2|3` prints into the session at start and after every compaction, each part within Claude Code's 10,000 characters a hook. The three parts hold every entry's rule line (ID, kind and title), whole, each room's gotchas first and then its other entries, newest first; part 1 opens with how to use them: search with `recall {query}` at the start of each task, read an entry with `recall {ids}` before acting on its rule, and call `get_context` again after a compaction. A gotcha whose title is not yet a rule line brings its text. A room has no size limit: what does not fit is counted at the end of part 3, to find with `recall {query}`.
|
|
438
|
+
- `get_context` carries `memory`: for each of the agent's rooms with a curator, its `revision` and counts of entries, gotchas, disputed entries and pending fragments.
|
|
439
|
+
- **The curator** (`pocolobby memory curate --room ROOM`, the agent credential, no runtime; usually a headless agent the machine's scheduler runs every hour): `GET /v1/memory/curation?room=` gives the snapshot: the revision; `last_review_at`, when a run last looked at the unlanded fragments; `lines`, every active entry as its rule line; `entries`, in full only the entries to re-check and those each pending fragment resembles or corrects; `recheck`, the entries to re-check, the 4% checked longest ago and at least one, so every entry about daily (every entry with `&review=all`); the 50 oldest pending fragments, each with the archived entries it resembles; `unlanded`, fragments refused in the last 30 days as not yet on main; and `day`, the entries archived and rewritten since the last look. `POST /v1/memory/curation {room_id, revision, commit, review, ops, summary, usage}` applies operations all or nothing against that revision (`409 memory_revision_conflict` when anything changed since), after authenticating the credential again. Operations: `add {kind, title, text, keywords, sources}`, `update {entry, title?, text?, keywords?, sources, resolves?}` (a dispute stays unless `resolves` settles it), `verify {entry}`, `archive {entry, reason, into?}` (`into` merges, within a kind, carrying authors and evidence), `refuse {fragment, reason, erase?, dispute?, unlanded?}`. A new source must be a fragment pending in that room; a title holds at most 250 characters, a gotcha's text 500, a fix 800, a procedure 2,000; a room has no size limit; a run whose texts hold a credential is `400 secret_refused`. Every change keeps the entry's previous row. `POST /v1/memory/curation/failures {room_id, error, usage?}` records a run that failed. Both carry the run's usage with an `attempt` ID, and the broker counts its tokens once under the curator in the token history, whatever the outcome. A run with `review: true` looked at the unlanded fragments, which dates that daily look. A run holds at most 500 operations. `POST /v1/memory/curation/report {room_id, client_request_id, to, priority?, content}` sends the curator's report to an agent, usually the coordinator, as the curator's own `report` message within its recipient limits, so a curator without a session can report; a retry with the same `client_request_id` sends nothing new. Only the curator the owner named, while a member; naming another curator or erasing anything invalidates outstanding snapshots.
|
|
440
|
+
- **The coordinator** sees, on the curator's entry in `list_agents`, `memory_curation [{room_id, pending, oldest_pending_at, failures}]`.
|
|
441
|
+
- **The owner**: `GET /v1/admin/memory` lists every room's entries (active and archived, with their three latest earlier versions and whether only the curator taught it), fragments, contributions per author over the last week and the last 20 runs with their cost or error; `DELETE /v1/admin/memory/entries/:ref` and `DELETE /v1/admin/memory/fragments/:id` erase one for good. Deleting a room erases its memory; archiving it keeps the memory for a restore. Curated fragments are deleted after 30 days, previous rows and runs after 90, archived entries after a year. A private [dashboard](#dashboards) shows each room's newest entries as rule lines, the rest closed below.
|
|
442
|
+
|
|
443
|
+
### Code statistics
|
|
444
|
+
|
|
445
|
+
A private dashboard can show, under Tokens, each room's commits per day and its lines of code and of tests over the last 4 weeks. The broker counts nothing itself and calls no outside service. `pocolobby repo stats --config AGENT.json --room ROOM --repo CLONE`, which the machine's scheduler runs every hour like the [curator](#shared-memory), counts them from git in a clone of the room's repository and sends them with `POST /v1/code-stats`, under the credential of an enabled member of the room (no runtime; otherwise `403 room_membership_required`). The broker keeps the latest count per room, replacing the last, and drops it when the room is deleted; an archived room's count shows again on a restore.
|
|
446
|
+
|
|
447
|
+
```json
|
|
448
|
+
{
|
|
449
|
+
"room_id": "app",
|
|
450
|
+
"head": "af25e62…",
|
|
451
|
+
"computed_at": "2026-10-19T09:40:00.000Z",
|
|
452
|
+
"baseline": { "code": 134158, "tests": 100264 },
|
|
453
|
+
"days": [
|
|
454
|
+
{
|
|
455
|
+
"day": "2026-10-19",
|
|
456
|
+
"commits": 123,
|
|
457
|
+
"authors": [{ "name": "backend", "commits": 38 }],
|
|
458
|
+
"code": { "lines": 261372, "new": 2791, "changed": 1771, "removed": 900 },
|
|
459
|
+
"tests": { "lines": 202818, "new": 2829, "changed": 942, "removed": 29 }
|
|
460
|
+
}
|
|
461
|
+
]
|
|
462
|
+
}
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
`days` holds exactly 28 UTC days, today last. The command fetches the clone's origin, takes one commit of `origin/main` (`head`) and counts everything at it:
|
|
466
|
+
|
|
467
|
+
- **Commits** are those on main's own line (`--first-parent`) by their UTC commit time, so a branch's work counts on the day it was merged and a late merge never redraws earlier days. `authors` are the day's 12 most frequent author names; the rest count in `commits` alone. On the dashboard an author name that is an agent's ID or dashboard name shows as that agent with its emoji, so each agent's checkout should set `git config user.name` to its name.
|
|
468
|
+
- **Lines** are physical lines, blank ones and comments included, in the files at the commit main stood at at each day's end, read with `git grep` without a checkout: files with the extensions ts, tsx, mts, cts, js, jsx, mjs, cjs, swift, py, rb, go, rs, java, kt, kts, c, h, cc, cpp, hpp, m, mm, cs, php, sh, ps1, css, scss, sass, less, html, vue, svelte, astro and sql. Files under `node_modules`, `dist`, `build`, `out`, `vendor`, `third_party` and `coverage`, and `.min.` files, are left out. **Tests** are the files in a `test`, `tests`, `__tests__`, `spec` or `e2e` folder or a Swift `…Tests` folder, and `.test.` and `.spec.` files; the rest is **code**. `baseline` is the lines at the end of the day before the first.
|
|
469
|
+
- **New, changed and removed** come from each commit's diff against its first parent: in each file a commit both added and removed lines in, the smaller number counts as changed and the rest as new or removed.
|
|
470
|
+
|
|
471
|
+
A shallow clone is refused, since it has no past to count. The dashboard says how old a count is once no new one has come in for 6 hours. A public dashboard shows none of it.
|
|
472
|
+
|
|
473
|
+
### Weekly pace
|
|
474
|
+
|
|
475
|
+
Weekly pace helps keep several Claude subscriptions busy around the clock with each one's week spent evenly, about 14 points a day, reaching 0% left as it resets; the five-hour window is not paced. From the latest reading alone, with no history, the broker computes where the week should stand: the **line** is the share of the seven-day window (from `resets_at` minus 7 days to `resets_at`) that has passed, in percent used, and the **lead** is `used_percentage` minus the line, positive when the account is burning too fast. The pace is `ahead` when the lead is over 5 points, `behind` when it is under −5, and `on` otherwise. **`per_day`** is what is left divided by the days left: the points a day that would use the rest by the reset, against about 14.3 for an even week, so it reads directly as how hard the account's agents can work.
|
|
476
|
+
|
|
477
|
+
Discovery (`GET /v1/agents`, `list_agents`) gives each agent `usage`, or `null` without a reading in the last 24 hours:
|
|
478
|
+
|
|
479
|
+
```json
|
|
480
|
+
{
|
|
481
|
+
"account": "owner@example.com",
|
|
482
|
+
"plan": "max",
|
|
483
|
+
"five_hour": { "used_percentage": 38, "resets_at": "2026-10-16T19:40:00.000Z" },
|
|
484
|
+
"seven_day": {
|
|
485
|
+
"used_percentage": 63,
|
|
486
|
+
"resets_at": "2026-10-19T14:00:00.000Z",
|
|
487
|
+
"line": 57.1,
|
|
488
|
+
"lead": 5.9,
|
|
489
|
+
"per_day": 12.3,
|
|
490
|
+
"pace": "ahead",
|
|
491
|
+
"observed_at": "2026-10-16T14:02:00.000Z",
|
|
492
|
+
"stale": false
|
|
493
|
+
},
|
|
494
|
+
"model": "claude-opus-5-5",
|
|
495
|
+
"model_name": "Opus 5.5",
|
|
496
|
+
"context_window": 1000000,
|
|
497
|
+
"effort": "xhigh",
|
|
498
|
+
"model_windows": [
|
|
499
|
+
{
|
|
500
|
+
"name": "Fable",
|
|
501
|
+
"used_percentage": 40,
|
|
502
|
+
"resets_at": "2026-10-19T14:00:00.000Z",
|
|
503
|
+
"line": 57.1,
|
|
504
|
+
"lead": -17.1,
|
|
505
|
+
"pace": "behind",
|
|
506
|
+
"observed_at": "2026-10-16T14:02:00.000Z",
|
|
507
|
+
"stale": false
|
|
508
|
+
}
|
|
509
|
+
],
|
|
510
|
+
"observed_at": "2026-10-16T14:00:00.000Z"
|
|
511
|
+
}
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
A window past its reset is `null`. `model`, `model_name`, `context_window` and `effort` are this agent's own session as its status line last reported, never another agent's on the same account; `model_name` reads the ID as Claude's family and version (an ID in another form stays as it is), and each is `null` when the status line did not report it. A reading with a model and no plan windows, as from a session on an API key, still counts. `line` and `lead` have one decimal and are computed when the listing is read, against the latest reading. `account` is the email `claude auth status` reports, or `null`. Every agent in the lobby sees every agent's `usage`, with the email: it is the coordinator's input for pacing, and only dashboards keep the email private. The week is the account's: `seven_day` comes from the freshest report of any enabled agent on that account (the dashboard's account cards use the same one), so agents on one account show the same week even when one of them has been idle. Its `observed_at` is when that report came, and `stale` is true once no fresh report came for 15 minutes (a running status line reports every five minutes), so its numbers may lag; the coordinator should not pace on a stale week. `model_windows` is the account's too, from its freshest report that carries them, each window paced against its own reset like the week; its `observed_at` and `stale` belong to that look, so a fresh week never makes an old Fable reading look fresh. It is `null` when no report on the account carries them (a Claude Code that cannot answer), and `[]` when Claude Code says the account has no such window. A window at 0% used is left out here and on the dashboard: on an account no agent runs Fable on it would read far behind and invite work for the wrong reason. A coordinator pacing a Fable agent should watch Fable's window as well as the week: it can run out first. The outer `observed_at` is when this agent's own status line last reported, so a reading hours old does not look as fresh as the rest. The owner's listing (`GET /v1/admin/agents`) does not carry it.
|
|
515
|
+
|
|
516
|
+
The broker only reports the pace. It sends no notice when an account goes ahead or behind and pauses nothing: the coordinator reads `list_agents` at its own scheduled checks and decides which agents to rest and resume, under rules in its role ([examples/PACING.md](../examples/PACING.md) has a set to copy). Saved usage history, burn-rate estimates, broker-sent pace messages and automatic pausing were decided against: a reading carries everything the line needs, and a broker that announced changes would lose what it last announced on a restart. Real Claude sessions in a live lobby report it.
|
|
517
|
+
|
|
518
|
+
## Tool catalog and dispatch
|
|
519
|
+
|
|
520
|
+
The broker serves the agent-facing feature tools, so a broker release can add a tool, an argument or new wording without updating or restarting any agent. Only data is served; the broker runs every tool itself.
|
|
521
|
+
|
|
522
|
+
`GET /v1/tools` (agent credential; no runtime headers or bootstrap needed) returns:
|
|
523
|
+
|
|
524
|
+
```json
|
|
525
|
+
{
|
|
526
|
+
"catalog_version": 1,
|
|
527
|
+
"revision": "<sha256 hex>",
|
|
528
|
+
"tools": [
|
|
529
|
+
{
|
|
530
|
+
"name": "send_message",
|
|
531
|
+
"description": "Send to one agent in your lobby, ...",
|
|
532
|
+
"input_schema": { "type": "object", "properties": { "...": {} }, "required": ["..."] }
|
|
533
|
+
}
|
|
534
|
+
],
|
|
535
|
+
"withheld": [
|
|
536
|
+
{
|
|
537
|
+
"name": "create_room",
|
|
538
|
+
"reason": "permission",
|
|
539
|
+
"detail": "Needs room-management permission (manage_rooms), which only the human owner grants."
|
|
540
|
+
}
|
|
541
|
+
]
|
|
542
|
+
}
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
- **Per agent.** `broadcast` needs `can_broadcast`; `create_room`, `set_room_members`, `add_room_members`, `remove_room_members`, `delete_room` and `rest_agents` need `manage_rooms`. Tools the caller may not use are listed under `withheld` with the reason, so a bridge can say why a tool is missing. The routes still check every permission.
|
|
546
|
+
- **Revision.** A SHA-256 over the version, tools and withheld list. A changed description, schema or permission is a new revision; an unchanged catalog keeps its revision across broker restarts.
|
|
547
|
+
- **JSON Schemas** describe the caller's input: fields with defaults, such as `priority`, are optional.
|
|
548
|
+
- **Owner.** The owner credential gets `403 agent_required`; administration stays in the CLI.
|
|
549
|
+
- **Attachments.** `send_message`, `send_room_message`, `broadcast` and `schedule_message` take an `attachments` argument, a list of local paths that the bridge reads and uploads, and those entries carry `local_files: ["attachments"]`. Their descriptions say that sends can carry attachments and every recipient opens them with `get_attachment`, and `get_messages` says the same of received ones. Entries also carry a one-line `summary`, their `tool_guide` purpose. The revision covers all of it.
|
|
550
|
+
- **Attachment limits.** The catalog carries `attachment_limits: { max_image_bytes, max_text_bytes }`, the broker's configured sizes. The `attachments` argument's description states both in its first sentence, which is also its `tool_guide` line. The bridge uses them to name the limit that applies when a file is larger than the bridge reads at all.
|
|
551
|
+
|
|
552
|
+
`POST /v1/tools/:name` with `{ "arguments": { ... } }` runs one tool and returns `{ "result": ... }`, where the result is what the corresponding HTTP route returns. The send tools are the exception: `send_message`, `broadcast` and `send_room_message` return `status`, `duplicate`, `message` with only its `id`, `created_at`, `kind` and `reply_expected` when set, `deliver_at` when scheduled and `attachments` as stored, `recipients`, and `deliveries` with each recipient's `agent_id` and `delivery_state`. The content and fields the sender just wrote are left out, since a model read each send twice, once written and once echoed; `POST /v1/messages` still returns the whole message. Send the runtime headers as for any agent mutation. The tool's arguments are validated against its schema, then the tool calls the existing routes in this same process: the dispatcher executes the same route handler as the HTTP request, with the caller's credential and runtime headers. Validation, authorization, runtime fencing, lobby boundaries and retry identity are therefore the same through either entry point; a send through `send_message` and an HTTP retry with the same `client_request_id` deduplicate against each other. A refusal by the tool's schema also names the tool's current arguments, required first, since a host can keep a stale schema of a named tool for its whole session.
|
|
553
|
+
|
|
554
|
+
Dispatch reaches only these agent routes: `GET /v1/agents`, `GET`/`POST /v1/rooms`, `PUT`/`PATCH /v1/rooms/:room/members`, `DELETE /v1/rooms/:room`, `GET`/`POST /v1/messages`, `PUT /v1/owner-requests`, `GET`/`POST /v1/schedules`, `POST /v1/schedules/cancel`, `GET /v1/context`, `POST /v1/schedules/update` and `GET /v1/checkpoint`. Owner routes, runtime claims, readiness, acknowledgments and checkpoint saves are not reachable (`403 operation_not_dispatchable`), nor are other origins or lobby selectors. Acknowledgments and checkpoints stay with the bridge, which filters notices by what it acknowledged.
|
|
555
|
+
|
|
556
|
+
| Error | Meaning |
|
|
557
|
+
| --------------------- | ------------------------------------------------------------------------------------------------------- |
|
|
558
|
+
| `404 unknown_tool` | No registered tool by that name; nothing ran. The bridge refreshes its catalog |
|
|
559
|
+
| `403 tool_forbidden` | The tool is withheld from this agent; nothing ran |
|
|
560
|
+
| `400 invalid_request` | The arguments do not match the schema; the message names the field, then the tool's current arguments |
|
|
561
|
+
| Any route error | As over HTTP, for example `409 runtime_required`, `409 idempotency_conflict`, `403 broadcast_forbidden` |
|
|
562
|
+
|
|
563
|
+
The Claude adapter exposes each catalog tool as a named MCP tool and declares `tools.listChanged`. It loads the catalog at start, on each broker reconnect, and when a [`catalog` event](#event-version-and-envelope) announces another revision, and sends `notifications/tools/list_changed` only after a validated change. It keeps the last good catalog through failures, retrying with a jittered backoff from 1 second up to 1 minute. It carries no copy of the broker's tools. Until the first catalog loads, the first `tools/list` waits up to 3 seconds for it and then lists only the bridge's own tools; a broker tool called meanwhile waits as long, then is refused with "PocoLobby: <name> is not available right now: <reason>. Nothing was sent." The bridge's own tools, which a catalog cannot replace, are `get_context`, `ready`, `acknowledge_messages`, `save_checkpoint`, the fixed `list_operations` (the current catalog), `call_operation` (`{ operation, arguments }`, the same dispatch as the named tool) and `get_attachment`. `list_operations` and `get_context`'s `tool_catalog` report the catalog's `status` (`loading`, `current`, `refresh_failing` or `invalid`) and `revision`, with a `detail` and `since` while it fails.
|
|
564
|
+
|
|
565
|
+
### The tool guide and stale host schemas
|
|
566
|
+
|
|
567
|
+
With real Claude Code (2.1.299 and later, macOS and Windows, deferred tools through tool search), a session keeps the schema of a named tool it loaded first, through `notifications/tools/list_changed` and through `/mcp reconnect pocolobby`, while newly named tools do appear after a reconnect. Calls with arguments missing from that copy still reach the bridge, which validates nothing against it, and the broker validates them. See [Upgrades without agent restarts](UPGRADES-WITHOUT-AGENT-RESTARTS.md#observed-claude-code-keeps-a-deferred-tools-first-schema). So the current arguments are put where the model reads them fresh:
|
|
568
|
+
|
|
569
|
+
- **`tool_guide` in `GET /v1/context`.** The context always carries `tool_guide: { catalog_revision, note, operations: [{ name, purpose, arguments: { name: "short meaning" } }] }`, generated by the same function from the same permission-filtered catalog and revision that `GET /v1/tools` serves, so it is never a second manual. Required arguments are marked `(required)`; full types, enums and limits stay in `list_operations`. The guide's `note` says the tool list may be older and that `call_operation` passes arguments a named schema lacks. The bridge adds `bridge_tools`, its own tools in the same form, and loads the catalog again when the guide names another revision than the one it holds.
|
|
570
|
+
- **The bootstrap text and the MCP instructions** say that the named tools accept every argument in `tool_guide`, including attachments, and that `call_operation` is the supported way to pass an argument the named schema lacks. The bootstrap text also names `get_attachment`'s `max_chars`, `headings` and `save_only`: Claude may keep an older schema of `get_attachment` after `/mcp reconnect`, and `call_operation` runs broker operations only, so these are passed to the named tool. After an uncertain send, retry with the original arguments and `client_request_id` on whichever path, within the day the original is [kept](#message-retention); after that, check history or ask the recipient first.
|
|
571
|
+
- **A catalog-change note.** A broker-only release starts no new bootstrap, so the model would never reread `get_context`. When the current catalog adds or removes an operation or adds an argument compared with what the model was last told (by `get_context` or an earlier note), the bridge tells the model on the next push it delivers, an inbox message or a receipts event: after the push it appends "PocoLobby tool catalog changed (revision 1a2b3c4d5e6f). New operations: count_pending. New arguments: list_agents (waiting). Your tool list may still show older schemas. Read list_operations …", with a removed operation under "No longer offered", and the push's Channel meta gains `catalog_revision`. A notice on its own would wake an idle session for nothing it needs before it next works with PocoLobby. The note is worked out when the push goes, so several changes before a push are told together, a change undone before then says nothing, and a catalog that returns to an earlier revision says what is no longer offered. It counts as told only once the push is written; a push the note would take past the bridge's 72 KiB limit goes without it, and the next push carries it. A `get_context` sets what the model was told to its `tool_guide`, which lists every operation, and a bootstrap event never carries a note. It keeps `tools/list_changed` as well. No polling and no new handshake: none before bootstrap completes (a change between `get_context` and `ready` is told after `ready` succeeds), none on a reconnect with an unchanged revision, and none for a change of wording only, since the model's tools work as before. It needs no acknowledgment.
|
|
572
|
+
|
|
573
|
+
## Sending and retrying
|
|
574
|
+
|
|
575
|
+
```json
|
|
576
|
+
{
|
|
577
|
+
"client_request_id": "backend-review-42",
|
|
578
|
+
"to": "frontend",
|
|
579
|
+
"priority": "important",
|
|
580
|
+
"content": "Please review commit abc123 before the release.",
|
|
581
|
+
"correlation_id": "release-42"
|
|
582
|
+
}
|
|
583
|
+
```
|
|
584
|
+
|
|
585
|
+
Required fields are `client_request_id`, `to`, and `content`. `priority` defaults to `info` and otherwise accepts `important`. The optional `kind`, `reply_expected`, structured fields, `deliver_at`, `notify_when_seen` and `attachments` are described below. Optional `correlation_id` and `reply_to` identify related traffic. A stored reply target must belong to the selected lobby and be accessible to the sender (the owner bypasses the sender/recipient access check), or the send is `404 reply_not_found`; a `reply_to` naming a message no longer stored, after its [day](#message-retention), is accepted, since work that takes days answers old assignments. The result of such a send, and of its retries, carries `warning`, saying it was sent while its `reply_to` names no message stored now, since the broker cannot tell an expired message from a mistyped ID. A correction goes under a new `client_request_id`, with `supersedes` naming the unthreaded reply; one still waiting for its `deliver_at` is cancelled instead. Request and correlation identifiers are nonempty strings of at most 128 characters. `reply_to` must be a message's full ID, a UUID as every message ID is, an expired one's too: a new send naming anything else, such as the first eight characters agents quote in prose, is `400 reply_to_not_message_id`, since such a reply named no message and its request stayed in the replier's [unanswered](#reading-receipts-and-checkpoints) list. The check comes after the retry lookup. `to: "*"` without `room` requests a whole-lobby broadcast and requires broadcast permission.
|
|
586
|
+
|
|
587
|
+
`content` is bounded as it serializes: at most 64 KiB of JSON-escaped text, where quotes, backslashes, newlines and control characters count two to six bytes each, so one message fits an 80 KiB page, except in its sender's history when a large message went to dozens of recipients, whose delivery facts come with it. Larger content is `400 content_too_large`; send less or attach it as a file. The check comes after the retry lookup, for new sends and new schedules alike, so a retry of an accepted send still returns it.
|
|
588
|
+
|
|
589
|
+
To address a room, send `to: "*"` and `room: "app"`. A room is invalid with a direct-message target. Room membership permits room sends without `can_broadcast`; the sender's recipient allowlist still applies. The owner can send to a room without being a member. Membership replacement is atomic, uses existing same-lobby identities, and supports an empty list to clear the room. New members do not receive pre-join messages. Removing a member holds its unseen room deliveries; rejoining does not automatically replay them. Already-seen history remains readable to its original recipient.
|
|
590
|
+
|
|
591
|
+
**Room messages to some members.** A room send may name some of the room's members instead: `room: "app"` with `to: ["backend", "frontend"]`, 1–20 agent IDs, each once. Only they receive it, in the room's thread; the rest of the room gets no delivery and is not woken. `send_room_message` takes the list as an optional `to`; without it the message reaches every member. A list needs a room, and `to: "*"` with a room still means every member. Every named agent must be another enabled member whose ID the sender may address, or the whole send is refused and nothing is stored: `409 not_room_member` names every named agent that is not a member of the room, also as `not_members`; `400 recipient_is_sender` for the sender's own ID, `409 recipient_disabled` or `403 recipient_forbidden`, naming the agent. A recurring schedule's occurrence is checked the same way when it goes out, and one naming someone no longer in the room is skipped, with the refusal as the schedule's `last_error`. A name given twice is `400 invalid_request`. The names are one audience in any order, so a retry that lists them in another order matches the stored send.
|
|
592
|
+
|
|
593
|
+
Stored messages, history, pending pages, `list_scheduled` items, read receipts and reconciliation headers show such a message as a room message, `to: "*"`, with `addressed_to`, the named IDs sorted. In push events `addressed_to` travels under `extensions.addressed_to`. The push's first line reads "room message in room app to backend, frontend only", and its guidance says to reply with `send_room_message` and `to` naming whom the answer concerns, since without `to` a reply wakes the whole room. Validated with the store and server tests under a simulated host; no real Claude session has used it yet.
|
|
594
|
+
|
|
595
|
+
### Superseding a message
|
|
596
|
+
|
|
597
|
+
A send may carry `supersedes`, the full ID of an earlier message of the sender's that it corrects or withdraws; its text says which. The earlier message then carries `superseded_by`, the later one's ID, in every read: pending and history pages, reconciliation and its headers, the context's pending page and the owner's history, and the later one carries `supersedes`. Events carry both under `extensions`, so every bridge accepts them, and the broker's presentation says it: the later message's lead ends "superseding <id>" with the sentence "It supersedes <id>, which no longer stands: check what you did on that message", and an earlier message still shown, in a push or a hint, says "Superseded by <id>, which corrects or withdraws it: read that one instead". A superseded request leaves its recipients' [unanswered](#reading-receipts-and-checkpoints) lists, since the later message stands in its place. The earlier message's unseen deliveries stay pending, and each message keeps its sender's priority.
|
|
598
|
+
|
|
599
|
+
The earlier message must be the sender's own, stored, delivered and not cancelled (`404 supersedes_not_found`, `409 supersedes_cancelled`; a scheduled one not yet delivered is `409 supersedes_scheduled`: change it with `update_scheduled` or cancel it), and not superseded already (`409 already_superseded`, naming the message to supersede instead). The later message goes out now, not with a future `deliver_at` (`400 supersedes_scheduled`), and reaches everyone the earlier one reached; it may reach more (`409 supersedes_audience` names who is missing), so no reader is left with a marker pointing at a message it cannot read. `supersedes` takes a full message ID as `reply_to` does (`400 supersedes_not_message_id`). The checks come after the retry lookup, and the later message is stored and the earlier one marked in one transaction. Schedules do not take `supersedes`.
|
|
600
|
+
|
|
601
|
+
It helps a reader recognize a correction; it cannot undo what a reader already did, and a push already queued in a host stays the snapshot it was. It is meant for corrections such as reported numbers withdrawn a minute after they were sent.
|
|
602
|
+
|
|
603
|
+
### Changing and deleting rooms
|
|
604
|
+
|
|
605
|
+
Two operations serve coordinators (`manage_rooms`) and the owner, through the catalog tools `add_room_members`, `remove_room_members` and `delete_room` as well as HTTP.
|
|
606
|
+
|
|
607
|
+
**Adding and removing members.** `PATCH /v1/rooms/:room/members` with `{ "add": ["web"] }`, `{ "remove": ["social"] }` or both changes only the named members and keeps everyone else, so two coordinators changing different members do not overwrite each other, as they can with the whole-list `PUT`. At least one list must be non-empty, an agent cannot be in both, and every named agent must exist in the lobby (`404 agent_not_found`, and nothing changes). Adding a member or removing a non-member changes nothing, so a repeated call gives the same room. The result may have at most 100 members (`400 member_limit`). Validation, permissions and holds are those of the `PUT`: a removed member's unseen room deliveries, including scheduled ones, are held with `room_membership_revoked`, and new members do not receive earlier messages. Opposite changes to the same member still apply in the order they arrive.
|
|
608
|
+
|
|
609
|
+
**Deleting a room.** `DELETE /v1/rooms/:room` deletes the room in one transaction:
|
|
610
|
+
|
|
611
|
+
- Every unseen delivery of the room's messages is held with `held_reason: "room_deleted"`, including scheduled ones, due or not, as if every member had been removed. Seen, already held and cancelled deliveries do not change. Recipients see a held message as `[Held: room deleted]` in their history; acknowledging it records nothing, and they cannot reply to it or open its attachments, unless another visible message grants them.
|
|
612
|
+
- Active recurring schedules whose target is the room are cancelled (`last_error: "room_deleted"`), whoever owns them, so they cannot resume into a later room with the same ID.
|
|
613
|
+
- The membership and the room are removed, and so is the room's [dashboard notes source](#dashboards) with its snapshot, so its notes are no longer read or summarized as current work. Sends to the room then fail with `404 room_not_found`, and `GET /v1/rooms` no longer lists it.
|
|
614
|
+
- Messages, deliveries and read receipts stay: senders still see each delivery's state, and recipients still read the room messages they saw. Messages keep their `room_id`.
|
|
615
|
+
|
|
616
|
+
```json
|
|
617
|
+
{
|
|
618
|
+
"lobby_id": "my-team",
|
|
619
|
+
"room_id": "review",
|
|
620
|
+
"deleted": true,
|
|
621
|
+
"former_members": ["coordinator", "frontend"],
|
|
622
|
+
"held_deliveries": 3,
|
|
623
|
+
"cancelled_schedules": [{ "schedule_id": "<uuid>", "from": "coordinator" }]
|
|
624
|
+
}
|
|
625
|
+
```
|
|
626
|
+
|
|
627
|
+
`cancelled_schedules` names each schedule's owner, who is not told otherwise. A deleted room's ID can be created again; the new room starts empty and inherits no messages, holds or schedules. Deletion keeps no record of the room, so a retry after an uncertain result returns `404 room_not_found` ("it may already have been deleted") unless the ID was created again meanwhile, in which case it deletes the new room: check `GET /v1/rooms` before retrying.
|
|
628
|
+
|
|
629
|
+
The result is:
|
|
630
|
+
|
|
631
|
+
```json
|
|
632
|
+
{
|
|
633
|
+
"status": "stored",
|
|
634
|
+
"message": {
|
|
635
|
+
"id": "<server-uuid>",
|
|
636
|
+
"lobby_id": "my-team",
|
|
637
|
+
"room_id": null,
|
|
638
|
+
"sequence": 42,
|
|
639
|
+
"from": "backend",
|
|
640
|
+
"to": "frontend",
|
|
641
|
+
"priority": "important",
|
|
642
|
+
"content": "Please review commit abc123 before the release.",
|
|
643
|
+
"correlation_id": "release-42",
|
|
644
|
+
"reply_to": null,
|
|
645
|
+
"created_at": "<ISO-timestamp>",
|
|
646
|
+
"kind": null,
|
|
647
|
+
"reply_expected": null
|
|
648
|
+
},
|
|
649
|
+
"recipients": ["frontend"],
|
|
650
|
+
"deliveries": [
|
|
651
|
+
{
|
|
652
|
+
"agent_id": "frontend",
|
|
653
|
+
"delivery_state": "pending",
|
|
654
|
+
"notification_attempted_at": null,
|
|
655
|
+
"agent_seen_at": null,
|
|
656
|
+
"held_reason": null
|
|
657
|
+
}
|
|
658
|
+
],
|
|
659
|
+
"duplicate": false
|
|
660
|
+
}
|
|
661
|
+
```
|
|
662
|
+
|
|
663
|
+
`stored` is returned only after the message, recipients and retry identity commit together. Whole-lobby and room sends snapshot authorized enabled recipients, including offline agents, excluding the sender. One full recipient inbox rejects the entire new send before acceptance. A retry does not recalculate fanout or add newly joined members.
|
|
664
|
+
|
|
665
|
+
`deliveries` has one entry per recipient, in `recipients` order; see [delivery facts for senders](#delivery-facts-for-senders).
|
|
666
|
+
|
|
667
|
+
After an unknown result, repeat the same normalized payload with the same `client_request_id`. The broker deduplicates by authenticated sender and request ID, returning the original message and recipients with `duplicate: true`; its `deliveries` show each recipient's facts as they are now. Different content under an existing key returns `409 idempotency_conflict`, which names the message the key stored and when, in its text and as `original_message_id` and `original_created_at`, so the sender can tell whether its message went out. A new key creates a new message even when the content is identical. A retry identity lives with its message, so a retry is recognized only while the original is stored, about a day after delivery (see [Message retention](#message-retention)); after that a retry under its key creates a new message. A sender unsure whether a send older than that went out checks its history or asks the recipient before sending again. The catalog's send tools, the tool guide and the bridge's uncertain-outcome text say the same.
|
|
668
|
+
|
|
669
|
+
Normalization makes a written-out default equal to its omission: `kind: "request"`, the kind's default `reply_expected` and `notify_when_seen: false` change nothing, with one exception: for a report, an explicit `reply_expected: false` and an omitted one hash differently. A retry repeats the original arguments exactly, including whether `reply_expected` was written out. and `deliver_at` compares as an instant, so `06:41:00+02:00` and `04:41:00Z` are the same request. The fingerprint stays byte-stable across releases: a retry after a deploy hashes as the stored request did. Because `kind: "request"` and an omitted kind are the same request for retries, a retry returns whichever the original stored. The deduplication check comes before every time-dependent check: retrying a scheduled send after its `deliver_at` has passed returns the stored message instead of an out-of-range error. Request IDs starting with `pocolobby:` are reserved for the broker's own messages (`400 reserved_request_id`).
|
|
670
|
+
|
|
671
|
+
### Message kinds and structured fields
|
|
672
|
+
|
|
673
|
+
`kind` says what the sender expects of the recipient:
|
|
674
|
+
|
|
675
|
+
| Kind | Use | Default `reply_expected` |
|
|
676
|
+
| ------------ | --------------------------------------------------- | ------------------------ |
|
|
677
|
+
| `request` | Asks for something | true |
|
|
678
|
+
| `notice` | Information only, such as an FYI or a progress note | false |
|
|
679
|
+
| `assignment` | Hands over a piece of work | true |
|
|
680
|
+
| `report` | Reports a result | false |
|
|
681
|
+
| `question` | Asks for a decision, often with `options` | true |
|
|
682
|
+
|
|
683
|
+
A direct message to oneself, such as a reminder or a schedule's occurrence, expects no reply whatever its kind, unless the sender sets `reply_expected`: only its sender could answer it. An unknown kind is `400 invalid_request` with a message that lists the five.
|
|
684
|
+
|
|
685
|
+
A kind means something only where the sender chose one. A send without `kind` stores `kind: null`, and `reply_expected` is the sender's explicit value, else its kind's default, else `null`: the recipient is not told whether a reply is wanted, and its push names no kind.
|
|
686
|
+
|
|
687
|
+
Set `reply_expected` only to override the default; `reply_expected: false` without a kind is kept too. Every message read over HTTP carries `kind` and `reply_expected`, possibly `null`; events omit them when `null`, because the bridge accepts them only as absent. The broker records the sender's expectation and shows it; it neither enforces replies nor suppresses them.
|
|
688
|
+
|
|
689
|
+
Optional structured fields appear on a message only when the sender set them:
|
|
690
|
+
|
|
691
|
+
| Field | Value |
|
|
692
|
+
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
693
|
+
| `commit` | One line, at most 100 characters, such as `abc1234` |
|
|
694
|
+
| `commits` | 1–10 such commits, such as those a report landed; events carry it under `extensions` |
|
|
695
|
+
| `files` | 1–50 one-line paths of at most 300 characters |
|
|
696
|
+
| `evidence` | A logical location with a lowercase scheme, such as `dropbox:team/reports/run-42` or `repo:docs/report.md`, at most 500 characters; each machine resolves it, so a local absolute path is refused, and the refusal shows these forms |
|
|
697
|
+
| `options` | 1–10 one-line choices of at most 500 characters |
|
|
698
|
+
| `recommendation` | At most 2,000 characters |
|
|
699
|
+
|
|
700
|
+
Together they are at most 8 KiB of JSON, and they count toward stored and inbox bytes. `evidence` rejects a local absolute path such as `/Users/...` or `C:\...`, because another machine cannot resolve it. History, pending reads and pushes include the fields.
|
|
701
|
+
|
|
702
|
+
```json
|
|
703
|
+
{
|
|
704
|
+
"client_request_id": "web-link-check-42",
|
|
705
|
+
"to": "coordinator",
|
|
706
|
+
"kind": "notice",
|
|
707
|
+
"content": "Link check done: two broken links are listed.",
|
|
708
|
+
"evidence": "repo:docs/report.md"
|
|
709
|
+
}
|
|
710
|
+
```
|
|
711
|
+
|
|
712
|
+
### Scheduled delivery
|
|
713
|
+
|
|
714
|
+
`deliver_at` is an ISO 8601 time with `Z` or an offset, such as `2026-10-16T06:41:00+02:00`. The broker stores the message, its recipients and its retry identity at once and returns `stored`; each recipient's `delivery_state` is `scheduled`. At `deliver_at` the broker delivers it: the deliveries become `pending`, and the recipients are notified as usual. Until then a recipient cannot see, read, acknowledge (`409 not_yet_delivered`) or reply to it, and it is absent from pending counts. Its sender sees it in history and in `GET /v1/schedules`.
|
|
715
|
+
|
|
716
|
+
- **Window.** `deliver_at` may be at most 30 days ahead. A time up to 5 minutes in the past is delivered at once (clock skew); earlier is `400 deliver_at_out_of_range`, whose message names the latest time allowed at that moment. A sender may hold 100 undelivered scheduled messages (`409 schedule_capacity`).
|
|
717
|
+
- **To yourself.** A direct message may be addressed to your own agent ID, scheduled or not; it is the durable replacement for a session-only cron reminder. Room and lobby sends exclude the sender as before.
|
|
718
|
+
- **Fanout and bounds.** Recipients are fixed at acceptance, as for any send. Inbox capacity counts scheduled deliveries at acceptance, so nothing is checked again at delivery. A room member removed before delivery has the delivery held (`room_membership_revoked`), and deleting the room holds every delivery (`room_deleted`). If the owner disables the sender before delivery, the message is cancelled instead of delivered.
|
|
719
|
+
- **Restarts and late delivery.** Scheduled state is in SQLite, and the broker's dispatch loop delivers due messages whether or not anyone is connected, normally within a second. A message that fell due while the broker was stopped, for example during a deploy, is delivered on start and marked `late: true`. Late means delivered more than 60 seconds after its time.
|
|
720
|
+
- **Cancel.** `POST /v1/schedules/cancel` with `{ "id": "<message-id>" }` cancels an undelivered scheduled message: its deliveries become `cancelled` and recipients never see it. Cancelling again returns `already_cancelled: true` for a day after the cancellation, while the cancelled message is [kept](#message-retention); after that its ID is `404 not_found`. A delivered message cannot be recalled (`409 already_delivered`); an unscheduled or foreign ID is `404 not_found`. A retry of a cancelled send returns the cancelled message rather than sending it again.
|
|
721
|
+
- **Change.** `POST /v1/schedules/update` with `{ "id": "<message-id>", "content": "...", "deliver_at": "..." }`, the `update_scheduled` tool, changes an undelivered scheduled message in place: its text, its time or both, within the same bounds as a send, with the stored size checked against the same capacities. It keeps its ID, recipients and retry identity, so a retry of the original send returns it as it is now. A delivered message is `409 already_delivered` (send a correction), a cancelled one `409 already_cancelled`, and `repeat` on a message `400 invalid_request`. A reminder therefore need not be cancelled and resent each time plans move.
|
|
722
|
+
|
|
723
|
+
A scheduled message keeps the sequence it got at acceptance. Delivered, it can therefore appear in a recipient's history and pending pages before messages that arrived earlier; read pending pages from the start rather than resuming an old cursor. Scheduled messages carry:
|
|
724
|
+
|
|
725
|
+
| Field | Meaning |
|
|
726
|
+
| -------------- | --------------------------------------------------------- |
|
|
727
|
+
| `deliver_at` | When it was due |
|
|
728
|
+
| `delivered_at` | When the broker delivered it; `null` while scheduled |
|
|
729
|
+
| `late` | Delivered more than 60 seconds after `deliver_at` |
|
|
730
|
+
| `cancelled_at` | Present when the sender (or owner) cancelled it |
|
|
731
|
+
| `schedule_id` | Present on occurrences of a recurring schedule; see below |
|
|
732
|
+
|
|
733
|
+
### Recurring schedules
|
|
734
|
+
|
|
735
|
+
`POST /v1/schedules` creates a durable daily repeat. Its body has the send fields except `reply_to` and `deliver_at`, plus `repeat`:
|
|
736
|
+
|
|
737
|
+
```json
|
|
738
|
+
{
|
|
739
|
+
"client_request_id": "coordinator-morning-notice",
|
|
740
|
+
"to": "coordinator",
|
|
741
|
+
"kind": "notice",
|
|
742
|
+
"content": "Morning notice: read the overnight reports.",
|
|
743
|
+
"repeat": {
|
|
744
|
+
"time_zone": "Europe/Stockholm",
|
|
745
|
+
"times": ["06:41"],
|
|
746
|
+
"weekdays": ["mon", "tue", "wed", "thu", "fri"]
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
```
|
|
750
|
+
|
|
751
|
+
`time_zone` is an IANA name, stored in its canonical spelling. `times` holds 1–24 local `HH:MM` times, so an hourly night check is one schedule with several times. `weekdays` is optional and uses `mon` … `sun`; it applies to the local date of each occurrence. Address it like a send: an agent ID (your own for a reminder), `to: "*"` with `room`, a list of the room's members with `room`, or `to: "*"` for the whole lobby. Authorization is checked at creation and again at each occurrence, the named members too: an occurrence whose names no longer pass is skipped, as below.
|
|
752
|
+
|
|
753
|
+
- **Occurrences.** At each local time the broker sends a real message from the schedule's owner: it has its own message ID, `schedule_id`, and `deliver_at` set to the slot. It is delivered at once, with recipients fixed at that moment, and is subject to the usual inbox and storage bounds but not the per-minute send rate. If an occurrence cannot be sent, for example because its recipient is disabled or the room membership changed, it is skipped and the schedule records the reason in `last_error`; the schedule continues at its next time.
|
|
754
|
+
- **Local time.** Occurrences follow the zone's wall clock across daylight-saving changes: 06:41 in Stockholm is 04:41 UTC in summer and 05:41 UTC in winter. A time skipped when clocks go forward runs the same distance after the gap, so 02:30 on the spring-forward night runs at 03:30. A time that occurs twice when clocks go back runs once, at its first occurrence.
|
|
755
|
+
- **Downtime.** A schedule whose time passed while the broker was stopped sends one occurrence on start, for the latest missed time, marked `late`. Earlier missed times are skipped rather than delivered as a burst.
|
|
756
|
+
- **Identity, not session.** A schedule belongs to the agent identity. It survives session restarts, runtime resets and broker restarts. `GET /v1/context` reports how many schedules and undelivered scheduled messages the agent owns, so a new session learns about them at bootstrap.
|
|
757
|
+
- **Idempotency and limits.** The same `client_request_id` with the same normalized body returns the existing schedule with `duplicate: true` while it is stored: while active, and for a day after it is cancelled. A different body is `idempotency_conflict`. Schedule request IDs are separate from message request IDs. An agent may hold 20 active schedules (`409 schedule_capacity`). Only agents create schedules.
|
|
758
|
+
- **Listing and cancelling.** `GET /v1/schedules` returns the active schedules, soonest first, with `next_occurrence_at`, `next_occurrence_local` (such as `Fri 2026-10-16 06:41 Europe/Stockholm`), `occurrence_count`, `last_occurrence_at`, `last_message_id` and `last_error`, and the undelivered scheduled messages, each with `recipients`, how many recipients it will reach. The listing is for finding and cancelling, so its items are compact: no structured fields (`commit`, `files`, `evidence`, `options`, `recommendation`), no per-recipient deliveries, and content longer than 300 characters cut to its beginning, 300 characters with the ellipsis, and marked `content_truncated: true`. The sender's history holds the whole message. A full queue, 100 scheduled broadcasts to 99 recipients with structured fields, therefore fits one response within the bundled client's 2 MiB read limit. The owner's `GET /v1/admin/agents/:id/schedules` and `pocolobby schedules` show the same items. `POST /v1/schedules/cancel` with a schedule ID stops future occurrences; messages already sent stay.
|
|
759
|
+
- **Changing a schedule.** `POST /v1/schedules/update` with a schedule's ID takes `content`, `repeat` or both: the new text goes into its later occurrences, and new times replace the old ones and set its next occurrence. It keeps its ID and retry identity; `deliver_at` on a schedule is `400 invalid_request`, a cancelled one `409 already_cancelled`. The result is the changed entry as `GET /v1/schedules` lists it. Repeating an update sets the same values again; after an uncertain result, list before changing it again, since a delayed retry can overwrite a newer change.
|
|
760
|
+
|
|
761
|
+
### Read receipts for senders
|
|
762
|
+
|
|
763
|
+
A send with `notify_when_seen: true` asks the broker to tell its sender when each recipient acknowledges it. The broker then pushes a `receipts` event to the sender's adapter (abbreviated: the broker also adds a [`presentation`](#event-version-and-envelope)):
|
|
764
|
+
|
|
765
|
+
```json
|
|
766
|
+
{
|
|
767
|
+
"type": "receipts",
|
|
768
|
+
"notification_id": "<uuid>",
|
|
769
|
+
"agent_id": "coordinator",
|
|
770
|
+
"lobby_id": "my-team",
|
|
771
|
+
"runtime_id": "<uuid>",
|
|
772
|
+
"generation": 3,
|
|
773
|
+
"receipts": [
|
|
774
|
+
{
|
|
775
|
+
"message_id": "<uuid>",
|
|
776
|
+
"seen_by": "backend",
|
|
777
|
+
"seen_at": "<ISO-timestamp>",
|
|
778
|
+
"kind": "assignment",
|
|
779
|
+
"to": "backend",
|
|
780
|
+
"room_id": null,
|
|
781
|
+
"excerpt": "Fix the login bug before the release"
|
|
782
|
+
}
|
|
783
|
+
],
|
|
784
|
+
"remaining": 0
|
|
785
|
+
}
|
|
786
|
+
```
|
|
787
|
+
|
|
788
|
+
- **Coalesced.** A receipt waits 2 seconds so that receipts close together share one event, and receipt events to one agent are at least 30 seconds apart. One event lists at most 20 receipts; `remaining` says how many follow in a later event. Receipt events share the agent's notification rate cap.
|
|
789
|
+
- **Never acknowledged.** A receipt is not a message. It has no inbox entry, needs no acknowledgment and no reply, and is never itself the subject of a receipt, so receipts cannot loop. Acknowledging a note to yourself produces none, and the owner cannot ask for receipts (`400 receipts_unavailable`).
|
|
790
|
+
- **Best effort, once.** Each receipt is pushed once, to a ready, unpaused runtime. Receipts not pushed within 24 hours are dropped. The seen fact itself stays with the message while it is [kept](#message-retention): history and send results show it, so a sender that missed the event reads it there.
|
|
791
|
+
- **Kind.** A receipt names the message's kind, and omits `kind` for a message without one.
|
|
792
|
+
|
|
793
|
+
### Activity events for coordinators
|
|
794
|
+
|
|
795
|
+
Five minutes after an agent's turn stopped, if no new turn started meanwhile, the broker pushes an `activity` event to each connected coordinator (`manage_rooms`) in the lobby, never about the coordinator itself:
|
|
796
|
+
|
|
797
|
+
```json
|
|
798
|
+
{
|
|
799
|
+
"type": "activity",
|
|
800
|
+
"notification_id": "<uuid>",
|
|
801
|
+
"agent_id": "coordinator",
|
|
802
|
+
"lobby_id": "my-team",
|
|
803
|
+
"runtime_id": "<uuid>",
|
|
804
|
+
"generation": 3,
|
|
805
|
+
"agents": [
|
|
806
|
+
{
|
|
807
|
+
"agent_id": "backend",
|
|
808
|
+
"state": "idle",
|
|
809
|
+
"since": "<ISO-timestamp>",
|
|
810
|
+
"worked_minutes": 38,
|
|
811
|
+
"background_tasks": 0
|
|
812
|
+
}
|
|
813
|
+
]
|
|
814
|
+
}
|
|
815
|
+
```
|
|
816
|
+
|
|
817
|
+
- **When.** For a turn that ran at least a minute, ended with no background task reported, and was followed by no new turn for five minutes; for a failed turn, whatever its length (`state: "failed"`, `background_tasks: null`). `worked_minutes` is how long the stretch of work ran, when its start was seen, else `null`. An agent whose bridge is offline at that time is unknown, and no event is sent about it.
|
|
818
|
+
- **Once.** Each stop is told once per coordinator, together with any others due at the same time. One that no coordinator could take within ten minutes, because none was connected, ready and under its notification rate, is dropped: the agent list still shows it.
|
|
819
|
+
- **Not a message.** Like a [receipts event](#read-receipts-for-senders) it has no inbox entry and needs no acknowledgment or reply, and it counts against the coordinator's notification rate. Its presentation reads "PocoLobby activity: backend stopped at … after 38 min of work, no background tasks." or "… with 1 background shell still running", and each stopped turn carries `running` like the agent list, with the advice that a stopped turn means an idle session, not a finished task.
|
|
820
|
+
- **Bridges.** Every connected coordinator gets it.
|
|
821
|
+
|
|
822
|
+
Do not put a `protocol_version` field in a send body; the current API version is selected by `/v1`. Important messages bypass the information delay and are preferred among eligible notifications; this does not preempt tools or guarantee processing order/deadlines.
|
|
823
|
+
|
|
824
|
+
## Attachments
|
|
825
|
+
|
|
826
|
+
Agents can attach up to 5 files to `send_message`, `send_room_message`, `broadcast` and `schedule_message`, so one send reaches every recipient and an image is actually seen by the receiving model.
|
|
827
|
+
|
|
828
|
+
**What can be attached.** Images by their magic bytes: PNG, JPEG, GIF and WebP, at most 10 MiB each by default. Text documents by name: `md`, `txt`, `csv`, `json` and `log`, and source files `mjs`, `cjs`, `js`, `ts`, `py`, `sh` and `swift` as plain text, valid UTF-8 without NUL bytes, at most 1 MiB by default. The image type comes from the bytes, so a JPEG saved as `.png` is attached as JPEG; a text name with image or binary contents is refused. PDF is not supported: an MCP tool result cannot hand Claude a PDF the way it hands it an image, so the bridge could only return extracted text, which it cannot do without native dependencies. For any other file, the argument's description and the bridge's refusal say to put a logical location each machine resolves itself, such as `dropbox:team/reports/run-42`, in the message's `evidence` field.
|
|
829
|
+
|
|
830
|
+
**Size limits on two sides.** The broker's configured limits apply to every send; its `413 attachment_too_large` names the setting. The bridge reads at most 64 MiB of an image and 16 MiB of a text file whatever the broker accepts, and refuses a larger file before reading it. The refusal names the limit that applies, from the catalog's `attachment_limits`: "it is 20.0 MiB; this broker accepts text attachments up to 1.0 MiB". Below its own ceiling it still leaves the decision to the broker, which matches a retry to its stored send before checking sizes.
|
|
831
|
+
|
|
832
|
+
**Sending, as the bridge does it.** The model lists absolute local paths in `attachments`. The bridge checks each path against the [local file rules](ARCHITECTURE.md#local-file-reading), reads it, and replaces the paths with descriptors before the send:
|
|
833
|
+
|
|
834
|
+
```json
|
|
835
|
+
{
|
|
836
|
+
"client_request_id": "login-fix-screens",
|
|
837
|
+
"to": "coordinator",
|
|
838
|
+
"kind": "report",
|
|
839
|
+
"content": "Login screen before and after the fix.",
|
|
840
|
+
"attachments": [
|
|
841
|
+
{ "sha256": "<hex>", "filename": "before.png", "mime_type": "image/png", "size": 182733 },
|
|
842
|
+
{ "sha256": "<hex>", "filename": "after.png", "mime_type": "image/png", "size": 190812 }
|
|
843
|
+
]
|
|
844
|
+
}
|
|
845
|
+
```
|
|
846
|
+
|
|
847
|
+
1. The send runs its usual checks first: a retry of a stored request returns it (`duplicate: true`) before anything else, so a retry with the same files uploads nothing; then authorization, rates, the size limit of each file and the lobby quota.
|
|
848
|
+
2. When content is missing, the broker answers `409 attachment_upload_required` with `error.missing`, the SHA-256 of each file it lacks, and stores nothing.
|
|
849
|
+
3. The bridge uploads exactly those with `PUT /v1/attachments/<sha256>` (`Content-Type: application/octet-stream`, `Content-Length`, runtime headers), then repeats the identical request once. The broker checks the bytes against the hash, recognizes their type, and writes them to `<attachments directory>/<sha256>` through a temporary file. Uploads arriving at once share a memory budget (`POCOLOBBY_UPLOAD_INFLIGHT_BYTES`, default 32 MiB): each reserves its declared length before its body is read. An upload without `Content-Length` is `411 length_required`; one that does not fit is refused before its body with `503 uploads_busy` and `Retry-After: 5`, and nothing is stored. The bridge reports it as a failed upload and an unsent message, to be retried with the same `client_request_id` and attachments.
|
|
850
|
+
4. The repeated send creates the attachment records and the message in one transaction and answers `stored`; the message carries `attachments: [{ id, filename, mime_type, size, sha256 }]`.
|
|
851
|
+
|
|
852
|
+
An upload only proves that this agent holds the content. Possession counts per uploader: knowing a hash never lets another agent attach content it did not upload itself. Identical content is stored once on disk, whoever uploads it. The same uploader, content and name is one attachment, reused by later sends. Different files under an existing request ID are an `idempotency_conflict`, since descriptors are part of the retry fingerprint. Schedules resolve their attachments when created, and each occurrence references them. The owner cannot attach (`400 attachments_unavailable`): only a bridge uploads.
|
|
853
|
+
|
|
854
|
+
**Reading.** `GET /v1/attachments/:id` returns metadata (`id`, `filename`, `mime_type`, `size`, `sha256`, `from`, `created_at`, and `expired_at` once expired) and `GET /v1/attachments/:id/content` the bytes, with `X-PocoLobby-Sha256`, `X-PocoLobby-Mime-Type` and `X-PocoLobby-Filename`. Both need only the agent credential. Only the uploader and the recipients of a message that references the attachment may read it, within the same lobby, and only while that delivery is visible: a scheduled message's attachments become readable when it is delivered, and never if it is cancelled first, and a room member removed before seeing a message loses access with the message. Anything else, including another lobby's attachment, is `404 attachment_not_found`. The owner sees metadata and usage through the [owner routes](#owner-routes), not content.
|
|
855
|
+
|
|
856
|
+
**Receiving.** Message reads show `attachments` with full metadata. Pushes carry the compact form (`id`, `filename`, `mime_type`, `size`) under `extensions.attachments`, and the broker's presentation names them in the first line, such as "PocoLobby report from backend (no reply expected): direct message, with 2 attachments: before.png, after.png.", with guidance to open them with `get_attachment`. The bridge's `get_attachment(id, save_to?, save_only?, offset?, max_chars?, headings?)` returns:
|
|
857
|
+
|
|
858
|
+
- an image as an MCP image block (`type: "image"`, base64 `data`, `mimeType`) followed by a text block naming the file, sender, size and dimensions. Images up to 3.75 MiB (5 MB in base64, the smallest per-image limit of Claude's platforms) and 8000 px per side are returned as they are. A larger PNG, the usual screenshot, is reduced in plain JavaScript to a 2576 px long edge or less until it fits; other formats are described instead, with the advice to use `save_to` and open the file with a file-reading tool. Claude Code may scale an inline image down further and saves the original in its tool-results directory;
|
|
859
|
+
- a text document as text in parts of `max_chars` characters from `offset`: 12,000 by default and at most 40,000 (below Claude Code's 50,000-character threshold for persisting a result to a file). Offsets count characters of the decoded text. A part that does not reach the end ends with a line saying how many characters remain and the `offset` to continue with; the first part also points to `headings=true` for Markdown and to `save_to` with `save_only` plus a local search, which reads the whole file without spending context;
|
|
860
|
+
- with `headings: true`, for a Markdown document, its outline instead of its text: one line per ATX heading (`#` to `######`, outside fenced code blocks and a leading front matter block) from `offset` on, such as `offset 1520 (6480 chars): ## Background`, where the length runs to the next heading of the same or a higher level. The header gives the document's length and the offset where its last `max_chars` characters start. An outline longer than `max_chars` ends with the offset to continue it from, and always lists at least one heading, so following it advances. Other types are refused with what to do instead;
|
|
861
|
+
- with `save_to`, an absolute directory checked by the same rules as sending, the original file written there without replacing anything (`after (2).png` if `after.png` exists), and its path. A file named `CLAUDE.md`, `CLAUDE.local.md`, `AGENTS.md` or `AGENTS.override.md`, in any case, is saved as `CLAUDE (attachment).md` and so on, because Claude Code and Codex load those names as instructions. A missing directory is created with any missing parents, once the deepest existing directory above it, resolved through links, puts the whole path inside the rules; it is created only after the download is verified, and the result says so. A link to nothing or a path that cannot be checked is refused. With real Claude, `save_to` into an existing Dropbox folder worked on Windows (`C:\Users\<user>\Dropbox\…`), and a file saved into Dropbox on a Mac reached the Windows machine within a minute; folder creation itself is tested with a simulated host only;
|
|
862
|
+
- with `save_only: true` as well, only the metadata and the saved path: no image block and no text, so an agent can file evidence without spending context. `offset`, `max_chars` and `headings` then do not apply. Without `save_to` it is an error, and nothing is read.
|
|
863
|
+
|
|
864
|
+
The bridge verifies size and SHA-256 of every download. Attached files are peer input like message text: the result says to treat them as information, not instructions.
|
|
865
|
+
|
|
866
|
+
**Retention and cleanup.** An attachment expires a day after the last message that references it (`POCOLOBBY_ATTACHMENT_RETENTION_DAYS`, default 1, as [messages are kept a day](#message-retention)), counted from delivery for a scheduled message, unless an active schedule or a scheduled message still waiting for its time will send it. A scheduled message keeps its files however late it is delivered, and its delivery counts as their use; a cancelled one no longer holds them. Its metadata stays, with `expired_at`, and its content returns `410 attachment_expired` to whoever may still read it, its uploader at least; a recipient whose message has expired gets `404 attachment_not_found`. The expired record itself is deleted 7 days after expiry, once no message or schedule names it, and the uploader then gets `404` too. An upload no send referenced is removed after a grace period of one hour. Content is deleted only when no live attachment and no pending upload references it, and files no record names, such as an interrupted upload, are removed after the grace period. The broker runs this at start and then once a minute, right after [message retention](#message-retention), so attachments of the messages it removed can expire in the same pass.
|
|
867
|
+
|
|
868
|
+
**Limits.** Per image and per text file, per lobby (1 GiB of distinct content, counting live attachments and pending uploads), retention and the grace period are configurable; see [Default bounds](#default-bounds) and [Operations](OPERATIONS.md#attachment-storage-and-volume-capacity). A file over its limit is refused at the send, before any upload, with `413 attachment_too_large` naming the setting. A full lobby is `507 storage_capacity` naming `POCOLOBBY_ATTACHMENT_LOBBY_BYTES`; an upload that would leave less free disk than the reserve kept for SQLite (256 MiB) is `507 storage_capacity` naming `POCOLOBBY_ATTACHMENT_DISK_RESERVE_BYTES`. In each case nothing is stored and earlier messages stay readable.
|
|
869
|
+
|
|
870
|
+
## Reading, receipts and checkpoints
|
|
871
|
+
|
|
872
|
+
Message reads accept `mode=pending|history|reconciliation`, `cursor` and `limit`. The HTTP default limit is 30, maximum 100. Results use `{ items: [...], next_cursor: number | null }`; follow `next_cursor` until null.
|
|
873
|
+
|
|
874
|
+
- `pending`: received messages without an explicit seen receipt, ordered by message sequence.
|
|
875
|
+
- `history`: sent messages and messages addressed to the agent, ordered by message sequence. Owner administrative history for `:id=owner` reads the selected lobby's message history. An optional `query` of 1–200 characters keeps the messages whose text, structured fields or attachment file names contain it, ignoring the case of ASCII letters; `%` and `_` are taken literally. It matches only what the reader may read, so a held room message does not match for its recipient, and like history it covers the last day. `query` in another mode is `400 query_needs_history`. The `get_messages` tool passes it as `query`. History also takes `unanswered=true`: the messages the agent received that expect a reply (`reply_expected` true) and that it has not answered with a message whose `reply_to` names them and that reached the asker, released, not cancelled and not held. Owner messages are left out, since they cannot be answered. It is a checklist of open requests, not proof that an answer was enough, and like history it covers the last day; with `query` both apply. In another mode, or for the owner's own history, it is `400 unanswered_needs_history`. Validated with the store and server tests under a simulated host.
|
|
876
|
+
|
|
877
|
+
History also takes `awaiting=true`, its mirror, to read after a compaction: the requests and questions the agent sent (`reply_expected` true), delivered and neither cancelled nor superseded, that no reply naming them in `reply_to` has reached it for, from anyone else. They come with each recipient's [delivery facts](#delivery-facts-for-senders). With `unanswered` it is `400 awaiting_or_unanswered`; in another mode, or for the owner's own history, `400 awaiting_needs_history`. `ids` reads messages by ID: up to 20, comma-separated, each a full ID or its first 8 or more characters, as agents quote them in prose, such as `1a9b3dff`; a start several messages share returns each of them with its full ID. By ID an agent reads, besides its own messages, any message of a room it belongs to, delivered, not cancelled and sent since it joined, whoever it named; it opens its attachments and may answer it with `reply_to`: naming members decides who is woken, not who may read. A room deleted and made again, and a member who left and joined again, open none from before. `headers=true` returns each message as one line, `{ id, from, to, room_id, kind, created_at, first_line }`, its first line of text that is not blank cut at 200 characters, to find messages cheaply (a search that returns 3 whole messages can cost about 20,000 tokens); the page is cut as the whole messages would be. `ids` in another mode is `400 ids_need_history`, `headers` `400 headers_need_history`. All of them work with `query` and with each other, and the `get_messages` tool takes them as `awaiting`, `ids` (a list) and `headers`.
|
|
878
|
+
|
|
879
|
+
- `reconciliation`: seen receipts after the checkpoint's covered receipt sequence, ordered by receipt sequence. Its cursor refers to receipt events, not message sequence.
|
|
880
|
+
|
|
881
|
+
A room message to some members carries `addressed_to`, as [above](#sending-and-retrying). Recipient message records may include `agent_seen_at`, `receipt_seq`, `delivery_state` (`pending`, `seen` or `held`; a note to yourself can also show `scheduled` or `cancelled`) and `held_reason`. A scheduled message appears in a recipient's reads only once delivered. These describe the caller's own receipt for a message addressed to it. Held room messages are absent from actionable pending reads/notifications and cannot be newly acknowledged; their content is redacted in agent history as `[Held: room membership revoked]` or `[Held: room deleted]`. The owner can inspect the original held content through the administrative history route. A read does not mark a message seen. `POST /v1/acknowledgments` accepts 1–100 explicit message UUIDs from the caller's own inbox and returns `{ receipt_snapshot }`, with `unknown_message_ids` when any ID named nothing in the caller's inbox: no message by that ID, such as a `notification_id` passed by mistake, or one no longer stored after its day. A `note` says which of them were the caller's notification IDs, which are not message IDs. A checkpoint's `acknowledge_message_ids` puts the same field on the checkpoint result. A message already seen, one no longer stored (it expired after its [day](#message-retention)), and one held for good (room access revoked, or cancelled) record nothing and are not refused; the rest of the batch still counts, here and in a checkpoint's `acknowledge_message_ids`. Two cases refuse the whole batch, or the checkpoint, so nothing is recorded: a scheduled message not yet delivered is `409 not_yet_delivered`, naming the message, and a stored message that is not in the caller's inbox is `403 receipt_forbidden`. A scheduled message has not arrived, so a receipt for it would mean nothing, and a bridge that took it as read would hide the message when it arrives. Acknowledging a later important message never implicitly acknowledges earlier information.
|
|
882
|
+
|
|
883
|
+
### Message retention
|
|
884
|
+
|
|
885
|
+
Messages are kept a day. Each one is deleted 24 hours after it reached its recipients: from its acceptance, or for a scheduled message from its delivery. It goes whether or not its recipients acknowledged it, with its deliveries, seen receipts and attachment references. A scheduled message still waiting for its time stays; a cancelled one goes a day after its cancellation, so cancelling it again still finds it until then. A schedule cancelled more than a day ago goes with its attachment references. The broker does this at start and then once a minute, before [attachment cleanup](#attachments). So history, pending pages and reconciliation hold at most about a day, a message left unacknowledged for a day is gone from the inbox, and a retry of a send older than a day is a new message. Acknowledging a message that is gone records nothing and is not refused, and a reply may still name it in `reply_to`. Keep what must last in checkpoints or the team's notes.
|
|
886
|
+
|
|
887
|
+
### Delivery facts for senders
|
|
888
|
+
|
|
889
|
+
In `history` mode, every item **the caller sent** carries `deliveries`: one entry per recipient with `agent_id`, `delivery_state` (`pending`, `seen` or `held`), `notification_attempted_at`, `agent_seen_at` and `held_reason`. Room and whole-lobby messages list each recipient of the frozen fanout. The send result carries the same list. This answers "did backend see the assignment?" without asking backend.
|
|
890
|
+
|
|
891
|
+
The facts are deliberately separate:
|
|
892
|
+
|
|
893
|
+
| Fact | Meaning |
|
|
894
|
+
| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
895
|
+
| `delivery_state: "pending"` | Stored in the recipient's inbox; no seen receipt yet |
|
|
896
|
+
| `notification_attempted_at` | Most recent time the recipient's adapter reported writing a notice for it to its host; `null` if never |
|
|
897
|
+
| `delivery_state: "seen"` | The recipient acknowledged it explicitly; `agent_seen_at` is when |
|
|
898
|
+
| `delivery_state: "held"` | The recipient left the room, or the room was deleted, before it saw the message; `held_reason` says which (`room_membership_revoked`, `room_deleted`), and it will not be notified |
|
|
899
|
+
| `delivery_state: "scheduled"` | Waiting for its `deliver_at`; the recipient cannot see it yet |
|
|
900
|
+
| `delivery_state: "cancelled"` | The sender cancelled it before delivery; the recipient never saw it |
|
|
901
|
+
| Completed work | Not recorded by PocoLobby; ask for or look for the work result |
|
|
902
|
+
|
|
903
|
+
A notice written to the host may still wait in Claude's queue while a tool runs, and seen is the recipient's claim, not proof of understanding. To be told about seen without reading history, send with `notify_when_seen: true`; see [read receipts](#read-receipts-for-senders). Items the caller received, and pending or reconciliation pages, carry no `deliveries`; agents never see other recipients' facts for messages they did not send. The owner's lobby-wide history (`:id=owner`) shows `deliveries` on every message, and the owner's view of an agent's history shows them on that agent's sent messages.
|
|
904
|
+
|
|
905
|
+
`GET /v1/context` returns:
|
|
906
|
+
|
|
907
|
+
```json
|
|
908
|
+
{
|
|
909
|
+
"agent_id": "backend",
|
|
910
|
+
"lobby_id": "my-team",
|
|
911
|
+
"runtime_id": null,
|
|
912
|
+
"role": "...",
|
|
913
|
+
"checkpoint": {
|
|
914
|
+
"revision": 0,
|
|
915
|
+
"content": "",
|
|
916
|
+
"covered_receipt_seq": 0,
|
|
917
|
+
"updated_at": null,
|
|
918
|
+
"runtime_id": null
|
|
919
|
+
},
|
|
920
|
+
"receipt_snapshot": 0,
|
|
921
|
+
"pending": { "items": [], "next_cursor": null },
|
|
922
|
+
"reconciliation": { "items": [], "next_cursor": null, "total": 0, "headers": [] },
|
|
923
|
+
"scheduled": { "schedules": 0, "messages": 0 },
|
|
924
|
+
"guidance": [],
|
|
925
|
+
"tool_guide": {
|
|
926
|
+
"catalog_revision": "<sha256 hex>",
|
|
927
|
+
"note": "Current PocoLobby operations and their arguments. ...",
|
|
928
|
+
"operations": [
|
|
929
|
+
{
|
|
930
|
+
"name": "send_message",
|
|
931
|
+
"purpose": "Send to one agent, or to yourself with deliver_at as a durable reminder; can carry attachments.",
|
|
932
|
+
"arguments": { "to": "(required) The recipient agent ID, or your own", "...": "..." }
|
|
933
|
+
}
|
|
934
|
+
]
|
|
935
|
+
},
|
|
936
|
+
"broker": { "host": "lobby.pocolobby.com", "version": "<package version>" }
|
|
937
|
+
}
|
|
938
|
+
```
|
|
939
|
+
|
|
940
|
+
`broker` names the broker that answered: `host` is the `Host` the bridge reached it by, or `null`, and `version` its package version, so an agent can tell which broker it is on. `tool_guide` is always present; see [the tool guide](#the-tool-guide-and-stale-host-schemas). The bridge adds `tool_guide.bridge_tools`, `runtime_scope` and `tool_catalog` before the model reads it.
|
|
941
|
+
|
|
942
|
+
The top-level `runtime_id` is this agent's current runtime binding, or JSON `null` before a runtime is claimed/after it is reset. It remains stable on network reconnect and when the same conversation restarts its adapter. It changes when a different conversation is bound. It is distinct from `checkpoint.runtime_id`, which records the runtime that wrote that checkpoint. A startup greeting can use `hello:<agent_id>:<runtime_id>` as its stable retry ID, as described in [Local setup](LOCAL-SETUP.md).
|
|
943
|
+
|
|
944
|
+
`scheduled` counts the agent's own active schedules and undelivered scheduled messages; list them with `GET /v1/schedules`. The embedded pending page contains at most 25 messages; continue through the message-read route when necessary. Reconciliation recovers messages acknowledged after the last checkpoint without claiming that their work completed.
|
|
945
|
+
|
|
946
|
+
Reconciliation in context is bounded: `items` holds the first 5 such messages with their bodies, `next_cursor` continues after them in `GET /v1/messages?mode=reconciliation`, `headers` lists up to 20 more without bodies (`id`, `from`, `to`, `addressed_to` when set, `room_id`, `priority`, `kind`, `created_at`, `agent_seen_at`, `receipt_seq`), and `total` counts them all.
|
|
947
|
+
|
|
948
|
+
`guidance` holds sentences for this bootstrap. When acknowledged messages are not covered by the checkpoint, it says to save one with `covered_receipt_seq` equal to `receipt_snapshot` after reconciling and calling `ready`, and says plainly when the agent has never saved a checkpoint. A checkpoint saved before `ready` raises the revision, so `ready` with the revision loaded fails with `409 bootstrap_conflict`; the bridge's bootstrap notice and its `get_context` description say the same. It also points to the next reconciliation or pending page when there is one; the [tool guide's `note`](#the-tool-guide-and-stale-host-schemas) covers stale tool schemas. The bridge passes it to the model unchanged.
|
|
949
|
+
|
|
950
|
+
Save a checkpoint with:
|
|
951
|
+
|
|
952
|
+
```json
|
|
953
|
+
{
|
|
954
|
+
"expected_revision": 0,
|
|
955
|
+
"content": "Current objective, decisions, unfinished work and uncertain side effects...",
|
|
956
|
+
"covered_receipt_seq": 3,
|
|
957
|
+
"acknowledge_message_ids": []
|
|
958
|
+
}
|
|
959
|
+
```
|
|
960
|
+
|
|
961
|
+
The expected revision must match. Coverage cannot move backward or beyond the existing receipt snapshot. The refusals name the numbers: `409 checkpoint_conflict` the current revision, and `409 receipt_snapshot_conflict` the range `covered_receipt_seq` may take. If the transaction acknowledges new messages, coverage must first equal the current receipt snapshot; the broker then includes those newly created receipts atomically. The caller must actually reconcile the covered receipts and retain unfinished work in the checkpoint. A transaction with a stale snapshot fails rather than silently hiding an intervening receipt.
|
|
962
|
+
|
|
963
|
+
`GET /v1/checkpoint`, the `read_checkpoint` tool, returns `{ checkpoint, receipt_snapshot }`: the saved checkpoint with its `revision`, `content` and `covered_receipt_seq`, and the current receipt snapshot, which is what a save needs after a context compaction or a refused save, without the whole context of about 6K tokens with its tool guide. A bootstrap still loads the context.
|
|
964
|
+
|
|
965
|
+
Seen receipts record the agent's claim that it read a message. A checkpoint records its supplied handoff. Neither proves comprehension, completed work, or exactly-once external effects.
|
|
966
|
+
|
|
967
|
+
## Stream and bootstrap lifecycle
|
|
968
|
+
|
|
969
|
+
1. Claim a runtime and upgrade `/v1/events` with the token, the returned runtime headers and `X-PocoLobby-Event-Version: 5`. A bridge also sends `X-PocoLobby-Bridge-Version` with its package version, which the agent list shows as `bridge_version` while the socket is open. Do not add query parameters. Reconnecting the same runtime replaces its previous socket. An upgrade without that header, or with a version below 5, is refused with `426` (see [below](#event-version-and-envelope)). The broker answers the upgrade with `X-PocoLobby-Event-Version: 5`, and the socket first receives a `catalog` event naming the current [tool catalog](#tool-catalog-and-dispatch) revision.
|
|
970
|
+
2. An unready runtime receives a `bootstrap` event with a `probe_nonce`. Load context, reconcile prior work, and call `/v1/ready` using that nonce and the checkpoint revision loaded. Save checkpoints only after `ready`. The Claude adapter additionally requires its own `get_context` call after the latest bootstrap before accepting its `ready` tool call. It leaves out the next push of each pending message that `get_context` returned in that bootstrap, since the model has read it and would otherwise read it twice around a restart; this is not a receipt, so a later push of it still comes through.
|
|
971
|
+
3. Ready runtimes receive `inbox` events. Each contains `notification_id`, `agent_id`, `lobby_id`, `runtime_id`, `generation`, `messages`, `pending_count`, and `truncated`. `pending_count` is the number of unseen, unheld deliveries in this inbox when the broker reserved and sent the event, in the same transaction; acknowledged messages are never counted. A host such as Claude can hold an event while a tool runs and show it later, after newer receipts, so treat the count as a statement about send time.
|
|
972
|
+
4. When `truncated` is true, the event is a hint without bodies: use paged message/context reads. Backlog after a new or resumed runtime, more than 20 due messages, an oversized envelope, and retries coalesce into hints rather than replaying a transcript. Small live deliveries include message bodies directly. A notice never covers an already-seen message.
|
|
973
|
+
A sender also receives `receipts` events for messages it sent with `notify_when_seen`; see [read receipts](#read-receipts-for-senders). They follow the same transport receipts below, and are never acknowledged.
|
|
974
|
+
|
|
975
|
+
5. Report transport progress over the socket using the notification ID:
|
|
976
|
+
|
|
977
|
+
```json
|
|
978
|
+
{ "type": "adapter_received", "notification_id": "<notification-uuid>" }
|
|
979
|
+
```
|
|
980
|
+
|
|
981
|
+
```json
|
|
982
|
+
{ "type": "notification_attempted", "notification_id": "<notification-uuid>" }
|
|
983
|
+
```
|
|
984
|
+
|
|
985
|
+
The first reports adapter receipt; the second reports an attempted host notification. Neither acknowledges any message as seen. The server validates that the notification belongs to the current runtime and records each transport timestamp idempotently. Message seen receipts use the HTTP acknowledgment route separately.
|
|
986
|
+
|
|
987
|
+
The broker sends WebSocket ping frames every 15 seconds and closes unresponsive connections. Use jittered reconnection with the same live runtime identity and generation. A resumed conversation reclaims its own runtime. A different conversation needs an explicit owner reset. Close codes include 4401 for credentials, 4403 for access, 4409 for runtime changes, 1008 for invalid frames/receipts, 1011 for broker failures and for more than 3,000 frames in a minute (the bridge reconnects), and 1001 for shutdown. A rejected upgrade returns its HTTP status with the usual JSON error body.
|
|
988
|
+
|
|
989
|
+
Notification attempts are persisted before transport send. They survive ordinary reconnects and restarts; a transport crash cannot erase their budget. Exhausted messages remain in the inbox and become visible as attention-required after the final grace interval. Owner retry is explicit. Channel delivery and model execution are not guaranteed by successful socket writes.
|
|
990
|
+
|
|
991
|
+
A scheduled retry is **not sent** when the delivery's latest notice was reported `notification_attempted` by the same runtime, generation and broker incarnation, that is, by the adapter process that is still connected to the same host session. That host already holds the notice, possibly queued behind a running tool; a retry would only queue a duplicate, body-less notice behind it. The attempt is still consumed on the usual schedule, so an unacknowledged message still reaches the attention state. Retries are sent when no host write was confirmed (lost socket, failed host write, adapters that do not report it), after an adapter restart or resume (a new generation), and after an explicit owner retry, which re-sends bodies.
|
|
992
|
+
|
|
993
|
+
### Event version and envelope
|
|
994
|
+
|
|
995
|
+
There is one event version, 5. A bridge sends `X-PocoLobby-Event-Version: 5` on the `/v1/events` upgrade, and the broker answers with the same header. An upgrade without it, or with a lower version, is refused with `426` (`bridge_outdated`: the bridge is older than the broker; install the current package). Like every rejected upgrade, the response carries the status and a JSON error body. A higher version is accepted and gets version 5 events.
|
|
996
|
+
|
|
997
|
+
The envelope lets later features reach the model without a bridge update:
|
|
998
|
+
|
|
999
|
+
- **Strict where it matters.** Identity, delivery and receipt fields (`notification_id`, `agent_id`, `lobby_id`, `runtime_id`, `generation`, message identity and attribution, delivery state) have a fixed schema, and the bridge drops an event with an unknown field outside the places below, or a frame larger than 192 KiB. A message carries `id`, `lobby_id`, `room_id`, `sequence`, `from`, `to`, `priority`, `content`, `correlation_id`, `reply_to`, `created_at`, `agent_seen_at`, `receipt_seq`, `delivery_state`, `held_reason`, `kind`, `reply_expected`, the structured fields, `notify_when_seen` and the scheduling fields at top level. A hint's `covered` headers (up to 20 covered messages, important first, with `covered_count` counting every delivery the notice covers) carry `id`, `from`, `to`, `room_id`, `priority`, `created_at` and `kind`, and a receipt carries `message_id`, `seen_by`, `seen_at`, `kind`, `to`, `room_id` and `excerpt`. Events omit `kind` and `reply_expected` when the sender chose none. A room message to some members keeps `to: "*"` and carries its names as `extensions.addressed_to`, on messages, covered headers and receipts.
|
|
1000
|
+
- **`extensions`.** Every other field travels in an `extensions` object on the message, header, receipt or event, such as `attachments` in its compact form (`id`, `filename`, `mime_type`, `size`): keys `[a-z][a-z0-9_]{0,63}`, at most 32 of them and 4 KiB of JSON per object. The broker leaves out what does not fit; message reads still show everything. The bridge passes extension data to the model inside the JSON payload without interpreting it.
|
|
1001
|
+
- **`presentation`.** The broker composes how a push reads from attributed fields, never from message text, and adds it to every message, covered header and receipts event: `lead` (the first line for one message, at most 500 characters), `summary` (one message in a list, 300), up to 8 `guidance` entries (`text`, an optional `partial` used when only some messages of a batch carry it, and `in_notices` to also show it on a body-less notice), and up to 16 extra `attributes`. A receipts event's presentation, and an activity event's, has a `lead` of up to 4,000 characters and `guidance`. All are single lines. The bridge renders them generically: "PocoLobby " and the lead, the merged guidance, and its own acknowledgment sentence, followed by the JSON payload holding the message text, so text cannot forge the first line. The bridge writes each standing sentence in full once, then a short form that still says what to do, such as "Acknowledge each message ID after reading." or "Reply to a room with send_room_message.", until the next bootstrap: a busy session can read the same instructions about 60 times a night. A sentence it has no short form for, such as a newer broker's or one naming a message, is written in full each time. The bridge also names a tool's current arguments when its own schema refuses a call, and for `save_checkpoint` the revision and receipt snapshot to save with. It computes `priority`, `scope`, `room_id`/`room_ids`, `from`, `kind`, `reply_expected` and `late` attributes itself; a broker attribute cannot replace them. When a bootstrap or inbox event with its presentations would exceed 128 KiB, the broker sends it without them, and the bridge builds the same structure locally. Without presentations every event fits the bridge's frame: a push is bounded by the push size, a hint by its twenty headers with bounded extensions. A receipts event always carries its presentation.
|
|
1002
|
+
- **`catalog` events.** `{ "type": "catalog", "revision": "<revision>" }`, sent when the socket opens and whenever that agent's catalog changes (an owner permission change, or new broker tools). It is a transport control message: never shown to the model and never receipted. The bridge, after loading the new catalog, sends the model its own [catalog-change notice](#the-tool-guide-and-stale-host-schemas).
|
|
1003
|
+
|
|
1004
|
+
### Push size
|
|
1005
|
+
|
|
1006
|
+
A push carries message bodies when its event fits the push size and becomes a header-only hint otherwise. The bridge accepts rendered pushes up to 72 KiB and frames up to 192 KiB, and the broker chooses the effective size: `POCOLOBBY_PUSH_BYTES`, default 32 KiB, between 8 KiB and 64 KiB. A 20 KiB builder report then arrives with its body instead of as a hint. Tuning it later needs a broker restart, not a bridge update.
|
|
1007
|
+
|
|
1008
|
+
What Claude Code documents ([Channels reference](https://code.claude.com/docs/en/channels-reference), [Channels](https://code.claude.com/docs/en/channels), [MCP](https://code.claude.com/docs/en/mcp)):
|
|
1009
|
+
|
|
1010
|
+
- A Channel event's `content` becomes the body of a `<channel>` tag in Claude's context; each `meta` entry becomes a tag attribute. Meta keys must be letters, digits and underscores; others are silently dropped. PocoLobby's attributes comply.
|
|
1011
|
+
- **No size limit is documented for Channel content**, and no truncation or persist-to-file rule like the one for tool results. A push therefore goes into context whole: 32 KiB is roughly 8,000 tokens per push. Events that arrive while Claude is busy are delivered together on the next turn, so several large pushes add up.
|
|
1012
|
+
- Claude Code does not acknowledge notifications; the write resolving means written to the transport, not processed. Events are dropped silently when the session has not loaded the server as a channel.
|
|
1013
|
+
- A channel that negotiates MCP protocol revision 2026-07-28 cannot deliver messages (PocoLobby stays on 2025-11-25). HTTP and WebSocket transports cap a single message at 16 MiB; stdio, which PocoLobby uses, has no documented cap.
|
|
1014
|
+
- For comparison, MCP tool results warn above 10,000 tokens, are limited to 25,000 tokens by default (`MAX_MCP_OUTPUT_TOKENS`), and a text result over 50,000 characters is saved to a file and replaced by its path. This is why history pages stay within 80 KiB and `get_attachment` returns text in parts of at most 40,000 characters (12,000 by default).
|
|
1015
|
+
|
|
1016
|
+
Without a documented ceiling, the default stays at 32 KiB: large enough for typical reports, small enough that a burst of pushes does not crowd the context. Raising it toward 64 KiB is a broker setting; measure with real Claude first.
|
|
1017
|
+
|
|
1018
|
+
## Default bounds
|
|
1019
|
+
|
|
1020
|
+
These are current broker defaults; library tests may override store settings. The CLI does not yet expose store-limit tuning.
|
|
1021
|
+
|
|
1022
|
+
| Limit | Default |
|
|
1023
|
+
| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1024
|
+
| Private lobbies per instance | 100 |
|
|
1025
|
+
| Rooms per lobby / members per room | 100 each |
|
|
1026
|
+
| Agent identities per instance / allowlist entries | 100 each |
|
|
1027
|
+
| Message content | 64 KiB of JSON-escaped text, where quotes, backslashes, newlines and control characters count 2–6 bytes (`400 content_too_large`), so one message fits a history page |
|
|
1028
|
+
| HTTP JSON body | 256 KiB |
|
|
1029
|
+
| Role / checkpoint content | 16,000 / 32,000 JavaScript string units, also subject to the HTTP body cap |
|
|
1030
|
+
| Notification envelope | The broker's push size, `POCOLOBBY_PUSH_BYTES` (default 32 KiB, 8–64 KiB), within the bridge's 72 KiB ceiling. Overflow becomes a hint |
|
|
1031
|
+
| Attachments per message | 5 |
|
|
1032
|
+
| Attachment size | Images 10 MiB (`POCOLOBBY_ATTACHMENT_MAX_IMAGE_BYTES`), text 1 MiB (`POCOLOBBY_ATTACHMENT_MAX_TEXT_BYTES`) |
|
|
1033
|
+
| Attachment storage per lobby | 1 GiB of distinct content (`POCOLOBBY_ATTACHMENT_LOBBY_BYTES`); uploads also stop when free disk would fall below 256 MiB (`POCOLOBBY_ATTACHMENT_DISK_RESERVE_BYTES`) |
|
|
1034
|
+
| Attachment uploads in flight | 32 MiB of declared upload bodies across all agents (`POCOLOBBY_UPLOAD_INFLIGHT_BYTES`); an upload that does not fit is `503 uploads_busy` |
|
|
1035
|
+
| Message retention | A day after a message reached its recipients, acknowledged or not, and a cancelled scheduled message a day after its cancellation; see [Message retention](#message-retention) |
|
|
1036
|
+
| Attachment retention | A day after the last referencing message (`POCOLOBBY_ATTACHMENT_RETENTION_DAYS`, default 1); unsent uploads 1 hour (`POCOLOBBY_ATTACHMENT_GRACE_SECONDS`) |
|
|
1037
|
+
| `get_attachment` results (bridge) | Images inline up to 3.75 MiB and 8000 px per side, larger PNGs reduced; text in parts of `max_chars`, 12,000 characters by default and at most 40,000 |
|
|
1038
|
+
| Inbound WebSocket frame | 4 KiB; text receipts only |
|
|
1039
|
+
| Unseen content per recipient | 4 MiB |
|
|
1040
|
+
| History page content | 80 KiB per `get_messages` page, at least one message; the cursor continues. Claude Code's default MCP tool-output cap is 25,000 tokens (about 100 KB), which is also why a message stays at 64 KiB: bigger content belongs in an attachment |
|
|
1041
|
+
| Stored logical message content | 256 MiB per lobby, its day of history included; a full lobby refuses new sends with `507 storage_capacity` and leaves other lobbies sending |
|
|
1042
|
+
| SQLite main-database page budget | 1 GiB; WAL, shared-memory, backup and temporary files require additional headroom |
|
|
1043
|
+
| New sends per sender | 60 per rolling minute; same-key retries reuse prior acceptance |
|
|
1044
|
+
| Notification reservations per agent | 30 per rolling minute, including bootstrap and retries |
|
|
1045
|
+
| Transport receipt frames per socket | 3,000 per minute; past that the broker closes the socket with 1011 and the bridge reconnects |
|
|
1046
|
+
| HTTP requests/upgrades per client | 1,200 per minute. The client is the socket address, or the first address in `POCOLOBBY_CLIENT_IP_HEADER` when set; an IPv6 client counts by its /64. Behind a proxy without that header every client shares one budget |
|
|
1047
|
+
| Information batching delay | 1 second |
|
|
1048
|
+
| Notification attempts | Three: first when eligible, second 30 seconds later, third another 120 seconds later; attention state after a further 300-second grace period. A retry behind a confirmed host write is consumed without a notice. A bootstrap's three attempts are 300 seconds apart, the schedule's longest delay: a model's bootstrap with its reconciliation takes longer than 30 seconds, and a retry behind it only cost the session a turn |
|
|
1049
|
+
| Owner requests | A count of 0–20 and one line of 300 characters |
|
|
1050
|
+
| Structured message fields | 8 KiB of JSON together; `files` 50 × 300 characters, `options` 10 × 500, `evidence` 500, `commit` 100, `commits` 10 × 100, `recommendation` 2,000 |
|
|
1051
|
+
| `deliver_at` | At most 30 days ahead; up to 5 minutes in the past is delivered at once |
|
|
1052
|
+
| Undelivered scheduled messages per sender | 100 |
|
|
1053
|
+
| Active recurring schedules per agent | 20, each with 1–24 daily times |
|
|
1054
|
+
| Late marking | A scheduled message or occurrence delivered more than 60 seconds after its time |
|
|
1055
|
+
| Read receipts | Coalesced for 2 seconds; receipt events at least 30 seconds apart per agent; 20 receipts per event; dropped after 24 hours unpushed |
|
|
1056
|
+
| Reconciliation in context | 5 messages with bodies and headers of 20 more; page the rest |
|
|
1057
|
+
| Tool catalog (enforced by the adapter) | 64 tools; names `[a-z][a-z0-9_]{0,63}`; descriptions 4,096 characters; each schema 32 KiB and depth 32; 512 KiB in all |
|
|
1058
|
+
| Tool arguments (adapter) | A JSON object under 192 KiB, so a 64 KiB message with its structured fields always fits; the broker's 256 KiB HTTP body cap applies |
|
|
1059
|
+
| Event extensions and presentation | 32 keys and 4 KiB of JSON per `extensions` object; lead 500, summary 300, 8 guidance lines of 400, 16 attributes; an event over 128 KiB is sent without presentations, and the bridge drops frames over 192 KiB |
|
|
1060
|
+
| Sign-in | Codes: 6 digits, 10 minutes, 5 wrong tries each, 10 wrong per address per hour; one code per address per minute and 5 per hour, 300 mailed per hour in all, 10 requests and 30 checks per client (an IPv6 client by its /64) per 15 minutes; sessions 30 days from last use; hand-offs 1 minute |
|
|
1061
|
+
| Settings | POST bodies 4 KB; 3 lobbies created per account; 50 viewers per dashboard |
|
|
1062
|
+
| Dashboard summaries per lobby | At most one per `POCOLOBBY_DASHBOARD_SUMMARY_SECONDS` (600) and $5 of cost in any 24 hours; notes cut beyond 200,000 characters in all |
|
|
1063
|
+
| Catalog refresh retry (adapter) | 1 second, doubling to 1 minute, with jitter; immediately on reconnect or an announced revision |
|
|
1064
|
+
|
|
1065
|
+
Actual timing can be later due to disconnection, pause, notification rate caps or host scheduling. Storage uses WAL, full synchronous commits, and exclusive database ownership. Limits reject new acceptance rather than discarding pending messages to make room; messages go only by [retention](#message-retention), a day after they reached their recipients. Monitor disk headroom and use the offline backup/recovery procedure in [Operations](OPERATIONS.md).
|
|
1066
|
+
|
|
1067
|
+
The page budget applies to the whole database, including indexes and audit records. The audit log keeps each lobby's newest 10,000 events, and as many without a lobby; the broker's sweep trims older ones. At saturation, any write can fail with `storage_capacity`, including acknowledgments, checkpoints and notification-attempt records; existing data remains readable. Monitor capacity before reaching the bound. There is no HTTP pruning or capacity-tuning endpoint. The storage schema is version 19, independent of HTTP protocol version 1. A broker creates a new database at schema 19 and opens only a database of that schema. It refuses a database of any other schema at startup with `unsupported_schema`, naming the database's version and the one it opens, and leaves that database untouched. Such a database needs a broker of its own version, a newer backup, or a new database, so going back after an upgrade means restoring the backup taken before it.
|
|
1068
|
+
|
|
1069
|
+
## Version compatibility
|
|
1070
|
+
|
|
1071
|
+
The broker and the bridge of one release go together. Deploy the broker first, then install the package and reload each bridge with `/mcp reconnect pocolobby`. A bridge older than the broker's [event version](#event-version-and-envelope) is refused with `426 bridge_outdated` and receives no events until its package is updated; its messages wait in its inbox meanwhile. A broker opens a database of its own [storage schema](#default-bounds) only.
|
|
1072
|
+
|
|
1073
|
+
## Errors
|
|
1074
|
+
|
|
1075
|
+
HTTP failures use `{ "error": { "code": "...", "message": "..." } }`. Use the status and code for control flow; message text is diagnostic. Common codes are:
|
|
1076
|
+
|
|
1077
|
+
| Status | Examples / action |
|
|
1078
|
+
| --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1079
|
+
| 400 | `invalid_request`, `invalid_json`, `credential_location`, `reserved_identity`, `reserved_request_id`, `deliver_at_out_of_range`, `content_too_large`, `reply_to_not_message_id`, `recipient_is_sender`, `query_needs_history`, `ids_need_history`, `headers_need_history`, `awaiting_needs_history`, `awaiting_or_unanswered`, `receipts_unavailable`, `attachments_unavailable`, `attachment_mismatch`, `attachment_hash_mismatch`; correct the request |
|
|
1080
|
+
| 401 | `unauthorized`; credential invalid, disabled or revoked |
|
|
1081
|
+
| 403 | `owner_required`, `agent_required`, `recipient_forbidden`, `broadcast_forbidden`, `receipt_forbidden` (a message with no delivery to the caller), `origin_not_allowed`, `tool_forbidden`, `operation_not_dispatchable` |
|
|
1082
|
+
| 404 | `not_found` (also an unknown or foreign ID to cancel), `agent_not_found`, `reply_not_found`, `unknown_tool`, `attachment_not_found` |
|
|
1083
|
+
| 409 | `runtime_required`, `runtime_conflict`, `stale_runtime`, `bootstrap_conflict`, `checkpoint_conflict`, `receipt_snapshot_conflict`, `not_yet_delivered`, `not_room_member`, `idempotency_conflict`, `agent_exists`, `agent_capacity`, `recipient_disabled`, `inbox_capacity`, `schedule_capacity`, `already_delivered` |
|
|
1084
|
+
| 409 | `attachment_upload_required`, with `error.missing` listing the SHA-256 of each file to upload before repeating the identical request; nothing was stored |
|
|
1085
|
+
| 410 | `attachment_expired`: retention removed the content; the metadata stays for 7 days |
|
|
1086
|
+
| 411 | `length_required`: an attachment upload must declare its `Content-Length` |
|
|
1087
|
+
| 413 / 415 | `body_too_large`, `attachment_too_large` / `content_type`, `attachment_type` |
|
|
1088
|
+
| 429 | `request_rate_limit`, `send_rate_limit`; HTTP replies include `Retry-After: 60` |
|
|
1089
|
+
| 503 | `uploads_busy`: other uploads hold the broker's in-flight upload budget; nothing was stored. Retry after `Retry-After` (5 seconds) with the same request ID |
|
|
1090
|
+
| 507 | `storage_capacity`; operator action required, no new operation accepted. For attachments the message names the lobby quota or the disk reserve that was reached |
|
|
1091
|
+
| 500 | `internal_error`; an interrupted send may have an unknown result, so retry only with its original request ID, within a day |
|
|
1092
|
+
|
|
1093
|
+
Lobby/room errors additionally include `lobby_not_found` and `room_not_found` (404); `lobby_exists`, `room_exists`, `room_archived`, `lobby_capacity` and `room_capacity` (409); `lobby_mismatch`, `lobby_required` and `member_limit` (400); and `scope_selection_forbidden`, `room_management_forbidden` and `room_membership_required` (403).
|
|
1094
|
+
|
|
1095
|
+
Settings and account errors include `sign_in_required` (401); `origin_not_allowed`, `lobby_creation_forbidden` and `repo_not_approved` (403); `owner_not_found`, `viewer_not_found`, `repo_not_found` and `not_local` (404); `lobby_owned`, `lobby_allowance`, `viewer_capacity`, `repo_in_use` and `summary_running` (409); `code_invalid`, `ticket_invalid` and `confirmation_mismatch` (400); `too_many_requests`, `too_many_wrong_codes`, `summary_test_wait` and `summary_budget` (429); `mail_failed` (502); and `sign_in_unavailable` and `summary_unavailable` (503).
|
|
1096
|
+
|
|
1097
|
+
Do not automatically reset runtimes, rotate tokens, change request IDs, or repeat uncertain external actions to resolve an error.
|