Files
box/docs/BOX-API-DESIGN-THREADS.md
T

179 lines
6.8 KiB
Markdown

# Box API: Per-Agent Thread Oversight — Contract Draft
> **Box is the main surface.** All operator work goes through Box (box.muse-dev.online). The web UI, `box` CLI, and agents share the same API endpoints. No UI-only powers.
Status: **draft 2026-10-04** — for the UI/UX agents. Not implemented, not deployed.
Companion docs: `BOX-API-DESIGN-DMS.md`, `BOX-UI-DESIGN.md`, `BOX-API-DESIGN-TIMERS.md`.
## Goal
A human (and any agent) can oversee **every thread on a given agent**: main chat,
side chats, and DM threads — list them, open one, read messages. Same API serves
the UI and agents. **No UI-only capabilities**: every view below is a thin render
over a documented endpoint, and every page shows its curl equivalent.
## Non-goals
- No new write paths. Sending is already covered (`POST /api/box/chat/send`,
`POST /api/box/dms` designs). This contract is **reads only**.
- No cross-agent impersonation: an agent sees only its own threads.
## Resource model
```
agent = muse | pip | 646 | opm | operator-main (node == agent == profile)
thread = { id, kind, title, participants[], last_message_at, message_count }
message = { id, from: { role, name }, text, ts }
```
- `kind`: `main` | `sidechat` | `dm`
- `thread.id`: the muse.ai thread UUID (`chat:thread:<uuid>`); `main` uses the
literal id `main`.
- `title`: side chats carry their auto/user title; DM threads are titled
`dm:<a>:<b>`.
- `participants`: agent names plus `human` where a human participates.
## Endpoints
Base: `https://box.muse-dev.online/api/box`
### 1. `GET /agents`
List known agents with presence. (May alias existing node inventory; contract
is what matters, not the table it reads.)
Response `200`:
```json
{
"agents": [
{"name": "muse", "status": "active", "threads": 4, "last_active": "2026-10-04T04:02:11Z"},
{"name": "pip", "status": "active", "threads": 2, "last_active": "2026-10-04T03:58:44Z"}
]
}
```
### 2. `GET /agents/{agent}/threads`
Query: `kind=main|sidechat|dm|all` (default `all`), `limit` (default 50, max 200),
`offset` (default 0).
Response `200`:
```json
{
"agent": "pip",
"threads": [
{"id": "main", "kind": "main", "title": "Main chat",
"participants": ["pip", "human"],
"last_message_at": "2026-10-04T03:58:44Z", "message_count": 231},
{"id": "5f18476d-8994-49e7-a9e0-4838732363fe", "kind": "sidechat",
"title": "646 pip cross-operator coordination chat",
"participants": ["pip", "human"],
"last_message_at": "2026-10-04T02:11:03Z", "message_count": 17}
],
"fetched_at": "2026-10-04T04:10:02Z"
}
```
`fetched_at` is honest staleness: the VM may serve a short-TTL cache (≤15s) so
one page view doesn't fan out to every agent browser.
### 3. `GET /agents/{agent}/threads/{thread_id}`
Query: `limit` (default 50, max 200), `before` (message id cursor for paging back).
Response `200`:
```json
{
"agent": "pip",
"thread": {"id": "5f18476d-8994-49e7-a9e0-4838732363fe",
"kind": "sidechat", "title": "646 pip cross-operator coordination chat",
"participants": ["pip", "human"]},
"messages": [
{"id": "m_001", "from": {"role": "human", "name": "operator-main"},
"text": "status check", "ts": "2026-10-04T02:10:01Z"},
{"id": "m_002", "from": {"role": "agent", "name": "pip"},
"text": "all green", "ts": "2026-10-04T02:11:03Z"}
],
"next_before": "m_001",
"fetched_at": "2026-10-04T04:10:05Z"
}
```
Messages are chronological ascending; `next_before` pages further back.
`null` when at the start.
## Auth
Same tier system as the rest of Box:
| Caller | `/agents` | own threads | other agent's threads | DM threads |
|---|---|---|---|---|
| operator (PIN/bearer) | yes | yes | yes | yes |
| agent (box-signature) | yes | yes | **no (403)** | only threads they participate in |
| dev / anonymous | no (403) | no | no | no |
Box-signatures bind to the **full resource path** (`agents/pip/threads/<uuid>`),
so a signature for one thread can't be replayed for another. DM threads are
additionally restricted: operator tier, or a participating agent.
## Validation & errors
- `{agent}` and `{thread_id}`: `^[a-z0-9-]{1,64}$`, plus `main` literal and
UUID form accepted for thread ids. Invalid → `400 INVALID_NAME`.
- Unknown agent → `404 AGENT_NOT_FOUND`. Unknown thread → `404 THREAD_NOT_FOUND`.
- Bad `before` cursor → `400 BAD_CURSOR`.
- bl unreachable / helper missing → `502 BL_UNREACHABLE` (honest, never silent).
- Shape follows existing Box errors: `{"ok": false, "code": "...", "detail": "..."}`.
## bl-side helper (read-only)
New allowlisted helper `bin/box-chat.py` on bl (sibling of `box-ctl.py`),
invoked by the VM over the existing VM→bl SSH path. **Read-only by construction**:
only two actions, no writes, no shell.
```
box-chat.py thread-list <agent> [--kind main|sidechat|dm|all] [--limit N]
box-chat.py thread-messages <agent> <thread-id> [--limit N] [--before MSGID]
```
- `<agent>` validated against the known node list; anything else → error, no SSH.
- `<thread-id>` validated `^[a-zA-Z0-9-]{1,64}$`; `main` maps to the main chat.
- Reads via the existing `muse-chat-api.py` in the agent's netns (CDP) — the
same path operators already use; no new browser machinery.
- Every invocation audit-logged on bl (`box-chat.jsonl`); VM audit-logs the API
read to `/srv/box/audit.jsonl` as with other Box reads.
## Rate limits, caching, audit
- Reads: 60/min/actor → `429` + `Retry-After` (lighter than the 10/min mutation
budget; reading is cheap, hammering browsers is not).
- VM caches thread lists ≤15s, keyed by `(agent, kind)`; `fetched_at` always
returned so the UI can say "updated 12s ago" instead of faking live.
- All reads audit-logged (actor, agent, thread id, ts). Message *contents* are
not written to the audit log — ids and counts only.
## UI mapping (for the UI agents)
| Page | Endpoint | Notes |
|---|---|---|
| `/box/agents` | `GET /agents` | agent cards w/ status + thread counts |
| `/box/agents/{agent}` | `GET /agents/{agent}/threads` | thread table; filter by kind; shows `fetched_at` age |
| `/box/agents/{agent}/threads/{id}` | `GET /agents/{agent}/threads/{id}` | message view; "load older" uses `before` |
Server-rendered pages + unified theme (per `BOX-UI-DESIGN.md`), ops-gated for
humans. Every page embeds its curl equivalent, e.g.:
```bash
curl -s -H "Authorization: Bearer $BOX_TOKEN" \
"https://box.muse-dev.online/api/box/agents/pip/threads?kind=sidechat"
```
## Open questions (for review, not blockers)
1. Unread counts: computable cheaply? If not, v1 ships without; "last message"
ordering is the oversight signal.
2. Should `GET /agents` include the human's own operator threads? Currently
agent-scoped; operator DMs to agents appear under each agent's `dm` kind.
3. Message `id` stability across reads (needed for `before` cursors) — confirm
against `muse-chat-api.py` output during implementation.