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

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 Request: no body. Response: JSON object shown below.

Response

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 the people the caller administers, one row per person. A workspace OWNER or ADMIN gets the members of the current workspace. An instance administrator (see People and workspaces across the instance) gets every account on the instance, including accounts that belong to no workspace. The X-Admin-Scope response header says which: workspace or instance. Required role: ADMIN or OWNER (manage permission). Request: no body. Response: JSON array shown below.

Response

GET /api/v1/admin/users/{userId}/sessions

A person’s signed-in devices, with device and IP address, and their live CLI tokens (name, scopes, last use — never the secret). current marks the session the caller is using. Required role: ADMIN or OWNER (manage permission). The person must be a member of the caller’s workspace unless the caller is the instance owner; anyone else is 404, the same answer as an id that does not exist. An ADMIN cannot act on an OWNER of the workspace. Request: no body; userId is the path parameter. Response:

POST /api/v1/admin/users/{userId}/sessions/{sessionId}/revoke

Signs a person out of one device. The session is marked revoked with reason admin_revoke, and an audit entry user.session_revoked is written. Other devices and CLI tokens are untouched. Required role: ADMIN or OWNER, with the same reach rules as the sessions list above. Request: no body; userId and sessionId are path parameters. Response: 204 No Content.

POST /api/v1/admin/users/{userId}/sessions/revoke-all

Signs a person out of every device. Sessions are marked revoked with reason admin_invalidate — the reason the host-side crewship admin invalidate-sessions writes — and an audit entry user.sessions_revoked is written. The password stays as it is; CLI tokens are separate credentials and stay valid. Required role: ADMIN or OWNER, with the same reach rules as above. Request: no body; userId is the path parameter. Response:

POST /api/v1/admin/users/{userId}/unlock

Lifts a sign-in lockout: clears locked_until and resets the failed-attempt counter, so the person can sign in straight away. Writes an audit entry user.unlocked. Unlocking an account that is not locked succeeds and changes nothing. Required role: ADMIN or OWNER, with the same reach rules as above. Request: no body; userId is the path parameter. Response: 204 No Content.

People and workspaces across the instance

The routes under /api/v1/admin/instance/ act on the whole server: every account and every workspace, whether or not the caller belongs to it. They take no workspace_id. Who may call them: an instance administrator. That is:
  1. the account whose email is CREWSHIP_OWNER_EMAIL on the server, which cannot be removed or suspended over the API;
  2. anyone named with PUT /api/v1/admin/instance/admins/{userId} (users.instance_role).
The list is always explicit. On an install that names nobody and has no CREWSHIP_OWNER_EMAIL, the first time anyone’s status is asked the server names the OWNERs of the oldest workspace — the people it treated as admins before — records that in app_settings and in the instance audit (instance.admin_bootstrapped), and never does it again. Removing every named admin later does not bring that rule back; the way in when nobody can administer is crewship admin instance add-admin --local on the host. The last active instance administrator cannot be removed or suspended (409): with no CREWSHIP_OWNER_EMAIL, someone named and unsuspended has to be left. A suspended account is never an instance administrator. Being ADMIN or OWNER of a workspace is not enough for these routes, nor for the instance-wide settings that also require it (log level, rate limits, re-encryption, legacy resource pruning, Keeper’s judge and evaluator models, notification providers, instance settings and feature-flag definitions). A CLI token needs the instance:admin scope, or no scope restriction, to reach them. An instance administrator can mint an instance:admin token whatever their workspace role, including with no workspace at all. Every instance change and its audit entry are written in one transaction: if the audit entry cannot be written, the change is undone and the route answers 500. An instance administrator also passes the ADMIN floor of every other /api/v1/admin/ route, and need not belong to any workspace: on those routes they may name any live workspace in workspace_id, member or not (an unknown one is 404), and the instance-wide reads — GET /api/v1/admin/stats, /admin/health, /admin/log-level, /admin/rate-limits, /admin/security-posture, /admin/keeper/config, /admin/keeper/aux, /admin/keeper/judge/models, /admin/legacy-resources, /api/v1/system/keeper, /api/v1/system/aux-status and /api/v1/notification-providers — also answer with no workspace at all, for the whole instance. A route about one workspace’s data answers an instance administrator who names none with 400 (“choose a workspace”). The instance settings writes (log level, rate limits, Keeper judge, providers) need no workspace either. Everyone else keeps the workspace rules: 400 without a workspace, 403 outside theirs. GET /api/v1/admin/stats without a workspace counts the whole instance. Also, and GET /api/v1/admin/users and GET /api/v1/admin/workspaces answer for the whole instance (X-Admin-Scope: instance). Those two, and a person’s sessions, unlock and sign-out routes, also answer an instance administrator who names no workspace, since they need not belong to one; anyone else still needs workspace_id. Those lists carry instance_admin and instance_admin_source (env or role), suspended_at, suspended_reason and setup_link_expires_at per person, and owners per workspace. GET /api/auth/session returns is_instance_admin for the signed-in user. Every write below adds a row to the instance audit (instance.* actions). It has no workspace of its own, so the record of a deleted workspace or erased account survives it.

POST /api/v1/admin/instance/people

Creates an account, puts it in any number of workspaces, and returns a setup link valid for 7 days. No email is sent: the administrator passes the link on, and the person chooses their own password. Roles may include OWNER. Auth: instance administrator. Request:
Response: 201 Created

PUT /api/v1/admin/instance/workspaces/{workspaceId}/members/{userId}

Gives a person access to a workspace, or changes their role there. Unlike the workspace members route, the role ladder does not apply: an instance administrator may make someone OWNER. The last OWNER of a workspace cannot be moved down; hand the workspace over first. Auth: instance administrator. Request: { "role": "ADMIN" }, one of OWNER, ADMIN, MANAGER, MEMBER, VIEWER. Response: 201 Created when access was added, 200 OK when a role changed:

DELETE /api/v1/admin/instance/workspaces/{workspaceId}/members/{userId}

Takes a person out of a workspace, with their crew memberships there. Pages they own move first, as when a workspace admin removes them. The last OWNER stays. Auth: instance administrator. Request: no body. Response: 204 No Content.

POST /api/v1/admin/instance/workspaces

Creates an empty workspace owned by the person named. The administrator is not added to it. Auth: instance administrator. Request:
Response: 201 Created

POST /api/v1/admin/instance/workspaces/{workspaceId}/transfer-ownership

Makes a member the workspace’s owner. Every previous owner becomes an ADMIN, so the workspace keeps exactly one. Auth: instance administrator. Request: { "user_id": "uuid" }. The person must already be a member. Response:

DELETE /api/v1/admin/instance/workspaces/{workspaceId}

