> ## Documentation Index
> Fetch the complete documentation index at: https://docs.crewship.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Inbox

> Unified human-in-the-loop inbox — list, count, and transition items between unread / read / resolved. Backed by inbox_items (migration v85).

# 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](/guides/inbox).

<Note>
  All endpoints require an authenticated session and a workspace context.
</Note>

## Endpoints

| Method | Endpoint                                      | Purpose                                                      |
| ------ | --------------------------------------------- | ------------------------------------------------------------ |
| GET    | [`/api/v1/inbox`](#get-apiv1inbox)            | List items (newest-first, with unread count)                 |
| GET    | [`/api/v1/inbox/count`](#get-apiv1inboxcount) | Unread count for the bell badge                              |
| PATCH  | [`/api/v1/inbox/{id}`](#patch-apiv1inboxid)   | Flip an item's state                                         |
| POST   | [`/api/v1/inbox/bulk`](#post-apiv1inboxbulk)  | Apply 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

| Field                 | Type                                   | Notes                                                                 |
| --------------------- | -------------------------------------- | --------------------------------------------------------------------- |
| `id`                  | string (CUID)                          |                                                                       |
| `workspace_id`        | string                                 | Always the current workspace.                                         |
| `kind`                | string                                 | `waitpoint`, `escalation`, `failed_run`, `message` (extensible).      |
| `source_id`           | string                                 | Source row id — e.g. waitpoint token, escalation id, pipeline run id. |
| `target_user_id`      | string \| omitted                      | Set when addressed to a specific user.                                |
| `target_role`         | string \| omitted                      | Set when addressed to a workspace role.                               |
| `title`               | string                                 | Display title.                                                        |
| `body_md`             | string \| omitted                      | Markdown body for the detail panel.                                   |
| `sender_type`         | string \| omitted                      | `user` or `agent`.                                                    |
| `sender_id`           | string \| omitted                      |                                                                       |
| `sender_name`         | string \| omitted                      | Pre-joined display name.                                              |
| `state`               | `"unread"` \| `"read"` \| `"resolved"` |                                                                       |
| `priority`            | string                                 | `low` / `normal` / `high` / `urgent`.                                 |
| `blocking`            | bool                                   | When `true`, the source flow is paused until the item is resolved.    |
| `payload`             | object \| omitted                      | Parsed JSON — inlined so the UI doesn't need a second `JSON.parse`.   |
| `read_at`             | RFC3339 \| omitted                     |                                                                       |
| `resolved_at`         | RFC3339 \| omitted                     |                                                                       |
| `resolved_by_user_id` | string \| omitted                      |                                                                       |
| `resolved_action`     | string \| omitted                      | `approved`, `rejected`, `retried`, `cancelled` — what the human did.  |
| `created_at`          | RFC3339                                |                                                                       |
| `updated_at`          | RFC3339                                |                                                                       |

## Reading the inbox

List visible items or fetch just the unread count for the bell badge — both share the [visibility predicate](#visibility-model) above.

### `GET /api/v1/inbox`

Paginated, newest-first list. The UI calls this on first load and after every WS `inbox.updated` event.

| Query param | Type                                      | Default | Notes                                                                                                |
| ----------- | ----------------------------------------- | ------- | ---------------------------------------------------------------------------------------------------- |
| `state`     | `unread` \| `read` \| `resolved` \| `all` | `all`   | Linear-Triage default — resolved items stay visible-but-dimmed. Invalid value → `400 invalid state`. |
| `kind`      | string                                    | (unset) | Narrows by item type.                                                                                |
| `limit`     | int                                       | `100`   | Capped 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):

```json theme={null}
{
  "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.

```json theme={null}
{ "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:

```json theme={null}
{
  "state": "resolved",
  "resolved_action": "approved"
}
```

| Field             | Type                                   | Notes                                                                                                                                                                                                |
| ----------------- | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `state`           | `"unread"` \| `"read"` \| `"resolved"` | Required. Invalid → `400 state must be unread\|read\|resolved`.                                                                                                                                      |
| `resolved_action` | string                                 | Optional. 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`:

```json theme={null}
{ "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:

```json theme={null}
{
  "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:

```json theme={null}
{
  "ids": ["ibx_01HVZ...", "ibx_01HW0...", "ibx_01HW1..."],
  "state": "resolved",
  "resolved_action": "approved"
}
```

| Field             | Type                                   | Notes                                                                                                   |
| ----------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `ids`             | string\[]                              | 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_action` | string                                 | Optional. 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](#visibility-model) 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:

```json theme={null}
{ "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](#source-managed-kinds--409-conflict); 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](#websocket-event) below.

## WebSocket event

Every successful PATCH broadcasts on the workspace channel:

```json theme={null}
{
  "type": "inbox.updated",
  "channel": "workspace:wsp_01HVZ...",
  "payload": { "id": "ibx_01HVZ...", "state": "resolved" }
}
```

A [bulk transition](#post-apiv1inboxbulk) 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:

```json theme={null}
{
  "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](/guides/inbox) — the user-facing UI on top of these endpoints, and the source-handler conflict rules.
* [Notifications](/api-reference/notifications) — the read-only activity feed (different table, different fan-out path).
* [Approvals](/api-reference/approvals) — waitpoint approve/reject endpoints invoked when an inbox item's source is a waitpoint.
* [WebSocket](/api-reference/websocket) — channel + event format for `inbox.updated`.
