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 three endpoints are the read + state-flip surface the UI consumes — no inserts. 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.

Endpoints

MethodEndpointPurpose
GET/api/v1/inboxList items (newest-first, with unread count)
GET/api/v1/inbox/countUnread count for the bell badge
PATCH/api/v1/inbox/{id}Flip an item’s state
POST/api/v1/inbox/bulkApply one state transition to many items in a single request

Visibility model

Every query restricts to:
workspace_id = <current workspace> AND (
   (target_user_id IS NULL AND target_role IS NULL)   -- workspace-wide
   OR target_user_id = <session user>                 -- addressed to me
   OR target_role    = <my workspace role>            -- addressed to my role (OWNER, ADMIN, ...)
)
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

FieldTypeNotes
idstring (CUID)
workspace_idstringAlways the current workspace.
kindstringwaitpoint, escalation, failed_run, message (extensible).
source_idstringSource row id — e.g. waitpoint token, escalation id, pipeline run id.
target_user_idstring | omittedSet when addressed to a specific user.
target_rolestring | omittedSet when addressed to a workspace role.
titlestringDisplay title.
body_mdstring | omittedMarkdown body for the detail panel.
sender_typestring | omitteduser or agent.
sender_idstring | omitted
sender_namestring | omittedPre-joined display name.
state"unread" | "read" | "resolved"
prioritystringlow / normal / high / urgent.
blockingboolWhen true, the source flow is paused until the item is resolved.
payloadobject | omittedParsed JSON — inlined so the UI doesn’t need a second JSON.parse.
read_atRFC3339 | omitted
resolved_atRFC3339 | omitted
resolved_by_user_idstring | omitted
resolved_actionstring | omittedapproved, rejected, retried, cancelled — what the human did.
created_atRFC3339
updated_atRFC3339

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.
Query paramTypeDefaultNotes
stateunread | read | resolved | allallLinear-Triage default — resolved items stay visible-but-dimmed. Invalid value → 400 invalid state.
kindstring(unset)Narrows by item type.
limitint100Capped at 500.
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):
{
  "rows": [
    {
      "id": "ibx_01HVZ...",
      "workspace_id": "ws_01HV...",
      "kind": "waitpoint",
      "source_id": "wp_tok_abc",
      "target_role": "OWNER",
      "title": "Review production deploy",
      "body_md": "PR #128 ready for production roll-out…",
      "sender_type": "agent",
      "sender_id": "ag_01HVY...",
      "sender_name": "Daniel",
      "state": "unread",
      "priority": "high",
      "blocking": true,
      "payload": { "deploy_target": "prod-us-east-1" },
      "created_at": "2026-05-20T08:14:22Z",
      "updated_at": "2026-05-20T08:14:22Z"
    }
  ],
  "count": 1,
  "unread_count": 7
}

GET /api/v1/inbox/count

Bell-badge endpoint. Same visibility predicate as List, cheaper payload — no JSON parse, no payload column read.
{ "unread_count": 7 }

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}

Flip an item’s state. The body is JSON:
{
  "state": "resolved",
  "resolved_action": "approved"
}
FieldTypeNotes
state"unread" | "read" | "resolved"Required. Invalid → 400 state must be unread|read|resolved.
resolved_actionstringOptional. Recorded on resolved transitions so the audit trail captures what the user did, not just that they did something. Conventional values: approved, rejected, retried, cancelled.
Returns 200:
{ "id": "ibx_01HVZ...", "state": "resolved" }

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:
{
  "error": "use the source endpoint for this kind (e.g. /pipelines/waitpoints/{token}/approve) — inbox PATCH only supports 'read' for source-managed items",
  "kind": "waitpoint"
}
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:
{
  "ids": ["ibx_01HVZ...", "ibx_01HW0...", "ibx_01HW1..."],
  "state": "resolved",
  "resolved_action": "approved"
}
FieldTypeNotes
idsstring[]Required. Explicit id list. Empty → 400 ids required. Duplicate / empty ids are de-duped and ignored.
state"unread" | "read" | "resolved"Required. Invalid → 400 state must be unread|read|resolved.
resolved_actionstringOptional. Recorded on resolved transitions, same as PATCH.
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": 22, "skipped": 3, "skipped_ids": ["ibx_…", "ibx_…", "ibx_…"], "not_found": 0, "state": "resolved" }
  • 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.

WebSocket event

Every successful PATCH broadcasts on the workspace channel:
{
  "type": "inbox.updated",
  "channel": "workspace:wsp_01HVZ...",
  "payload": { "id": "ibx_01HVZ...", "state": "resolved" }
}
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:
{
  "type": "inbox.updated",
  "channel": "workspace:wsp_01HVZ...",
  "payload": { "bulk": "true", "state": "resolved" }
}
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.
  • Notifications — the read-only activity feed (different table, different fan-out path).
  • Approvals — waitpoint approve/reject endpoints invoked when an inbox item’s source is a waitpoint.
  • WebSocket — channel + event format for inbox.updated.