Deletes a workspace: its agents and crews are soft-deleted and its memberships removed, as with DELETE /api/v1/workspaces/{workspaceId}. The owner’s “not your only workspace” rule does not apply. Auth: instance administrator. Request: { "confirm_slug": "marketing" }, the workspace’s slug. Response: 204 No Content.

POST /api/v1/admin/instance/people/{userId}/suspend

Blocks an account. Sign-in, token refresh and CLI tokens are refused, and every session and CLI token ends now. Workspace access is kept, so reactivation restores it. A suspended sign-in gets the same answer as a wrong password. Auth: instance administrator. Request: optional { "reason": "left the company" }, kept for the audit. Response:

POST /api/v1/admin/instance/people/{userId}/reactivate

Lifts a suspension. Sessions and tokens it ended stay ended. Auth: instance administrator. Request: no body. Response: 204 No Content.

POST /api/v1/admin/instance/people/{userId}/setup-link

Issues a new setup link and voids the previous one. The plaintext link is never stored, so reissuing is the only way to show it again. Only an account nobody controls gets one: no password, no linked sign-in provider and no verified email. Anyone else resets their own password. Auth: instance administrator. Request: no body. Response:
Voids the pending setup link, for example one sent to the wrong place. Auth: instance administrator. Request: no body. Response: 204 No Content.

PUT /api/v1/admin/instance/admins/{userId}

Names a person an instance administrator. Auth: instance administrator. Request: no body. Response: 204 No Content.

DELETE /api/v1/admin/instance/admins/{userId}

Removes a named instance administrator. Nobody removes themselves, so there is always another administrator who saw it happen. CREWSHIP_OWNER_EMAIL is changed in the server’s environment, not here. Auth: instance administrator. Request: no body. Response: 204 No Content.

GET /api/v1/admin/instance/audit

Instance-level actions, newest first. ?limit= (default 100, at most 500) and ?entity_id= narrow it; entity_id matches the person or workspace acted on, and a workspace also matches the rows that name it as their target. Auth: instance administrator. Request: no body. Response:
Actions: instance.user_created, instance.member_added, instance.member_role_changed, instance.member_removed, instance.workspace_created, instance.workspace_ownership_transferred, instance.workspace_deleted, instance.user_suspended, instance.user_reactivated, instance.setup_link_issued, instance.setup_link_revoked, instance.admin_granted, instance.admin_revoked, instance.admin_bootstrapped, instance.keeper_governance_updated, instance.keeper_defaults_updated.

GET /api/v1/admin/instance/keeper/governance

The Keeper watchdog settings of every workspace on the instance, including workspaces the caller is not a member of, and the defaults for new workspaces. Every workspace has its own settings: it takes a copy of the defaults when it is created, and a later change of the defaults does not reach it. configured: false marks a workspace with no row of its own, which runs on the built-in opt-out: watchdog off, DENY alerts from risk 7. Auth: instance administrator. Request: no body. Response:

PUT /api/v1/admin/instance/keeper/governance

Sets the watchdog for one, several or all workspaces. set is a partial update with the same fields and bounds as the per-workspace PUT /api/v1/admin/keeper/governance: only the fields sent change.
  • workspaces names the targets by id or slug. all: true targets every existing workspace. Send one or the other. Neither changes what a new workspace starts with; that is PUT /api/v1/admin/instance/keeper/governance/defaults.
  • dry_run: true computes the changes and writes nothing. The console’s confirmation dialog shows this response. Every response carries a preview_id, a fingerprint of every target’s settings before and after. Send it back as expect_preview when confirming: if a workspace was created or a value changed since the preview, the save answers 409 and writes nothing.
  • The save is one transaction. When the merged settings are invalid in any workspace, the response is 400 naming that workspace, and nothing is written anywhere.
  • A workspace that already has the values is reported with empty changes and left alone. Each workspace that changes gets an instance.keeper_governance_updated entry in the instance audit, with the fields it changed from and to.
  • security_contact_user_id and gov_model_credential_id name something that belongs to one workspace, and are refused for more than one.
Auth: instance administrator. Request:
Confirming that preview:
Response:

GET /api/v1/admin/instance/keeper/governance/defaults

The template a new workspace’s Keeper watchdog starts with. A workspace copies it when it is created — through signup, the workspaces API or POST /api/v1/admin/instance/workspaces — and keeps that copy: changing the template later changes no existing workspace. configured: false means it was never saved and is the built-in opt-out. Auth: instance administrator. Request: no body. Response:

PUT /api/v1/admin/instance/keeper/governance/defaults

Changes the template for new workspaces. set takes the same fields and bounds as the workspace PUT, except security_contact_user_id and gov_model_credential_id: a person and a vault credential belong to one workspace, so they are refused here. dry_run: true returns the changes and a preview_id and writes nothing; send the id back as expect_preview to save exactly that, or get 409 if the template changed in between. A save records instance.keeper_defaults_updated in the instance audit, in the same transaction. Auth: instance administrator. Request:
Response: as for GET, with applied: true and the changes made.

GET /api/v1/admin/instance/keeper/requests

Keeper decisions across workspaces, newest first: credential requests and the background reviews, each row with its workspace. A request whose agent is gone has no workspace to show under and is left out. Query parameters:
  • workspace: ids or slugs, comma-separated. Omitted or all means every workspace.
  • request_type: one or more kinds, comma-separated, e.g. access,execute for credential requests or behavior.
  • decision: ALLOW, DENY, ESCALATE or PENDING.
  • limit: default 200, at most 1000. offset: rows to skip.
total is how many rows the whole filter matches, for paging. counts are the decisions under the workspace and kind filter, leaving decision out, so the decision chips keep their numbers while one is picked. by_type counts every kind under the workspace filter alone, and by_workspace every workspace regardless of the filter, for the panel beside the list. Auth: instance administrator. Request: no body. Response:

GET /api/v1/admin/instance/keeper/health

The rolling decision window of every workspace, in the shape of GET /api/v1/admin/keeper/health plus the workspace’s name and slug. An empty window is listed too: zero samples is its own answer, not a healthy one. Auth: instance administrator. Request: no body. Response:

GET /api/v1/admin/instance/retention

How long every workspace keeps each kind of data, including workspaces the caller is not a member of. Every window is a number of days, or null for forever. Inbox items, chats and Keeper decisions are forever until an administrator sets them. A chat holds open work, and is kept however old, while it has any delegation, an unresolved escalation, an unresolved reply item in the inbox, a pending approval that names it, an unfinished routine run, or an access grant issued inside the window that is not revoked. Query parameters: Auth: instance administrator. Request: no body. Response:
defaults is what a workspace created now starts with; defaults_configured names the windows an administrator set with PUT /api/v1/admin/instance/retention/defaults, the rest are the product defaults. keys describes every window, in display order. housekeeping lists the limits the server applies on its own and nobody sets: journal compaction, the instance-wide memory version floor, the routine runs always kept, routine and agent webhook receipts, delivered work bodies, page project history, orphaned attachment files, unattributed credential reads and the pre-upgrade database snapshots.

