Inbox
The inbox is the unified human-in-the-loop surface for the workspace. Rows are written by source-of-truth handlers (waitpoint create, escalation create, run-failure terminal, generic agent messages); these endpoints are the read + state-flip surface the UI consumes — no inserts (the one destructive op is an ADMIN purge). For the source endpoints that create inbox items (and the conflict rules about how items get resolved), see the Inbox guide.
All endpoints require an authenticated session and a workspace context.
Authentication and scope
The request has no authentication payload: send the session cookie or bearer token established by the normal API login flow. The server derives bothuser_id and workspace_id from that context. List, count, get, patch, and
bulk operations apply the visibility model below; purge additionally requires
the workspace OWNER or ADMIN role.
Endpoints
Visibility model
Every query restricts to:404, not a silent no-op, so the surface can’t be probed for which items exist.
Item shape
The four
second_approver_* / security_level_label fields are the same ones
GET /api/v1/crews/{id}/escalations carries, and they are computed
when the row is read — not stored on the item at raise time. Both inputs change
afterwards (the workspace toggle, and the credential’s tier), so a stored answer would
promise a resolve that then 403s. Omitted means the server said nothing, not that a
single approver will do: an agent with no recorded owner has no identity for the rule
to compare against, so it cannot be enforced and nothing is claimed. See
Keeper → The switch is a floor, not a master switch.Reading the inbox
List visible items or fetch just the unread count for the bell badge — both share the visibility predicate above.GET /api/v1/inbox
Paginated, newest-first list. The UI calls this on first load and after every WS inbox.updated event.
The response inlines both row count and unread count so the bell badge renders from the same fetch (no second round-trip on every poll):
GET /api/v1/inbox/count
Request: no body. Response: 200 OK with unread_count; 401 is
returned without authenticated workspace context and 500 on a database
failure.
Bell-badge endpoint. Same visibility predicate as List, cheaper payload — no JSON parse, no payload column read.
GET /api/v1/inbox/{id}
Fetch a single item with its full body and parsed payload — the detail
the list view omits. Backs crewship inbox get <id> and the web detail
pane. Visibility is enforced exactly like List / PATCH: an id in another
workspace, or one targeted at another user/role, returns 404 (never a
leak of an item you can’t see).
Response: 200 OK — a single item object (same shape
as one rows[] entry from List).
Updating state
Flip an item betweenunread / read / resolved. Source-managed kinds are restricted to read — see the conflict rule below.
PATCH /api/v1/inbox/{id}
Response: 200 OK with the updated item state. 400 indicates an invalid
state or body, 401 missing workspace context, 404 an invisible item, and
409 a source-managed item that must be resolved through its source endpoint.
Flip an item’s state. The body is JSON:
Returns
200:
Source-managed kinds — 409 Conflict
For kind in { waitpoint, escalation }, the inbox row is a mirror of an authoritative source row (the waitpoint token, the escalation row). PATCH only supports state: "read" for these — unread and resolved would desync the inbox row from the source, because the user expects the flip to also approve the waitpoint / close the escalation, and the inbox PATCH doesn’t do that.
failed_run (and message) are not source-managed — they resolve freely between all three states via PATCH or the bulk endpoint, because there is no source row whose state the inbox flip would contradict.
The non-read transition returns 409 with a hint at the right endpoint:
message, failed_run) flip freely between all three states.
State transitions
- → read sets
read_atandread_by_user_id(bothCOALESCE’d so re-reading doesn’t move the timestamp). - → unread clears
read_at,read_by_user_id,resolved_at,resolved_by_user_id,resolved_action. - → resolved sets
resolved_at,resolved_by_user_id,resolved_action(and overwrites whatever’s currently set, in case the user resolved with a different action).
Bulk state transition
Apply one state transition over many ids in a single round-trip. This backs the tree-grouped UI’s “resolve all under this routine / crew” action — instead of N PATCHes, the client sends one request and gets back a per-id breakdown.POST /api/v1/inbox/bulk
Request body is JSON:
More than 500 ids →
400 too many ids (max 500), matching the list LIMIT ceiling.
Per-id visibility re-check. The server applies the inbox visibility clause per id. Ids targeted at another user or another workspace count as not_found, not an error — you cannot flip rows you can’t see, and the bulk call can’t be used to probe which ids exist.
Decision-item protection (partial skip, never whole-batch fail). Decision items are skipped individually rather than failing the request:
- On
state: "resolved", rows are skipped whenkind∈{ waitpoint, escalation }orblocking = true(any kind) — these need their source endpoint to resolve. - On
state: "unread",waitpoint/escalationrows are skipped (flipping them would desync the source). - Non-blocking
message/failed_runrows resolve freely. state: "read"is always harmless and applies to all rows.
200 with a per-id breakdown:
updated— rows that took the transition.skipped/skipped_ids— rows the server refused to flip on this transition. Two reasons: source-managed kinds (waitpoint/escalation) must be resolved via their source endpoint; andblocking=truerows of any kind are left for individual handling (they have no generic source endpoint). The UI surfaces e.g. “22 resolved, 3 left open” and routes each skipped id to the appropriate action.not_found— ids that didn’t resolve to a visible row (wrong workspace / target, or already gone).
inbox.updated event broadcasts for the whole batch — see the WebSocket section below.
Purge
Bulk-clear inbox rows for the whole workspace — an operator cleanup, not a per-user action. Unlike List / PATCH / bulk, purge does not apply the per-user visibility clause: it deletes the whole workspace partition regardless of who each row was targeted at. Because that’s destructive and cross-user, it’s gated on themanage role (OWNER/ADMIN).
DELETE /api/v1/inbox
Response:
200 OK
inbox.updated (payload { "purge": "true" }) when at
least one row was deleted, so open inboxes repaint their list + bell badge.
WebSocket event
Every successful PATCH broadcasts on the workspace channel:inbox.updated for the whole batch instead of one per id — but only when updated > 0 (a request that skipped everything broadcasts nothing). Its payload carries the batch markers rather than a single row id:
See also
- Inbox guide — the user-facing UI on top of these endpoints, and the source-handler conflict rules.
- Approvals — waitpoint approve/reject endpoints invoked when an inbox item’s source is a waitpoint.
- WebSocket — channel + event format for
inbox.updated.