> ## 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.

# Journal

> Read and stream the Crew Journal -- the canonical append-only event log.

The Crew Journal is the canonical, append-only event log for a workspace. These endpoints let you read entries (with rich filtering), stream them live over SSE, count a filtered set, look up display metadata for journal cards, and set the one operator-mutable field — the priority marker. See the [Crew Journal guide](/guides/crew-journal) for the data model and entry-type catalog.

<Note>
  Every endpoint requires authentication and is workspace-scoped via the session context. `workspace_id` is always pulled from the session, never from query params — cross-workspace reads are impossible.
</Note>

The journal is **append-only at the entry level** — there is no endpoint that creates, mutates, or deletes a journal row's payload; entries come from backend code emitting via `journal.Writer`. The one writable surface is the operator-facing **priority marker** ([`POST /api/v1/journal/{id}/priority`](#set-entry-priority)), which updates only the `priority` column on an existing row and emits its own `memory.priority_changed` audit entry.

## Endpoints

| Method | Endpoint                                               | Purpose                                     |
| ------ | ------------------------------------------------------ | ------------------------------------------- |
| GET    | [`/api/v1/journal`](#list-entries)                     | List entries with filters and pagination    |
| GET    | [`/api/v1/journal/stream`](#stream-entries-sse)        | Stream entries live over Server-Sent Events |
| GET    | [`/api/v1/journal/{id}`](#get-single-entry)            | Get a single entry by ID                    |
| GET    | [`/api/v1/journal/count`](#count-entries)              | Count entries matching a filter             |
| POST   | [`/api/v1/journal/{id}/priority`](#set-entry-priority) | Set an entry's priority marker              |
| GET    | [`/api/v1/journal/lookup`](#workspace-lookup-table)    | Workspace lookup table for card enrichment  |

***

## List entries

The primary read surface — page through entries with a rich, AND-combined filter set.

```
GET /api/v1/journal
```

**Query parameters:**

All filters are AND-combined; CSV-valued ones expand to `IN (?, ?, ...)` predicates.

| Param                | Type    | Description                                                                                                                                       |
| -------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `crew_id`            | string  | Filter by single crew ID.                                                                                                                         |
| `agent_id`           | string  | Filter by single agent ID.                                                                                                                        |
| `mission_id`         | string  | Filter by mission ID.                                                                                                                             |
| `trace_id`           | string  | Narrow to one run's spans (`trace_id` == run id).                                                                                                 |
| `crew_ids`           | string  | CSV multi-crew filter — takes precedence over `crew_id` when non-empty.                                                                           |
| `agent_ids`          | string  | CSV multi-agent filter — takes precedence over `agent_id` when non-empty.                                                                         |
| `entry_type`         | string  | CSV of entry types (e.g. `peer.escalation,keeper.decision`).                                                                                      |
| `exclude_entry_type` | string  | CSV of entry types to exclude (`NOT IN`). Useful for hiding `container.metrics` noise. AND-combines with `entry_type`.                            |
| `severity`           | string  | CSV of `info`, `notice`, `warn`, `error`.                                                                                                         |
| `actor_type`         | string  | CSV of `agent`, `user`, `system`, `keeper`, `sidecar`, `orchestrator`.                                                                            |
| `priority`           | string  | CSV of `normal`, `high`, `pin`, `permanent`. Invalid values → 400.                                                                                |
| `since`              | string  | RFC3339 lower bound on `ts`.                                                                                                                      |
| `until`              | string  | RFC3339 upper bound on `ts`.                                                                                                                      |
| `q`                  | string  | FTS5 search across `summary` + `payload` (migration 55). Bounded to 200 chars; phrase-wrapped before MATCH; AND-combined with structural filters. |
| `cursor`             | string  | Opaque pagination token from a prior response.                                                                                                    |
| `limit`              | integer | 1-500, default 100.                                                                                                                               |

**Response:** `200 OK`

```json theme={null}
{
  "entries": [
    {
      "id": "j_a1b2c3d4e5f60718",
      "workspace_id": "ws_123",
      "crew_id": "crw_backend",
      "agent_id": "agt_viktor",
      "mission_id": "MIS-42",
      "ts": "2026-04-17T10:23:41.000Z",
      "entry_type": "peer.escalation",
      "severity": "warn",
      "priority": "normal",
      "actor_type": "agent",
      "actor_id": "agt_viktor",
      "summary": "escalating DB migration to eva",
      "payload": { "escalation_reason": "unsure about schema impact" },
      "trace_id": "0af7651916cd43dd8448eb211c80319c"
    }
  ],
  "next_cursor": "2026-04-17T10:23:41.000Z|j_a1b2c3d4e5f60718",
  "count": 1
}
```

| Field                  | Type    | Description                                                                    |
| ---------------------- | ------- | ------------------------------------------------------------------------------ |
| `entries`              | array   | Page of entries, newest first.                                                 |
| `entries[].id`         | string  | `j_<16-hex>` stable identifier.                                                |
| `entries[].ts`         | string  | RFC3339Nano (milli precision on write).                                        |
| `entries[].entry_type` | string  | See [type catalog](/guides/crew-journal#entry-type-catalog).                   |
| `entries[].severity`   | string  | `info`, `notice`, `warn`, or `error`.                                          |
| `entries[].priority`   | string  | Always present: `normal`, `high`, `pin`, or `permanent`.                       |
| `entries[].actor_type` | string  | `agent`, `user`, `system`, `keeper`, `sidecar`, `orchestrator`.                |
| `entries[].summary`    | string  | Human-readable one-liner.                                                      |
| `entries[].payload`    | object? | Typed payload, shape varies by entry\_type. Omitted when empty.                |
| `entries[].refs`       | object? | Cross-entry links (`parent_entry_id`, `approval_id`, ...). Omitted when empty. |
| `entries[].trace_id`   | string? | W3C trace ID if telemetry is enabled.                                          |
| `next_cursor`          | string? | Pass back via `?cursor=` for the next page. Absent on the last page.           |
| `count`                | integer | Number of entries in this page.                                                |

**Errors:**

| Status | Condition                                   |
| ------ | ------------------------------------------- |
| 400    | Bad `since` / `until` / `limit` / `cursor`. |
| 401    | No workspace context.                       |
| 500    | DB error.                                   |

***

## Stream entries (SSE)

Live tail of the journal over Server-Sent Events, seeded with a recent batch then switched to polling.

```
GET /api/v1/journal/stream
```

Server-Sent Events feed. Same query-param filters as List (limit is forced to 50 for the seed batch).

**Response headers:**

```
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
X-Accel-Buffering: no
```

**Event frames:**

```
id: j_a1b2c3d4e5f60718
event: entry
data: {"id":"j_a1b2c3d4e5f60718","ts":"...","entry_type":"peer.escalation", ...}

: heartbeat

id: j_b2c3d4e5f6071829
event: entry
data: {...}
```

* **Seed:** sends the most recent 50 entries matching filters, then switches to live polling.
* **Poll interval:** 1 second.
* **Heartbeat:** `: heartbeat` comment every 15s to keep proxies from closing idle connections. Event handlers ignore comments.
* **Watermark:** compound (ts, id) so bursts sharing a ms timestamp aren't partially dropped.
* **Reconnect:** clients send the SSE `Last-Event-ID` header; the server looks the entry up, treats its `ts` as the lower bound, and pages through the gap (up to **500 entries**, 10 × 50-row pages) before switching to live polling. A disconnect that produced more than 500 matching entries truncates the older end of the gap and the server logs a `warn` — clients should reconcile by re-fetching `/api/v1/journal?since=<last-known-ts>` if they need a longer window.

**Errors:**

| Status | Condition                                                                            |
| ------ | ------------------------------------------------------------------------------------ |
| 401    | No workspace context.                                                                |
| 400    | Bad query parameters.                                                                |
| 500    | Streaming not supported by writer (ResponseWriter doesn't implement `http.Flusher`). |

***

## Get single entry

Fetch one entry by ID, workspace-scoped.

```
GET /api/v1/journal/{id}
```

Returns one entry by ID, workspace-scoped. The response body is a single object whose shape matches one element of the `entries[]` array on List — same fields, same omitempty rules.

**Response:** `200 OK`

```json theme={null}
{
  "id": "j_a1b2c3d4e5f60718",
  "workspace_id": "ws_123",
  "ts": "2026-04-17T10:23:41.000Z",
  "entry_type": "peer.escalation",
  "severity": "warn",
  "priority": "normal",
  "actor_type": "agent",
  "summary": "escalating DB migration to eva",
  "payload": {"escalation_reason": "unsure about schema impact"},
  "trace_id": "0af7651916cd43dd8448eb211c80319c"
}
```

**Errors:**

| Status | Condition                                                                                                                                        |
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| 401    | No workspace context.                                                                                                                            |
| 404    | Unknown entry, OR an entry that exists but belongs to another workspace. The shape matches "not found" — existence is not leaked across tenants. |

***

## Count entries

Total matching a filter, for result-set badges that stay honest under filter changes.

```
GET /api/v1/journal/count
```

Returns the total number of entries matching the same query parameters as List, ignoring `cursor` and `limit`. The UI uses this to render result-set badges that stay honest under filter changes — without it, the only way to know the total was to page through every entry.

**Query parameters:** identical to [List](#list-entries), except `cursor` and `limit` are silently ignored.

**Response:** `200 OK`

```json theme={null}
{ "total": 1283 }
```

**Errors:**

| Status | Condition                                                             |
| ------ | --------------------------------------------------------------------- |
| 400    | Bad query parameter (`since`, `until`, `priority`, oversized `q`, …). |
| 401    | No workspace context.                                                 |
| 500    | DB error.                                                             |

***

## Set entry priority

The only operator-mutable surface — flag an entry's importance without touching its payload.

```
POST /api/v1/journal/{id}/priority
```

Annotate one entry with an importance marker (`normal`, `high`, `pin`, `permanent`). Marker affects compaction and recall — see the [Crew Journal guide](/guides/crew-journal#priority-markers).

<Note>
  **Authorization:** caller must hold `OWNER` or `ADMIN` on the workspace. `MEMBER` and below get `403`.
</Note>

**Request body:**

```json theme={null}
{ "priority": "permanent", "reason": "FX compliance constraint" }
```

| Field      | Type   | Required | Description                                                                                                                          |
| ---------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `priority` | string | yes      | One of `normal`, `high`, `pin`, `permanent`.                                                                                         |
| `reason`   | string | no       | Free-text rationale recorded on the audit emit. Empty is allowed but discouraged — the audit log is the only place the reason lives. |

**Response:** `200 OK`

```json theme={null}
{
  "id": "j_a1b2c3d4e5f60718",
  "priority": "permanent",
  "previous": "normal",
  "reason": "FX compliance constraint"
}
```

**Side effect:** writes a `memory.priority_changed` audit entry to the journal whose `payload` carries `target_entry_id`, `previous_priority`, `new_priority`, and the supplied `reason`. The audit emit is best-effort — the priority update is durable even if the audit write fails (a warning is logged in that case).

**Errors:**

| Status | Condition                                        |
| ------ | ------------------------------------------------ |
| 400    | Bad JSON body or unknown priority value.         |
| 401    | No workspace context.                            |
| 403    | Caller is not OWNER/ADMIN on the workspace.      |
| 404    | Entry not found, OR exists in another workspace. |
| 500    | DB error.                                        |

***

## Workspace lookup table

The join surface for journal cards — entries store stable IDs only, this resolves them to display strings.

```
GET /api/v1/journal/lookup
```

Returns workspace-scoped crews, agents, and missions for journal-card enrichment (palette colours and lucide icons). Fetched once on page mount; the frontend invalidates it from realtime crew/agent/mission events. Backend handler: `internal/api/journal_lookup.go`.

**Response:** `200 OK`

```json theme={null}
{
  "crews": [
    {"id":"crw_abc","slug":"backend","name":"Backend","icon":"server","color":"emerald"}
  ],
  "agents": [
    {"id":"agt_xyz","slug":"viktor","name":"Viktor","crew_id":"crw_abc","avatar_seed":"viktor","avatar_style":"pixel-art"}
  ],
  "missions": [
    {"id":"MIS-42","title":"Migrate auth","status":"IN_PROGRESS"}
  ]
}
```

Each list is capped at 1000 rows; workspaces beyond that get a useful subset. Empty lists are guaranteed non-`null` (the JSON arrays are always present), so the frontend can `.find()` without nil guards.

| Field                   | Type    | Description                                                                                        |
| ----------------------- | ------- | -------------------------------------------------------------------------------------------------- |
| `crews[].id`            | string  | Stable crew ID.                                                                                    |
| `crews[].slug`          | string  | Workspace-unique URL slug.                                                                         |
| `crews[].icon`          | string? | Lucide icon name (`server`, `code`, `rocket`, ...). May be null.                                   |
| `crews[].color`         | string? | Palette ID (`blue`, `emerald`, `violet`, `amber`, `rose`, `cyan`, `lime`, `fuchsia`). May be null. |
| `agents[].crew_id`      | string? | Foreign key to a `crews[]` entry in this same payload. May be null for crew-less agents.           |
| `agents[].avatar_seed`  | string? | Deterministic seed; the frontend renders an avatar from this.                                      |
| `agents[].avatar_style` | string? | Avatar style hint (`pixel-art`, `lorelei`, ...).                                                   |
| `missions[].status`     | string  | `PENDING`, `IN_PROGRESS`, `COMPLETED`, `CANCELLED`.                                                |

Soft-deleted crews and agents (`deleted_at IS NOT NULL`) are filtered out. Missions have no soft-delete column; all rows are returned, ordered by `created_at DESC` within the cap.

The journal entries themselves never carry display strings — they store stable IDs only. This endpoint is the join surface used by every UI card.

**Errors:** `401` on missing workspace; `500` on DB error.

***

## Tenancy

* `workspace_id` is always pulled from the session context -- not from query params.
* Cross-workspace reads are impossible: `journal.List` / `Get` / `Count` refuse to run without a workspace filter.
* Unknown or cross-tenant IDs return `404` with the same body as "not found".

## Related

* [Crew Journal guide](/guides/crew-journal).
* [`crewship journal`](/cli/journal).