PUT /api/v1/admin/instance/retention

Sets retention windows for one, several or every existing workspace. windows carries only the windows being changed; null is forever where the window allows it.
  • workspace_ids is required. A list names the targets by id or slug; null targets every workspace that exists now. Neither changes what a workspace created later starts with: that is PUT /api/v1/admin/instance/retention/defaults, a separate operation.
  • dry_run: true computes the changes and writes nothing. The preview lists every workspace and, in it, every window that changes, each with rows_affected_next_sweep: how many rows the next sweep deletes under the new window. The rows are counted, not deleted, and backups taken before still hold them. For page panel data the count is what each panel’s next push drops.
  • Every response carries a preview_id fingerprinting the targets and every change. Send it back as expect_preview when confirming: if a workspace was created or a window changed since the preview, the save answers 409 and writes nothing. A save without expect_preview, or without any preview, is allowed; the console always previews first.
  • The save is one transaction. A workspace that already has the values is reported with empty changes and left alone. Each workspace that changes gets an instance.retention_updated entry in the instance audit, with each window’s old and new value.
  • Saving a window deletes nothing by itself. The daily sweep does, at its next run.
Auth: instance administrator. Request:
Response:

GET /api/v1/admin/instance/retention/defaults

The retention windows a workspace created now starts with, every window. configured names the windows an administrator set; the others are the product defaults in the table under GET /api/v1/admin/instance/retention. Auth: instance administrator. Request: no body. Response:

PUT /api/v1/admin/instance/retention/defaults

Changes the retention windows a workspace created later starts with. Only the windows sent change; null is forever where the window allows it. No existing workspace changes and nothing is deleted, so the response has no row counts and says so with affects_existing: false.
  • dry_run: true returns the changes and writes nothing.
  • A window already at the value is not a change. When anything changes, the instance audit records instance.retention_defaults_updated with each window’s old and new value.
  • defaults in the response is every window as a new workspace starts after the save (on a dry run, would).
Auth: instance administrator. Request:
Response:

GET /api/v1/admin/instance/backups/bundles

Every catalogued backup bundle on the server, across every workspace, newest first. Rows whose file is gone are pruned before the list is built. ?workspace=a,b (slugs or ids) and ?scope=workspace|crew narrow it. proof_level is 1 (written, payload checksum recorded), 2 (decrypted and every section checked against the manifest) or 3 (restored in isolation; drill_result says how that went: ok, partial or failed). incomplete lists what the bundle should hold and does not; [] means it recorded no gap, and null means the bundle was catalogued before gaps were recorded — not that it is complete. kind is full for every bundle made so far. Auth: instance administrator. Request: no body. Response:

POST /api/v1/admin/instance/backups/bundles/pin

Pins a catalogued bundle: no retention rule deletes it — not the age rule, not the count floor. The change and its instance audit entry (instance.backup_pinned) commit together. Auth: instance administrator. Request:
Response:

POST /api/v1/admin/instance/backups/bundles/unpin

The reverse of pin: retention rules apply to the bundle again. The change and its instance audit entry (instance.backup_unpinned) commit together. Auth: instance administrator. Request:
Response:

GET /api/v1/admin/instance/backups/bundles/inspect

The manifest of one catalogued bundle of any scope — an instance bundle included, which the per-workspace GET /api/v1/admin/backups/inspect cannot find because it belongs to no workspace. The manifest is plaintext: nothing is decrypted and no key is needed. ?path= is the bundle’s path as GET …/backups/bundles lists it; only a path in the catalog is ever opened. Auth: instance administrator. Request: no body. Response: the manifest, the same shape as the per-workspace inspect returns:
CLI: crewship backup inspect <path> asks the per-workspace route first and this one when that answers 404 or 400.

GET /api/v1/admin/instance/backups/bundles/verify

Proof level 1 for one catalogued bundle of any scope: the sealed payload’s SHA-256 against the manifest, without a key. The body is the per-workspace GET /api/v1/admin/backups/verify’s: valid, size_bytes, manifest, error, and — for an unencrypted bundle only — the row-count completeness fields. To check an encrypted bundle’s contents, use POST …/backups/bundles/check with the key. Auth: instance administrator. Request: no body. Response:
CLI: crewship backup verify <path>, with the same fallback as inspect.

GET /api/v1/admin/instance/backups/bundles/download

The bytes of one catalogued bundle of any scope, as stored (encrypted, as every new bundle is), as application/zstd with Content-Disposition: attachment, Cache-Control: no-store and Content-Length. The download is recorded in the instance audit log (instance.backup_downloaded, with the bytes sent). Backup history’s Download uses this route for an instance bundle. Auth: instance administrator. Request: no body. Response: the bundle file. CLI: crewship backup download <path> [--out file], with the same fallback as inspect.

GET /api/v1/admin/instance/backups/restores

Every restore, dry run and drill the server has run, newest first, with the full report the restore returned at the time. kind is restore, dry_run or drill. result is ok, partial — the restore landed, but its report names something skipped, missing or lowered (crew files not landed, credential tiers clamped, columns dropped, row counts short, attachment files missing or in conflict, or a gap the bundle itself recorded) — or failed, whose report carries the error. ?limit=N returns at most N rows (default 100, at most 500). Auth: instance administrator. Request: no body. Response:

GET /api/v1/admin/instance/backups/plans

Every backup plan, by name. A plan says what goes in (preset, scope, workspace_ids, contents, env_mode), when (cadence, time_of_day, weekday, monthday, cron_expr in timezone; env_cadence picks the runs that also take complete environments), for how long (keep_min, the newest copies no rule ages out, and keep_daily, keep_weekly, keep_monthly), who can open its bundles (recipient_ids) and how long a run waits for busy workspaces (busy_wait_minutes, looking again every busy_retry_minutes). counts_as_protection is false for custom plans. Plans run on the server’s backup scheduler, never through an agent. Auth: instance administrator. Request: no body. Response:

POST /api/v1/admin/instance/backups/plans

Creates a plan. The body is a plan without id, next_run_at and last_run_at; every field it leaves out takes the preset’s default. complete covers the whole instance with data daily at 03:00 and complete environments weekly; workspace covers the listed workspaces (none listed: every workspace) with every category; custom carries only contents plus what those need (see preview-contents). workspace_ids takes ids or slugs. timezone must be an IANA zone, cron_expr a five-field cron expression (parsed like routine schedules), keep_min at least 1, and recipient_ids must name at least one age public key or recipient: every backup is encrypted. Written to the instance audit log in the same transaction. Auth: instance administrator. Request:
Response: the stored plan, as in the list.

