Skip to main content

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

Endpoints

MethodEndpointPurpose
GET/api/v1/admin/statsAggregate stats for the current workspace
GET/api/v1/admin/usersList workspace users with roles
GET/api/v1/admin/users/{userId}/dataGDPR Art. 15 — export every row referencing a user
DELETE/api/v1/admin/users/{userId}/dataGDPR Art. 17 — cascade purge a user’s data
GET/api/v1/admin/workspacesWorkspace details with member/agent/crew counts
GET/api/v1/admin/memory/statsMemory subsystem totals + per-tier/per-agent rollups
GET/api/v1/admin/memory/versionsRow-level drill-down into memory_versions
GET/api/v1/admin/memory/versions/{id}/contentRaw blob bytes for a single memory version
GET/api/v1/admin/memory/configRead per-workspace memory configuration
PATCH/api/v1/admin/memory/configPartial-merge update of memory configuration
GET/api/v1/admin/keeper/requestsKeeper access-request audit log
POST/api/v1/internal/keeper/skill-reviewF4.1 — periodic skill audit
POST/api/v1/internal/keeper/behaviorF4.2 — post-tool-call behavior monitor
POST/api/v1/internal/keeper/memory-healthF4.3 — periodic memory consolidation review
POST/api/v1/internal/keeper/negative-learningF4.4 — failure-event lesson capture
GET/api/v1/system/setup-statusFirst-run bootstrap + signup gate
GET/api/v1/system/telemetryRead-only Sentry consent gate
GET/api/v1/system/versionRunning binary version + latest release
GET/api/v1/metrics/timeseriesBucketed 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

{
  "workspaces": 1,
  "users": 12,
  "agents": 8,
  "running": 3
}
FieldTypeDescription
workspacesintegerAlways 1 (scoped to current workspace)
usersintegerNumber of workspace members
agentsintegerTotal agents (excluding soft-deleted)
runningintegerAgents with a currently running agent run
Stats are scoped to the current workspace to prevent cross-workspace data leakage. The workspaces field always returns 1.

GET /api/v1/admin/users

Lists all users in the current workspace with their roles. Required role: OWNER

Response

[
  {
    "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"
  }
]
FieldTypeDescription
idstringUser ID
emailstringUser email address
full_namestring?Display name (nullable)
avatar_urlstring?Avatar URL (nullable)
created_atstringISO 8601 creation timestamp
workspaceobject?Workspace details (id, name, slug)
rolestring?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.
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.

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

{
  "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..."
    }
  ]
}
FieldTypeDescription
data_subject_idstringThe userId whose data is being exported.
workspace_idstringWorkspace the export was scoped to.
exported_atstringRFC3339Nano timestamp of the export.
action_idstringAudit row in gdpr_actions for this SAR call — the operator’s defensible artefact.
peer_cardsarrayPer-agent peer card rows referencing this user.
memory_versionsarrayMemory rows with data_subject_id = userId. Content blob is not inlined — payload_ref points at the content-addressed store.
inbox_itemsarrayInbox items addressed to or referencing this user.
lessons.md content is not scanned for user mentions — a known gap (see GDPR guide). Operators must manually review lessons after a SAR if any lesson body could carry user-attributable text.

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

{
  "reason": "User SAR ticket #INC-1234 — Right to Erasure request"
}
FieldTypeRequiredDescription
reasonstringyesNon-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).
{
  "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.
FieldTypeDescription
action_idstringThe 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_deletedintSum across all cascadable tables.
scopeobjectPer-table count summary (extensible — new keys appear additively as new tables join the cascade).
errorstring?Present only on HTTP 207. First error encountered; consult the audit row for the full per-table picture.
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 for the operator workflow.

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

[
  {
    "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
  }
]
This endpoint is scoped to the current workspace only. It does not list other workspaces in the system.

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

{
  "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" }
  ]
}
FieldTypeDescription
workspace_idstringCurrent workspace ID
totals.versionsintegerTotal memory_versions rows
totals.bytesintegerSum of bytes across all rows
totals.blobsintegerDistinct sha256s; content-identical re-writes share one blob
totals.oldest_atstringRFC3339 of the oldest row; "" when no rows
totals.newest_atstringRFC3339 of the newest row; "" when no rows
by_tier[].tierstringagent, crew, workspace, pins, or learned
by_tier[].versionsintegerRow count for the tier
by_tier[].bytesintegerSum of bytes for the tier
by_agent[].agent_slugstringSlug extracted from canonical agent:<slug>/... prefix; "" for crew/workspace-tier rows
by_agent[].versionsintegerRow count for the agent
by_agent[].bytesintegerSum of bytes for the agent
by_agent[].newest_atstringRFC3339 of the agent’s newest row

Example

