# HTTP API Reference

The CoreReflex HTTP API is a single, framework-free Node server. Every request
flows through `src/server.js`, which serves static pages and hands anything under
`/api/` to `handleApiRequest` in `src/api.js`. That function is one long
`if (method && pathname)` route ladder — there is no router library, no
middleware stack, and no `next()`. Three families are delegated to their own
modules: chat/support (`src/chat-routes.js` + `src/chat-handoff-routes.js`, which
own the raw response for Server-Sent Events), admin (`routeAdminApi` in
`src/admin-api.js`, which further delegates chat/support/self-heal admin routes
to `src/admin-chat-api.js`), and the real-time voice WebSocket bridge
(`src/voice-agent/index.js`, attached to the HTTP server directly). The
browser-side `fetch` helpers the app itself uses live in `src/client-api.js`;
they are not a separate API surface.

This document is the integrator's reference for talking to that surface directly
over HTTP. The public origins are:

| Surface | Origin |
|---------|--------|
| App + API | `https://corereflex.com` |
| Editor studio (SPA) | `https://editor.corereflex.com` |

The canonical product surface is the dashboard at `/app` (served from
`public/dashboard.html`); `/admin` and `/dashboard` are compatibility paths that
serve the same dashboard (the God Console is a route inside it — the legacy
standalone admin SPA is gone). The editor is a separate React-Router SPA
(`editor/`); the render queue worker is `render-worker/` behind the
`src/render-engine.js` seam. Both call the same API documented here. There are
also two real-time voice **WebSocket** surfaces — browser `wss://…/api/voice/live`
and Twilio `wss://…/api/voice/twilio/media` — described below; they are not part
of the JSON `/api/` ladder.

For how the studio consumes these routes, see
[Editing & mastering](../user/editing-and-mastering.md); for the generation
internals behind `/api/generate`, `/api/director/plan`, and
`/api/director/produce`, see [`../MODEL-ROUTER.md`](../MODEL-ROUTER.md) and
[`../PROMPTING.md`](../PROMPTING.md). For the consent-gated voice-clone /
persona seams, see [own-the-stack.md](own-the-stack.md).

## On this page