POST /api/v1/admin/instance/backups/plans/preview-contents

What a plan’s bundles would carry. included is what the plan chooses (a preset other than custom: every category; complete environments follow env_mode), required what is kept because an included category cannot be restored without it, with the categories that need it, and excluded the rest. The console shows the same rule while a plan is edited. Auth: instance administrator. Request:
Response:

GET /api/v1/admin/instance/backups/plans/{id}

One plan, as in the list. Auth: instance administrator. Request: no body. Response: the plan.

PUT /api/v1/admin/instance/backups/plans/{id}

Changes a plan. Fields the body leaves out keep their stored value; the same rules as create apply, and next_run_at is recomputed from now. Written to the instance audit log, with the plan before the change, in the same transaction. Auth: instance administrator. Request:
Response: the stored plan.

DELETE /api/v1/admin/instance/backups/plans/{id}

Deletes a plan. Its bundles and its run history stay. Written to the instance audit log in the same transaction. Auth: instance administrator. Request: no body. Response: no body.

GET /api/v1/admin/instance/backups/plans/{id}/next

The plan’s next n run times (default 5, at most 50) after now, and whether each also takes complete environments. Empty for a switched-off plan. Auth: instance administrator. Request: no body. Response:

GET /api/v1/admin/instance/backups/plans/{id}/calendar

Day by day from from to to (YYYY-MM-DD in the plan’s timezone; by default the last 7 and the next 28 days, at most 400): the plan’s runs as done, skipped, catchup or failed, and its due times after now as planned. Runs of one due time over several workspaces fold into one entry with the worst status. kind is data or environments; environments entries of past runs read skipped until complete environments are captured. Auth: instance administrator. Request: no body. Response:

GET /api/v1/admin/instance/backups/runs

Backup runs, newest first, each with its persisted phases (wait while busy workspaces were waited out, then copy, pack, encrypt, check — the checksum of the written bundle — and off-site, skipped with “not configured” until off-site copies exist). status is running, done, incomplete (a bundle was written and its manifest names what it lacks, in incomplete), failed, interrupted (the server stopped during the run; it was retried once, and retried_at says when the retry finished) or skipped (busy past the plan’s window, or scheduled backups were held: while an instance restore’s routines or all hold is set, scheduled and catch-up runs are recorded as skipped with the reason until an admin resumes it; manual runs still start). trigger is schedule, manual or catchup. hold_ms is how long writes were held for the consistent copy of an instance run (null for workspace runs, which hold no instance-wide window). The bundle’s proof_level, pinned, drill_*, format_version and restorable come from the catalog. Narrow with plan, scope (instance or workspaces), ws (workspace ids or slugs, comma-separated) and limit (default 100, at most 500). Auth: instance administrator. Request: no body. Response:

POST /api/v1/admin/instance/backups/run

Starts a backup now and answers at once. Every run — scheduled, catch-up or manual, instance or workspace — is a row of the server’s backup service (never an agent), so a client that disconnects, or the CLI’s 30-second timeout, cannot cancel it; follow it with GET …/backups/run/{runId}.
  • With plan_id, runs that plan now (one run per workspace for a workspaces plan).
  • Without it, scope says what to back up. instance writes one instance bundle: the whole database, every file store and every crew container, copied in one quiet window — running work is drained first, then every write to protected data is held for the copy only (at most the plan’s hold_cap_minutes, 20 by default) and released before packing and encryption. workspaces writes one bundle per workspace in workspace_ids (ids or slugs; default every workspace), with preset, contents and env_mode choosing what goes in. Without preset, an instance run is complete and a workspaces run workspace.
Every backup is encrypted. Give recipient_ids (age age1… public keys or recipient ids; recipients is accepted as another name) or passphrase, not both. Without either, the recipients of the enabled plan covering the scope are used; with plan_id, keys given in the request replace the plan’s for this run. A passphrase is held in the server’s memory until the run starts and is never stored or logged: a run whose server restarts before it starts fails and says so. When the vault keys’ recovery kit is on, instance bundles carry them. note is shown with the run. Written to the instance audit log (the passphrase only as passphrase: true). Auth: instance administrator. Request:
Response — id and run_id name the first run, run_ids every run queued:

GET /api/v1/admin/instance/backups/overview

Whether a restore would work, for scope=instance (the default) or scope=workspaces narrowed by ws (ids or slugs; none selects nothing). status.verdict is the weakest proof among the latest full bundles of the selection — verified (a test restore went through), partial (a test restore or the bundle found gaps), failed, contents_checked, checksum_only or none; custom bundles never count, and an instance selection counts only instance bundles. The status rows say what is protected, how often, where, for how long and when it was really restored; offsite_verified stays false until an off-site copy has been uploaded and checked. needs_attention lists concrete problems: the latest bundle’s gaps per workspace, workspaces never or not recently backed up, workspaces on no plan, and failed, skipped or late runs. For scope=instance it also says when the latest instance bundle cannot unlock credentials on a new server (id vault): the key versions its sealed values use that its recovery kit does not carry — all of them when the kit was off — read from the bundle’s own manifest. When the next run would be refused for lack of disk space it lists id space first (severity bad, action storage), whose detail gives the figures and what to do. nights is always fourteen entries ending today in the plan’s timezone. space.staging_need_bytes is the largest recent bundle × 1.1 and space.restore_need_bytes the largest bundle × 3 (payloads compress about 3:1 and a restore stages the decrypted payload). space.min_free_percent is the space floor in force (CREWSHIP_BACKUP_MIN_FREE_PERCENT, default 10) and space.refusal why the next run would not start, or null. workspaces is present for scope=workspaces. Auth: instance administrator. Request: no body. Response:

GET /api/v1/admin/instance/backups/run/{runId}

One run, in the same shape as an item of GET …/backups/runs: status (running until it ends), the phases, the bundle it wrote (bundle_path, size_bytes, proof_level), how long writes were held (hold_ms) and every gap (incomplete). Runs are durable: they survive a restart, and a run the server stopped during is marked interrupted and retried once. Auth: instance administrator. Request: no body. Response:

GET /api/v1/admin/instance/backups/vault-keys

The vault key versions a restore needs: every version the database’s sealed values (credentials, webhook secrets, integration keys) reference or this server has configured, the environment variable each is read from, how many sealed values use it, whether it mints new ones (active) and whether this server has a key for it (present, following the same ENCRYPTION_KEY fallback decryption applies). recovery_kit.enabled says whether new instance bundles carry the keys; available whether every referenced version could be carried. Counts only: no key material leaves the server. Auth: instance administrator. Request: no body. Response:

