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

# Admin

> Workspace administration endpoints for stats, user management, Keeper audit logs, and the license system.

# Admin API

The operational surface for OWNER and ADMIN roles: workspace stats, user management, GDPR subject-access and erasure, memory administration, the Keeper security audit log, and a set of cross-cutting system and metrics endpoints. Most endpoints require the OWNER role; the GDPR and memory-admin endpoints require ADMIN or OWNER (manage permission). The Keeper Phase 2 evaluators and three system endpoints have their own auth model, noted inline.

<Note>
  Unless a section says otherwise, every endpoint on this page requires the **OWNER** role. The GDPR, memory-admin, and Keeper-requests endpoints accept **ADMIN or OWNER** (manage permission).
</Note>

## Endpoints

| Method   | Endpoint                                                                                    | Purpose                                              |
| -------- | ------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
| `GET`    | [/api/v1/admin/stats](#get-api-v1-admin-stats)                                              | Aggregate stats for the current workspace            |
| `GET`    | [/api/v1/admin/users](#get-api-v1-admin-users)                                              | List workspace users with roles                      |
| `GET`    | [/api/v1/admin/users/\{userId}/data](#get-api-v1-admin-users-userid-data)                   | GDPR Art. 15 — export every row referencing a user   |
| `DELETE` | [/api/v1/admin/users/\{userId}/data](#delete-api-v1-admin-users-userid-data)                | GDPR Art. 17 — cascade purge a user's data           |
| `GET`    | [/api/v1/admin/workspaces](#get-api-v1-admin-workspaces)                                    | Workspace details with member/agent/crew counts      |
| `GET`    | [/api/v1/admin/memory/stats](#get-api-v1-admin-memory-stats)                                | Memory subsystem totals + per-tier/per-agent rollups |
| `GET`    | [/api/v1/admin/memory/versions](#get-api-v1-admin-memory-versions)                          | Row-level drill-down into `memory_versions`          |
| `GET`    | [/api/v1/admin/memory/versions/\{id}/content](#get-api-v1-admin-memory-versions-id-content) | Raw blob bytes for a single memory version           |
| `GET`    | [/api/v1/admin/memory/config](#get-api-v1-admin-memory-config)                              | Read per-workspace memory configuration              |
| `PATCH`  | [/api/v1/admin/memory/config](#patch-api-v1-admin-memory-config)                            | Partial-merge update of memory configuration         |
| `GET`    | [/api/v1/admin/keeper/requests](#get-api-v1-admin-keeper-requests)                          | Keeper access-request audit log                      |
| `POST`   | [/api/v1/internal/keeper/skill-review](#post-api-v1-internal-keeper-skill-review)           | F4.1 — periodic skill audit                          |
| `POST`   | [/api/v1/internal/keeper/behavior](#post-api-v1-internal-keeper-behavior)                   | F4.2 — post-tool-call behavior monitor               |
| `POST`   | [/api/v1/internal/keeper/memory-health](#post-api-v1-internal-keeper-memory-health)         | F4.3 — periodic memory consolidation review          |
| `POST`   | [/api/v1/internal/keeper/negative-learning](#post-api-v1-internal-keeper-negative-learning) | F4.4 — failure-event lesson capture                  |
| `GET`    | [/api/v1/system/setup-status](#get-api-v1-system-setup-status)                              | First-run bootstrap + signup gate                    |
| `GET`    | [/api/v1/system/telemetry](#get-api-v1-system-telemetry)                                    | Read-only Sentry consent gate                        |
| `GET`    | [/api/v1/system/version](#get-api-v1-system-version)                                        | Running binary version + latest release              |
| `GET`    | [/api/v1/metrics/timeseries](#get-api-v1-metrics-timeseries)                                | Bucketed time-series metrics for dashboard charts    |

## Stats & users

Read surfaces for workspace-level counts and membership. Both require the OWNER role.

## GET /api/v1/admin/stats

Returns aggregate statistics for the current workspace.

**Required role:** OWNER

### Response

```json theme={null}
{
  "workspaces": 1,
  "users": 12,
  "agents": 8,
  "running": 3
}
```

| Field        | Type      | Description                               |
| ------------ | --------- | ----------------------------------------- |
| `workspaces` | `integer` | Always 1 (scoped to current workspace)    |
| `users`      | `integer` | Number of workspace members               |
| `agents`     | `integer` | Total agents (excluding soft-deleted)     |
| `running`    | `integer` | Agents with a currently running agent run |

<Note>
  Stats are scoped to the current workspace to prevent cross-workspace data leakage. The `workspaces` field always returns 1.
</Note>

## GET /api/v1/admin/users

Lists all users in the current workspace with their roles.

**Required role:** OWNER

### Response

```json theme={null}
[
  {
    "id": "user-uuid",
    "email": "alice@example.com",
    "full_name": "Alice Chen",
    "avatar_url": "https://...",
    "created_at": "2025-01-01T00:00:00Z",
    "workspace": {
      "id": "ws-uuid",
      "name": "Engineering",
      "slug": "engineering"
    },
    "role": "ADMIN"
  }
]
```

| Field        | Type      | Description                                           |
| ------------ | --------- | ----------------------------------------------------- |
| `id`         | `string`  | User ID                                               |
| `email`      | `string`  | User email address                                    |
| `full_name`  | `string?` | Display name (nullable)                               |
| `avatar_url` | `string?` | Avatar URL (nullable)                                 |
| `created_at` | `string`  | ISO 8601 creation timestamp                           |
| `workspace`  | `object?` | Workspace details (id, name, slug)                    |
| `role`       | `string?` | Role in this workspace (OWNER, ADMIN, MEMBER, VIEWER) |

## GDPR

Subject-access export and right-to-erasure across the cascadable tables. Both require ADMIN or OWNER (manage permission) and write a `gdpr_actions` audit row.

<Warning>
  The DELETE endpoint cascade-purges every row referencing the user across the cascadable tables and cannot be undone. A `reason` is required for the audit trail.
</Warning>

## GET /api/v1/admin/users/\{userId}/data

GDPR Art. 15 (Right of Access) — return every row referencing the given user across the cascadable tables in the current workspace. Writes a `gdpr_actions` audit row with `action='export'`.

**Required role:** ADMIN or OWNER (`manage` permission). MANAGER is intentionally **not** a SAR actor — auditor framing is Compliance/Founder separation of duties.

### Response

```json theme={null}
{
  "data_subject_id": "user-uuid",
  "workspace_id": "ws-uuid",
  "exported_at": "2026-05-27T17:52:40.123456789Z",
  "action_id": "gdpr-action-cuid",
  "peer_cards": [
    {
      "id": "pc-uuid",
      "agent_id": "agent-uuid",
      "user_slug": "alice",
      "path": "/crew/agents/<slug>/peer-cards/alice.md",
      "bytes": 412,
      "created_at": "2026-05-01T...",
      "updated_at": "2026-05-26T..."
    }
  ],
  "memory_versions": [
    {
      "id": "mv-uuid",
      "path": "agents/<slug>/memory/long_term/...",
      "tier": "long_term",
      "sha256": "deadbeef...",
      "bytes": 1024,
      "written_at": "2026-05-10T...",
      "written_by": "agent-uuid",
      "payload_ref": "blob://..."
    }
  ],
  "inbox_items": [
    {
      "id": "ib-uuid",
      "kind": "waitpoint",
      "source_id": "run-uuid",
      "title": "Approve PR #142",
      "body_md": "...",
      "state": "open",
      "payload_json": "{...}",
      "created_at": "2026-05-15T..."
    }
  ]
}
```

| Field             | Type     | Description                                                                                                                     |
| ----------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `data_subject_id` | `string` | The `userId` whose data is being exported.                                                                                      |
| `workspace_id`    | `string` | Workspace the export was scoped to.                                                                                             |
| `exported_at`     | `string` | RFC3339Nano timestamp of the export.                                                                                            |
| `action_id`       | `string` | Audit row in `gdpr_actions` for this SAR call — the operator's defensible artefact.                                             |
| `peer_cards`      | `array`  | Per-agent peer card rows referencing this user.                                                                                 |
| `memory_versions` | `array`  | Memory rows with `data_subject_id = userId`. Content blob is not inlined — `payload_ref` points at the content-addressed store. |
| `inbox_items`     | `array`  | Inbox items addressed to or referencing this user.                                                                              |

<Note>
  `lessons.md` content is **not** scanned for user mentions — a known gap (see [GDPR guide](/security/gdpr)). Operators must manually review lessons after a SAR if any lesson body could carry user-attributable text.
</Note>

## DELETE /api/v1/admin/users/\{userId}/data

GDPR Art. 17 (Right to Erasure) — cascade purge every row referencing the user across the cascadable tables. Writes a `gdpr_actions` audit row with `action='delete'`. **`reason` is required** for the audit trail.

**Required role:** ADMIN or OWNER (`manage` permission).

### Request

```json theme={null}
{
  "reason": "User SAR ticket #INC-1234 — Right to Erasure request"
}
```

| Field    | Type     | Required | Description                                                                                                           |
| -------- | -------- | -------- | --------------------------------------------------------------------------------------------------------------------- |
| `reason` | `string` | yes      | Non-empty after trim. Persisted on the `gdpr_actions` row alongside the actor and target. Used in compliance reviews. |

### Response

`202 Accepted` on full success; `207 Multi-Status` on partial success (some tables purged, others returned errors — the `gdpr_actions` row carries the full per-table summary).

```json theme={null}
{
  "action_id": "gdpr-action-cuid",
  "data_subject": "user-uuid",
  "workspace_id": "ws-uuid",
  "rows_deleted": 7,
  "scope": {
    "peer_cards": 3,
    "memory_versions": 2,
    "inbox_items": 2
  }
}
```

On partial failure the same shape is returned with an additional `"error": "<first-error>"` field and HTTP 207.

| Field          | Type      | Description                                                                                                                                                     |
| -------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `action_id`    | `string`  | The audit row id. The same SAR re-run writes a **second** action row — idempotent in row counts (zero on second run) but the audit trail records every attempt. |
| `rows_deleted` | `int`     | Sum across all cascadable tables.                                                                                                                               |
| `scope`        | `object`  | Per-table count summary (extensible — new keys appear additively as new tables join the cascade).                                                               |
| `error`        | `string?` | Present only on HTTP 207. First error encountered; consult the audit row for the full per-table picture.                                                        |

<Warning>
  Memory `payload_ref` content-addressed blobs on disk are **not** deleted by this endpoint — blobs are deduplicated across workspaces and require a separate sweep job (planned). The audit/index rows ARE purged so the SAR is honoured at the DB-visibility layer. See the [GDPR guide](/security/gdpr) for the operator workflow.
</Warning>

## Workspaces

Workspace detail read, scoped to the current workspace. Requires the OWNER role.

## GET /api/v1/admin/workspaces

Lists workspace details with member, agent, and crew counts.

**Required role:** OWNER

### Response

```json theme={null}
[
  {
    "id": "ws-uuid",
    "name": "Engineering",
    "slug": "engineering",
    "created_at": "2025-01-01T00:00:00Z",
    "updated_at": "2025-06-15T12:00:00Z",
    "_count_members": 12,
    "_count_agents": 8,
    "_count_crews": 3
  }
]
```

<Note>
  This endpoint is scoped to the current workspace only. It does not list other workspaces in the system.
</Note>

## Memory admin

Inspect and tune the memory subsystem: aggregate stats, row-level version drill-down, raw blob retrieval, and the per-workspace retention config. All require ADMIN or OWNER (manage permission).

## GET /api/v1/admin/memory/stats

Returns aggregate statistics for the memory subsystem within the current workspace -- totals, per-tier rollups, and per-agent rollups derived from `memory_versions`.

**Required role:** ADMIN or OWNER (manage permission)

### Response

```json theme={null}
{
  "workspace_id": "ws-uuid",
  "totals": {
    "versions": 1284,
    "bytes": 4823104,
    "blobs": 612,
    "oldest_at": "2026-03-02T07:14:22Z",
    "newest_at": "2026-05-18T08:30:00Z"
  },
  "by_tier": [
    { "tier": "agent", "versions": 904, "bytes": 3120388 },
    { "tier": "crew", "versions": 220, "bytes": 1102716 },
    { "tier": "workspace", "versions": 160, "bytes": 600000 }
  ],
  "by_agent": [
    { "agent_slug": "", "versions": 380, "bytes": 1702716, "newest_at": "2026-05-18T08:12:11Z" },
    { "agent_slug": "martin", "versions": 412, "bytes": 1880388, "newest_at": "2026-05-18T08:30:00Z" }
  ]
}
```

| Field                   | Type      | Description                                                                                |
| ----------------------- | --------- | ------------------------------------------------------------------------------------------ |
| `workspace_id`          | `string`  | Current workspace ID                                                                       |
| `totals.versions`       | `integer` | Total `memory_versions` rows                                                               |
| `totals.bytes`          | `integer` | Sum of `bytes` across all rows                                                             |
| `totals.blobs`          | `integer` | Distinct sha256s; content-identical re-writes share one blob                               |
| `totals.oldest_at`      | `string`  | RFC3339 of the oldest row; `""` when no rows                                               |
| `totals.newest_at`      | `string`  | RFC3339 of the newest row; `""` when no rows                                               |
| `by_tier[].tier`        | `string`  | `agent`, `crew`, `workspace`, `pins`, or `learned`                                         |
| `by_tier[].versions`    | `integer` | Row count for the tier                                                                     |
| `by_tier[].bytes`       | `integer` | Sum of `bytes` for the tier                                                                |
| `by_agent[].agent_slug` | `string`  | Slug extracted from canonical `agent:<slug>/...` prefix; `""` for crew/workspace-tier rows |
| `by_agent[].versions`   | `integer` | Row count for the agent                                                                    |
| `by_agent[].bytes`      | `integer` | Sum of `bytes` for the agent                                                               |
| `by_agent[].newest_at`  | `string`  | RFC3339 of the agent's newest row                                                          |

### Example

```bash theme={null}
curl -H "Authorization: Bearer $TOKEN" \
  https://crewship.example.com/api/v1/admin/memory/stats
```

| Status | Condition                                |
| ------ | ---------------------------------------- |
| `400`  | Missing workspace context                |
| `403`  | MEMBER role (manage permission required) |
| `500`  | Underlying SQLite query failure          |

<Note>
  Tiers with zero rows are omitted from `by_tier`. Tiers with rows the operator has never written to are NOT included as zero entries.
</Note>

## GET /api/v1/admin/memory/versions

Row-level drill-down into `memory_versions`. Pairs with the stats endpoint above: stats answers "how much memory does this workspace have?", versions answers "which rows specifically?". Results are ordered newest-first by `written_at DESC, id DESC` and paginated via an opaque keyset cursor.

**Required role:** ADMIN or OWNER (manage permission)

### Query Parameters

All parameters are optional and AND-composed.

| Parameter     | Type      | Default | Description                                                                                   |
| ------------- | --------- | ------- | --------------------------------------------------------------------------------------------- |
| `tier`        | `string`  | --      | Exact tier filter: `agent`, `crew`, `workspace`, `pins`, or `learned`                         |
| `agent_slug`  | `string`  | --      | Slug from canonical `agent:<slug>/...` prefix; literal `%` / `_` in the slug match themselves |
| `path_prefix` | `string`  | --      | Match rows whose canonical path starts with this string; literal `%` / `_` match themselves   |
| `since`       | `string`  | --      | RFC3339 lower bound on `written_at` (inclusive)                                               |
| `until`       | `string`  | --      | RFC3339 upper bound on `written_at` (exclusive)                                               |
| `limit`       | `integer` | `50`    | Page size; hard cap at `500`                                                                  |
| `cursor`      | `string`  | --      | Opaque `next_cursor` from a prior response                                                    |

### Response

```json theme={null}
{
  "workspace_id": "ws-uuid",
  "rows": [
    {
      "id": "mv_01HZ...",
      "path": "agent:martin/AGENT.md",
      "tier": "agent",
      "sha256": "abc...",
      "bytes": 1234,
      "written_at": "2026-05-18T08:30:00Z",
      "written_by": "audit-watcher",
      "parent_sha": "def..."
    }
  ],
  "next_cursor": "djE6MjAyNi0wNS0xOFQwODozMDowMC4wMDBafG12XzAxSFo...",
  "limit": 50,
  "filters_applied": {
    "tier": "agent",
    "agent_slug": "martin"
  }
}
```

| Field               | Type      | Description                                                |
| ------------------- | --------- | ---------------------------------------------------------- |
| `workspace_id`      | `string`  | Current workspace ID                                       |
| `rows[].id`         | `string`  | `memory_versions.id`                                       |
| `rows[].path`       | `string`  | Canonical memory path                                      |
| `rows[].tier`       | `string`  | Tier (`agent` / `crew` / `workspace` / `pins` / `learned`) |
| `rows[].sha256`     | `string`  | Content sha256 of the blob                                 |
| `rows[].bytes`      | `integer` | Byte length of the blob                                    |
| `rows[].written_at` | `string`  | RFC3339 timestamp                                          |
| `rows[].written_by` | `string`  | Writer identifier; `""` when NULL                          |
| `rows[].parent_sha` | `string?` | Previous version's sha256; omitted when NULL               |
| `next_cursor`       | `string?` | Opaque cursor for the next page; `null` on last page       |
| `limit`             | `integer` | The resolved page size                                     |
| `filters_applied`   | `object`  | Echo of the normalised filters that produced this response |

The cursor is a base64url-encoded `v1:<rfc3339nano>|<id>` tuple pinning `(written_at, id)`. Offset pagination would duplicate or skip rows because the audit watcher writes continuously; keyset pagination pins the boundary so concurrent inserts above the cursor land on the next refresh naturally.

### Example

```bash theme={null}
curl -H "Authorization: Bearer $TOKEN" \
  "https://crewship.example.com/api/v1/admin/memory/versions?tier=agent&agent_slug=martin&limit=20"
```

| Status | Condition                                                                                              |
| ------ | ------------------------------------------------------------------------------------------------------ |
| `400`  | Unknown tier, malformed `since` / `until` (not RFC3339), non-positive `limit`, or unparseable `cursor` |
| `403`  | MEMBER role                                                                                            |
| `500`  | Underlying SQLite query failure                                                                        |

## `GET /api/v1/admin/memory/versions/{id}/content`

Returns the raw blob bytes for a single `memory_versions` row. Used by the dashboard's row-detail view and by compliance auditors who need the literal content (not just the metadata) -- for example, to confirm a PII scrubber fired on the offending payload.

**Required role:** ADMIN or OWNER (manage permission)

### Response

The body is the raw blob bytes (NOT JSON-wrapped). `Content-Type` is `text/markdown; charset=utf-8` for paths ending in `.md`, otherwise `application/octet-stream` so the client cannot auto-render untrusted bytes as HTML.

Audit metadata travels alongside the body via response headers:

| Header                | Description                                                           |
| --------------------- | --------------------------------------------------------------------- |
| `X-Memory-Sha256`     | sha256 from the `memory_versions` row                                 |
| `X-Memory-Bytes`      | Length from the row (matches the row's recorded size)                 |
| `X-Memory-Tier`       | Tier (`agent` / `crew` / `workspace` / `pins` / `learned`)            |
| `X-Memory-Path`       | Canonical memory path                                                 |
| `X-Memory-Written-At` | RFC3339 timestamp                                                     |
| `X-Memory-Written-By` | Writer identifier (omitted when NULL)                                 |
| `Cache-Control`       | `private, max-age=31536000, immutable` -- blobs are content-addressed |

### Example

```bash theme={null}
curl -H "Authorization: Bearer $TOKEN" \
  -D - \
  https://crewship.example.com/api/v1/admin/memory/versions/mv_01HZ.../content
```

| Status | Condition                                                                                               |
| ------ | ------------------------------------------------------------------------------------------------------- |
| `400`  | Missing workspace context OR missing `id` path segment                                                  |
| `403`  | MEMBER role                                                                                             |
| `404`  | Unknown id OR cross-workspace probe (no existence leak)                                                 |
| `410`  | Row exists but the blob file is missing on disk (retention sweep, restore-from-backup race)             |
| `413`  | Blob exceeds the 10 MB cap (DB-claimed size OR on-disk size)                                            |
| `500`  | sha mismatch (blob tampered after recording) OR `payload_ref` resolves outside the configured blob root |
| `503`  | Memory versioning is not configured (lite-mode deployment without blob root)                            |

<Warning>
  The handler refuses to follow symlinks under `payload_ref`. The on-disk layout is fixed at `blobRoot/<sha[:2]>/<sha>`; `filepath.EvalSymlinks` is used to verify the resolved path stays inside the blob root, defending against path-traversal vectors in a corrupted or malicious `payload_ref`.
</Warning>

## GET /api/v1/admin/memory/config

Returns the per-workspace memory configuration. Drives the retention sweep (`versions_retention_days`) and is the operator's read surface for inspecting drift between "what's stored on the row" and "what's effective".

**Required role:** ADMIN or OWNER (manage permission)

### Response

```json theme={null}
{
  "workspace_id": "ws-uuid",
  "versions_retention_days": 30,
  "is_default": true,
  "raw_config": null
}
```

| Field                     | Type      | Description                                                                                     |
| ------------------------- | --------- | ----------------------------------------------------------------------------------------------- |
| `workspace_id`            | `string`  | Current workspace ID                                                                            |
| `versions_retention_days` | `integer` | Resolved retention window; falls back to the built-in default (30) when no row or no key is set |
| `is_default`              | `boolean` | `true` when no row exists, the key is missing, or the value fell back to the default            |
| `raw_config`              | `string?` | Literal JSON stored on the `workspaces.memory_config` column; `null` when empty                 |

### Example

```bash theme={null}
curl -H "Authorization: Bearer $TOKEN" \
  https://crewship.example.com/api/v1/admin/memory/config
```

| Status | Condition                                                 |
| ------ | --------------------------------------------------------- |
| `400`  | Missing workspace context                                 |
| `403`  | MEMBER role                                               |
| `500`  | Stored JSON is malformed (PATCH still works -- see below) |

## PATCH /api/v1/admin/memory/config

Partial-merge update of the per-workspace memory configuration. Merges the request body's keys into the existing JSON document; unspecified keys are preserved.

**Required role:** ADMIN or OWNER (manage permission)

### Request Body

```json theme={null}
{
  "versions_retention_days": 7
}
```

| Field                     | Type      | Description                                                                                                                 |
| ------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------- |
| `versions_retention_days` | `integer` | Positive integer in `[1, 3650]`. Zero, negative, fractional, or non-numeric values produce a 400 naming the offending field |

Unknown top-level keys are passed through to the stored document for forward compatibility (e.g. future fields like `compaction_hour_override`).

### Response

Returns the post-merge config in the same shape as the GET response above. A PATCH that produces no diff (e.g. resetting to the same value) returns `200` with the current shape and emits NO journal entry -- the audit trail tracks actual change, not request count.

### Example

```bash theme={null}
curl -X PATCH \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"versions_retention_days": 7}' \
  https://crewship.example.com/api/v1/admin/memory/config
```

| Status | Condition                                                                                                                                                               |
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `400`  | Missing workspace context, malformed JSON, trailing garbage after the JSON value, empty body, or `versions_retention_days` outside `[1, 3650]` / not a positive integer |
| `403`  | MEMBER role                                                                                                                                                             |
| `413`  | Request body exceeds 16 KB                                                                                                                                              |
| `500`  | UPDATE / commit failure                                                                                                                                                 |

<Note>
  The read-merge-write runs inside a SQLite `BEGIN IMMEDIATE` (serializable) transaction so concurrent PATCHes touching different keys serialise rather than last-write-wins. Each real diff emits a `memory.config_updated` journal entry (Notice severity, ActorUser) with payload `{workspace_id, changes: {field: {from, to}}}`. If the stored JSON is corrupt, PATCH still succeeds (treats the existing document as empty) so operators can fix the row without resorting to manual SQL.
</Note>

## Keeper

The operator-facing Keeper audit log, plus the Phase 2 F4 evaluator routes. The audit-log read requires ADMIN or OWNER (manage permission); the Phase 2 evaluator routes are internal-auth (see below).

## GET /api/v1/admin/keeper/requests

Returns the Keeper access request audit log -- every credential access and command execution request evaluated by the Keeper.

**Required role:** ADMIN or OWNER (manage permission)

### Query Parameters

| Parameter | Type      | Default | Description                   |
| --------- | --------- | ------- | ----------------------------- |
| `limit`   | `integer` | `50`    | Max entries to return (1-200) |
| `offset`  | `integer` | `0`     | Offset for pagination         |

### Response

```json theme={null}
[
  {
    "id": "request-uuid",
    "agent_id": "agent-uuid",
    "agent_name": "Viktor",
    "crew_id": "crew-uuid",
    "credential_id": "cred-uuid",
    "credential_name": "Anthropic Production",
    "intent": "Need to call the Claude API to analyze code",
    "request_type": "credential",
    "command": null,
    "decision": "ALLOW",
    "reason": "Low-risk API call with clear intent",
    "risk_score": 2,
    "exit_code": null,
    "ollama_prompt": "...",
    "ollama_raw_response": "...",
    "created_at": "2025-01-15T10:30:00Z",
    "decided_at": "2025-01-15T10:30:01Z"
  }
]
```

| Field                 | Type       | Description                                                                                                                      |
| --------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `id`                  | `string`   | Request ID                                                                                                                       |
| `agent_id`            | `string`   | Requesting agent ID                                                                                                              |
| `agent_name`          | `string`   | Agent display name                                                                                                               |
| `crew_id`             | `string`   | Crew the agent belongs to                                                                                                        |
| `credential_id`       | `string`   | Credential being accessed                                                                                                        |
| `credential_name`     | `string`   | Credential display name                                                                                                          |
| `intent`              | `string`   | Agent's stated intent for access                                                                                                 |
| `request_type`        | `string`   | One of `credential` / `execute` / `skill_review` / `behavior` / `memory_health` / `negative_learning` (see Keeper Phase 2 below) |
| `command`             | `string?`  | Shell command (for execute requests); omitted when null                                                                          |
| `decision`            | `string?`  | `ALLOW`, `DENY`, or `ESCALATE`                                                                                                   |
| `reason`              | `string?`  | LLM-generated explanation                                                                                                        |
| `risk_score`          | `integer?` | Risk assessment (1-10 scale)                                                                                                     |
| `exit_code`           | `integer?` | Command exit code (for execute requests); omitted when null                                                                      |
| `ollama_prompt`       | `string?`  | Full prompt sent to the Keeper LLM; omitted when null                                                                            |
| `ollama_raw_response` | `string?`  | Raw LLM response text; omitted when null                                                                                         |
| `created_at`          | `string`   | ISO 8601 request timestamp                                                                                                       |
| `decided_at`          | `string?`  | ISO 8601 decision timestamp                                                                                                      |

<Note>
  Phase 2 request types (`skill_review`, `behavior`, `memory_health`, `negative_learning`) populate the same audit log surface as Phase 1 (`credential`, `execute`) with the same shape. The `intent` column carries the F4 evaluator's structured summary instead of a free-form access reason; the `ollama_prompt` + `ollama_raw_response` capture the LLM evaluation round-trip. Filter by `request_type` to slice the log into per-evaluator views.
</Note>

## Keeper Phase 2 — F4 evaluators

<Note>
  Phase 2 endpoints are **internal-auth** (X-Internal-Token) — they're invoked by the platform itself (scheduler routines + the post-tool-call hook), not by operators directly. The admin-facing surface is the `/api/v1/admin/keeper/requests` log above, plus the per-type filters in the admin UI's "Keeper P2 reviews" panel (PR-F2).
</Note>

If an operator needs to trigger an evaluator ad-hoc (debugging, manual re-evaluation), the routes are reachable via the internal token. The expected production path is automated: routines fire on cron, the behavior hook fires on tool-call sampling.

### POST /api/v1/internal/keeper/skill-review

**F4.1 — periodic skill audit.** Cron-fires daily 03:00 UTC per `Scheduler.RegisterPlatformRoutine`. The evaluator reads each `skills` row + `skill_invocations` history, asks the F3 `Curator` aux model whether the skill should stay `active`, transition to `stale` (no recent invocations), or be `archived` (failures dominating). DENY decisions write a blocking inbox row; ALLOW updates `skills.lifecycle_state` in place.

**Request body:**

```json theme={null}
{
  "workspace_id": "ws_…",
  "crew_id": "crw_…",
  "skill_id": "skl_…",
  "skill_name": "rotate-anthropic-keys",
  "skill_description": "Rotate the Anthropic API key quarterly",
  "lifecycle_state": "active",
  "last_used_at": "2026-05-15T08:00:00Z",
  "assignments": 3,
  "assigned_agents": ["agt_…", "agt_…"],
  "stats": { "invocations_30d": 4, "failures_30d": 0 },
  "failure_snippets": []
}
```

| Field                                                | Required | Notes                                                                                                                                   |
| ---------------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `workspace_id`                                       | yes      | MUST match the request context `workspace_id` (see [Internal IPC](/api-reference/internal#tenant-isolation-on-internal-auth-handlers)). |
| `crew_id`                                            | yes      | Used for per-crew policy resolution.                                                                                                    |
| `skill_id`                                           | yes      | The skill being reviewed.                                                                                                               |
| `skill_name`, `skill_description`, `lifecycle_state` | yes      | Current state snapshot the LLM evaluates.                                                                                               |
| `last_used_at`                                       | optional | RFC3339; empty = "never".                                                                                                               |
| `assignments`, `assigned_agents`                     | optional | Fan-out context for ESCALATE → inbox routing.                                                                                           |
| `stats`                                              | optional | 30-day invocation + failure counts.                                                                                                     |
| `failure_snippets`                                   | optional | Up to 3 most-recent failure excerpts (truncated).                                                                                       |

**Response:**

```json theme={null}
{
  "request_id": "kpr_skr_a3f8e2…",
  "decision": "ALLOW",
  "reason": "Skill still actively used (4 invocations in 30d, 0 failures)",
  "risk_score": 1,
  "verify_after_decide": true,
  "unverify_after_decide": false,
  "proposed_lifecycle": "active"
}
```

DENY routes a blocking `inbox_items` row to the assigned agents' workspace (per `assigned_agents` fan-out). ESCALATE routes a `MANAGER`-targeted blocking row.

### POST /api/v1/internal/keeper/behavior

**F4.2 — post-tool-call behavior monitor.** Fires from `behaviorhook.MaybeEvaluate` (orchestrator `EventPostToolCall` event), sampled at the per-crew rate (default 1-in-5). The evaluator reads the `(tool_name, tool_args_snippet, current crew behavior_mode)` triple and returns ALLOW / DENY / ESCALATE.

`behavior_mode=warn` (default): DENY → non-blocking inbox; agent's NEXT tool call proceeds. `behavior_mode=block`: DENY → blocking inbox + `ShouldBlock=true` in the response so the orchestrator interrupts the agent's next call. Forbidden combination `(autonomy=full + behavior_mode=block)` rejected at API + DB layer.

**Request body:**

```json theme={null}
{
  "workspace_id": "ws_…",
  "crew_id": "crw_…",
  "agent_id": "agt_…",
  "agent_name": "Anna",
  "crew_name": "Ops",
  "tool_name": "shell_exec",
  "tool_args_snippet": "{\"cmd\":\"rm -rf node_modules\"}"
}
```

**Response:**

```json theme={null}
{
  "request_id": "kpr_bhv_b71e9c…",
  "decision": "DENY",
  "reason": "Destructive command without scoped path; recommend restricting to a specific directory",
  "risk_score": 7,
  "should_block": true,
  "policy_decision": "block_inbox"
}
```

`policy_decision` is the resolved per-crew policy verdict (e.g. `inbox_approve`, `auto_log_inbox`, `block_inbox`) that drives whether an inbox row is written and whether it blocks.

### POST /api/v1/internal/keeper/memory-health

**F4.3 — periodic memory consolidation review.** Cron-fires daily 03:30 UTC. The evaluator consumes a `consolidate.HealthSnapshot` (the 5-metric health score: Freshness / Coverage / Coherence / Efficiency / Reachability, each in `[0, 100]`, plus the weighted `Overall`) and decides whether to auto-trigger a consolidation routine. Staleness and contradiction counts travel as separate top-level body fields, not inside `snapshot`.

**Request body:**

```json theme={null}
{
  "workspace_id": "ws_…",
  "crew_id": "crw_…",
  "crew_name": "Ops",
  "agent_name": "Anna",
  "snapshot": {
    "WorkspaceID": "ws_…",
    "CrewID": "crw_…",
    "Freshness": 80,
    "Coverage": 70,
    "Coherence": 65,
    "Efficiency": 90,
    "Reachability": 87,
    "Overall": 76
  },
  "agent_md_bytes": 2840,
  "persona_md_bytes": 1120,
  "crew_md_bytes": 5120,
  "stalest_entry_days": 412,
  "contradiction_count": 3
}
```

<Note>
  `snapshot` is the `consolidate.HealthSnapshot` Go struct serialised **without JSON tags**, so its wire keys are PascalCase (`Freshness`, `Reachability`, `Overall`, …) and each metric is on a `0–100` scale — not the `reachability_pct` / `0–1` shape an external caller might guess. `stalest_entry_days` and `contradiction_count` are top-level fields alongside `snapshot`, not nested inside it.
</Note>

**Response:**

```json theme={null}
{
  "request_id": "kpr_mhc_c83a4b…",
  "decision": "DENY",
  "reason": "412-day stalest entry + 3 contradictions; consolidator recommended",
  "risk_score": 6,
  "auto_consolidate": true,
  "overall_score": 38
}
```

`auto_consolidate=true` triggers `consolidator.Run` for the workspace; ESCALATE writes a blocking inbox row instead.

### POST /api/v1/internal/keeper/negative-learning

**F4.4 — failure-event lesson capture.** Fires after a guardrail trip, run failure, or explicit operator "log this lesson" action. The evaluator decides whether the failure is worth a `kind=negative` lesson in the agent's `lessons.md`. ALLOW writes through `consolidate.WriteLesson` (PR-Z Z.7) which enforces YAML schema + idempotency by ID + flock + atomic-rename.

**Self-learning gate:** ALLOW auto-applies only when the agent has `self_learning_enabled = 1` (migration v106). With `self_learning_enabled = 0` (default), ALLOW queues a blocking inbox row with the full lesson proposal in `payload_json` and the marker `"self_learning_gate": "off"` so the UI can distinguish the gate-demoted path. See [Autonomy + self-learning](/guides/autonomy-and-self-learning).

**Trigger kinds:** `run_failed`, `guardrail_warn`, `guardrail_error`, `keeper_execute_deny`.

**Request body:**

```json theme={null}
{
  "workspace_id": "ws_…",
  "crew_id": "crw_…",
  "agent_id": "agt_…",
  "agent_name": "Anna",
  "crew_name": "Ops",
  "agent_memory_dir": "/output/agt_…/.memory",
  "trigger": "run_failed",
  "tool_name": "shell_exec",
  "failure_snippet": "deploy.sh: missing DATABASE_URL",
  "prior_lesson": ""
}
```

| Field              | Notes                                                                                                                                              |
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `agent_memory_dir` | Container-side path where `lessons.md` lives; the consolidate writer mkdir-p's as needed.                                                          |
| `trigger`          | One of the four kinds above.                                                                                                                       |
| `prior_lesson`     | Optional dup-suppression: if non-empty, the evaluator gets it as "the agent already knows X" context and is less likely to ALLOW a near-duplicate. |

**Response:**

```json theme={null}
{
  "request_id": "kpr_neg_d94f5a…",
  "decision": "ALLOW",
  "reason": "Recurring deploy-env mistake worth a permanent lesson",
  "risk_score": 4,
  "write_lesson": true,
  "lesson_id": "les_abc123…"
}
```

When `self_learning_enabled = 0` the response still says `write_lesson: true` (the evaluator's intent), but no lesson lands on disk — the operator must approve via inbox. Check `gdpr_actions`-style audit via `GET /api/v1/admin/keeper/requests?request_type=negative_learning`.

### Cross-tenant guard

<Accordion title="How the four evaluators enforce workspace isolation">
  All four endpoints assert `body.workspace_id == ctx.workspace_id` via `assertBodyWorkspaceMatchesCtx` before any evaluator runs. Asymmetric forgery (query=A, body=B) returns `400 Bad Request`. Empty ctx workspace also returns 400 — the gate refuses to operate without the middleware that's supposed to set it. Symmetric forgery (caller picks one workspace consistently) requires PR-F24 token-to-workspace binding to close fully. See [Internal IPC — Tenant isolation](/api-reference/internal#tenant-isolation-on-internal-auth-handlers).
</Accordion>

## System

Small cross-cutting endpoints that don't belong to any single domain handler. They surface install state, telemetry consent, the running binary's version, and dashboard time-series metrics.

<Note>
  `setup-status` and `telemetry` are intentionally **unauthenticated** because the login page needs to read them before any session exists; `version` and `metrics/timeseries` require an authenticated user (`metrics/timeseries` also needs workspace context).
</Note>

### GET /api/v1/system/setup-status

First-run gate. Returns whether the install needs to be bootstrapped (empty `users` table) and whether public signup is enabled. The login page calls this on every page paint -- when `needs_bootstrap` is `true`, the browser routes to `/bootstrap` instead of `/login`.

**Auth:** none -- the answer is what tells the browser which page to render.

#### Response

```json theme={null}
{
  "needs_bootstrap": false,
  "allow_signup": false
}
```

| Field             | Type    | Description                                                                                                                                                   |
| ----------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `needs_bootstrap` | boolean | `true` when `COUNT(*) FROM users = 0`. DB errors fail closed as `false` -- a transient blip won't ship the user into the bootstrap flow on a healthy install. |
| `allow_signup`    | boolean | Mirrors the `CREWSHIP_ALLOW_SIGNUP` server flag. When `false`, the login page hides the "Sign up" link.                                                       |

### GET /api/v1/system/telemetry

Read-only consent gate for the frontend's Sentry client. The Next.js `sentry.client.config.ts` fetches this before calling `Sentry.init` and bails out if `enabled=false`. Consent is flipped via the CLI (`crewship telemetry on/off`), never over HTTP -- making this endpoint mutating would create a CSRF vector that flips the bit on every cross-site navigation.

**Auth:** none -- the login page must boot crash reporting before any session exists.

#### Response

```json theme={null}
{
  "enabled": true,
  "install_id": "a3f9c2e1b8d74f5a"
}
```

| Field        | Type    | Description                                                                                                                                         |
| ------------ | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `enabled`    | boolean | `true` only when the operator has opted in, a DSN is wired, AND the backend Sentry init succeeded.                                                  |
| `install_id` | string  | Anonymous identifier the backend already ships as Sentry's `ServerName`. Exposed so frontend events group with backend events for the same install. |

Errors fall back to `{enabled: false, install_id: ""}` rather than 5xx -- a transient DB blip defaults to the privacy-preserving outcome.

### GET /api/v1/system/version

Reports the running binary's version and (cache-permitting) the latest release from GitHub. The web UI uses this to render an "update available" banner.

**Auth:** required (any authenticated user, no workspace role needed).

#### Response

```json theme={null}
{
  "current": "0.4.1",
  "latest": "0.4.2",
  "newer": true,
  "url": "https://github.com/crewship-ai/crewship/releases/tag/v0.4.2"
}
```

| Field     | Type    | Description                                                              |
| --------- | ------- | ------------------------------------------------------------------------ |
| `current` | string  | The running binary's version (`SetVersion`-injected from `cmd_start`).   |
| `latest`  | string? | Latest release tag from GitHub. `null` on a cold cache + GitHub timeout. |
| `newer`   | boolean | `true` when `latest > current` by semver.                                |
| `url`     | string? | Release page URL. `null` paired with `null` `latest`.                    |

The handler imposes a 4 s upper bound on top of the update package's 5 s internal HTTP timeout -- a cold cache + slow network still returns "no info" rather than blocking the UI render.

### GET /api/v1/metrics/timeseries

Bucketed time-series metrics for the dashboard charts. Returns zero-filled bucket sequences so the client never has to patch visual gaps. Reads `workspace_id` from the request context, never from a query param.

**Auth:** required + workspace context.

#### Query parameters

| Param      | Type   | Default  | Description                                                                                            |
| ---------- | ------ | -------- | ------------------------------------------------------------------------------------------------------ |
| `metric`   | string | --       | Required. One of `issues_closed`, `cost_usd`, `runs_count`, `active_missions`.                         |
| `window`   | string | `"24h"`  | One of `24h`, `7d`, `30d`.                                                                             |
| `bucket`   | string | `"1h"`   | One of `15m`, `1h`, `1d`. Combinations producing >200 buckets are rejected.                            |
| `group_by` | string | `"none"` | One of `none`, `crew`, `model`, `status`. Some group-by values are metric-specific (see error matrix). |

#### Response

```json theme={null}
{
  "metric": "issues_closed",
  "window": "7d",
  "bucket": "1d",
  "group_by": "none",
  "buckets": [
    { "ts": "2026-05-14T00:00:00Z", "series": { "total": 4 } },
    { "ts": "2026-05-15T00:00:00Z", "series": { "total": 0 } },
    { "ts": "2026-05-16T00:00:00Z", "series": { "total": 7 } }
  ],
  "series_labels": { "total": "Total" }
}
```

| Field                                       | Type   | Description                                                                                                                        |
| ------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------- |
| `metric` / `window` / `bucket` / `group_by` | string | Echo of the resolved query parameters.                                                                                             |
| `buckets[].ts`                              | string | Bucket-start in UTC, aligned to wall-clock boundaries (`15m` → `:00/:15/:30/:45`, `1h` → top of hour, `1d` → UTC midnight).        |
| `buckets[].series`                          | object | Map of series key to numeric value. Always floats on the wire so cost and counts share the JSON shape.                             |
| `series_labels`                             | object | Map of series key to display label (e.g. crew name, model name, status). For `group_by=none` always contains `{"total": "Total"}`. |

| Status | Condition                                                                                                                                                                                                                                                                                                                  |
| ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `400`  | Unknown `metric` / `window` / `bucket` / `group_by`; bucket larger than window; combination produces >200 buckets; `group_by=model` for a metric other than `cost_usd`; `group_by=crew` for a metric other than `issues_closed`/`runs_count`; `group_by=status` for a metric other than `issues_closed`/`active_missions`. |
| `401`  | Workspace context missing from the request.                                                                                                                                                                                                                                                                                |
| `500`  | SQLite fill query failed.                                                                                                                                                                                                                                                                                                  |

## License System

<Note>
  **Roadmap (v0.2).** The license/edition system below is on the v0.2 roadmap. v0.1 ships as fully open-source Apache-2.0 with no edition gating.
</Note>

Crewship will use a three-tier licensing model that controls workspace limits and feature availability.

### Editions

| Edition        | Description           | Limits                              |
| -------------- | --------------------- | ----------------------------------- |
| **Community**  | Free, default edition | 15 crews, 10 agents/crew, 5 members |
| **Team**       | Paid team tier        | Higher limits, additional features  |
| **Enterprise** | Full enterprise tier  | Unlimited, all features enabled     |

### License Claims

Each license contains signed claims:

| Claim                 | Type       | Description                          |
| --------------------- | ---------- | ------------------------------------ |
| `license_id`          | `string`   | Unique license identifier            |
| `licensee_name`       | `string`   | Licensed user name                   |
| `licensee_org`        | `string`   | Licensed organization                |
| `edition`             | `string`   | `community`, `team`, or `enterprise` |
| `max_crews`           | `integer`  | Maximum number of crews              |
| `max_agents_per_crew` | `integer`  | Maximum agents per crew              |
| `max_members`         | `integer`  | Maximum workspace members            |
| `features`            | `string[]` | Enabled feature flags                |
| `issued_at`           | `integer`  | Unix timestamp of issuance           |
| `expires_at`          | `integer`  | Unix timestamp of expiration         |

### License Verification

Licenses are verified using Ed25519 digital signatures:

<Steps>
  <Step title="Signed format">
    A license file contains a JSON object with `payload` (the claims as a JSON string) and `signature` (base64-encoded Ed25519 signature).
  </Step>

  <Step title="Public key embedding">
    The Ed25519 public key is embedded into the binary at build time via `ldflags`. This prevents license tampering by tying verification to the specific build.
  </Step>

  <Step title="Signature verification">
    On startup, Crewship decodes the public key and signature from base64, then verifies the payload using `ed25519.Verify()`.
  </Step>

  <Step title="Expiration check">
    If the license has an `expires_at` timestamp and it is in the past, the license is rejected and community defaults apply.
  </Step>

  <Step title="Fallback">
    If no license file exists, verification fails, or the license is expired, Crewship runs with community edition defaults.
  </Step>
</Steps>

<Warning>
  The public key variable is set at build time. Without a valid public key embedded in the binary, license loading will fail with "no public key embedded in binary" and community defaults will apply.
</Warning>

## What's Next

<CardGroup cols={2}>
  <Card title="RBAC" icon="users-gear" href="/security/rbac">
    Role-based access control and permission levels.
  </Card>

  <Card title="Keeper Guide" icon="shield-check" href="/guides/keeper">
    Configure the AI-powered security gatekeeper.
  </Card>
</CardGroup>