- [Conventions](#conventions) — auth, errors, CORS, CSP, limits, credits
- [Auth & session](#auth--session)
- [Assets & content credentials](#assets--content-credentials)
- [Files, collections & media search](#files-collections--media-search)
- [Compositions](#compositions) — CRUD, engine, versions, comments (+ SSE)
- [Programmable JSON engine, catalog & API keys](#programmable-json-engine-catalog--api-keys)
- [Suite agent](#suite-agent-apiagent) — plan / execute / ask / runs / tools / events (SSE)
- [Generation, director & render](#generation-director--render)
- [Auto-Clips & highlights](#auto-clips--highlights)
- [Captions](#captions)
- [Image tools, upscale & enhance](#image-tools-upscale--enhance)
- [Voice, music & soundscapes](#voice-music--soundscapes)
- [Meeting briefs](#meeting-briefs) · [Gallery, votes & import](#gallery-votes--import)
- [Recipes & autopilot](#recipes--autopilot)
- [Writing & content](#writing--content-write-pillar)
- [Brand kit, theme, brands & brand voice](#brand-kit-theme-brands--brand-voice)
- [Knowledge base](#knowledge-base)
- [CRM](#crm--contacts-activity-tags) · [Tasks](#tasks) · [Booking](#booking--scheduling) · [Automations & planners](#automations--planners) · [Campaigns](#campaigns)
- [Personas](#personas) · [Scripts](#scripts)
- [Proofing & approvals](#proofing--approvals-ashore)
- [Social publishing](#social-publishing) · [Team](#team)
- [Analytics & leads](#analytics--leads) · [Marketing content](#marketing-content-blog-case-studies-testimonials)
- [Prompt specs & presets](#prompt-spec-registry--presets)
- [Billing & Stripe](#billing--stripe) · [Connections & editor health](#connections--editor-health)
- [Chat & support](#chat--support)
- [Admin (God Mode)](#admin-god-mode)
- [Quick reference — full route index](#quick-reference--full-route-index)

---

## Conventions

### Base path and content type

Everything API-related is mounted under `/api/`. Requests that don't start with
`/api/` are never seen by `handleApiRequest` — they fall through to server-side
page rendering and static file serving. The unauthenticated liveness probe at
`GET /healthz` is the one exception: it is handled directly in `src/server.js`
before the API dispatcher and returns `{"status":"ok","service":"CoreReflex"}`.

Request bodies are JSON. The server reads the raw body and `JSON.parse`s it; an
empty body is treated as `{}`, and a malformed body returns
`400 {"error":"Request body must be valid JSON"}`. Set
`Content-Type: application/json` on writes. The single exception is the Stripe
webhook (see below), whose raw bytes are read *before* any JSON parsing so the
signature can be verified over the exact payload. (The Twilio voice webhook also
reads its raw form body, and returns TwiML XML rather than JSON.)

Responses are JSON (`application/json; charset=utf-8`) except for the binary /
streaming routes — asset GET/HEAD/PUT, asset download, the `.ics` booking
export, the Twilio TwiML XML, and the Server-Sent Event streams — which set
their own content types.

### Authentication — sessions, bearer tokens, and API keys

There are three credential forms, and two auth classes of routes.

**1. Session cookie.** A HS256 JWT minted on register/login and stored as a
HttpOnly cookie — `corereflex_session` (override with `AUTH_COOKIE_NAME`),
`HttpOnly; SameSite=Lax; Path=/`. In production the cookie `Domain` is widened
to the apex (`.corereflex.com` by default, via `AUTH_COOKIE_DOMAIN`) so
`editor.corereflex.com` is sent the same session. Tokens default to a 12-hour
TTL (`AUTH_TOKEN_TTL_SECONDS`, default `43200`).

**2. Bearer session JWT.** The same token in `Authorization: Bearer <token>`.
`optionalSession` in `src/session.js` checks the `Authorization` header first,
then falls back to the cookie. The token is returned in the JSON body (`token`)
on `POST /api/auth/register` and `POST /api/auth/login` (the same response also
sets the cookie).

**3. Platform API key** — `Authorization: Bearer cr_live_…` (`src/api-keys.js`,
migration 018). Keys are minted at `POST /api/keys` from a logged-in session;
the full secret is shown **once** at creation (only `sha256(secret)` + a short
prefix are stored). A key acts with its creating member's workspace + role, so
all workspace scoping and credit metering apply unchanged. A revoked/expired/
orphaned key is a `401` — a presented-but-invalid key never silently falls
through to the session path.

The **auth ladder** (`requireApiUser`): routes wired through it accept an API
key when a `cr_live_` bearer is presented, otherwise fall back to the normal
session check. These are the programmatic endpoints — generation-adjacent tools,
the JSON engine, captions/highlights, auto-clips, render, and the media library.
Routes wired through plain `requireUser` are **session-only** — auth, account,
API-key management, billing, team, CRM, agent, recipes, and everything else. In
the tables below:

| Auth label | Meaning |
|------------|---------|
| **Public** | No credential (often rate-limited, sometimes token-in-URL) |
| **Session** | Session cookie or bearer session JWT only |
| **Key/Session** | API key (`cr_live_…`) **or** a session |
| **Pro** | Session + the client-ops/Pro gate (see below) |
| **Admin · `<privilege>`** | Session whose user is an app admin holding that privilege |

`requireUser` re-validates the session against the `auth_sessions` table on
every call (the session must exist, be un-revoked, and unexpired), then loads
the user's workspace + role, email-verification state, and any app-admin
profile. A failed check throws `401 {"error":"Authentication required"}`.

> All data is scoped to `user.workspace.id`. Every authenticated query filters on
> the workspace resolved from the session (or the API key's workspace) — there is
> no way to read another workspace's data through this API.

### Error shape

Errors are always `{"error": "<message>"}` with an HTTP status code. Internally,
handlers throw via the `httpError(statusCode, message)` helper (and the
`badRequest`/`unauthorized`/`notFound`/`conflict` shortcuts). The dispatcher's
outer `catch` maps the thrown error to its `statusCode` (default `500`):

- Any status `< 500` returns the real `message`.
- A `500`-class error returns the generic `"Server error"` — unless the error is
  explicitly flagged `expose: true`, in which case its message is returned as-is.
  This is how config-state errors surface useful text, e.g.
  `503 "Captions are disabled: the Vertex service account is not configured"`
  or a `402` out-of-credits message.

Common statuses: `400` (bad input), `401` (no/invalid session or API key), `402`
(out of credits, only when billing is enabled), `403` (Pro-gate, persona consent,
workspace-binding of a media URL, unverified email on checkout, or
admin-privilege failure), `404` (not found / unknown route), `405` (method not
allowed on a matched path), `409` (conflict — duplicate account, a just-taken
booking slot, duplicate team member, or "no subscription yet" on the billing
portal), `413` (request body / upload over the size cap), `429` (rate limit),
`502` (an upstream engine/storage/Stripe call failed), `503` (a dependency is
unconfigured — e.g. a worker URL, Stripe, or the Vertex service account).

`POST /api/agent/execute` has one structured extension: on a mid-run failure the
error body also carries `runId` and (when exposable) a `failure` report — see
the agent section.

### CORS

CORS is allow-listed by exact origin. `CORS_ALLOWED_ORIGINS` is a comma-separated
list (default `https://editor.corereflex.com`). When the request `Origin` is on
the list, the response carries
`Access-Control-Allow-Origin: <origin>`,
`Access-Control-Allow-Credentials: true`, `Vary: Origin`,
`Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS`, and
`Access-Control-Allow-Headers: content-type`. An `OPTIONS` preflight is answered
with `204`. Origins not on the list get no CORS headers (the browser blocks the
cross-origin read). Because credentials are allowed, the editor's cross-origin
XHR can carry the session cookie.

### Health and build version

`GET /api/health` (public) returns the live build identifier as `version`,
alongside component checks. `BUILD_VERSION` is **env-first**: set the
`BUILD_VERSION` environment variable in the deploy (e.g. to the git SHA in
Coolify) and `/api/health` reflects the real build automatically; the in-code
constant (`agent-g1-kernel`) is only the fallback when no stamp is provided.
This is the canonical way to confirm which build is actually serving — useful
when a Coolify redeploy stalls.

```jsonc
// GET /api/health
{
  "ok": true,                       // db && storage && auth all healthy
  "version": "agent-g1-kernel",     // BUILD_VERSION env stamp, or the code fallback
  "database": { "ok": true, "configured": true },
  "storage":  { "ok": true, "configured": true },
  "auth":     { "ok": true }
}
```

`GET /api/editor/health` (public) probes the editor SPA's `/healthz` from the
server (`EDITOR_URL`, default `https://editor.corereflex.com`; timeout
`EDITOR_HEALTH_TIMEOUT_MS`, clamped 250–5000 ms, default 1800) and returns
`{ ok, url, healthUrl, status, latencyMs, checkedAt, error? }` — the dashboard's
"is the editor up" check.

### Security headers and the CSP nonce

Every response carries a baseline security header set, applied in `src/server.js`
*before* the API dispatcher (so static pages and `/api/` both get them):
`X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`,
`Referrer-Policy: strict-origin-when-cross-origin`, `Alt-Svc: clear` (HTTP/3 is
disabled at the edge — cached `h3` entries were stalling video range requests),
`Strict-Transport-Security: max-age=31536000; includeSubDomains` (no `preload`),
and a `Permissions-Policy` that keeps camera/microphone `self` (the editor
records via getUserMedia) and denies the rest.

The Content-Security-Policy is **nonce-based** for scripts: each response mints
a per-request nonce, every inline `<script>` in served HTML is stamped with it
on the way out, and `script-src` is `'self' 'nonce-<random>'` — **no
`'unsafe-inline'` for scripts**, so an injected inline script without the
unguessable nonce is refused by the browser. `style-src` keeps `'unsafe-inline'`
(the pages rely on inline `style=""` attributes, which nonces cannot cover).
Media/images allow `https:`, and `connect-src 'self' https: wss:` keeps the
voice WebSocket and asset XHRs working. When `GA_MEASUREMENT_ID` is set,
`script-src` is widened to Google's tag domains. Because the nonce is
per-request, HTML is always served `Cache-Control: no-store`.

### Body size limits

JSON/text bodies are capped at **1 MiB** (`MAX_BODY_BYTES`); over the cap returns
`413 "Request body too large"` without buffering unbounded bytes. Exceptions:

| Route | Cap |
|-------|-----|
| `PUT /api/assets/:key` (upload proxy, streamed) | 512 MiB (`MAX_UPLOAD_BYTES`) |
| `POST /api/image/edit` · `POST /api/image/subject-box` (base64 image + mask) | 12 MiB (`IMAGE_EDIT_MAX_BODY_BYTES`) |
| `POST /api/recipes/:id/bulk` (CSV batch) | 2 MiB |
| Chat routes (`/api/chat*`) and admin bodies | 64 KiB |

### Rate limits

Limits are a per-process sliding window keyed on the client IP or the signed-in
user id (`src/rate-limit.js`). The IP is taken from `cf-connecting-ip` first
(Cloudflare fronts the fleet and sets the true client IP; a client-supplied
`X-Forwarded-For` can be spoofed to rotate buckets), then `X-Forwarded-For`'s
first hop, then the socket peer. Over the limit returns
`429 {"error":"Too many requests"}` (some routes use a route-specific message).

| Route | Limit (per minute) |
|-------|--------------------|
| `POST /api/auth/register` | 5 / IP |
| `POST /api/auth/login` · `/api/auth/reset` · `/api/auth/change-password` | 10 / IP |
| `POST /api/auth/forgot` | 5 / IP |
| `POST /api/auth/verify` | 20 / IP |
| `POST /api/auth/resend-verification` | 3 / user |
| `POST /api/generate` · `POST /api/director/plan` | 20 / IP |
| `POST /api/director/produce` | 6 / IP |
| `POST /api/json` · `POST /api/compositions/:id/engine` (shared bucket) | 120 / user |
| `POST /api/agent/plan` · `/api/agent/execute` | 30 / user |
| `POST /api/agent/ask` | 60 / user |
| `POST /api/agent/plan-to-recipe` | 20 / user |
| `GET /api/agent/compositions/:id/events` (SSE opens) | 20 / user |
| `POST /api/compositions/:id/comments` | 60 / user |
| `GET /api/compositions/:id/comments/stream` (SSE opens) | 20 / user |
| `POST /api/recipes/:id/run` | 12 / user |
| `POST /api/recipes/:id/bulk` | 3 / user |
| `POST /api/auto-clips` | 8 / user |
| `POST /api/auto-clips/story` | 12 / user |
| `POST /api/auto-clips/publish` | 6 / user |
| `POST /api/auto-clips/voiceover` | 30 / user |
| `POST /api/highlights/detect` · `/api/captions/generate` | 12 / user |
| `POST /api/highlights/proxy` | 6 / user |
| `POST /api/audio/fx` | 30 / user |
| `POST /api/upscale` · `/api/image/remove-bg` · `/api/image/vectorize` | 20 / user |
| `POST /api/image/edit` | 10 / user |
| `POST /api/image/subject-box` | 30 / user |
| `GET /api/media/search` | 30 / user |
| `POST /api/content/write` · `/api/content/rewrite` (shared bucket) | 30 / user |
| `POST /api/social/connection` | 10 / user |
| `GET /api/social/accounts` | 30 / user |
| `POST /api/social/posts` | 20 / user |
| `POST /api/analytics/event` | 240 / IP |
| `POST /api/leads` | 10 / IP |
| `POST /api/chat` | 12 / IP |
| `POST /api/chat/handoff` | 6 / IP |
| `POST /api/chat/feedback` | 30 / IP |
| `GET /api/calendars/:slug/slots` | 60 / IP |
| `POST /api/calendars/:slug/book` | 10 / IP |
| `GET/POST /api/voice/twilio` | 60 / IP (429 = TwiML `<Reject reason="busy"/>`) |
| `WS /api/voice/live` · `/api/voice/twilio/media` (new sessions) | 5 / IP (`VOICE_MAX_NEW_PER_IP`) |

The cost guards on `/api/generate`, `/api/director/plan`, and
`/api/director/produce` apply **even when billing is off**, so the paid
Veo/Gemini paths can't be hammered on the free product.

### SSRF guard and workspace binding on media URLs

Routes where the server (or the render worker) fetches a caller-supplied media
URL — audio FX, upscale, remove-bg, vectorize, clip proxy, highlight detection,
caption generation, auto-clips sources, persona reference media, voice-clone
audio — pass it through `safePublicUrl` (`src/net-guard.js`). It requires
`https:` and rejects loopback, private (RFC 1918), link-local, and
cloud-metadata (`169.254.169.254`) hosts, including IPv4 addresses embedded in
mapped/NAT64 IPv6 literals. A blocked URL is a `400`. (It validates literal IPs
and obvious local names but does not resolve DNS, so it's paired with fleet
egress rules for full coverage.)

Two extensions on the video-analysis / auto-clips routes:

- A `gs://` URI is allowed (it is read by Vertex or the worker internally, never
  fetched by the app box).
- When the URL points at **our own asset proxy** (`/api/assets/<key>`), the
  object key must belong to the caller's workspace (`assetUrlInWorkspace`) —
  otherwise `403 "That source asset is not in your workspace."`. This closes the
  hole where a leaked signed asset URL (the capability token is key-bound, not
  workspace-bound) could feed another tenant's footage into analysis. The same
  `keyInWorkspace` check binds `fileKey`/`assetKey` bodies on `/api/captions`
  and `/api/meeting/from-asset`.

### Async jobs and SSE streams

Generation, render, shorts, auto-clips, recipe runs, and brand draft generation
are asynchronous: the POST returns **`202 Accepted`** with a job/batch id, and
clients **poll** the matching GET (`/api/generate/:id`, `/api/render/:id`,
`/api/recipes/:id/runs…`). There is no push channel for job status.

There *are* Server-Sent Event streams for live collaboration surfaces (all
hand-rolled on the in-process realtime bus, `src/realtime-bus.js` — no realtime
SDK):

| SSE stream | Auth | Events |
|------------|------|--------|
| `GET /api/compositions/:id/comments/stream` | Session (ownership checked first) | comment activity on the composition channel |
| `GET /api/agent/compositions/:id/events` | Session (ownership checked first) | `agent_plan` / `agent_step` / `agent_error` / `agent_done` for live agent runs |
| `POST /api/chat` (response body) | Public | `meta` / `delta` / `done` / `error` answer stream |
| `GET /api/chat/conversations/:id/stream` | Public (session-id or owner scoped) | live handoff / human-agent events |
| `GET /api/admin/chat/stream` | Admin · `support_debug` | all support-channel activity |

By contrast `POST /api/music`, `POST /api/imagen`, `POST /api/voice`, and the
image/audio tool routes are synchronous (`200`) because the underlying engine
returns inline.

### Pro-gating and credit metering

Two independent gates apply to subsets of routes:

- **Pro-gate (client-operations layer).** CRM, tasks, scheduling, automations,
  personas, meeting-brief persistence, KB search, proofing, and brand cascade
  are gated by `clientOpsEnabled(user)` = `CLIENT_OPS_ENABLED === 'true'` **OR**
  the workspace plan is at least `pro`. When the gate fails the route throws
  `403` with an exposed message such as `"Tasks are a Pro feature — upgrade to
  enable"`. These routes are marked **Pro** in the tables below. The platform
  owner's account resolves to the top tier by code (`resolveEffectivePlan`), so
  every Pro gate opens for the owner; the God Console preview-plan route can
  view the product as a lower tier.
- **Credit metering (billing).** The genuinely expensive AI routes call
  `meterUsage(user, reason)` before doing the work. `BILLING_ENABLED=true` is now
  **set in production**, so metering is live (it was a no-op while unset). When the
  account is out of credits, the route throws `402` *before* spending. The
  gate-and-spend is atomic — `meterUsage` decrements only when the balance still
  covers the cost, so two concurrent calls can't double-spend. Fan-out routes pass
  a `multiplier` and bill once per unit of work in a single all-or-nothing
  decrement. **The platform owner and app-admins are exempt** — `meterUsage`
  returns `{ metered: false, exempt: true }` for them, so flipping billing on can
  never lock them out (the safe-flip guarantee).
- **Consumption confirmation (`402 premium_confirm_required`).** Before the meter,
  each paid POST calls `gateConsumption` (`src/consumption.js`): if the op's credit
  cost clears `CONSUMPTION_CONFIRM_THRESHOLD_CREDITS` (default `1`) **and** the
  user/workspace requires confirmation **and** the body lacks `confirm: true`, it
  throws the same `402 { code: "premium_confirm_required", estimate }` contract the
  video gate uses. The caller retries with `confirm: true`. Settings +
  audit at `/api/settings/consumption` and `/api/admin/audit` (migration 038). See
  [Billing](billing.md#consumption-confirmation-migration-038).

Per-call cost table (`USAGE_COSTS` in [`src/billing.js`](../../src/billing.js)).
**Self-hosted open-model lanes bill a cheaper `*-own` / `corereflex*` reason**,
resolved from the model that runs *before* metering — full list in
[Billing](billing.md#credits--metering):

| Meter reason | Credits | Charged by |
|--------------|--------:|------------|
| `usage:persona` | 120 | `POST /api/personas/:id/clip` |
| `usage:veo` (or routed provider: `kling` 60 / `seedance` 45 / `wan` 25 / `hailuo` 20) | 100 | `POST /api/generate` (**the provider that runs**, `creditReasonForProvider`) · `POST /api/director/produce` (`usage:veo` **× shots**) |
| `usage:corereflex-lf` / `usage:corereflex` | 20 / 15 | `POST /api/generate` when a **self-hosted** video engine runs |
| `usage:lyria` / `usage:music-own` | 20 / 3 | `POST /api/music` (self-hosted lane bills `music-own`; `engine:"lyria"` pins premium) |
| `usage:imagen` / `usage:image-own` | 5 / 2 | `POST /api/imagen` (engine-routed) · `POST /api/logo/options` (`usage:imagen` **× delivered concepts**) |
| `usage:imagen-edit` | 6 | `POST /api/image/edit` |
| `usage:voice` / `usage:voice-own` | 5 / 1 | `POST /api/voice` (`voice-own` when a self-hosted model is pinned, e.g. `model:"kokoro"`) · `POST /api/voice/design` (`usage:voice`) · `POST /api/auto-clips/voiceover` |
| `usage:render` | 10 | `POST /api/render` · `POST /api/shorts` · `POST /api/auto-clips` (**× clips**) |
| `usage:audio-fx` | 5 | `POST /api/audio/fx` |
| `usage:upscale` | 8 | `POST /api/upscale` |
| `usage:remove-bg` · `usage:vectorize` | 4 | `POST /api/image/remove-bg` · `POST /api/image/vectorize` |
| `usage:content` | 3 | `POST /api/content/write` · `/rewrite` · `/api/captions/generate` · `/translate` · `/highlights` · `/api/highlights/detect` · `/proxy` · `/api/auto-clips/story` · `/api/image/subject-box` · `/assist` · diagram AI |
| `usage:lyrics` | 2 | `POST /api/music/lyrics` |
| `usage:publish` | 2 | `POST /api/social/posts` (also per clip via `/api/auto-clips/publish`) |

Grants (subscription start / monthly renewal / add-on purchase) are idempotent
on the Stripe session/invoice id via a partial unique index on the credit ledger
(migration 011), and webhook events are processed exactly-once (migration 012).

---

## Auth & session

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| POST | `/api/auth/register` | Public (5/min) | Create account + workspace + first user (owner) in one transaction. Returns `{ user, workspace, token, verificationSent, emailVerified }` and sets the session cookie. Requires a password ≥ 12 chars; duplicate email → `409`. Runs a **deliverability check** on the email (usermails verify, when configured): an undeliverable address → `400`; `VERIFY_REJECT_RISKY=true` also blocks disposable/role addresses. A verification email is sent on success. |
| POST | `/api/auth/login` | Public (10/min) | Verify email + password, mint a session. Returns `{ user, workspace, token }` + cookie. Bad credentials or a disabled user → `401`. |
| GET | `/api/auth/me` | Session | Current user profile: `{ user }` with `workspace`, `role`, `appAdmin`, email-verification state, and `impersonation` context. |
| PATCH | `/api/auth/me` | Session | Update the signed-in user's display name. Body: `{ name }` (required, ≤ 120 chars). Returns `{ ok: true, name }`. |
| POST | `/api/auth/change-password` | Session (10/min) | Change password. Body: `{ currentPassword, newPassword }` (`newPassword` ≥ 12 chars, different from current). Wrong `currentPassword` → `401`. On success, revokes every **other** session (keeps the caller's). Returns `{ ok: true }`. |
| POST | `/api/auth/forgot` | Public (5/min) | Request a password-reset email. Body: `{ email }`. **Anti-enumeration**: always returns `200 {"ok":true}` whether or not the email exists. The reset link is delivered out-of-band via the email seam — never in the HTTP response. |
| POST | `/api/auth/reset` | Public (10/min) | Complete a reset. Body: `{ token, password }` (≥ 12 chars). The token is a short-lived (30 min, `PWRESET_TTL_SECONDS`) signed JWT bound to a fingerprint of the current password hash — **single-use**. On success, revokes all of that user's sessions. Invalid/expired/used → `400`. |
| POST | `/api/auth/verify` | Public (20/min) | Complete email verification from the emailed token. Body: `{ token }`. Returns `{ ok: true }`; invalid/expired → `400`. |
| POST | `/api/auth/resend-verification` | Session (3/min/user) | Send another verification email. Already-verified users are a no-op. Returns `{ ok: true, delivered }`. |
| POST | `/api/auth/logout` | Session (optional) | Revoke the current session and clear the cookie. Always `200 {"ok":true}`. |

Roles within a workspace are `owner` / `producer` / `reviewer` / `client`
(`memberships.role`); register makes the first user the `owner`.

> **Email delivery** (`src/email.js`) is wired through the `usermails` provider:
> set `EMAIL_PROVIDER=usermails` + `USERMAILS_API_KEY` and reset/verification
> emails are actually sent (`delivered: true`). With `EMAIL_PROVIDER` unset,
> sends are logged server-side and report `delivered: false` (dev mode), so
> flows that depend on email degrade safely. Any other provider value throws —
> only `usermails` is implemented. Email **verification matters**: Stripe
> checkout, the billing portal, and add-on purchases require a verified email
> (`403 "Verify your email address before using this feature"`; app admins are
> exempt).

---

## Assets & content credentials

Private footage lives on in-house MinIO, which is **not** browser-reachable, so
all asset I/O is proxied through the app. Playback (`streamAsset`) is authorized
**three ways, cheapest first**: (1) a non-expiring HMAC capability token
(`?t=<token>`, key-bound — no DB touch); (2) a live session whose workspace id
appears in the object key's namespace (`<ns>/<workspaceId>/…`); (3) a `cr_live_…`
**API key** whose workspace owns the key. Way 3 is the **headless round-trip**: an
agent fetches its own output by bare object key with just its API key — see
[Platform API](platform-api.md#headless-asset-round-trip). Download and delete are
stricter (session + workspace ownership only).

> **Headless fix** (commit `0b73b41`). `optionalSession` (`src/session.js`) now
> **ignores `cr_live_` bearers** instead of trying to `verifyJwt` them — an
> API-key agent that fetched its own asset used to get a `500`. The `?t=` token
> (or a session) authorizes the stream; the API key adds the third path above.

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| POST | `/api/assets/presign` | Session | Mint a presigned MinIO/R2 PUT URL for a browser upload. Body: `{ key, contentType?, expires?, size? }` (default 900 s). Keys are forced under `uploads/<workspaceId>/…` (any caller-supplied prefix is stripped and re-scoped); keys containing `..` are rejected. Also records a best-effort `assets` metadata row (Files pillar) with the declared byte size so the upload appears in `/api/files`, search, and the storage meter. |
| GET | `/api/assets/:key` | Token, session, **or** API key | Stream the object. Forwards `Range` so `<video>` seeking works; `Cache-Control: private, max-age=31536000, immutable` (object keys are immutable — new content mints a new key) with `Vary: Origin`. |
| HEAD | `/api/assets/:key` | Token, session, **or** API key | Existence + full `Content-Length` check (the editor's `checkFileExists`), no body. |
| PUT | `/api/assets/:key` | Token **or** session | Upload proxy — streams the request body into MinIO server-side (browser can't PUT to private MinIO directly). Capped at 512 MiB. Returns `{ ok, key }`. |
| GET | `/api/assets/:key/download` | Session + **ownership** | Ownership-gated attachment download. The asset must belong to the caller's workspace (capability tokens are playback-only, never download). Sends the whole file (no `Range`); public R2 buckets are `302`-redirected to a short-lived presigned GET. |
| GET | `/api/assets/:key/credentials` | **Public** | CoreReflex Content Credentials: anyone can verify an asset's signed provenance record (AI-generated flag, model/provider, timestamps — the prompt is redacted). Honestly **not** C2PA — it is CoreReflex's own signed-JSON scheme (`corereflex-content-credentials/v1`). |
| DELETE | `/api/assets/:key` | Session + **ownership** | Delete an owned object: removes it from active storage, then deletes its `assets` metadata row. Returns `{ deleted: "<object_key>" }`. Not in your workspace → `404`; tokens are not accepted for deletes. `503` (exposed) if storage isn't configured; an upstream delete failure (other than a 404) → `502`. |
| GET | `/api/credentials/pubkey` | **Public** | The CoreReflex credential-signing public key, so a credential can be verified offline. |

---

## Files, collections & media search

The Files pillar: one workspace-wide repository of every uploaded + generated
file, with named collections (membership-only folders — the storage meter is
untouched) and semantic search over asset embeddings.

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| GET | `/api/files` | Key/Session | The workspace's full file repository. Query: `?type=` · `?kind=` · `?q=` (semantic search) · `?sort=` · `?collection=<id>` · `?limit=` (default 24). |
| PATCH | `/api/files/:assetId/metadata` | Key/Session | Update an asset's user metadata. Body: `{ metadata: {…} }`. |
| GET | `/api/collections` | Key/Session | List collections. |
| POST | `/api/collections` | Key/Session | Create a collection. `201`. |
| POST | `/api/collections/:id/assets` | Key/Session | Add assets. Body: `{ assetIds: […] }`. |
| DELETE | `/api/collections/:id/assets/:assetId` | Key/Session | Remove one asset from the collection. |
| DELETE | `/api/collections/:id` | Key/Session | Delete the collection (membership rows only; assets untouched). |
| GET | `/api/media/search` | Key/Session (30/min) | Semantic media search: `?q=<natural language>` → cosine ranking over the caller's own asset embeddings. `?limit=` (default 24). Lives under `/api/media/*` because `/api/assets/*` is the signed-proxy namespace. |

---

## Compositions

A composition is the editor's manifest (the `UndoableState` JSON). It is
workspace-scoped through its parent campaign.

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| GET | `/api/compositions` | Session | List the workspace's renderable projects (newest first, max 50), each tagged with `lastRenderStatus`/`lastRenderAt`. Returns `{ compositions: [{ id, label, createdAt, lastRenderStatus, lastRenderAt }] }`. |
| GET | `/api/compositions/:id` | Session | Load `{ id, manifest }`. `:id` must be a UUID; not found in the workspace → `404`. |
| PUT | `/api/compositions/:id` | Session | Save the manifest. Body: `{ manifest: {…} }`. A UUID updates in place; a non-UUID id (e.g. `new`) lazily provisions an `Untitled` draft campaign + empty composition, then stores the manifest. Session-tagged audio tracks are normalized into `manifest.audioSessions` on write. A version snapshot rides the save best-effort. Returns `{ id }`. |
| DELETE | `/api/compositions/:id` | Session | Delete a workspace-owned composition. Render jobs cascade through the FK; the owning campaign remains. |

### Composition-scoped engine

The stable HTTP shape SDKs/agents use without putting the composition id in the
body. Delegates to the same `evaluateComposition` path as `/api/json`, so auth,
workspace scope, row-locking, and persistence stay single-sourced.

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| GET | `/api/compositions/:id/schema` | Key/Session | The engine's describe result for this composition (targets/operators/commands). |
| POST | `/api/compositions/:id/engine` | Key/Session (120/min, shared with `/api/json`) | Run an engine request against the stored composition. Body: `{ request: {…} }` (or the bare request object). Mutations persist atomically under a row lock. |

### Version history

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| GET | `/api/compositions/:id/versions` | Key/Session | List saved snapshots (identical re-saves are deduped by manifest hash). |
| POST | `/api/compositions/:id/versions/:n/restore` | Key/Session | Restore snapshot `n` as the live manifest. |

### Comments (+ SSE)

In-editor comment threads. **Ungated** (team comments are core, not client-ops);
ownership is enforced per composition.

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| GET | `/api/compositions/:id/comments` | Session | List the thread. |
| POST | `/api/compositions/:id/comments` | Session (60/min) | Add a comment. `201`. |
| GET | `/api/compositions/:id/comments/stream` | Session (20 opens/min) | SSE stream of comment activity. Ownership is verified **before** the socket is held open. |
| POST | `/api/compositions/:id/comments/:commentId/resolve` | Session | Resolve/reopen. Body: `{ resolved }` (default `true`). |

---

## Programmable JSON engine, catalog & API keys

The headless "query the tool like Remotion" surface (`src/json-api.js`), the
self-describing platform catalog, and API-key management.

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| GET | `/api/json/schema` | Key/Session | Discover every engine target/operator/command: `{ ok, kind: "describe", result }`. |
| POST | `/api/json` | Key/Session (120/min) | Run a query/command/describe/batch. Body: `{ compositionId, request }` (stored: load → evaluate → persist mutations atomically) **or** `{ state, request }` (inline, stateless). Neither → `400`. |
| GET | `/api/catalog` | Key/Session | The self-describing "full model + full API" surface: live model IDs, generation capabilities + params, camera moves, upscale tiers, per-operation credit cost, and the public endpoint list. The SDK/MCP introspect this. |
| POST | `/api/keys` | Session **only** | Create an API key. Body: `{ name?, expiresAt? }`. `201` with `{ id, name, keyPrefix, createdAt, expiresAt, key }` — **`key` is returned exactly once**. `503` until migration 018 is applied. |
| GET | `/api/keys` | Session **only** | List keys (metadata only — never the secret): `{ keys: [{ id, name, keyPrefix, scopes, lastUsedAt, expiresAt, revokedAt, createdAt, active }] }`. |
| DELETE | `/api/keys/:id` | Session **only** | Revoke a key (idempotent, workspace-scoped). Returns `{ id, revoked: true }`; unknown → `404`. |

Key management is deliberately session-only: you mint/revoke keys from a
logged-in session, never with a key.

---

## Suite agent (`/api/agent`)

The suite-wide AI agent is **two-phase by design**: `/plan` interprets a
natural-language request into a validated, cost-estimated AgentPlan with **zero
side effects**; `/execute` runs an approved plan atomically. All agent routes
are **session-only** (`requireUser`).

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| POST | `/api/agent/plan` | Session (30/min) | Interpret a request. Body: `{ message, compositionId? \| state?, context?, policy? }`. The server binds fuzzy references against the live composition, assembles a grounding bundle (composition summary, brand voice, connected accounts, credits, KB hits, recent-run transcript — best-effort), and resolves the plan-feasibility tier **server-side** (a client can't fake Pro for tier-locked verbs like upscale/publish; a publish plan also checks a live social connection). Returns the plan + `valid`/`errors`, a `safety` block (KB docs carrying injection-shaped content are excluded from the prompt and flagged `kb:*`), and a `preview` — a zero-side-effect dry run of the projected diff + plain-English change list. `policy` may **downgrade** the caller's own tier (`readonly`/`editor`); it can never escalate. |
| POST | `/api/agent/execute` | Session (30/min) | Execute an approved plan. Body: `{ plan: { steps: […] }, compositionId? \| state?, approved: true, confirm?, policy?, approvalPolicy?, context? }`. The untrusted client plan is re-normalized; for a stored composition, references are re-bound against the **server's** manifest (never `body.state`). Engine steps compile to one batch persisted through the same row-locked path as `/api/json`. An audit row opens before execution; live progress streams over the composition channel. Success: `{ ok, runId, steps, summary, persisted: true }` (stored) or `{ ok, runId, steps, summary, state, changed }` (inline). Failure: the error body carries `runId` and, when exposable, a structured `failure` report (which step, why, suggested fixes); partially-run metered steps are recorded so the ledger never under-reports. `confirm: true` is required for bulk/irreversible steps; `approvalPolicy` is `auto`/`guided`/`manual` (generate + publish are always gated). |
| POST | `/api/agent/ask` | Session (60/min) | Read-only "ask the project" Q&A: `{ question, compositionId? \| state? }` → an instant computed answer from engine queries/aggregates + run history. No mutation is possible by construction. Unsupported questions return `ok: false` (fall back to `/plan`). |
| POST | `/api/agent/plan-to-recipe` | Session (20/min) | Convert an approved one-off plan into a reusable, parameterized recipe. Body: `{ plan, name }`. Generative steps convert with `{{params.slot}}` prompts; composition-bound engine edits are reported `skipped`. Returns `{ ok, recipe: { id, name, slug }, slots, skipped }`. |
| GET | `/api/agent/runs` | Session | Auditable run ledger (workspace-scoped metadata). Query: `?compositionId=` · `?limit=`. |
| GET | `/api/agent/runs/:id` | Session | Full plan + per-step record for one run (`404` across workspaces). |
| GET | `/api/agent/tools` | Session | The canonical tool registry — every capability the agent can reach, with typed params + cost/reversible/class. `?format=functions` returns the function-declarations shape for a model tool-use loop. |
| GET | `/api/agent/compositions/:id/events` | Session (20 opens/min) | SSE live-progress stream for agent runs on a composition: `agent_plan` → `agent_step` → `agent_done` / `agent_error` (event names are dotless). Ownership 404s **before** subscribing. |

---

## Generation, director & render

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| POST | `/api/generate` | Key/Session · 20/min · meters the routed provider (`usage:veo`/`kling`/`wan`/`hailuo`/`seedance`/`corereflex*`) | Enqueue a video generation (spectrum-routed — billed per the provider that actually runs, not a flat `usage:veo`). **`202`** + `{ jobId, estimate, … }`; poll the job. A premium Veo run over the $ threshold needs `confirmPremium: true`. |
| GET | `/api/generate/:id` | Key/Session | Poll generation job status + artifact URLs. |
| POST | `/api/director/plan` | Session · 20/min | Agentic multi-shot film planning from a brief (`{ concept, … }`). Biased by this workspace's vote-learning (`getPreferenceBrief`) plus pgvector semantic recall of the nearest upvoted prior prompts and public, shared-learning-opted-in community winners, and grounded on the caller's own KB (the workspace id is session-derived — a client can never point grounding at another tenant). Returns the plan synchronously (`200`). |
| POST | `/api/director/produce` | Key/Session · 6/min · meters `usage:veo` **× shots** | **The flagship agentic loop: PRODUCE → CRITIQUE → ASSEMBLE.** Accepts a ready `{ plan: { shots: […] } }` or a `{ concept }` to plan first. A hand-crafted plan is clamped to `MAX_SHOTS`, then metered **once per shot** up front. PRODUCE executes each shot (shots 2..n chain the previous shot's last frame for continuity); CRITIQUE scores each shot and selectively regenerates ones that miss the QA bar; ASSEMBLE emits an editor/Remotion composition manifest. Synchronous `200` with `{ title, aspectRatio, shots, assembly, summary }`. **Long-running** — browser surfaces do client-side plan→generate→poll→assemble instead of calling this route directly. |
| POST | `/api/shorts` | Session · meters `usage:render` | Repurpose a finished film into a vertical short. Body: `{ compositionId, aspect?, codec? }` (`aspect` ∈ `9:16`/`1:1`/`16:9`, default `9:16`). A pure manifest transform — one uniform center-cover reframe into a new composition — then enqueues a render. **`202`** + `{ jobId, compositionId, aspect }`. |
| POST | `/api/render` | Key/Session · meters `usage:render` | Enqueue a Remotion render of a composition to MP4 on the self-hosted render worker. **`202`** + `{ jobId, … }`. Gated on `RENDER_ENABLED=true` (set in production — render is live); when the flag is off the route returns a clear `503` instead of hanging. |
| GET | `/api/render/:id` | Key/Session | Poll render job status + output asset URL. |

> **Render engine.** Export runs as a `@remotion/renderer` HTTP service on the
> fleet, behind the `src/render-engine.js` adapter (`RENDER_WORKER_URL`), and is
> deployed and serving in production. Finished renders land in storage and the
> Media browser automatically (the worker inserts the `assets` row on success).

---

## Auto-Clips & highlights

The long-form → shorts pipeline: detect the best moments in a source video, cut
N vertical shorts, style captions, optionally narrate, and publish. The studio
UI at `/auto-clips` drives exactly these routes client-side
(prepare → detect → select → captions → generate → poll).

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| POST | `/api/highlights/detect` | Key/Session (12/min) · meters `usage:content` | Video-native highlight detection: the model watches the whole video and returns ranked, non-overlapping, type-diverse moments to cut into shorts. Body: `{ videoUrl, … }` — a public `https://` URL (SSRF-guarded, workspace-bound if it's our proxy) or a `gs://` URI (read by Vertex directly). |
| POST | `/api/highlights/proxy` | Key/Session (6/min) · meters `usage:content` | Long-video prepare: the render worker builds a small **time-preserving** low-res proxy (timestamps map 1:1 to the source) so hour-class sources fit the analysis window. Body: `{ sourceUrl, fps?, height? }`. Returns a signed-proxy derivative descriptor whose URL feeds `/detect`. `503` (exposed) unless `CLIP_PROXY_URL` is set (or `CLIP_PROXY_MOCK=true`). |
| POST | `/api/auto-clips` | Key/Session (8/min) · meters `usage:render` **× clips** | Cut N vertical shorts from a source + detected highlights. Body: `{ source: { url, width?, height?, fps?, hasAudioTrack?, sizeBytes?, durationMs? }, highlights: [{ startMs, endMs, title, type, score, captions?, voiceover? }], aspect?, codec?, duckDb? }`. Each highlight becomes a trimmed + center-cover-reframed single-clip composition and an ordinary render job (renders land in the Media browser automatically). Batch cap `AUTO_CLIPS_MAX` (default 10, `AUTOCLIPS_BATCH_MAX`, hard max 50). Attached `captions` burn in on their own track; a `voiceover` is mixed in with the source ducked (−10.5 dB). **`202`** + `{ clips: [{ jobId, compositionId, startMs, endMs, title, type, score, aspect }], count, aspect }`. |
| POST | `/api/auto-clips/story` | Key/Session (12/min) · meters `usage:content` | Story mode: generate **one coherent narrative arc** across all N selected clips. The body carries only the clips' metadata (no storage access → no IDOR surface). Each segment's narration feeds that clip's captions + voiceover client-side. |
| POST | `/api/auto-clips/voiceover` | Key/Session (30/min) · meters `usage:voice` | Synthesize a persona voiceover for one clip (the caption persona resolves the voice description). Returns `{ audioUrl, style }` for attaching as `highlight.voiceover`. |
| POST | `/api/auto-clips/publish` | Key/Session (6/min) | Schedule finished auto-clip shorts to the workspace's connected social accounts, staggered across a cadence. Reuses the social publish path (which meters `usage:publish` per clip and audits each post). Returns `{ scheduled, failed, results }`. |

---

## Captions

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| POST | `/api/captions` | Key/Session | Transcribe a stored asset via speech-to-text. Body: `{ fileKey, … }` — the key must be in the caller's workspace (`403` otherwise). Returns Remotion-ready captions (`{ startMs, endMs, text }` segments with word timings). `503` (exposed) if the Vertex STT service account isn't configured. |
| POST | `/api/captions/generate` | Key/Session (12/min) · meters `usage:content` | AI-caption **generation**: the model watches a clip and invents persona-styled captions for footage with no useful speech. Body: `{ videoUrl, style?, … }` (same URL rules as `/api/highlights/detect`). Styles: `gaming`, `dramatic`, `funny`, `minimal`, `genz`, `story_news`, `story_roast`, `story_creepypasta`, `story_dramatic`, or `auto` (maps the detected moment type to the best-fit persona). Output is the same `{ startMs, endMs, text }` shape as `/api/captions`. |
| POST | `/api/captions/translate` | Key/Session · meters `usage:content` | Multi-language captions: segment-level translation with word timings redistributed on the source timeline. The body carries the captions themselves (no storage access). |
| POST | `/api/captions/highlights` | Key/Session · meters `usage:content` | Long-to-short clipping from a **transcript**: rank the best standalone moments. The body carries the captions themselves. |

---

## Image tools, upscale & enhance

The worker-backed tools (`remove-bg`, `vectorize`, `upscale`, `audio/fx`,
`highlights/proxy`) share a pattern: SSRF-guarded `sourceUrl`, an **honesty gate
before metering** (a clear `503` when the worker env var is unset — never a
masked 500, never a charge), and the result returned as a signed-proxy
**sibling** derivative (the source is never overwritten). Each has a
`*_MOCK=true` passthrough for testing without the worker.

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| POST | `/api/imagen` | Key/Session · meters `usage:imagen` | Generate a still image. Synchronous `200`. |
| POST | `/api/logo/options` | Key/Session · meters `usage:imagen` × delivered | Staged logo workflow: generate N distinct flat logo **concepts** to pick from. Meters the *delivered* concept count (1–4), not the requested one — one image is pre-gated, the remainder reconciled after generation. Treatment derivatives (grayscale/stamp/reverse) are built editor-side. |
| POST | `/api/image/edit` | Key/Session (10/min) · meters `usage:imagen-edit` | AI mask editing: generative fill / erase / expand-outpaint. The body carries the working image + painted mask as base64 (12 MiB cap; no storage fetch → no SSRF surface). Result lands as a sibling asset. |
| POST | `/api/image/assist` | Key/Session · meters `usage:content` | Conversational canvas edits: one instruction + design/node context → a structured action (`adjust` / `fill` / `erase` / `expand` / `none`) the editor applies client-side. Text-only. |
| POST | `/api/image/remove-bg` | Key/Session (20/min) · meters `usage:remove-bg` | Background removal: subject segmentation + alpha composite on the render worker; returns a transparent-PNG sibling asset. `503` unless `REMOVE_BG_URL` (or `BG_REMOVE_MOCK=true`). |
| POST | `/api/image/vectorize` | Key/Session (20/min) · meters `usage:vectorize` | Trace a raster logo to a clean SVG on the render worker; returns a sibling SVG asset. `503` unless `VECTORIZE_URL` (or `VECTORIZE_MOCK=true`). |
| POST | `/api/image/subject-box` | Key/Session (30/min) · meters `usage:content` | Auto-reframe: one frame in (base64, 12 MiB cap) → the main subject's bounding box out (normalized 0..1). The editor keeps that box centered when converting a clip to a new aspect. |
| POST | `/api/upscale` | Key/Session (20/min) · meters `usage:upscale` | Own-tech video upscale to a target tier; auto mode lifts sub-1080 sources to 1080. Body: `{ sourceUrl, target?, maxTier? }`. Returns the sibling derivative. `503` unless `UPSCALE_URL` (or `UPSCALE_MOCK=true`). |
| POST | `/api/audio/fx` | Key/Session (30/min) · meters `usage:audio-fx` | Per-clip audio FX rack (compress/limit/reverb/…): real DSP on the render worker. Body: `{ sourceUrl, audioFx }`. Returns the processed-derivative descriptor the editor stores on the clip. `503` unless `AUDIO_FX_URL` (or `AUDIO_FX_MOCK=true`). |
| POST | `/api/enhance` | Key/Session | Prompt enhancement — a pre-reflection rewrite any studio surface can call. Body: `{ input, taskKind?, tone?, context? }`. Free (no metering). |

---

## Voice, music & soundscapes

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| GET | `/api/voice/voices` | Session | List available TTS voice profiles, plus an `engine` block (`{ selfHosted, voiceDesign, label }`) describing the configured backend's capabilities. |
| POST | `/api/voice` | Key/Session · meters `usage:voice` (self-hosted lane: `usage:voice-own`) | Synthesize narration. **Plain TTS only.** Body fields are whitelisted to `{ text, voice, speakingRate, model }`; any voice-clone fields are dropped. Pin `model` to a self-hosted engine (e.g. `"kokoro"`, when deployed) to run the CoreReflex Voice and bill the cheaper `usage:voice-own`; otherwise the vendor Chirp lane bills `usage:voice`. Voice **cloning** must go through the consent-gated persona route. Synchronous `200`. |
| POST | `/api/voice/design` | Key/Session · meters `usage:voice` | **Voice design** — `{ description, text }` → speech in a voice matching the plain-language description. Takes **no reference audio** (never clones a real person), so it's not consent-gated. The `design` block reports `method` (`engine`/`mapped`) + resolved traits. |
| GET/POST | `/api/voice/twilio` | Public (60/min) | Twilio Voice webhook. Verifies `X-Twilio-Signature` when `TWILIO_AUTH_TOKEN` is set, then returns TwiML that connects a Media Stream to `/api/voice/twilio/media`. Params may include `calendar` and `persona`. Over the limit → TwiML `<Reject reason="busy"/>`. |
| WS | `/api/voice/live` | Public (5/min/IP + global cap) | Browser real-time voice bridge used by `/voice`. Query: `?persona=<id>&calendar=<slug>`. Same-origin is always allowed; extra origins via `VOICE_ALLOWED_ORIGINS`. The server mints upstream tokens and runs tools server-side. |
| WS | `/api/voice/twilio/media` | Public (5/min/IP + global cap) | Twilio Media Streams bridge. Converts G.711 mulaw 8 kHz to PCM16, binds `calendar`, `persona`, `from`, and `callSid` into the tool/transcript context; shares the lifetime/idle caps of `/api/voice/live`. |
| GET | `/api/music/moods` | Session | List music mood presets. |
| POST | `/api/music` | Key/Session · meters `usage:lyria` | Generate a music track. Synchronous `200` (the model returns inline). |
| GET | `/api/soundscapes` | Session | List procedural soundscape presets (rain / wind / forest / ocean / city). |
| POST | `/api/soundscape` | Key/Session | Generate a procedural ambient soundscape (synthesized in-process — free, no metering). Body: `{ prompt, duration?, … }`. |

---

## Meeting briefs

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| POST | `/api/meeting/summarize` | Session | Summarize a transcript to a brief. Returns `{ summary, actionItems, decisions, chapters, followups, briefId }`. |
| POST | `/api/meeting/from-asset` | Session | Transcribe an asset (by `assetKey`, workspace-bound) then summarize — same brief shape. |
| GET | `/api/meeting/briefs` | **Pro** | List persisted briefs, optionally `?contactId=`. (Briefs persist into the CRM, which is Pro-gated.) |

Brief persistence is best-effort and Pro-gated: the AI inference always returns,
but `briefId` is non-null only when the workspace is Pro (or
`CLIENT_OPS_ENABLED`). Passing `createTasks: true` (with a `contactId`) seeds
the action items into tasks, and a successful persist fires any
`meeting_briefed` automations — both best-effort, never blocking the response.

---

## Gallery, votes & import

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| GET | `/api/gallery` | Public (optional session) | Public showcase feed. A valid session marks `mine`/`myVote` on items; a stale cookie is caught so it can't break the page. `?limit=` (default 24). |
| GET | `/api/gallery/mine` | Session | Workspace's own recent clips, including private. `?limit=`. |
| POST | `/api/gallery/:clipId/vote` | Session | Cast a vote. Body `{ value }`, clamped to `-1` / `0` / `1` (`0` retracts). Returns `{ votes, myVote }`. |
| POST | `/api/import` | Session | Import media from an external URL (Drive/OneDrive/Dropbox/direct URL) into storage; returns a playback URL. |

---

## Recipes & autopilot

Programmable creative workflows — the intent-wizard + autopilot engine
(`src/recipes.js`). A recipe is a parameterized JSON spec whose runs fan out
generation + render jobs, with optional human checkpoints. **Scheduling** rides
`/api/automations` (a `schedule` trigger wrapping a `run_recipe` action); the
routes here surface those schedules read-only.

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| GET | `/api/recipes/starters` | Session | The bundled starter recipe templates. |
| GET | `/api/recipes` | Session | List the workspace's recipes. |
| POST | `/api/recipes` | Session | Create a recipe. `201`. Body: `{ name, intent?, spec }`. |
| POST | `/api/recipes/cost-estimate` | Session | Pre-flight cost forecast for a saved recipe (`{ recipeId, params? }`) or an unsaved `{ spec }`. Returns `{ estimate: { creditsLow, creditsHigh }, billingEnabled, balance }`. |
| GET | `/api/recipes/schedules` | Session | The workspace's scheduled recipes (run_recipe schedule automations). |
| GET | `/api/recipes/schedules/:id/runs` | Session | A schedule's run history. |
| GET | `/api/recipes/:id/runs` | Session | Run history for one recipe. `?batch=` narrows to one bulk batch (progress polling). |
| GET | `/api/recipes/:id/runs/:runId` | Session | One run's detail (steps, outputs, checkpoint state). |
| POST | `/api/recipes/:id/runs/:runId/approve` | Session | Approve a run paused at a human checkpoint. |
| POST | `/api/recipes/:id/runs/:runId/send-back` | Session | Send a checkpointed run back. Body: `{ note? }`. |
| POST | `/api/recipes/:id/run` | Session (12/min) | Start a run. Body: `{ params }`. **`202`**. |
| POST | `/api/recipes/:id/bulk` | Session (3/min, 2 MiB) | Bulk create: one recipe × N CSV rows (≤ 200). Body: `{ csv }` (raw text — the server is the only CSV parser) or `{ rows: […] }`, plus `params?`, `concurrency?`. With `dryRun: true`: `200` with `{ headers, total, sample, perRow, batch, billingEnabled, balance, affordable }` — parses + prices without running anything. Otherwise **`202`** `{ batchId, total, status }`; a bounded background pool drains the rows. |
| GET | `/api/recipes/:id` | Session | Recipe detail. |
| DELETE | `/api/recipes/:id` | Session | Delete a recipe. |

---

## Writing & content (Write pillar)

Templated copywriting that produces a structured `{ blocks }` document and
hands off to Script / Slides / file export. Copy defaults to the workspace
brand voice when a kit is saved.

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| GET | `/api/content/templates` | Session | List content templates (bundled golden + user-authored): `{ templates: […] }`. |
| GET | `/api/content/templates/:id` | Session | Full template (variables, prompt, settings); unknown id → `404`. |
| POST | `/api/content/write` | Session (30/min) · meters `usage:content` | Generate a document. Body: `{ templateId?, values?, prompt?, tone?, audience?, length? }` → `{ title, blocks, templateId }`. `503` (exposed) if the AI backend is unconfigured. |
| POST | `/api/content/rewrite` | Session (30/min) · meters `usage:content` | Rewrite/refine existing text → `{ title, blocks }`. |
| POST | `/api/content/score` | Session | Quality-score a template/draft (4-pillar + slot/moderation checks). |
| GET | `/api/content/documents` | Session | List saved documents (workspace-scoped). |
| POST | `/api/content/documents` | Session | Save a document. `201`. |
| GET | `/api/content/documents/:id` | Session | Document detail (`body.blocks`). |
| PATCH/PUT | `/api/content/documents/:id` | Session | Update title / body / status. |
| DELETE | `/api/content/documents/:id` | Session | Delete a workspace-owned document. |

> The Slides / Script handoffs and `.docx`/`.md`/`.txt`/PDF export are
> **editor-side** (client-built files + the shared composition store), not API
> routes.

---

## Brand kit, theme, brands & brand voice

### Workspace brand kit

The one promoted workspace default the recipe/Write engine reads to keep
generated copy + images on-brand. Per-project kits live in the editor manifest.

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| GET | `/api/brand-kit` | Session | `{ brandKit: { name, voice, voiceText, colors, typography } \| null }`. |
| PUT/POST | `/api/brand-kit` | Session | Promote a kit (atomic upsert, one row per workspace). Accepts the editor shape (`palette`/`fonts`/voice-string) or the table shape. |
| POST | `/api/brand-voice` | Session | Synthesize a reusable brand-voice system instruction from a brand profile. |

### Workspace UI theme (white-label)

The workspace's saved `{ preset, overrides }` theme. Members load it on
sign-in; the server's HTML handler also injects the visitor's `cr-theme` cookie
server-side for a zero-flash first paint.

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| GET | `/api/theme` | Session | `{ theme }`. |
| PUT/POST | `/api/theme` | Session | Save. Body: `{ theme: {…} }` or the bare theme object. |

### Brands (Account → Brand → Project cascade)

A Brand is a living container of individually-approved assets; a Project
(workspace) links to one and inherits its approved kit. Pro-gating is enforced
inside `src/brands.js` / `src/brand-generate.js`.

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| GET | `/api/brands` | Session (Pro inside) | List the account's brands. |
| POST | `/api/brands` | Session (Pro inside) | Create a brand. `201`. |
| GET | `/api/brands/storage` | Session | Account storage usage (plan allowance + purchased bundles vs. bytes used). |
| POST/PUT | `/api/brands/link` | Session | Link/unlink the current workspace to a brand. Body: `{ brandId }` (`null` unlinks). |
| POST | `/api/brands/:id/generate` | Session (Pro inside) | Generate a draft brand asset in-platform. **`202`**. |
| POST | `/api/brands/:id/assets` | Session (Pro inside) | Add an asset (draft). `201`. |
| POST | `/api/brands/:id/assets/:assetId/approve` | Session (Pro inside) | Approve a draft → it cascades to linked projects. |
| DELETE | `/api/brands/:id/assets/:assetId` | Session (Pro inside) | Archive an asset. |
| GET | `/api/brands/:id` | Session (Pro inside) | Brand detail incl. assets + approval state. |

---

## Knowledge base

Workspace-scoped RAG documents — grounding for the voice agent, director
planning, chat, and the suite agent.

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| POST | `/api/kb` | Session | Ingest a document (chunk + embed). Body: `{ title, content, … }`. |
| GET | `/api/kb` | Session | List the workspace's docs. |
| DELETE | `/api/kb` | Session | Delete a doc **by title**. Body: `{ title }`. |
| GET | `/api/kb/search` | Session · **Pro** | Semantic search: `?q=` → `{ results }`. (`403 "The knowledge base is a Pro feature — upgrade to enable"` when gated.) |

---

## CRM — contacts, activity, tags

All CRM routes are **Pro-gated** and workspace-scoped. Contact email is unique
per workspace (case-insensitive). Deletes are GDPR hard-deletes that cascade
activity, tags, and briefs.

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| POST | `/api/crm/contacts` | **Pro** | Create a contact. `201`. Body: `{ name, email?, phone?, company?, status?, ownerId?, source? }`. |
| GET | `/api/crm/contacts` | **Pro** | List contacts. Filters: `?status=`, `?q=` (name/email/company search), `?limit=` (default 50). |
| GET | `/api/crm/contacts/:id` | **Pro** | Contact detail + recent activity timeline + tags. |
| PUT | `/api/crm/contacts/:id` | **Pro** | Update contact fields. |
| DELETE | `/api/crm/contacts/:id` | **Pro** | GDPR hard-delete (cascades). Returns `{ deleted: id }`. |
| GET | `/api/crm/contacts/:id/export` | **Pro** | GDPR export bundle: `{ exportedAt, contact, meetingBriefs }`. |
| POST | `/api/crm/contacts/:id/activity` | **Pro** | Append an activity entry (`note`/`meeting_brief`/`task`/`booking`/`review`). `201`. |
| POST | `/api/crm/contacts/:id/tags` | **Pro** | Attach/detach a tag. Body `{ tagId, attach? }` (default attach). |
| POST | `/api/crm/tags` | **Pro** | Create (upsert by workspace+name) a tag. |
| GET | `/api/crm/tags` | **Pro** | List tags. |
| PUT | `/api/crm/tags/:id` | **Pro** | Rename/recolor a tag. Duplicate names → `409`. |
| DELETE | `/api/crm/tags/:id` | **Pro** | Delete a tag. Contact-tag joins cascade. |

---

## Tasks

**Pro-gated**, workspace-scoped. Tasks can reference a contact and/or campaign
(both nullable).

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| POST | `/api/tasks` | **Pro** | Create a task. `201`. Body: `{ title, notes?, status?, assigneeId?, contactId?, campaignId?, dueAt?, source? }`. |
| GET | `/api/tasks` | **Pro** | List tasks. Filters: `?status=`, `?assigneeId=`, `?contactId=`, `?campaignId=`. |
| PUT | `/api/tasks/:id` | **Pro** | Update a task. |
| DELETE | `/api/tasks/:id` | **Pro** | Delete a task. Returns `{ deleted: id }`. |

---

## Booking & scheduling

The booking surface is split: the **public** endpoints (slots / book / ics) take
no auth and are rate-limited per client IP; the **management** endpoints are
Pro-gated and workspace-scoped. The public endpoints never leak the assigned
member's identity. The white-label scheduler page is served at `/book/:slug`.

### Public (no auth, rate-limited)

| Method | Path | Auth | Limit | Purpose |
|--------|------|------|-------|---------|
| GET | `/api/calendars/:slug/slots` | Public | 60/min/IP | Available slots for a date: `?date=YYYY-MM-DD`. Returns `{ calendar: { name, description, durationMin, timezone, locationType }, date, slots }`. |
| POST | `/api/calendars/:slug/book` | Public | 10/min/IP | Create a booking. Body: `{ startsAt, name, email, phone?, notes? }`. Serializes per-assignee via a Postgres advisory lock and re-checks the slot; a taken slot → `409`. Upserts the booker as a CRM contact, fires `booking_created` automations. Returns `{ id, token, startsAt, endsAt, calendar }`. |
| GET | `/api/bookings/:token/ics` | Public (token) | — | Download the booking as an `.ics` file (`text/calendar`, attachment). Unknown token → `404`. |

The booking `slug` is **globally unique** (not workspace-scoped) so a public
`/book/:slug` page can resolve without a tenant in the path.

### Management (Pro, workspace-scoped)

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| POST | `/api/calendars` | **Pro** | Create a bookable calendar (sets the slug). `201`. |
| GET | `/api/calendars` | **Pro** | List calendars. |
| PUT | `/api/calendars/:id` | **Pro** | Update name, slug, timing buffers, timezone, location, assignee, `isActive`. Duplicate slugs → `409`. |
| DELETE | `/api/calendars/:id` | **Pro** | Delete a booking link. Historical events remain; booking rows cascade. |
| POST | `/api/availability` | **Pro** | Set member working hours. Body: `{ userId?, windows: [{ weekday, startMin, endMin }] }`. |
| GET | `/api/availability` | **Pro** | Fetch availability windows (`?userId=` optional). |
| GET | `/api/calendar/events` | **Pro** | List events in a date range: `?from=`, `?to=`. |

---

## Automations & planners

**Pro-gated** event→action engine. An automation is a JSON node/edge graph with
a system `triggerType` (e.g. `booking_created`, `meeting_briefed`,
`campaign_approved`, `payment_completed`, `schedule`). Triggers fire inline from
domain events and are best-effort (a failing action never breaks the event that
fired it). A `schedule` trigger is driven by the in-process scheduler tick loop
(`AUTOMATION_SCHEDULER`, on by default) — this is what powers recipe autopilot.

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| POST | `/api/automations` | **Pro** | Create an automation. `201`. Body: `{ name, slug?, status?, triggerType, triggerConfig?, graph: { nodes, edges } }`. |
| GET | `/api/automations` | **Pro** | List automations. |
| GET | `/api/automations/:id` | **Pro** | Automation detail + recent runs. |
| PUT | `/api/automations/:id` | **Pro** | Update an automation. |
| DELETE | `/api/automations/:id` | **Pro** | Delete an automation. Returns `{ deleted: id }`. |
| POST | `/api/automations/:id/run` | **Pro** | Manually trigger with `{ data: {…} }`. Returns `{ ran: id }`. |
| POST | `/api/automations/plan` | Session | **Workflow planner**: a plain-English goal becomes a ready-to-create automation graph. Body: `{ goal }`. |
| POST | `/api/campaign/plan` | Session | **Campaign planner**: a brief becomes a multi-asset, multi-channel recipe list. |

---

## Campaigns

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| POST | `/api/campaigns/:id/approve` | Session · `approve_campaign` | Move a campaign from `draft`/`review` to `approved`. Gated by the workspace `approve_campaign` permission (owner + reviewer roles — others → `403`). State-guarded and workspace-scoped: a second approval, or approving an already-rendering/launched campaign, is a `404`. Fires the `campaign_approved` automation trigger best-effort. Returns `{ id, name, status }`. |

---

## Personas

**Pro** and **consent-gated**: a persona can never be rendered unless
`consent_status` is `granted`. Revoking consent disables the persona.

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| GET | `/api/personas/engines` | Session | Non-secret readiness report for the voice/lip-sync engines (live vs mock, whether an e2e smoke can run). Never returns configured URLs or credentials. |
| POST | `/api/personas` | **Pro** | Create a video-clone persona. `201`. |
| GET | `/api/personas` | **Pro** | List personas. |
| GET | `/api/personas/:id` | **Pro** | Persona detail incl. consent status. |
| PUT | `/api/personas/:id` | **Pro** | Update name, status, visibility, or voice binding. |
| DELETE | `/api/personas/:id` | **Pro** | Delete a persona. Reference media cascades; scripts referencing it are set null by the FK. |
| POST | `/api/personas/:id/consent` | **Pro** | Grant likeness/voice render consent. Body requires `{ agree: true }` plus the attestation; records who attested, when, and the scope. |
| DELETE | `/api/personas/:id/consent` | **Pro** | Revoke consent (sets the persona `disabled`). |
| POST | `/api/personas/:id/reference` | **Pro** | Add reference media for training. `201`. |
| POST | `/api/personas/:id/clip` | **Pro** · meters `usage:persona` | Render a clip of the persona speaking a script. Rendering without `granted` consent → `403` (exposed). |
| POST | `/api/personas/:id/voice-clone` | **Pro** | Clone the persona's **voice** so `/clip` speaks in their own voice. Consent-gated. Self-hosted mode stores an SSRF-guarded public `https:` `referenceAudioUrl`; hosted mode mints a cloning key from base64 `referenceAudio` + `consentAudio` and requires `VOICE_CLONE_ENABLED`. See [own-the-stack.md](own-the-stack.md). |

> **Voice cloning** is a consent-gated seam: this route is the **only** way to
> do a cloned voice — the plain `/api/voice` TTS route drops any voice-clone
> fields a caller sends.

---

## Scripts

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| POST | `/api/scripts/draft` | Session | Draft a natural, spoken-voice script from a brief (or rewrite). Returns the draft (no persistence). |
| POST | `/api/scripts` | Session | Create a script project. `201`. |
| GET | `/api/scripts` | Session | List scripts (workspace-scoped). |
| GET | `/api/scripts/:id` | Session | Script detail. |
| PUT | `/api/scripts/:id` | Session | Update a script. |
| DELETE | `/api/scripts/:id` | Session | Delete a script project. |

---

## Proofing & approvals (Ashore)

Share a deliverable for client sign-off over a tokenized link. Owner routes are
workspace-scoped + **Pro-gated** (enforced inside `src/proofing.js`); the public
review routes take **no auth** — the unguessable `pr_`-prefixed token is the
grant, and they never expose asset keys.

### Owner (Session, Pro)

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| GET | `/api/proofs` | Session (Pro) | List proofs with version + open-comment counts. |
| POST | `/api/proofs` | Session (Pro) | Create a proof (optional inline first `version`). `201`. |
| GET | `/api/proofs/:id` | Session (Pro) | Detail: versions, reviews (with `/proof/<token>` urls), comments, decisions. |
| POST | `/api/proofs/:id/versions` | Session (Pro) | Add a version (`sourceKind` = `asset`/`document`/`url`); reopens `in_review`. `201`. |
| POST | `/api/proofs/:id/share` | Session (Pro) | Mint a tokenized review link → `{ token, url }`. `201`. |
| POST | `/api/proofs/:id/comments` | Session (Pro) | Owner-side comment. `201`. |
| POST | `/api/proofs/comments/:id/resolve` | Session (Pro) | Resolve a comment (workspace-scoped). |

### Public (no auth, token-scoped)

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| GET | `/api/proofs/review/:token` | Public | Client-safe proof payload (versions with inlined documents, comments, decisions); stamps `last_seen_at`. Unknown token → `404`. |
| POST | `/api/proofs/review/:token/comments` | Public | Client comment (attributed to the review). `201`. |
| POST | `/api/proofs/review/:token/decision` | Public | Approve / request changes on a version → flips proof status. |
| GET | `/api/proofs/review/:token/asset/:versionId` | Public | Short-lived presigned URL for an asset version's preview (never exposes the object key). |

The public client surface is served at `/proof/:token` (`public/proof.html`).

---

## Social publishing

Relay renders/assets to the workspace's connected social platforms via a
GoHighLevel account connection. The API key is encrypted at rest and never
returned; egress is rate-limited per workspace.

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| GET | `/api/social/connection` | Session | Connection status (`{ connected, … }`). |
| POST | `/api/social/connection` | Session (10/min) | Connect. Body: `{ apiKey, locationId, label? }`. |
| DELETE | `/api/social/connection` | Session | Disconnect. |
| GET | `/api/social/accounts` | Session (30/min) | The connected platforms/accounts available to publish to. |
| GET | `/api/social/posts` | Session | List relayed posts. |
| POST | `/api/social/posts` | Session (20/min) · meters `usage:publish` | Publish now or schedule. Meters one `usage:publish` credit **before** egress and audits each post. `201`. |

---

## Team

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| GET | `/api/team/members` | Session | `{ members, roles, canManage }` — the workspace roster, the assignable roles (`owner`/`producer`/`reviewer`/`client`), and whether the caller may manage members. |
| POST | `/api/team/invites` | Session (manager) | Add a member **by existing account email**. Body: `{ email, role }`. If no CoreReflex account exists for the email, returns `{ invited: false, status: "needs_account", … }` (no email is sent — ask them to sign up first). Already a member → `409`. Success: `201` `{ invited: true, member }`. |
| PUT | `/api/team/members/:id` | Session (manager) | Change a member's role. |
| DELETE | `/api/team/members/:id` | Session (manager) | Remove a member from the workspace. |

---

## Analytics & leads

First-party analytics: an anonymous public beacon plus an owner/admin summary.
Server-derived identity (ip hash, user agent, workspace/account) overrides
anything the client sends; a failed insert never errors the caller.

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| POST | `/api/analytics/event` | Public (240/min/IP) | Record an event beacon. Always `202 {"ok":true}` (best-effort — analytics never breaks UX). A signed-in session attributes the event to the workspace/account. |
| GET | `/api/analytics/summary` | Session | Aggregate summary (`?days=`, clamped 1–365, default 30). App admins with `view_metrics` see **global** traffic incl. anonymous marketing visits; everyone else is scoped to their own workspace. |
| POST | `/api/leads` | Public (10/min/IP) | Marketing email capture. Body: `{ email, source?, path? }`. `201`. |
| GET | `/api/leads` | Session · app-admin | List captured leads (`?days=`, default 90; max 500 rows). Non-admins → `403 "Admins only"`. |

---

## Marketing content (blog, case studies, testimonials)

File-based, public, read-only. The server also renders these as SEO pages
(`/blog/:slug`, `/case-studies/:slug`); the JSON routes feed the client pages
and any external consumer.

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| GET | `/api/blog` | Public | With no params: the full listing (back-compat). With any of `?category=` / `?tag=` / `?q=` / `?page=` / `?limit=`: the paginated, filtered index. |
| GET | `/api/blog/:slug` | Public | One post. |
| GET | `/api/case-studies` | Public | List case studies. Empty until real approved customer stories exist under `content/case-studies/` — never fabricated. |
| GET | `/api/case-studies/:slug` | Public | One case study. |
| GET | `/api/testimonials` | Public | Attributable-only social proof from `content/testimonials/*.md`, featured-first. Empty until a real testimonial file exists — the home proof strip stays hidden when empty. |

---

## Prompt-spec registry & presets

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| GET | `/api/prompts` | Session | List the portable prompt-spec registry: `{ specs: […] }`. |
| GET | `/api/prompts/:specId` | Session | Fetch a single spec; unknown id → `404 {"error":"Unknown prompt spec"}`. |
| GET | `/api/presets` | Session | Curated visual style presets for the picker. `?kind=` (default `visual`). |

These are the versioned JSON director/model specs — see
[`../PROMPTING.md`](../PROMPTING.md) for the spec anatomy.

---

## Billing & Stripe

Billing is inert until configured: `STRIPE_SECRET_KEY` enables checkout/webhook,
`BILLING_ENABLED` enables credit metering. With neither set, `/api/billing`
reports the plan and zero credits, and the costly-AI routes never charge.

Plans (`src/billing.js`): `free` (watermarked, 5 exports/month, 0 included
credits, 1 GiB storage), `pro` (clean, 100 exports/month, 1,000 credits/month,
25 GiB), `enterprise` (private mode, unlimited exports, 10,000 credits/month,
500 GiB). Add-on bundles stack on any plan: +1,000 credits or +50 GiB storage
per purchase.

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| GET | `/api/billing` | Session | Entitlements: `{ plan, credits, subscriptionStatus, subscriptionPeriodEnd, billingEnabled, checkoutAvailable, features, clientOps }`. `clientOps` surfaces the Pro-gate state so the dashboard can show/hide the client-ops suite. The effective plan (owner override / preview tier) is preferred over the raw billed plan so the Plan page matches what the gates enforce. |
| POST | `/api/billing/checkout` | Session · verified email | Create a Stripe Checkout session (subscription). Body: `{ plan?, successUrl?, cancelUrl? }` (`plan` ∈ `pro`/`enterprise`). Returns `{ url, id }`. `503` (exposed) if Stripe or the plan's price isn't configured. |
| POST | `/api/billing/addon` | Session · verified email | One-time add-on purchase (payment mode). Body: `{ addon: "credits" \| "storage", successUrl?, cancelUrl? }`. Returns `{ url, id, addon }`. Applied idempotently by the webhook on the session id. |
| POST | `/api/billing/portal` | Session · verified email | Stripe Billing Portal session (payment method, plan change, invoices, cancellation). Body: `{ returnUrl? }`. Returns `{ url }`. `503` if Stripe isn't configured; `409` (exposed) if the account has no Stripe customer yet. |
| POST | `/api/billing/webhook` | Public (signature-verified) | Stripe webhook. Reads the **raw body** and verifies the HMAC `Stripe-Signature` — with a 300 s replay-tolerance window (`STRIPE_WEBHOOK_TOLERANCE_S`) — before parsing; a bad signature → `400`. Handles `checkout.session.completed` (plan activation + included credits, or add-on grants), `invoice.paid`/`invoice.payment_succeeded` (monthly refill), and `customer.subscription.created/updated/deleted`. Events are processed **exactly-once** (`processed_webhook_events`, marked only after success), and every grant is idempotent on the Stripe session/invoice id. |

The webhook is matched near the top of the ladder because it must consume the
raw bytes before any JSON parsing.

### Consumption-confirm settings

The granular controls behind the `402 premium_confirm_required` gate (migration
038). See [Billing](billing.md#consumption-confirmation-migration-038).

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| GET | `/api/settings/consumption` | Session | `{ workspace, user, effective }` — the per-workspace default, the per-user override (`null` = inherit), and the resolved effective flag. |
| PUT/POST | `/api/settings/consumption` | Session | Save. Body: `{ scope: "user" \| "workspace", requireConfirm }`. For `scope:"user"`, a null `requireConfirm` clears the override (inherit). **Turning it off is audit-logged** (`disable_paid_confirmation`). |

---

## Connections & editor health

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| GET | `/api/connections` | Session | Status of external service integrations (e.g. render worker, persona provider). |
| GET | `/api/editor/health` | Public | Server-side reachability probe of the editor SPA (see [Health](#health-and-build-version)). |

---

## Chat & support

The front-of-house chat widget (pre-sales + in-app support): grounded answers
streamed over SSE, human handoff, and ticket creation. Anonymous visitors are
tracked by a client-generated `sessionId`; signed-in users are attributed
automatically. Bodies are capped at 64 KiB; messages at 4,000 chars. User turns
are secret-redacted before persistence.

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| GET | `/api/chat/config` | Public | Widget bootstrap: `{ enabled, title, greeting, suggestedQuestions }`. `?context=dashboard` switches the suggested-question set. |
| POST | `/api/chat` | Public (12/min/IP) | Ask a question. Body: `{ message, conversationId?, sessionId?, contextType?, email?, name?, path? }`. Response is an **SSE stream**: `meta { conversationId, sessionId }` → `delta { content }`* → `done { conversationId, messageId, sources, grounded }`, or `error`. A volunteered `email` is captured as a lead best-effort. |
| GET | `/api/chat/conversations/:id/messages` | Scoped | Reload a transcript. Allowed for: an admin with `support_debug`, the conversation's owning user, or a caller presenting the matching `?sessionId=`. Others → `403`. |
| GET | `/api/chat/conversations/:id/stream` | Scoped (same rule) | SSE stream of live handoff events (human joined, live messages, resolution). |
| POST | `/api/chat/handoff` | Public (6/min/IP) | Request a human. Body: `{ conversationId? \| sessionId, contextType?, email?, name?, path? }` — creates/reuses the conversation. Returns `{ ok, conversationId, mode, notified }`. |
| POST | `/api/chat/conversations/:id/handoff` | Scoped | Request a human on an existing conversation. |
| POST | `/api/chat/conversations/:id/handoff/cancel` | Scoped | Cancel the human request. |
| POST | `/api/chat/conversations/:id/messages` | Scoped | Visitor live message during a human handoff. Body: `{ message, sessionId? }`. |
| POST | `/api/chat/conversations/:id/create-ticket` | Scoped | Create a support ticket from the conversation. Body: `{ subject?, priority?, category?, email? }`. Returns `{ ok, ticket }`. |
| POST | `/api/chat/feedback` | Public (30/min/IP) | Rate an assistant message. Body: `{ messageId, feedback }`. |

---

## Admin (God Mode)

Admin routes are handled by `routeAdminApi` (`src/admin-api.js`) with the
chat/support/self-heal family in `src/admin-chat-api.js`. They are gated by an
app-admin profile distinct from workspace roles: app-admins live in the
`app_admins` table with a `level` and a `privileges` array, and each route
requires a specific privilege — `view_metrics`, `manage_admins`,
`impersonate_users`, `support_debug`, or `god_mode`. A caller who is currently
impersonating someone is blocked from God Mode controls until they return to
their own session. All admin actions append to an immutable audit log.

### Console, admins & impersonation

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| GET | `/api/admin/overview` | Admin · `view_metrics` | The console payload: platform metrics, full profitability + unit-economics model (per-operation revenue vs. estimated vendor cost from real execution counts), usage/analytics summaries, operations/rollout status, admin list, recent users, audit log. |
| GET | `/api/admin/audit` | Admin · `view_metrics` | Recent durable audit events (`audit_events`, migration 038) — e.g. `disable_paid_confirmation` — for the God Console. `?limit=` (≤ 500, default 100). Empty pre-migration. |
| POST | `/api/admin/admins` | Admin · `manage_admins` | Upsert an app admin by `{ email, name?, level, privileges }` (`level` ∈ `admin`/`support`/`finance`/`ops`). **No self-escalation**: you cannot edit your own row, and you can only grant privileges you yourself hold; unknown privileges are sanitized out. |
| GET | `/api/admin/users` | Admin · `impersonate_users` | Search users for the switcher: `?q=` matches email/name/workspace (max 50). Rows are tier-labelled (`plan`) so the owner can spot lower-tier accounts to enter. |
| POST | `/api/admin/impersonate` | Admin · `impersonate_users` | Start an impersonation session for support. Body: `{ userId \| email, reason? }`. The minted session carries the actor's identity in its claims — impersonation is **not** invisible — and the action is audited. Sets the session cookie. |
| POST | `/api/admin/impersonation/stop` | Active impersonation | Exit impersonation and restore the original admin session. Sets the cookie back. |
| POST | `/api/admin/preview-plan` | Admin · `god_mode` · **platform owner only** | View-as-tier: re-mint the owner's own session carrying a preview-plan claim. Body: `{ plan: "free" \| "pro" \| "enterprise" \| "reset" \| null }`. Returns `{ token, previewPlan, plan }` + cookie; the previous session is revoked. |

### Live support chat

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| GET | `/api/admin/chat/stream` | Admin · `support_debug` | **SSE** — the global support channel (new handoffs, live messages) for the God Console inbox. |
| GET | `/api/admin/chat/settings` | Admin · `god_mode` | Widget/chat settings. |
| POST | `/api/admin/chat/settings` | Admin · `god_mode` | Update settings (body is a key→value map; an invalid setting → `400`). |
| GET | `/api/admin/chat/conversations` | Admin · `support_debug` | List conversations. `?mode=` · `?status=` · `?limit=` (≤ 200). Human-requested conversations sort first. |
| GET | `/api/admin/chat/conversations/:id` | Admin · `support_debug` | One conversation + up to 200 messages. |
| POST | `/api/admin/chat/conversations/:id/join` | Admin · `support_debug` | Take over as a live human agent (flips the mode, posts the join message). |
| POST | `/api/admin/chat/conversations/:id/messages` | Admin · `support_debug` | Send a staff message. Body: `{ message }` (≤ 4,000 chars). |
| POST | `/api/admin/chat/conversations/:id/resolve` | Admin · `support_debug` | Resolve the conversation (optionally with a closing note). |

### Support tickets & self-heal

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| GET | `/api/admin/support/tickets` | Admin · `support_debug` | List tickets. `?status=` · `?limit=`. |
| GET | `/api/admin/support/tickets/:id` | Admin · `support_debug` | The ticket's message thread. |
| POST | `/api/admin/support/tickets/:id` | Admin · `support_debug` | Update the ticket (status/priority/category…). |
| POST | `/api/admin/support/tickets/:id/messages` | Admin · `support_debug` | Add a staff reply. Body: `{ message }` (≤ 8,000 chars). |
| GET | `/api/admin/self-heal/queue` | Admin · `god_mode` | The self-healing queue: bug briefs distilled from support signals, each with severity/status and any resulting PR URL. |
| POST | `/api/admin/self-heal/:id/status` | Admin · `god_mode` | Update a brief's status. |

Anything else under `/api/admin/` returns `404`.

---

## Quick reference — full route index

Auth legend: **P** public · **S** session · **K** key or session · **Pro** Pro-gated · **A·x** admin privilege x.

| Method | Path | Auth |
|--------|------|------|
| GET | `/healthz` | P |
| GET | `/api/health` | P |
| GET | `/api/editor/health` | P |
| POST | `/api/auth/register` | P (5/min) |
| POST | `/api/auth/login` | P (10/min) |
| GET·PATCH | `/api/auth/me` | S |
| POST | `/api/auth/change-password` | S (10/min) |
| POST | `/api/auth/forgot` | P (5/min) |
| POST | `/api/auth/reset` | P (10/min) |
| POST | `/api/auth/verify` | P (20/min) |
| POST | `/api/auth/resend-verification` | S (3/min) |
| POST | `/api/auth/logout` | S (optional) |
| POST | `/api/assets/presign` | S |
| GET·HEAD·PUT | `/api/assets/:key` | Token/S (GET·HEAD also K by bare key) |
| GET | `/api/assets/:key/download` | S + ownership |
| GET | `/api/assets/:key/credentials` | P |
| DELETE | `/api/assets/:key` | S + ownership |
| GET | `/api/credentials/pubkey` | P |
| GET | `/api/files` | K |
| PATCH | `/api/files/:assetId/metadata` | K |
| GET·POST | `/api/collections` | K |
| POST | `/api/collections/:id/assets` | K |
| DELETE | `/api/collections/:id/assets/:assetId` | K |
| DELETE | `/api/collections/:id` | K |
| GET | `/api/media/search` | K (30/min) |
| GET | `/api/compositions` | S |
| GET·PUT·DELETE | `/api/compositions/:id` | S |
| GET | `/api/compositions/:id/schema` | K |
| POST | `/api/compositions/:id/engine` | K (120/min) |
| GET | `/api/compositions/:id/versions` | K |
| POST | `/api/compositions/:id/versions/:n/restore` | K |
| GET·POST | `/api/compositions/:id/comments` | S |
| GET | `/api/compositions/:id/comments/stream` | S (SSE) |
| POST | `/api/compositions/:id/comments/:commentId/resolve` | S |
| GET | `/api/json/schema` | K |
| POST | `/api/json` | K (120/min) |
| GET | `/api/catalog` | K |
| GET·POST | `/api/keys` | S only |
| DELETE | `/api/keys/:id` | S only |
| POST | `/api/agent/plan` | S (30/min) |
| POST | `/api/agent/execute` | S (30/min) |
| POST | `/api/agent/ask` | S (60/min) |
| POST | `/api/agent/plan-to-recipe` | S (20/min) |
| GET | `/api/agent/runs` · `/api/agent/runs/:id` | S |
| GET | `/api/agent/tools` | S |
| GET | `/api/agent/compositions/:id/events` | S (SSE) |
| POST | `/api/generate` | K (20/min · provider-routed) |
| GET | `/api/generate/:id` | K |
| POST | `/api/director/plan` | S (20/min) |
| POST | `/api/director/produce` | K (6/min · veo × shots) |
| POST | `/api/shorts` | S (render) |
| POST | `/api/render` | K (render) |
| GET | `/api/render/:id` | K |
| POST | `/api/auto-clips` | K (8/min · render × clips) |
| POST | `/api/auto-clips/story` | K (12/min · content) |
| POST | `/api/auto-clips/publish` | K (6/min) |
| POST | `/api/auto-clips/voiceover` | K (30/min · voice) |
| POST | `/api/highlights/detect` | K (12/min · content) |
| POST | `/api/highlights/proxy` | K (6/min · content) |
| POST | `/api/captions` | K |
| POST | `/api/captions/generate` | K (12/min · content) |
| POST | `/api/captions/translate` | K (content) |
| POST | `/api/captions/highlights` | K (content) |
| POST | `/api/imagen` | K (imagen) |
| POST | `/api/logo/options` | K (imagen × delivered) |
| POST | `/api/image/edit` | K (10/min · imagen-edit) |
| POST | `/api/image/assist` | K (content) |
| POST | `/api/image/remove-bg` | K (20/min · remove-bg) |
| POST | `/api/image/vectorize` | K (20/min · vectorize) |
| POST | `/api/image/subject-box` | K (30/min · content) |
| POST | `/api/upscale` | K (20/min · upscale) |
| POST | `/api/audio/fx` | K (30/min · audio-fx) |
| POST | `/api/enhance` | K |
| GET | `/api/voice/voices` | S |
| POST | `/api/voice` | K (voice) |
| POST | `/api/voice/design` | K (voice) |
| GET·POST | `/api/voice/twilio` | P (60/min) |
| WS | `/api/voice/live` | P (5/min/IP) |
| WS | `/api/voice/twilio/media` | P (5/min/IP) |
| GET | `/api/music/moods` | S |
| POST | `/api/music` | K (lyria) |
| GET | `/api/soundscapes` | S |
| POST | `/api/soundscape` | K |
| POST | `/api/meeting/summarize` · `/api/meeting/from-asset` | S |
| GET | `/api/meeting/briefs` | Pro |
| GET | `/api/gallery` | P (optional S) |
| GET | `/api/gallery/mine` | S |
| POST | `/api/gallery/:clipId/vote` | S |
| POST | `/api/import` | S |
| GET | `/api/recipes/starters` | S |
| GET·POST | `/api/recipes` | S |
| POST | `/api/recipes/cost-estimate` | S |
| GET | `/api/recipes/schedules` | S |
| GET | `/api/recipes/schedules/:id/runs` | S |
| GET | `/api/recipes/:id/runs` | S |
| GET | `/api/recipes/:id/runs/:runId` | S |
| POST | `/api/recipes/:id/runs/:runId/approve` · `/send-back` | S |
| POST | `/api/recipes/:id/run` | S (12/min) |
| POST | `/api/recipes/:id/bulk` | S (3/min) |
| GET·DELETE | `/api/recipes/:id` | S |
| GET | `/api/content/templates` · `/api/content/templates/:id` | S |
| POST | `/api/content/write` · `/api/content/rewrite` | S (30/min · content) |
| POST | `/api/content/score` | S |
| GET·POST | `/api/content/documents` | S |
| GET·PATCH·PUT·DELETE | `/api/content/documents/:id` | S |
| GET·PUT·POST | `/api/brand-kit` | S |
| POST | `/api/brand-voice` | S |
| GET·PUT·POST | `/api/theme` | S |
| GET·POST | `/api/brands` | S (Pro inside) |
| GET | `/api/brands/storage` | S |
| POST·PUT | `/api/brands/link` | S |
| POST | `/api/brands/:id/generate` | S (Pro inside) |
| POST | `/api/brands/:id/assets` | S (Pro inside) |
| POST | `/api/brands/:id/assets/:assetId/approve` | S (Pro inside) |
| DELETE | `/api/brands/:id/assets/:assetId` | S (Pro inside) |
| GET | `/api/brands/:id` | S (Pro inside) |
| POST·GET·DELETE | `/api/kb` | S |
| GET | `/api/kb/search` | Pro |
| POST·GET | `/api/crm/contacts` | Pro |
| GET·PUT·DELETE | `/api/crm/contacts/:id` | Pro |
| GET | `/api/crm/contacts/:id/export` | Pro |
| POST | `/api/crm/contacts/:id/activity` · `/tags` | Pro |
| POST·GET | `/api/crm/tags` | Pro |
| PUT·DELETE | `/api/crm/tags/:id` | Pro |
| POST·GET | `/api/tasks` | Pro |
| PUT·DELETE | `/api/tasks/:id` | Pro |
| GET | `/api/calendars/:slug/slots` | P (60/min) |
| POST | `/api/calendars/:slug/book` | P (10/min) |
| GET | `/api/bookings/:token/ics` | P (token) |
| POST·GET | `/api/calendars` | Pro |
| PUT·DELETE | `/api/calendars/:id` | Pro |
| POST·GET | `/api/availability` | Pro |
| GET | `/api/calendar/events` | Pro |
| POST·GET | `/api/automations` | Pro |
| GET·PUT·DELETE | `/api/automations/:id` | Pro |
| POST | `/api/automations/:id/run` | Pro |
| POST | `/api/automations/plan` | S |
| POST | `/api/campaign/plan` | S |
| POST | `/api/campaigns/:id/approve` | S · approve_campaign |
| GET | `/api/personas/engines` | S |
| POST·GET | `/api/personas` | Pro |
| GET·PUT·DELETE | `/api/personas/:id` | Pro |
| POST·DELETE | `/api/personas/:id/consent` | Pro |
| POST | `/api/personas/:id/reference` | Pro |
| POST | `/api/personas/:id/clip` | Pro (persona) |
| POST | `/api/personas/:id/voice-clone` | Pro |
| POST | `/api/scripts/draft` | S |
| POST·GET | `/api/scripts` | S |
| GET·PUT·DELETE | `/api/scripts/:id` | S |
| GET·POST | `/api/proofs` | S (Pro) |
| GET | `/api/proofs/:id` | S (Pro) |
| POST | `/api/proofs/:id/versions` · `/share` · `/comments` | S (Pro) |
| POST | `/api/proofs/comments/:id/resolve` | S (Pro) |
| GET | `/api/proofs/review/:token` | P |
| POST | `/api/proofs/review/:token/comments` · `/decision` | P |
| GET | `/api/proofs/review/:token/asset/:versionId` | P |
| GET·POST·DELETE | `/api/social/connection` | S |
| GET | `/api/social/accounts` | S (30/min) |
| GET·POST | `/api/social/posts` | S (publish on POST) |
| GET | `/api/team/members` | S |
| POST | `/api/team/invites` | S |
| PUT·DELETE | `/api/team/members/:id` | S |
| POST | `/api/analytics/event` | P (240/min) |
| GET | `/api/analytics/summary` | S |
| POST | `/api/leads` | P (10/min) |
| GET | `/api/leads` | A·view (admins only) |
| GET | `/api/blog` · `/api/blog/:slug` | P |
| GET | `/api/case-studies` · `/api/case-studies/:slug` | P |
| GET | `/api/testimonials` | P |
| GET | `/api/prompts` · `/api/prompts/:specId` | S |
| GET | `/api/presets` | S |
| GET | `/api/billing` | S |
| GET·PUT | `/api/settings/consumption` | S |
| POST | `/api/billing/checkout` · `/portal` · `/addon` | S (verified email) |
| POST | `/api/billing/webhook` | P (signed) |
| GET | `/api/connections` | S |
| GET | `/api/chat/config` | P |
| POST | `/api/chat` | P (12/min, SSE) |
| GET | `/api/chat/conversations/:id/messages` | scoped |
| GET | `/api/chat/conversations/:id/stream` | scoped (SSE) |
| POST | `/api/chat/handoff` | P (6/min) |
| POST | `/api/chat/conversations/:id/handoff` · `/handoff/cancel` | scoped |
| POST | `/api/chat/conversations/:id/messages` | scoped |
| POST | `/api/chat/conversations/:id/create-ticket` | scoped |
| POST | `/api/chat/feedback` | P (30/min) |
| GET | `/api/admin/overview` | A·view_metrics |
| GET | `/api/admin/audit` | A·view_metrics |
| POST | `/api/admin/admins` | A·manage_admins |
| GET | `/api/admin/users` | A·impersonate_users |
| POST | `/api/admin/impersonate` | A·impersonate_users |
| POST | `/api/admin/impersonation/stop` | active impersonation |
| POST | `/api/admin/preview-plan` | A·god_mode (owner) |
| GET | `/api/admin/chat/stream` | A·support_debug (SSE) |
| GET·POST | `/api/admin/chat/settings` | A·god_mode |
| GET | `/api/admin/chat/conversations` · `/:id` | A·support_debug |
| POST | `/api/admin/chat/conversations/:id/join` · `/messages` · `/resolve` | A·support_debug |
| GET | `/api/admin/support/tickets` · `/:id` | A·support_debug |
| POST | `/api/admin/support/tickets/:id` · `/:id/messages` | A·support_debug |
| GET | `/api/admin/self-heal/queue` | A·god_mode |
| POST | `/api/admin/self-heal/:id/status` | A·god_mode |

---

*Source of truth: `src/api.js` (route ladder), `src/admin-api.js` +
`src/admin-chat-api.js` (admin), `src/chat-routes.js` + `src/chat-handoff-routes.js`
(chat/SSE), `src/session.js` + `src/api-keys.js` (auth), `src/billing.js`
(gating + metering), `src/server.js` (`/healthz`, CSP, static serving),
`src/voice-agent/index.js` (WebSocket bridge). Verify any specific behavior
against those files.*