PUT /api/v1/admin/instance/backups/settings/recovery-kit

Turns the recovery kit on or off for new instance bundles. On, every key version the database references rides inside the encrypted payload of each instance bundle (never a workspace or crew bundle), so crewship recover can hand them to the restored server. Whoever holds a private key a bundle is encrypted to can then read every secret in it. The change and its instance audit entry (instance.backup_recovery_kit_enabled / _disabled) commit together. Auth: instance administrator. Request:
Response:

POST /api/v1/admin/instance/backups/bundles/check

Proof level 2 — contents checked. Decrypts a catalogued bundle with the key given (identity: the text of an age identity file, or passphrase), reads every section to the end, and compares table row counts and file digests with the manifest. Nothing is restored, and the key is used for this request only: never stored, never logged. On success the bundle’s proof level rises to 2; a failed check lowers a bundle recorded at 2 back to 1. absent repeats what the bundle itself recorded as missing (attachment files, memory blobs, crew containers). Recorded in the instance audit log (instance.backup_contents_checked). Auth: instance administrator. Request:
Response:

POST /api/v1/admin/instance/backups/restore/checks

The checks a restore runs before anything changes. space: room on the data directory (an instance bundle records exactly what it lands). format: the bundle’s format version, and whether this server reads it directly or through a converter. runtime: whether Docker answers; a crew built on another architecture gets a warning that its environment is rebuilt from the crew image. unsafe: host settings in the crews’ devcontainer configuration that a restore does not switch back on by itself — Docker socket mounts, privileged mode, host path binds; read only when a key is given, otherwise a runtime warning says they were not checked. conflicts: what the target (empty_server, isolated, replace, new_workspace, crew, in_place) already holds, decided by the same rule the restore applies, so a target that passes here is never refused by the restore: an instance bundle restores only to empty_server (crewship recover) or isolated (crewship backup drill), and those two take nothing else; replace needs a workspace bundle that is not custom (a partial bundle would delete what it does not carry); new_workspace needs a workspace bundle and crew a crew bundle, each with the new name in as_workspace / as_crew — a valid slug that no workspace on this server holds, deleted workspaces included (the slug stays reserved). The restore itself answers 409 for a taken name. in_place restores a workspace or crew bundle under its own names (no replace, no new name); an instance bundle is refused. environments: one runtime check per complete container environment the bundle carries — action is restore, rebuild (built for another architecture than this server’s Docker, or missing_blobs of its layers are neither in the bundle nor in the store beside it: the data comes back, the environment is rebuilt from the crew image) or skip (no Docker); runtime.detail then names the Docker version and platform, each rebuild is also a runtime warning, the layers’ bytes count toward space, and with a key unsafe also lists the recorded host settings of each environment. Always an array. Auth: instance administrator. Request:
Response:

POST /api/v1/admin/instance/backups/environments/land

Loads the complete container environments crewship recover staged under <data dir>/restore-staging/environments/ (each workspace’s records, and the image layers copied beside them), once the recovered server runs with Docker. Each image is reassembled and loaded; a Crewship crew’s image gets its crew image tag back when the server has none, so Crewship recreates the crew from the captured environment with the runtime settings it manages. A container Crewship does not manage is recreated with its sanitized configuration and its mounts’ content, then started. Settings recorded as not carried over for safety stay off. Every environment is reported with result restored, rebuilt (another architecture or missing layers) or skipped (no Docker, or an error) and its reason; staged is false when nothing was staged. dry_run checks only. The work runs on its own 30 minute deadline, so a client that gives up does not stop it halfway. A landing (not a dry run) is written to landed.json beside the staged files and recorded in the instance audit log (instance.environments_landed). Auth: instance administrator. Request (optional body):
Response:

POST /api/v1/admin/instance/backups/bundles/upload

Auth: instance administrator, with or without workspace membership. Request: encrypted .tar.zst archive bytes, Content-Type: application/octet-stream or application/zstd, up to 64 GiB. No private key, destination path or workspace parameter is accepted. One upload per server process is admitted at a time. Response (201):
Proof level 1 confirms the encrypted payload checksum, not decryptability or successful recovery. Duplicate bytes preserve the catalog’s existing proof. Publication, catalog and audit either commit together or the owned file is removed. Interrupted uploads remove staging; staging left by a crash or restart is removed on the next start. The upload is not bound by the server’s request read timeout, but one that sends nothing for 2 minutes ends as interrupted. No restore is started.

POST /api/v1/admin/instance/backups/services/land

Imports the verified quota-service disk images that offline crewship recover staged under the configured data directory’s restore-staging/services. The client cannot choose a host path, helper namespace or generation. The recovered database contains fresh generations and persistent maintenance; the server checks image checksums, exact declarations, intent revisions and fence ownership before import through its configured host quota helper. Auth: instance administrator, including one without workspace membership. Request (optional body):
Response:
A dry run verifies without importing or releasing maintenance. An actual landing holds producer leases and commits maintenance release and instance.services_landed audit together. Failure keeps services in maintenance, including when an image was already imported before the failure. After the producer lease expires, retry checks an already imported image with an exact offline export before accepting it. Work has a 30-minute deadline.

POST /api/v1/admin/instance/backups/drills

Records a test restore run offline by crewship backup drill --post. The bundle must be catalogued at path, and sha256 must be its payload digest — a drill of a different file is refused. result is ok, partial or failed; report is the drill’s full report (its checks and what failed). An ok or partial drill raises the bundle to proof level 3 — a partial one stays shown as partial — and every drill is written to the restore history as kind drill. Recorded in the instance audit log (instance.backup_drill_recorded). Auth: instance administrator. Request:
Response (201):

GET /api/v1/admin/instance/backups/drills

Every recorded drill, newest first, as an array. source_scope is instance or workspaces; warnings counts the checks that did not pass plus the gaps the restore recorded. Auth: instance administrator. Request: no body. Response:

GET /api/v1/admin/instance/backups/settings

The instance-wide backup settings. limits: how many runs execute at once (the rest queue), the CPU cores the compressor may use, and MB/s caps on writing staging and bundle files and on off-site uploads (0 is no cap; each cap is one limiter shared by every run). heartbeat_url is pinged after every good run, and heartbeat_last_* says how the last ping went. events says which incident kinds reach the instance admins, stale_alert_hours after how long without a good backup a plan counts as stale. drill_reminder (weekly, monthly or off) raises an instance-wide drill incident when the newest test restore is older than 7 or 31 days. Incidents go to each instance admin’s inbox and from there to that admin’s own notification channels (the system.health category), and to every notification channel whose id is in channels — one message per incident state change (opened, each repeat, resolved). available_channels lists the channels that can be added (workspace-wide, switched on, admitting System health, on a provider switched on in Admin › Notifications) with kind chat, push, incident, email or webhook and the newest backup alert each was sent; channel_status says, per channel in channels, whether it is still available and how its newest alert went (pending, sent or failed with the error). destinations lists where copies can be kept: this server, each S3-compatible destination (verified once one upload there was checked), and Google Drive as not yet available. instance_admins counts who receives incidents. Auth: instance administrator. Request: no body. Response:

PUT /api/v1/admin/instance/backups/settings

Changes the settings. The body is a partial settings object: what it leaves out keeps its value; destinations, instance_admins, local_path and the heartbeat’s last ping are ignored. heartbeat_url must be a public https URL; null or "" clears it. A channel id added to channels must be one of available_channels; one already there stays even if it has since become unusable (its alerts then fail and say why). The change and its instance audit entry (instance.backup_settings_updated, before and after) commit together. Answers with the whole settings object. Auth: instance administrator. Request:
Response: the whole settings object, in the shape GET answers.

POST /api/v1/admin/instance/backups/settings/test-alert

Sends a test alert (“Backup alert test”, with a link to Backups › Overview) to one notification channel now, through the same delivery a real backup alert uses — the channel’s sealed secret, the SSRF-safe transport and the provider kill switch — and says whether it arrived. The channel need not be in channels yet. A failed send is 200 with ok: false and the error. Test alerts are not written to the notification delivery log. Auth: instance administrator. Request:
Response:

GET /api/v1/admin/instance/backups/recipients

The backup keys: age public keys backups are encrypted to, who holds each private half, a fingerprint for the printed recovery sheet, and the plans that use each key (by id or by the raw age1… key). The private halves never reach the server. Auth: instance administrator. Request: no body. Response:

POST /api/v1/admin/instance/backups/recipients

Adds a backup key. public_key must be an age X25519 public key (age1…); a private key (AGE-SECRET-KEY-1…) is refused. Plans name the key by its id in recipient_ids. Audited as instance.backup_recipient_added. Auth: instance administrator. Request:
Response: 201 with the key, as in the list.

DELETE /api/v1/admin/instance/backups/recipients/{id}

Removes a backup key. Refused while a plan still encrypts to it — change the plan first, so no plan is left encrypting to nobody. Backups already made stay readable with the key’s private half. Audited as instance.backup_recipient_removed. Auth: instance administrator. Request: no body. Response: 204.

GET /api/v1/admin/instance/backups/destinations

The off-site destinations: endpoint, bucket and prefix, the last connection test, how many bundles have a verified copy there and their size, the plans that copy there, and what proved the newest copy’s stored bytes (last_verified_by: provider_checksum — the store’s own SHA-256 checksum of the object matched — or download_rehash — the copy was downloaded and re-hashed; "" for a copy recorded before this was kept). The secret access key is never returned. Auth: instance administrator. Request: no body. Response:

POST /api/v1/admin/instance/backups/destinations

Adds an S3-compatible destination. The endpoint must be https and public; allow_private_network: true also allows loopback and private (LAN) addresses and plain http, and the answer then carries a warning — cloud metadata and link-local addresses stay blocked either way. Unless skip_test, the server first puts, reads back and deletes one small object; when that fails it answers 422 and stores nothing. The secret access key is sealed with the server’s vault key and never returned, logged or audited. Audited as instance.backup_destination_added. A plan names the destination’s id in destinations (after local, which every plan has); each bundle it writes is then uploaded and counted as an off-site copy only once the upload is verified at the destination. Auth: instance administrator. Request:
Response:

POST /api/v1/admin/instance/backups/destinations/{id}/test

Tests a stored destination again (put, read back, delete one small object) and stores the outcome. Auth: instance administrator. Request: no body. Response:

DELETE /api/v1/admin/instance/backups/destinations/{id}

Removes a destination and the server’s records of the copies made there. The copies stay in the bucket. Refused while a plan copies there. Audited as instance.backup_destination_removed. Auth: instance administrator. Request: no body. Response: 204.

GET /api/v1/admin/instance/backups/incidents

Backup incidents, open ones first, newest first: one per plan and kind — failed (a run failed or was interrupted twice, or the plan’s runs were skipped twice in a row — a single skip raises nothing), incomplete (a bundle with recorded gaps), stale (no good backup for longer than stale_alert_hours), offsite (an upload failed or did not verify), drill (a test restore failed or was partial). A repeat raises count and moves last_at; the next good run, or the condition clearing, resolves it. plan_id is null for runs made without a plan. Each incident is also an inbox item for every instance admin (see the backup guide). ?state= is open or resolved; ?limit= 1–500 (100 by default). Auth: instance administrator. Request: no body. Response:

GET /api/v1/admin/instance/backups/recovery-sheet

A Markdown page (text/markdown, sent as a download) to keep off this server: where the bundles are (this server and every off-site destination), the backup keys’ names, holders and fingerprints with their public keys, whether the vault keys ride in the recovery kit, the exact crewship recover and crewship backup drill commands for the newest instance bundle, the plans, and who the instance admins are. It holds no private key, vault key or password. Auth: instance administrator. Request: no body. Response: the Markdown text.

GET /api/v1/admin/instance/backups/copies

Restore from an off-site copy, step one: every bundle a destination holds — not its .environments.json refs objects or environment layers — and whether this server already has it (local, with local_path). scope and workspace_id are read from the key (instance/… or workspaces/<workspace id>/…). ?destination= is the destination id and is required. Auth: instance administrator. Request: query parameters only. Response:

POST /api/v1/admin/instance/backups/copies/fetch

Downloads one bundle, with every environment layer its refs object names, into this server’s backups directory, each verified against its SHA-256, and records the layers as the bundle’s so retention keeps them. The bundle is then an ordinary local bundle: restore a workspace from it, run a drill, or run crewship recover with it. A fetch can take far longer than one request may (the CLI gives up on a request after 30 seconds), so it runs as a job with its own six-hour deadline and the request answers 202 at once. Follow it with GET …/copies/fetch/{id}. The same destination and key while a fetch of it runs answers that job again. Jobs live in the server process: a restart forgets them, and a half-downloaded bundle never appears. Audited as instance.backup_copy_fetched. Auth: instance administrator. Request:
Response: the job (see the next section), status running.

GET /api/v1/admin/instance/backups/copies/fetch/{id}

One off-site fetch: running, done (path is the local bundle, size its bytes, layers how many environment layers came with it) or failed (error says why; nothing partial is left behind). Auth: instance administrator. Request: no body. Response:

GET /api/v1/admin/instance/holds