curl -H "Authorization: Bearer $TOKEN" \
  https://crewship.example.com/api/v1/admin/memory/stats
StatusCondition
400Missing workspace context
403MEMBER role (manage permission required)
500Underlying SQLite query failure
Tiers with zero rows are omitted from by_tier. Tiers with rows the operator has never written to are NOT included as zero entries.

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.
ParameterTypeDefaultDescription
tierstringExact tier filter: agent, crew, workspace, pins, or learned
agent_slugstringSlug from canonical agent:<slug>/... prefix; literal % / _ in the slug match themselves
path_prefixstringMatch rows whose canonical path starts with this string; literal % / _ match themselves
sincestringRFC3339 lower bound on written_at (inclusive)
untilstringRFC3339 upper bound on written_at (exclusive)
limitinteger50Page size; hard cap at 500
cursorstringOpaque next_cursor from a prior response

Response

{
  "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"
  }
}
FieldTypeDescription
workspace_idstringCurrent workspace ID
rows[].idstringmemory_versions.id
rows[].pathstringCanonical memory path
rows[].tierstringTier (agent / crew / workspace / pins / learned)
rows[].sha256stringContent sha256 of the blob
rows[].bytesintegerByte length of the blob
rows[].written_atstringRFC3339 timestamp
rows[].written_bystringWriter identifier; "" when NULL
rows[].parent_shastring?Previous version’s sha256; omitted when NULL
next_cursorstring?Opaque cursor for the next page; null on last page
limitintegerThe resolved page size
filters_appliedobjectEcho 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

curl -H "Authorization: Bearer $TOKEN" \
  "https://crewship.example.com/api/v1/admin/memory/versions?tier=agent&agent_slug=martin&limit=20"
StatusCondition
400Unknown tier, malformed since / until (not RFC3339), non-positive limit, or unparseable cursor
403MEMBER role
500Underlying 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:
HeaderDescription
X-Memory-Sha256sha256 from the memory_versions row
X-Memory-BytesLength from the row (matches the row’s recorded size)
X-Memory-TierTier (agent / crew / workspace / pins / learned)
X-Memory-PathCanonical memory path
X-Memory-Written-AtRFC3339 timestamp
X-Memory-Written-ByWriter identifier (omitted when NULL)
Cache-Controlprivate, max-age=31536000, immutable — blobs are content-addressed

Example

curl -H "Authorization: Bearer $TOKEN" \
  -D - \
  https://crewship.example.com/api/v1/admin/memory/versions/mv_01HZ.../content
StatusCondition
400Missing workspace context OR missing id path segment
403MEMBER role
404Unknown id OR cross-workspace probe (no existence leak)
410Row exists but the blob file is missing on disk (retention sweep, restore-from-backup race)
413Blob exceeds the 10 MB cap (DB-claimed size OR on-disk size)
500sha mismatch (blob tampered after recording) OR payload_ref resolves outside the configured blob root
503Memory versioning is not configured (lite-mode deployment without blob root)
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.

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

{
  "workspace_id": "ws-uuid",
  "versions_retention_days": 30,
  "is_default": true,
  "raw_config": null
}
FieldTypeDescription
workspace_idstringCurrent workspace ID
versions_retention_daysintegerResolved retention window; falls back to the built-in default (30) when no row or no key is set
is_defaultbooleantrue when no row exists, the key is missing, or the value fell back to the default
raw_configstring?Literal JSON stored on the workspaces.memory_config column; null when empty

Example

curl -H "Authorization: Bearer $TOKEN" \
  https://crewship.example.com/api/v1/admin/memory/config
StatusCondition
400Missing workspace context
403MEMBER role
500Stored 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

{
  "versions_retention_days": 7
}
FieldTypeDescription
versions_retention_daysintegerPositive 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

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
StatusCondition
400Missing workspace context, malformed JSON, trailing garbage after the JSON value, empty body, or versions_retention_days outside [1, 3650] / not a positive integer
403MEMBER role
413Request body exceeds 16 KB
500UPDATE / commit failure
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.

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

ParameterTypeDefaultDescription
limitinteger50Max entries to return (1-200)
offsetinteger0Offset for pagination

Response

[
  {
    "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"
  }
]
FieldTypeDescription
idstringRequest ID
agent_idstringRequesting agent ID
agent_namestringAgent display name
crew_idstringCrew the agent belongs to
credential_idstringCredential being accessed
credential_namestringCredential display name
intentstringAgent’s stated intent for access
request_typestringOne of credential / execute / skill_review / behavior / memory_health / negative_learning (see Keeper Phase 2 below)
commandstring?Shell command (for execute requests); omitted when null
decisionstring?ALLOW, DENY, or ESCALATE
reasonstring?LLM-generated explanation
risk_scoreinteger?Risk assessment (1-10 scale)
exit_codeinteger?Command exit code (for execute requests); omitted when null
ollama_promptstring?Full prompt sent to the Keeper LLM; omitted when null
ollama_raw_responsestring?Raw LLM response text; omitted when null
created_atstringISO 8601 request timestamp
decided_atstring?ISO 8601 decision timestamp
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.

