179 lines
6.8 KiB
Markdown
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.
|