All endpoints serve JSON unless noted. Authentication uses bearer
tokens (Authorization: Bearer <jwt> for users, Authorization: Bearer cmt_<32> for machines). Errors follow a uniform shape:
{ "error": "code", "message": "human readable", "details": {} }| HTTP | Meaning |
|---|---|
| 200 / 201 | success |
| 400 | bad input (validation, malformed JSON) |
| 401 | missing / invalid token |
| 403 | authenticated but not allowed |
| 404 | resource not found (or visible) |
| 409 | conflict (duplicate username, etc.) |
| 410 | revoked share or deleted resource |
| 413 | payload too large (8 MB cap) |
| 422 | unprocessable (e.g. share mode invalid) |
| 429 | rate-limited |
| 500 | server bug — please file an issue |
Token lifetimes are config-driven (defaults below). All request
bodies are validated; max body size is 8 MiB for JSON, 32 MiB
for sync push (override via reverse proxy client_max_body_size).
- Times: RFC 3339 UTC strings (
2026-05-02T13:14:15Z). - IDs: UUID v7 strings; sortable by time.
- Pagination: cursor-based via
since_seq/next_since_seq(sync) or?limit=N&offset=M(admin). - Field names: snake_case in JSON.
- All write endpoints log to
audit_log.
Liveness probe.
curl http://127.0.0.1:8080/healthz{ "status": "ok", "version": "0.1.0" }Create a new user.
Request:
{
"username": "alice",
"password": "correct horse battery staple",
"email": "alice@example.com",
"invite_code": "abc123"
}| Field | Required | Notes |
|---|---|---|
username |
yes | 3–64 chars, [a-zA-Z0-9_-]+, unique |
password |
yes | min 8 chars; argon2id-hashed before storage |
email |
no | optional, no uniqueness constraint |
invite_code |
conditional | required when [auth].require_invite = true |
Response 201:
{
"user": {
"id": "019...",
"username": "alice",
"email": "alice@example.com",
"created_at": "2026-05-02T..."
}
}Errors: 409 username_taken, 422 invite_required, 400 invite_invalid.
{ "username": "alice", "password": "..." }Response 200:
{
"user": { "id": "...", "username": "alice", "email": "...", "created_at": "..." },
"access_token": "eyJhbGciOi...",
"access_token_expires_at": "2026-05-02T13:29:00Z",
"refresh_token": "eyJhbGciOi..."
}Failure (any reason — wrong username, wrong password, disabled user) returns the same 401 message to avoid user enumeration.
{ "refresh_token": "..." }Returns a fresh access + refresh pair. The old refresh token is revoked on success (rotate-on-use).
{ "refresh_token": "..." }Revokes the supplied refresh token; the access token remains valid until its TTL expires.
{ "old_password": "...", "new_password": "..." }Returns 200 on success. All refresh tokens for the user are revoked server-side.
A "machine" represents one device's persistent identity. Each one is
issued a cmt_<32 nanoid> token used by claude-mem sync push/pull to
authenticate without a user JWT round-trip.
{ "name": "alice-mac", "description": "MacBook Pro M3" }Response 201:
{
"machine": {
"id": "019...",
"name": "alice-mac",
"description": "MacBook Pro M3",
"created_at": "...",
"last_seen_at": null
},
"machine_token": "cmt_aBcDeFgHiJ..."
}The machine_token is shown once — store it on the device.
{
"machines": [
{ "id": "...", "name": "alice-mac", "last_seen_at": "...", "created_at": "..." }
]
}Revokes the machine's token. The device must re-register.
Returns projects owned by the user plus those forked from shares.
{
"projects": [
{
"id": "019...",
"name": "nginx-rce",
"display_name": null,
"description": "...",
"is_excluded": false,
"forked_from": null,
"observation_count": 127,
"paths": [
{ "machine_id": "...", "machine_name": "alice-mac", "path": "/Users/alice/work/nginx-rce" },
{ "machine_id": "...", "machine_name": "alice-linux", "path": "/home/alice/projects/nginx-rce" }
],
"shares": [
{
"id": "...",
"target_type": "user",
"target_user": { "id": "...", "username": "bob" },
"share_mode": "fork-allowed",
"created_at": "..."
}
],
"created_at": "..."
}
]
}Explicitly create a project (corresponds to cmem-sync project init).
{ "name": "nginx-rce", "description": "exploit chain notes" }Returns the created project. Client writes the id into a
.cmem-project.toml so the project marker is stable across machines.
Same shape as the list entry. 404 if not visible to the caller.
{ "name": "...", "display_name": "...", "description": "...", "is_excluded": true }Only the owner can patch. is_excluded = true hides the project from
default lists (useful for archival without delete).
Soft-deletes the project and all its observations. Hard-delete only
via admin CLI (admin project purge).
JSONL body — one observation per line:
{"id":"019...","timestamp":1714583400,"project_marker_id":"019...","project_name":"nginx-rce","project_path":"/Users/alice/work/nginx-rce","content":"...","obs_type":"decision","metadata":{},"derived_from":null}
{"id":"019...","timestamp":1714583500,"project_marker_id":null,"project_name":"55ai","project_path":"/Users/alice/work/55ai","content":"...","obs_type":"observation"}Server steps (per observation):
- Resolve project ID via
(user_id, marker_id, project_name, project_path)(see PROJECT_SHARING.md). INSERT OR IGNOREintoobservations, assignserver_seqinside the transaction.- Upsert
project_paths(machine_id, project_id, path). UPDATE machines SET last_seen_at = now().- Append to
audit_log.
Response 200:
{
"accepted": 95,
"duplicates": 5,
"errors": [],
"server_seq_max": 12345,
"projects_resolved": [
{ "submitted_name": "nginx-rce", "project_id": "019..." },
{ "submitted_name": "55ai", "project_id": "019..." }
]
}projects_resolved lets the client persist server-assigned project_id
into .cmem-project.toml.
{
"since_seq": 12000,
"limit": 500,
"include_shared": true,
"include_public": false,
"exclude_machines": ["019..."]
}exclude_machines is typically the caller's own machine_id, to avoid
echoing back what the client just pushed.
Response 200:
{
"own_observations": [/* Observation[] */],
"shared_observations": [
{
"observation": { /* Observation */ },
"share_mode": "fork-allowed",
"sharer_user_id": "...",
"sharer_username": "alice",
"project_id": "...",
"project_name": "nginx-rce"
}
],
"pending_downgrades": [
{
"share_id": "...",
"project_id": "...",
"project_name": "nginx-rce",
"old_mode": "fork-allowed",
"new_mode": "read-only",
"downgraded_at": "..."
}
],
"next_since_seq": 12345,
"has_more": true
}Clients call pull with since_seq = next_since_seq until has_more = false.
{ "share_ids": ["...", "..."] }Acknowledges seen downgrade notices so they stop appearing in
subsequent pull responses.
{
"project_id": "019...",
"target_type": "user",
"target_username": "bob",
"share_mode": "fork-allowed",
"expires_in_secs": 604800
}target_type |
extra fields | recipient |
|---|---|---|
user |
target_username |
one named user |
public |
(none) | any logged-in user |
link |
(none) | anonymous holder of share_token |
Response 201:
{
"share": {
"id": "...",
"target_type": "user",
"target_user": { "id": "...", "username": "bob" },
"share_mode": "fork-allowed",
"share_token": null,
"created_at": "..."
},
"share_url": null
}For target_type = "link" the response includes
"share_url": "https://cmem.example.com/p/<32-char-token>".
Shares the caller has created.
{ "share_mode": "read-only", "expires_at": "2026-06-01T..." }Only share_mode and expires_at are mutable. A downgrade
(e.g. fork-allowed → read-only) appends a row to
share_mode_downgrades; the recipient sees it on next pull.
Revokes the share. The recipient's next pull clears their
shared_view; already forked / auto-copied data stays.
Shares the caller has received.
{
"shares": [
{
"id": "...",
"project": { "id": "...", "name": "nginx-rce" },
"sharer": { "id": "...", "username": "alice" },
"share_mode": "fork-allowed",
"expires_at": null,
"created_at": "..."
}
]
}| Method + Path | Notes |
|---|---|
GET /api/admin/stats |
counters + 24-hour activity series |
GET /api/admin/users |
full user list (search via ?q=) |
POST /api/admin/users |
create user (mirrors register but no invite) |
PATCH /api/admin/users/:id |
toggle is_admin / is_active, change email |
DELETE /api/admin/users/:id |
cascade-delete user + machines + projects + observations |
POST /api/admin/users/:id/reset-password |
generate fresh password (response includes plaintext) |
GET /api/admin/invites |
list invite codes |
POST /api/admin/invites |
create invite (max_uses, expires_days) |
DELETE /api/admin/invites/:code |
revoke invite |
GET /api/admin/projects |
global list (filter by user, name) |
GET /api/admin/observations |
FTS search (?q=...&user=...&project=...) |
DELETE /api/admin/observations/:id |
hard delete one observation |
GET /api/admin/shares |
global share list |
DELETE /api/admin/shares/:id |
force-revoke a share |
GET /api/admin/audit |
audit log (filter by user, action_prefix) |
GET /api/admin/export/users.csv |
full users dump |
GET /api/admin/export/audit.csv?from=&to= |
audit window |
GET /api/admin/export/observations.csv?user=&project=&from=&to= |
observation export |
GET /api/admin/export/full.db.gz |
VACUUM INTO + gzip |
GET /api/admin/export/user/:id.zip |
one-user complete dump (json files) |
Auth for admin endpoints is the same JWT as the user-facing surface,
just gated by users.is_admin = 1 AND is_active = 1. The admin web
console (/admin) reuses the same API via an HttpOnly cookie.
Full UI walkthrough: ADMIN.md. Export schemas: same file under "Export formats".
Login (POST /api/auth/login) is throttled to 5 failures per IP per
15 minutes. Successful logins reset the counter. Other endpoints rely
on the reverse proxy for global rate limits — see
DEPLOYMENT.md#firewall--network-exposure.
docs/openapi.yaml is on the roadmap (M9 — see
Implementation_Plan.md). For now this
document is the source of truth.