Keeper Phase 2 — F4 evaluators

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).
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:
{
  "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": []
}
FieldRequiredNotes
workspace_idyesMUST match the request context workspace_id (see Internal IPC).
crew_idyesUsed for per-crew policy resolution.
skill_idyesThe skill being reviewed.
skill_name, skill_description, lifecycle_stateyesCurrent state snapshot the LLM evaluates.
last_used_atoptionalRFC3339; empty = “never”.
assignments, assigned_agentsoptionalFan-out context for ESCALATE → inbox routing.
statsoptional30-day invocation + failure counts.
failure_snippetsoptionalUp to 3 most-recent failure excerpts (truncated).
Response:
{
  "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:
{
  "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:
{
  "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:
{
  "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
}
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.
Response:
{
  "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. Trigger kinds: run_failed, guardrail_warn, guardrail_error, keeper_execute_deny. Request body:
{
  "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": ""
}
FieldNotes
agent_memory_dirContainer-side path where lessons.md lives; the consolidate writer mkdir-p’s as needed.
triggerOne of the four kinds above.
prior_lessonOptional 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:
{
  "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

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.

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

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

{
  "needs_bootstrap": false,
  "allow_signup": false
}
FieldTypeDescription
needs_bootstrapbooleantrue 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_signupbooleanMirrors 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

{
  "enabled": true,
  "install_id": "a3f9c2e1b8d74f5a"
}
FieldTypeDescription
enabledbooleantrue only when the operator has opted in, a DSN is wired, AND the backend Sentry init succeeded.
install_idstringAnonymous 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

{
  "current": "0.4.1",
  "latest": "0.4.2",
  "newer": true,
  "url": "https://github.com/crewship-ai/crewship/releases/tag/v0.4.2"
}
FieldTypeDescription
currentstringThe running binary’s version (SetVersion-injected from cmd_start).
lateststring?Latest release tag from GitHub. null on a cold cache + GitHub timeout.
newerbooleantrue when latest > current by semver.
urlstring?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

ParamTypeDefaultDescription
metricstringRequired. One of issues_closed, cost_usd, runs_count, active_missions.
windowstring"24h"One of 24h, 7d, 30d.
bucketstring"1h"One of 15m, 1h, 1d. Combinations producing >200 buckets are rejected.
group_bystring"none"One of none, crew, model, status. Some group-by values are metric-specific (see error matrix).

Response

{
  "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" }
}
FieldTypeDescription
metric / window / bucket / group_bystringEcho of the resolved query parameters.
buckets[].tsstringBucket-start in UTC, aligned to wall-clock boundaries (15m:00/:15/:30/:45, 1h → top of hour, 1d → UTC midnight).
buckets[].seriesobjectMap of series key to numeric value. Always floats on the wire so cost and counts share the JSON shape.
series_labelsobjectMap of series key to display label (e.g. crew name, model name, status). For group_by=none always contains {"total": "Total"}.
StatusCondition
400Unknown 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.
401Workspace context missing from the request.
500SQLite fill query failed.

License System

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.
Crewship will use a three-tier licensing model that controls workspace limits and feature availability.

Editions

EditionDescriptionLimits
CommunityFree, default edition15 crews, 10 agents/crew, 5 members
TeamPaid team tierHigher limits, additional features
EnterpriseFull enterprise tierUnlimited, all features enabled

License Claims

Each license contains signed claims:
ClaimTypeDescription
license_idstringUnique license identifier
licensee_namestringLicensed user name
licensee_orgstringLicensed organization
editionstringcommunity, team, or enterprise
max_crewsintegerMaximum number of crews
max_agents_per_crewintegerMaximum agents per crew
max_membersintegerMaximum workspace members
featuresstring[]Enabled feature flags
issued_atintegerUnix timestamp of issuance
expires_atintegerUnix timestamp of expiration

License Verification

Licenses are verified using Ed25519 digital signatures:
1

Signed format

A license file contains a JSON object with payload (the claims as a JSON string) and signature (base64-encoded Ed25519 signature).
2

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

Signature verification

On startup, Crewship decodes the public key and signature from base64, then verifies the payload using ed25519.Verify().
4

Expiration check

If the license has an expires_at timestamp and it is in the past, the license is rejected and community defaults apply.
5

Fallback

If no license file exists, verification fails, or the license is expired, Crewship runs with community edition defaults.
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.

What’s Next

RBAC

Role-based access control and permission levels.

Keeper Guide

Configure the AI-powered security gatekeeper.