The automations an instance restore left held, as an array. routines: schedules, routine schedules and recurring issues do not fire. webhooks: inbound webhooks answer 503. queue: queued work, delayed routine runs and notification retries do not start. all holds all three. count and detail say what is held. Auth: instance administrator. Request: no body. Response:

POST /api/v1/admin/instance/holds/resume

Resumes one held automation (routines, webhooks, queue, or all). It takes effect at once. The change and its instance audit entry (instance.hold_resumed) commit together. Auth: instance administrator. Request:
Response:

Instance administration

These routes require ADMIN or OWNER (manage permission). The mutation routes operate on instance or Docker state, although the caller must still have an authenticated workspace context.

GET /api/v1/admin/health

Returns process uptime, the current log-level state, encryption-key source, database liveness, and data-directory disk usage. The database and disk sections report their own errors while the endpoint remains a health snapshot. Auth: ADMIN or OWNER (manage permission). Request: no body.

Response

GET /api/v1/admin/log-level

Reads the live process level, configured baseline, and optional expiry of a temporary override. Auth: ADMIN or OWNER (manage permission). Request: no body.

Response

expires_at is omitted when no timed override is active.

PUT /api/v1/admin/log-level

Changes the live process log level. A positive TTL automatically restores the baseline; ttl_seconds: 0 leaves the override active until changed, and the server caps TTL at 24 hours. Auth: an instance administrator (see Instance administration); a workspace ADMIN or OWNER is not enough.

Request

level must be a supported logging level (debug, info, warn, or error).

Response

200 OK returns the same {level, baseline, expires_at} object as the GET endpoint.

GET /api/v1/admin/legacy-resources

Read-only, instance-wide detection of orphaned pre-C1 slug-only Docker containers or volumes. It does not remove anything. Auth: ADMIN or OWNER (manage permission). Request: no body.

Response

Rate limits

Rate-limit settings are instance-wide even though the request still requires an authenticated workspace context. Reads require ADMIN or OWNER (manage permission); writes require an instance administrator. Overrides apply immediately and persist until reset.

GET /api/v1/admin/rate-limits

Request: no body. Response: 200 OK with every tunable limiter.

PUT /api/v1/admin/rate-limits/

Overrides one registered limiter. Auth: an instance administrator (see Instance administration); a workspace ADMIN or OWNER is not enough.

Request

Response

200 OK returns the updated limiter state in the same shape as the list response. Unknown keys are rejected; values must be within the limiter’s published bounds.

DELETE /api/v1/admin/rate-limits/

Resets the named limiter to its shipped default. Auth: instance administrator. Request: no body. Response: 200 OK with the reset limiter state.

POST /api/v1/admin/prune-legacy-resources

Removes orphaned pre-C1, slug-only Docker containers and volumes across the instance. Live id-scoped resources are protected. The request has no body and the response lists the resource names removed. Auth: an instance administrator (see Instance administration); a workspace ADMIN or OWNER is not enough. Request: no body.

Response

200 OK is returned on success. A Docker-unavailable deployment returns 503; a mid-operation failure returns 500 with the partial removed list and count.

POST /api/v1/admin/reap-orphan-containers

Scans the current workspace’s running crew containers for a sidecar token fingerprint that definitively disagrees with the token derived from the current internal-token master. The default is a dry run. Add ?apply=true to stop and remove each detected orphan; the next dispatch recreates it. Auth: ADMIN or OWNER (manage permission). Request: no body; apply is an optional query parameter. Response: the scan object below.

Response

reaped is true only when apply=true and stop/remove succeeded. A running container with no advertised fingerprint is not classified or removed; inspected counts running containers reached and identified counts those that advertised a fingerprint. detector_inert is true when at least one container was inspected but none advertised a fingerprint. The endpoint returns 200 OK even when an individual reap fails; that entry remains reaped: false. Docker-unavailable deployments return 503.

POST /api/v1/admin/reencrypt

Re-encrypts every inventoried stored AES-256-GCM envelope to the current master-key version. The request has no body. It is instance-wide and idempotent: envelopes already at the current version are counted as skipped. Rows that cannot be decrypted are counted as failed and left untouched. Auth: an instance administrator (see Instance administration); a workspace ADMIN or OWNER is not enough. Request: no body.

Response

Each columns entry has the same three integer counters as the top-level response. A valid run returns 200 OK. Invalid current-key configuration fails before any row is written with 500; an infrastructure failure during the walk also returns 500 with partial counts and columns.

GET /api/v1/admin/security-posture

Returns read-only, instance-level security state. It reports configuration presence and derived state only; it never returns a key, secret, or client secret. Request: no body. Response: JSON object shown below.

Response

rate_limit_disabled is configured intent; in prod, the limiter still runs and rate_limit_effectively_disabled is false. Warning keys are stable machine-readable identifiers and may include plaintext_secrets_allowed, encryption_key_missing, rate_limit_disabled, rate_limit_disabled_ignored_in_prod, signup_open, private_endpoints_ceiling_open, encryption_key_generated, privileged_credentials_enabled, private_endpoints_in_use, seed_account_default_password, and no_backup_recorded.

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

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

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).
On partial failure the same shape is returned with an additional "error": "<first-error>" field and HTTP 207.
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

The workspaces the caller administers: the current one for a workspace admin, every workspace for the instance owner.

GET /api/v1/admin/workspaces

Lists workspaces with their counts, the week’s runs, the month’s spend, a few settings and the last audited change. The X-Admin-Scope response header is workspace (one row, the current workspace) or instance (every workspace, for the instance owner). Required role: ADMIN or OWNER (manage permission). Request: no body. Response: JSON array shown below.

Response

GET /api/v1/admin/journal/verify

Walks the current workspace’s journal hash-chain and reports whether it is intact — the tamper-evidence check for the append-only journal (internal/api/admin_journal_integrity.go). Detects content mutation, in-place reorder, and mid-chain deletion; see Audit Log → Tamper-Evidence for the guarantee. Required role: ADMIN or OWNER (canRole(role, "manage")) The endpoint takes no parameters; the workspace comes from request context (X-Workspace-ID). It stays 200 OK even when the chain is broken — inspect ok.

Response

A non-empty repairable also produces a priority break, so ok is false — see Audit Log → Recovered is not resolved. CLI parity: crewship journal verify (exits non-zero on a broken chain or when any entry has an unresolved priority).

Audit log

The workspace audit trail — every audit_logs row for the current workspace, newest first. Requires the manage permission (ADMIN or OWNER).

GET /api/v1/audit

Returns a paginated, filterable list of audit-log entries for the current workspace, joined to the acting user for email/name display (internal/api/audit.go:44). Required role: ADMIN or OWNER (canRole(role, "manage") — internal/api/audit.go:48)

Query Parameters

Response

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) Request: no body. Response: the totals and rollups object below.

