Skip to main content

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 both user_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:
An item addressed to OWNER never appears for a MEMBER, even if both belong to the same workspace. The visibility predicate is identical across List / UnreadCount / PatchState — a cross-workspace or cross-target id returns 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 between unread / 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:
Generic kinds (message, failed_run) flip freely between all three states.

State transitions

  • → read sets read_at and read_by_user_id (both COALESCE’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 when kind{ waitpoint, escalation } or blocking = true (any kind) — these need their source endpoint to resolve.
  • On state: "unread", waitpoint / escalation rows are skipped (flipping them would desync the source).
  • Non-blocking message / failed_run rows resolve freely.
  • state: "read" is always harmless and applies to all rows.
Returns 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; and blocking=true rows 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).
A single 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 the manage role (OWNER/ADMIN).

DELETE /api/v1/inbox

Response: 200 OK
Emits a single 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:
A bulk transition emits a single 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:
The frontend re-fetches list + count on this event so every connected client sees the same state without polling.

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.