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 workspaceOWNER 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:
- the account whose email is
CREWSHIP_OWNER_EMAILon the server, which cannot be removed or suspended over the API; - anyone named with
PUT /api/v1/admin/instance/admins/{userId}(users.instance_role).
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:
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:
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:
DELETE /api/v1/admin/instance/people/{userId}/setup-link
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:
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.
workspacesnames the targets by id or slug.all: truetargets every existing workspace. Send one or the other. Neither changes what a new workspace starts with; that isPUT /api/v1/admin/instance/keeper/governance/defaults.dry_run: truecomputes the changes and writes nothing. The console’s confirmation dialog shows this response. Every response carries apreview_id, a fingerprint of every target’s settings before and after. Send it back asexpect_previewwhen confirming: if a workspace was created or a value changed since the preview, the save answers409and writes nothing.- The save is one transaction. When the merged settings are invalid in any
workspace, the response is
400naming that workspace, and nothing is written anywhere. - A workspace that already has the values is reported with empty
changesand left alone. Each workspace that changes gets aninstance.keeper_governance_updatedentry in the instance audit, with the fields it changed from and to. security_contact_user_idandgov_model_credential_idname something that belongs to one workspace, and are refused for more than one.
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:
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 orallmeans every workspace.request_type: one or more kinds, comma-separated, e.g.access,executefor credential requests orbehavior.decision:ALLOW,DENY,ESCALATEorPENDING.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_idsis required. A list names the targets by id or slug;nulltargets every workspace that exists now. Neither changes what a workspace created later starts with: that isPUT /api/v1/admin/instance/retention/defaults, a separate operation.dry_run: truecomputes the changes and writes nothing. The preview lists every workspace and, in it, every window that changes, each withrows_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_idfingerprinting the targets and every change. Send it back asexpect_previewwhen confirming: if a workspace was created or a window changed since the preview, the save answers409and writes nothing. A save withoutexpect_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
changesand left alone. Each workspace that changes gets aninstance.retention_updatedentry 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.
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: truereturns 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_updatedwith each window’s old and new value. defaultsin the response is every window as a new workspace starts after the save (on a dry run, would).
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:
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:
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:
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:
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:
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,
scopesays what to back up.instancewrites 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’shold_cap_minutes, 20 by default) and released before packing and encryption.workspaceswrites one bundle per workspace inworkspace_ids(ids or slugs; default every workspace), withpreset,contentsandenv_modechoosing what goes in. Withoutpreset, an instance run iscompleteand a workspaces runworkspace.
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:
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:
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:
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:
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):
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):
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):
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:
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:
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:
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:
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:
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:
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:
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
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 agdpr_actions audit row.
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 agdpr_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 agdpr_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).
"error": "<first-error>" field and HTTP 207.
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. TheX-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 — everyaudit_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 frommemory_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 intomemory_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
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) returns200 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
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
"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 withmanage 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-placedecision 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
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.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).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):
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 perScheduler.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:
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 frombehaviorhook.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:
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 aconsolidate.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.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 akind=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:
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
How the four evaluators enforce workspace isolation
How the four evaluators enforce workspace isolation
All four endpoints assert
body.workspace_id == ctx.workspace_id via assertBodyWorkspaceMatchesCtx before any evaluator runs. Asymmetric forgery (query=A, body=B) returns 400 Bad Request. Empty ctx workspace also returns 400 — the gate refuses to operate without the middleware that’s supposed to set it. Symmetric forgery (caller picks one workspace consistently) requires PR-F24 token-to-workspace binding to close fully. See Internal IPC — Tenant isolation.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 (emptyusers 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.jssentry.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 usescurrent/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. Readsworkspace_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.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.
What’s Next
RBAC
Role-based access control and permission levels.
Keeper Guide
Configure the AI-powered security gatekeeper.