Response

Example

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.

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

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:

Example

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) Request: no body. Response: the configuration object below.

Response

Example

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

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

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.

POST /api/v1/admin/memory/user-model-sync

Runs the operator-model sweep now — the one the server otherwise runs once a day at 05:00 UTC — and returns the per-workspace summary the scheduled run only ever logs. It is the same sweep, on the same extractor (the curator aux slot, resolved per call, with the memory.user_model_profile setting read fresh), over the same set of workspaces; only the trigger and the report differ. Why it exists: everything about the operator model fails silently by design. Nothing is written when the model proposes nothing, when every candidate is refused by the evidence gate, when the curator slot cannot be built, or when the person is below the interaction threshold — and all four look identical from outside. The summary tells them apart, now, instead of at 05:00 UTC. The sweep is safe to re-run: the merge is idempotent and the write is capped. It runs synchronously inside the request, one model call per candidate on the slot’s per-call budget, so a workspace with many active operators can take minutes — clients must not cap the request at the usual 30 s (the CLI does not). Auth: ADMIN or OWNER (manage permission) in the session workspace. Without a workspace_id in the body the run is instance-wide, exactly like the scheduled sweep. Request: optional JSON body. ?workspace_id= in the query is the session workspace, as on every admin route — it is not a filter.
Response: 200 OK with the run’s report.

Example

CLI: crewship admin memory sync user-model [--all] [--dry-run].

POST /api/v1/admin/memory/peer-card-sync

Runs the peer-card sweep now — the 04:00 UTC one — and returns the same report shape as the operator-model route above, with "sweep": "peer_card". The sweep walks every (agent, operator) pair that crossed the interaction threshold, purges the cards of operators who have opted out, and writes a card where the extractor produced one. The server wires no peer-card extractor today, so a run indexes and purges without writing new bodies — which is also what the scheduled run does — and every threshold-crosser shows up under skipped_empty. Auth: ADMIN or OWNER (manage permission) in the session workspace; instance-wide without workspace_id. Request: the same optional body as the operator-model route — workspace_id (id or slug) and dry_run, the latter also as ?dry_run=true. Response: 200 OK with the report — sweep, dry_run, workspaces[], totals, duration_ms — where the per-workspace counters are candidates, writes, skipped_threshold, skipped_empty, skipped_opt_out, purged_opt_out, errors, plus error when that workspace’s sweep could not run. CLI: crewship admin memory sync peer-cards [--all] [--dry-run].

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

POST /api/v1/admin/prune-crew-runtimes

Prunes crew runtime containers and volumes that are no longer needed. The operation is restricted to callers with manage permission (ADMIN or OWNER). Request: no body. Response: 200 OK with the number of pruned runtimes. The operation is safe to repeat; an unavailable runtime provider returns 503 and a pruning failure returns 500. Statuses: 200 OK; 401 without authentication; 403 without manage permission; 503 when the runtime provider is unavailable; 500 for a prune failure.

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

Response

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.
This endpoint returns the current state of each request. decision is written PENDING and updated in place, so it cannot tell you how the request got there. For that, use the transition history below.

GET /api/v1/admin/keeper/requests//events

Returns the append-only transition history for one Keeper request, oldest first — the record that the in-place decision update would otherwise destroy (issue #1369). Required role: ADMIN or OWNER (manage permission) Request: no body. Statuses: 200 OK; 401 without authentication; 403 without manage permission; 404 when the request does not exist; 500 for a database error.

Path Parameters

Response

keeper_request_events is append-only at the database level — a BEFORE UPDATE trigger aborts any rewrite of a recorded transition. A decision can only be superseded by a further transition, which appears here as an extra row. See Audit → Keeper Decision Auditing.
Requests raised before the ledger migration have their current state backfilled (a PENDING at created_at plus the decision at decided_at) but no intermediate history.
CLI parity: crewship keeper history <request-id>.

Keeper Phase 2 — F4 evaluators

The four evaluator 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 POST /api/v1/admin/keeper/review/{slot}/run (below), the /api/v1/admin/keeper/requests log above, and the per-type filters in the admin UI’s “Keeper P2 reviews” panel (PR-F2).
The expected production path is automated: routines fire on cron, the behavior hook fires on tool-call sampling. To trigger an evaluator by hand, use the admin route below rather than the internal token.

POST /api/v1/admin/keeper/review/{slot}/run

Run one evaluator now. OWNER/ADMIN, workspace-scoped like every /api/v1/admin route (?workspace_id= is required by RequireWorkspace before the handler runs). slot is one of skill-review, behavior, memory-health, negative-learning; the aux-config spellings (curator, memory_health, negative) are accepted as aliases. Anything else is a 400 that names the valid slots. It calls the same handler the internal routes below do — same policy resolution, same keeper_requests row, same inbox escalation — and returns that handler’s response, so the shapes documented per-endpoint below apply unchanged. Request body (all fields optional; what you omit, the server derives from the workspace):
With an empty body: skill-review reviews the stalest skill assigned to one of the workspace’s agents; behavior judges a tool call named keeper.manual_probe; memory-health scores the crew’s memory via consolidate.ComputeHealth; negative-learning learns from the workspace’s most recent run.failed journal entry (400 when there is none — the trigger set is closed and inventing a failure would put fiction in the audit trail). workspace_id in the body is refused when it disagrees with the session’s, and a crew_id / agent_id from another workspace is a 403: the internal routes get that binding from a workspace-bound sidecar token, and an admin session has none. CLI: crewship keeper review run <slot> — see Keeper → Running a review on demand.

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:
Response:
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:
Response:
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:
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:
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:
Response:
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. Request: no body. Response: {needs_bootstrap, allow_signup}.

Response

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. Request: no body. Response: {enabled, install_id}.

Response

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 build identity and (cache-permitting) the latest release from GitHub. The web UI uses current/latest/newer/url to render an “update available” banner; the build fields answer the separate question of which build this server actually is. This is the only way to ask a running server what it is. crewship version --remote is the CLI for it. Auth: required (any authenticated user, no workspace role needed). Request: no body. Response: build identity and release metadata below.

Response

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. The build fields are computed in-process and are unaffected by it.
current alone cannot identify a build. Every binary an -ldflags-less go build has ever produced calls itself dev — which is how a dev slot once sat a full day behind main with nothing able to say so. Compare commit.

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

Response

License System

Current behavior. The open-source edition has unlimited crews, members and agents. The license API reports 0 for each resource limit, meaning unlimited. Resource licensing may change in future releases; the signed-license infrastructure remains available for that purpose.
The edition identifiers below are retained for future licensing. They do not impose resource limits in current releases, even when a loaded signed license contains finite caps.

Editions

License Claims

Each license contains signed claims:

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.