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. When the rule applies and the raising agent has no recorded
owner, the fields still say it applies, and the resolve fails closed with a 403
(“the initiating identity is unknown”) — an unknown initiator is never treated as a
different person. 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), plus
has_more so a client can tell a full page from the final page:
active_by_kind is an exact per-kind count of the caller’s visible not-resolved items, independent of the request’s own state/kind filters — the dashboard’s “Needs your attention” counts come from it, because rows are a limit window (default 100) and silently plateau there. Keys are real item kinds (waitpoint, escalation, failed_run, message, memory_consolidation, run_needs_human, schedule_missed, …), and a kind with no active items has no key. The field is omitempty: it is absent when nothing is active and when the aggregate could not be computed completely, so its absence alone never means “nothing active”.
decision_count is the number of active items that actually block on a person (waitpoints, actionable escalations, blocking items such as run_needs_human; source-less Keeper advisories are excluded). It is present on every successful aggregate — 0 is a real, known-empty answer — and absent only when the aggregate failed. The dashboard’s “Needs your attention” headline uses it, falling back to counting its windowed rows when it is absent.
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), plus the detail-only fields: the keeper
evidence block, the four-eyes second_approver_* fields, and — for a
waitpoint or escalation — source_missing, which says whether a
source-managed row can still be
decided at its source (false) or may be dismissed from the inbox (true).
crewship inbox get <id> --format json passes it through unchanged.
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 while that source row is live — 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.
Exception — the source is gone. state: "resolved" is accepted on a source-managed row whose backing record no longer exists, because there is no source endpoint left to drive it and the row would otherwise sit in Needs action forever. The server probes before refusing: a waitpoint is source-less when no pipeline_waitpoints token is still pending, no staged hire is in PENDING_REVIEW, and no pending approval names it as target_id; an escalation is source-less when no escalations row exists (a keeper-synthetic escalation — Skill review, memory health, behaviour verdicts — never had one). state: "unread" stays refused either way. The detail read reports the same probe as source_missing, so a client can offer the dismiss only where it will succeed.
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 marks the item read for the calling user only: it upserts the caller’s row in
inbox_item_reads(idempotent — a second “mark read” by the same user doesn’t move the timestamp). It also still sets the legacy sharedread_at/read_by_user_idcolumns oninbox_items, first-write-wins (COALESCE’d) — but those are not whatstatereflects in any response; see the note below. - → unread clears only the caller’s own row in
inbox_item_reads— a role-targeted item another recipient already read staysreadfor them. It also clears the legacy sharedread_at,read_by_user_id,resolved_at,resolved_by_user_id,resolved_actioncolumns oninbox_items. - → 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). Unlike read/unread, resolved is workspace-shared — one decision closes the item for everyone who can see it.
Reading is per-user.
state in every response (GET /api/v1/inbox, GET /api/v1/inbox/{id}, and the unread_count on both) is computed for the calling user: resolved is read straight off the shared inbox_items.state (one decision closes the item for everyone), but read vs unread comes from whether the caller has a row in the per-(item, user) table inbox_item_reads. Marking an item read or unread only ever affects the caller — a role-targeted item another recipient has already read still comes back unread for you, and marking it read yourself doesn’t touch their state.The legacy read_at / read_by_user_id columns on inbox_items are kept and still written on every read/unread transition, but they now answer a narrower, separate question — “has anyone dealt with this” (useful for e.g. an operator dashboard that doesn’t care which manager looked) — and are no longer what state reports.A refreshed item — one an upsert resurrects to unread (e.g. a stale escalation re-raised) — clears every caller’s per-user read marker along with the shared columns, so it reads as unread again for everyone, including users who had read the earlier version.Acting on a card
Arun_needs_human card — raised when a run reports outcome NEEDS_HUMAN (work package B6) — carries actions a person can perform. Performing one closes the loop with the session that asked (PRD §18 scenario 15, B15): the run resumes, a receipt is written, and the same card is updated rather than a new one raised.
POST /api/v1/inbox/{id}/act
What each action does:
answer— the input is posted as a comment on the issue, authored by you, and delivered to the same agent session that asked (§10.5: “the exact session that asked, never a generic assignment”). It goes through the mention door, so B2’s exactly-once delivery and B3’s one-turn rule apply: one delivery, one run. The run resumes from its checkpoint with your answer as the next unread delta in its context pack (B5). If the session is mid-run, the delivery queues and is consumed when that run ends.take_over— you continue the work yourself. Runs the same hold as Transfer Issue Work withaction: take_over(#2448): parked routine runs are cancelled, live runs are stampedcancel_requested_atand hard-stopped, non-running assignments and open plan tasks are cancelled, pending mentions are superseded,issue_workbecomesmode: humanwith you asworker_user_idand a bumpedwork_revision, and the issue moves toTODO. The agent’s session leavesawaiting_inputforidle. The action’seffectreads Transfers the issue to you and pauses automatic agent work.dismiss— no further work now. Same session transition.
inbox_acted event on the issue’s event log (GET .../events, seq-ordered) naming who acted, the action, the card, the session’s agent_version (§11.6 — the routine_version analogue for an issue run) and, for an answer, the comment, delivery and run it produced. The card is resolved in place — state: resolved, resolved_action: <action>, and the same receipt merged into payload.receipt — so the thread stays one card.
Response: 200 OK
CLI:
crewship inbox act <id> <action> [--input …].
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. Same exception as PATCH: awaitpoint/escalationwhose source is gone resolves rather than being skipped, so “select all → archive” does not leave the orphans behind. - 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.