# 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()`. Specialized surfaces 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`), the real-time voice WebSocket bridge
(`src/voice-agent/index.js`), and live-session sync (`src/live-sync.js`). The
two WebSocket bridges attach 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
three real-time **WebSocket** surfaces: browser voice
(`wss://…/api/voice/live`), Twilio media
(`wss://…/api/voice/twilio/media`), and authenticated live-session sync
(`wss://…/api/live/sync?session=:id`). They are attached through the HTTP
upgrade path rather than 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) · [Live sessions & teleprompter](#live-sessions--teleprompter) · [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.

**Workspace / brand selection.** An account can own many workspaces (sub-accounts)
and many brands. The session JWT pins one workspace. Send
`X-CoreReflex-Workspace: <workspaceUuid>` on any programmatic request to act in
another workspace on the **same account** that you belong to (disabled
workspaces return `403`). Persist that choice with `POST /api/session/workspace`.
Select which brand a workspace uses with `POST /api/brands/link`. Login accepts
optional `{ workspaceId }` to open a specific sub-account.

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` permits `content-type`, `authorization`,
`x-corereflex-workspace`, `x-corereflex-shell`, and `x-corereflex-presence`.
An `OPTIONS` preflight is answered
with `204`. Origins not on the list get no CORS headers (the browser blocks the
cross-origin read). Native webview origins such as `https://tauri.localhost` and
`tauri://localhost` must be explicitly added to the allowlist. 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) 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 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 },
  "egress":   { "dnsOrder": "ipv4first", "deterministic": true }
}
```

`egress` reports the outbound DNS family pin. `deterministic: false` means a
dual-stack host may call vendors from an address you never published, so
vendor IP allowlists can fail intermittently. The God Console surfaces this
as an operator warning. It never flips `ok`.

`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>'`. There is **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 |
| `POST /api/compositions/:id/presence` | 240 / 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/batch` | 3 / user |
| `GET /api/auto-clips/quota` · `GET /api/auto-clips/runs/:id` | (auth) |
| `POST /api/auto-clips/story` | 12 / user |
| `POST /api/auto-clips/publish` | 6 / user |
| `POST /api/auto-clips/voiceover` | 30 / user |
| `POST /api/highlights/list` · `/ingest` | 12 / 3 / 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/social/compose` | 10 / 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 plus `presence` (roster + who is editing) |
| `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 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) | 130 | `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**). Each option also carries `svgUrl`/`svgObjectKey`, a true-vector twin created at generation time (approve-the-exact-file; omit with `vectorize:false`). |
| `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` <!-- disclosure-ok: functional API value --> |
| `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`) |

- `POST /api/logo/suite` authors a COMPLETE identity suite in native vector: `{name, hero?, sub?, initial?, palette?, url?, face?, kern?, lines?, parts?, variants?, store?}`. Returns every lockup (nameplate, wide sign, horizontal, stacked, mark, icon, icon-small) in every cut (full-colour, reversed, universal, one-colour, black, white-trim), each landed in Files with an `svgUrl`, plus `rules` (the favicon size rule, clear space, minimum sizes), the measured `contrast` for every pair, and `typeLicence`. Pass `url` to ground the palette on a live site via the free brand scan. Pass `lines` for per-service sub-brands. Logotypes are set from stored glyph outlines, so no file contains live text. No vendor call in the path, so `vendorCost: 0` and nothing is metered. Also returns the measured proofs: `alignment.nameplate` (the qualifier solved to the hero's exact ink width, pass = delta <= 0.5%) and `gates` (form scored for 16px legibility and path-safety, every colour pair against a 3:1 floor): gate on `gates.pass` before presenting.
- `GET /api/algorithms/image-styles` lists the deterministic print/retro treatments (16-bit sprite, fat pixel, bootleg print, ASCII, terminal, risograph, 1-bit Atkinson dither, punk collage, CMYK rosette, halftone). Pure pixel math: composition preserved by construction, byte-for-byte reproducible, $0 at any batch size.
- `GET|POST /api/algorithms/animation-sequence` plans a reference-locked animation sequence: beats + a style in, role-weighted shots with computed continuity carry, per-shot verification hooks, and a runnable recipe out. Pure planning: no model call, nothing metered; GET returns the shot-role/camera catalog.

- `POST /api/logo/pack` is step 3 of the logo workflow: `{sourceUrl, title?, subtitle?}` (the approved master's public URL) → vectorizes it into a true-Bezier SVG and derives a one-color stamp and a round badge from the same paths. Returns `{vector, stamp, badge}` each with `objectKey` + `svgUrl`; all three land in Files. Metered as `usage:vectorize`.

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, and **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 CoreReflex in-house storage, 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 storage 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. |
| POST | `/api/assets/multipart/initiate` | Session or API key | Start an S3 multipart upload for Capture/Screen Studio (upload-while-recording). Same key rewrite as presign. Returns `{ key, uploadId, partSize, playbackUrl }`. |
| POST | `/api/assets/multipart/part` | Session or API key | Presign one part PUT. Body: `{ key, uploadId, partNumber }`. Private MinIO uses the app PUT proxy with `uploadId` + `partNumber` query params. |
| POST | `/api/assets/multipart/complete` | Session or API key | Complete the upload. Body: `{ key, uploadId, parts: [{ partNumber, etag }] }`. Returns `{ objectKey, playbackUrl }`. |
| POST | `/api/assets/multipart/abort` | Session or API key | Abort an in-flight multipart upload. |
| POST | `/api/assets/playback-proxy` | Session or API key | Enqueue a preview-only 15 fps / 480p stereo proxy for a heavy workspace video (`assetKey` or public `sourceUrl`). Light files return `{ status: "skipped" }`. Missing clip-proxy worker → `503`. Export always uses the original. |
| 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 workspace storage server-side (the private store is not browser-reachable). 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). |
| GET | `/api/media/probe` | Key/Session | What a stored file actually is: `container`, `durationSeconds`, `width`/`height`, `sampleRate`/`channels`, parsed from the file's own header bytes over at most two ranged reads. Also returns `nextSteps`, the verbs that apply to that media type. Anything it could not read is listed in `unknown` rather than guessed. Query: `?assetId=`. Free. |
| 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 }`. Editor clients send `X-CoreReflex-Presence: <sessionId>`. When two or more people are in the project, a save from anyone except the current controller returns `409` `{ error, code: "not_controller" }`. Clients that omit the header (API keys, CLI, MCP) still save. |
| 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. |

### NLE interchange (Final Cut, Premiere, Resolve)

Your edit is not locked inside CoreReflex. Any saved composition can be handed
to another editing application as a timeline, so a cut made here can be finished
somewhere else.

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| GET | `/api/cut/:compositionId/interchange` | Key/Session | The available formats plus what will and will not travel: `{ compositionId, formats, clips, fps, durationFrames, notes }`. |
| GET | `/api/cut/:compositionId/interchange?format=fcpxml` | Key/Session | Download FCPXML 1.8 (Final Cut Pro X; Premiere and Resolve also import it). |
| GET | `/api/cut/:compositionId/interchange?format=xmeml` | Key/Session | Download xmeml v5 (Premiere Pro's own import path). |
| GET | `/api/cut/:compositionId/interchange?format=otio` | Key/Session | Download OpenTimelineIO (DaVinci Resolve 18.5 and later). |

Optional `name` sets the sequence name and the download filename. An unknown
`format` returns `400` and names the three that work. A composition outside your
workspace returns `404`. A project with no video or audio clips returns `422`
rather than an empty file.

**What travels.** The clips, their source in and out points, their order, and
the timeline length, at exactly the frames our own renderer uses. Media is
referenced by filename as a `file://` URL, so the application prompts you to
relink to your own downloaded takes on first open rather than chasing a storage
link that has since expired.

**What stays behind.** An interchange timeline carries one layer, so anything
stacked above the main line is reported in `notes` instead of being flattened
without warning. Captions, titles and effects stay in the `.cr` project, which
remains the complete document. Dissolves export as hard cuts at the same frames,
and the count is reported in `notes`.

### 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`. |
| POST | `/api/compositions/:id/presence` | Session (240/min) | Join, heartbeat, leave, or take-control. Body: `{ sessionId, event, cursor? }`. `event` is `heartbeat` (default), `leave`, `request-control`, `accept-control`, `decline-control`, or `release-control`. Returns `{ members, control }`. The first joiner is the controller. A second editor must `request-control`; only the controller can `accept-control` or `decline-control`. |
| GET | `/api/compositions/:id/comments/stream` | Session (20 opens/min) | SSE stream of comment activity and `presence` (roster + control). 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 a database" 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`). Body `look?: "photo"\|"ugc"` last-miles craft (UGC adds phone-camera flaws; portraits get skin texture). **`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. Includes `craft` inspect when the outbound prompt was last-mile checked. |
| POST | `/api/director/plan` | Session · 20/min | Agentic multi-shot film planning from a brief (`{ concept, look?: "photo"\|"ugc", … }`). Photo is named light and skin texture; UGC lists phone-camera flaws. Each shot is last-mile crafted (`inspect` on the shot, `consistency` on the plan). 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 editable CoreReflex 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 CoreReflex motion-engine 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. |
| POST | `/api/hyperframes` | Key/Session (10/min) · meters `usage:content-own` (+ `usage:voice-own`/`usage:voice` per narrated scene, + `usage:render`) | **HyperFrames**: one brief → a fully editable, animated 1080×1920 film composed on the CoreReflex motion engine as native timeline items and keyframes (never flattened pixels). Body: `{ brief?: { campaignName, goal, offer, audience, proof, cta, tone, channels, look?: "photo"\|"ugc" }, format?: "film"\|"short"\|"wide", face?: { url, fileKey, width, height, durationInSeconds }, fileKey?, url?, overlays?, motion?: "calm"\|"punchy"\|"elastic"\|"kinetic", three?, overrides?, polish?, narrate?, voiceModel?, media?: { source: 'stock', query, generate? }, brand?, render?, codec? }`. `format:"short"` is the talking-head recipe (face-mode cuts + karaoke). When a take duration is measured, that length is the clock for face modes, overlays, and karaoke (`short.clock:"take"`). HyperFrames does not run Cut's silence-pass: pass a Cut take or a raw take. `fileKey` / `face.fileKey` must belong to the caller workspace (`403` otherwise). `url` is a public https page (og:title / og:description + an OG still; caller brief fields win). `overlays` lands native lower-third / x-post / app-frame items (ON by default for short). `motion:"kinetic"` is the chrome-type whip. Photo is named light and skin texture; UGC lists phone-camera flaws on generated plates. Narration runs on the CoreReflex voice lane; `media` adds licensed stock plates; the workspace brand kit drives the palette and type by default (`brand: false` opts out). Returns `{ documentId, editorHref, plan, brand, narration?, media?, short?, site?, renderJobId? }`. One consent dialog prices every leg. |
| GET | `/api/render/:id` | Key/Session | Poll render job status + output asset URL. |

> **Render engine.** Export runs as a CoreReflex render 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, … }`, where `videoUrl` is 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) · Core `$0` vendor; Premium detect meters `usage:content` after **Premium Flag**; cut meters `usage:render` **× clips** beyond the free daily allowance | **Paste-URL oneshot:** `{ sourceUrl, quality?: "core"\|"cr"\|"auto"\|"premium", count? }` runs import → detect → cut. Default `quality:"core"` (native). `quality:"premium"` requires `confirmPremium:true` (402 `premiumFlag` otherwise). Returns `{ runId, pollUrl, native, quality }`; poll `GET /api/auto-clips/runs/:id`. **Cut-only:** `{ source: { url, width?, height? }, 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. Batch cap `AUTO_CLIPS_MAX` (default 10). **`202`** + `{ clips: [{ jobId, compositionId, editorHref, … }], count }`. Every clip is an editable project. |
| 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 editor-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. |
| POST | `/api/dub` | Key/Session · meters `usage:content` (translation) + `usage:voice-own` per spoken line (`usage:voice` on a vendor model); the exact line count is confirmed before anything is spent | **Dub v1.** `{ fileKey, targetLanguage, sourceLanguage?, title?, glossary?, voices?, model?, lexicon?, qc?, condense? }` (`qc` re-hears every line on the free speech-to-text lane and reports per-line word-error drift; `condense` rewrites a line that cannot fit its slot, one `usage:content` per rewrite, priced into the ceiling). **Native first, never a silent swap:** if the native voice lane fails, the run stops with `402 native_unavailable_premium_offer` carrying the credit estimate for finishing on the premium partner voice (God-mode accounts also see the fiat figure) and nothing is generated on the vendor; resend with `premium: true` to approve it. Native is still tried first on every line; `spend.premium` and per-line `substituted` say what actually ran on the partner lane → `201` with the editable composition (`compositionId`, `editorHref`), per-line `segments` (translation, estimate verdict, speaking rate, measured seconds, picture fit, any residual overrun), the output `timeline`, translated `captions`, and `spend { ceiling, charged }`. Native stock voice per speaker; original dialogue muted under each dubbed line, ambience kept between lines. 5/min. |

---

## 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 no backend is configured, 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. `vectorize` is the
one with a second backend: a disclosed Recraft-on-fal vendor bridge that serves
the endpoint until the tracer-capable worker is deployed.

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| POST | `/api/imagen` | Key/Session · meters `usage:imagen` | Generate a still image. Synchronous `200`. |
| POST | `/api/logo/options` | Key/Session · 202 job · meters `usage:imagen` × delivered | Staged logo workflow: enqueue N distinct flat logo **concepts**. Returns **202** `{jobId}` immediately. Poll `GET /api/generate/:id` (options land incrementally on `result.options`). Send `Idempotency-Key` (or `idempotencyKey`) so a retry cannot start a second vendor batch. Meters the *delivered* concept count (1–4), not the requested one; a mid-batch abort charges only what landed. 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; returns a sibling SVG asset. Runs on the render worker (`VECTORIZE_URL`, native-tech VTracer, preferred) or, until that worker is provisioned, the disclosed **Recraft-on-fal vendor bridge** (`FAL_KEY`); the bridge response carries `backend: "fal"` + attribution. `503` only when neither is set (or `VECTORIZE_MOCK=true` for the test passthrough). |
| 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` | Native-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`. <!-- disclosure-ok: functional API value --> **Native first, never a silent swap:** if the native voice lane cannot serve the take, `402 native_unavailable_premium_offer` returns the credit estimate for the premium partner voice (God-mode accounts also see the fiat figure) and nothing is generated on the vendor; resend with `premium: true` to approve it. A take that ran on the partner lane comes back with `substituted: true`. |
| 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`. |
| POST | `/v1/audio/speech` | Key/Session · meters `usage:voice` (self-hosted lane: `usage:voice-own`) | **OpenAI-compatible TTS.** Point any OpenAI-audio client at `<origin>/v1` with your API key as the bearer token. Body `{ model, input, voice, response_format, speed }`: `input` is the text; `voice` is a CoreReflex voice id (`alloy`/`default`/blank = platform default); `model` is a CoreReflex voice model id (OpenAI names such as `tts-1` mean the default); `response_format` is `wav` or `mp3`; `speed` maps to `speakingRate`. Native first: a native failure answers `402 native_unavailable_premium_offer` with the estimate unless `premium: true` approves the partner voice. Returns the audio bytes with an honest `Content-Type`, plus `X-CoreReflex-Editor-Href` (the take already sits on an editable timeline), `X-CoreReflex-Take` and `X-CoreReflex-Duration-Seconds`. Same gate, meter and lane as `/api/voice`. |
| POST | `/v1/audio/transcriptions` | Key/Session (30/min) | **OpenAI-compatible STT.** `multipart/form-data` with `file` (or JSON `{ audioBase64 }`), optional `language`, `prompt` (spelling hints, comma-separated) and `response_format` (`json`, `text`, `verbose_json`, `srt`, `vtt`). Verbatim (no dictation cleanup), free, self-hosted only, no vendor fallback; `503` when the lane is not deployed. |
| GET | `/v1/audio/voices` | Key/Session | OpenAI-style voice + model catalogue (`{ object: 'list', voices: [...], models: [...] }`). |
| GET | `/.well-known/corereflex-speech` | Public | Speech transport discovery document (`corereflex.speech.v1`): OpenAI-compatible, native and MCP paths as relative URLs, so it works behind any proxy. |
| 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, briefMode, chunkCount, gaps }`. |
| POST | `/api/meeting/from-asset` | Session | Transcribe an asset (by `assetKey`, workspace-bound) then summarize. Same brief shape. Caption speakers and timestamps are passed through when STT returns them. |
| POST | `/api/meeting/import` | Session / API key | Transcribe a workspace recording, write a brief, and (by default) open Cut. Optional `stems[]`: `you`/`mic` + `them`/`system` land as mixable audio; `iso:cam` + `iso:screen` compile as Screen Studio picture (OS cursor hidden when a cursor sidecar is attached). |
| GET | `/api/meeting/briefs` | **Pro** | List persisted briefs, optionally `?contactId=`. (Briefs persist into the CRM, which is Pro-gated.) |

Long transcripts are read in overlapping caption/sentence slices (`briefMode:
"map-reduce"`) instead of being truncated. A slice that fails inference is
marked as a gap (`gaps > 0`); the brief still covers the slices that succeeded.
Short transcripts stay `briefMode: "single"`. The transcript is treated as
untrusted audio: spoken instructions in the recording are things people said,
not commands to the model.

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.

---

## Live sessions & teleprompter

Live control is workspace-scoped and programmatic. Producer writes accept an API
key or session; a paired device can only read or mark the session it is bound to
and only with its granted scope. Stream keys and device credentials are never
returned by list/get routes. Set `LIVE_ENABLED=false` to hide the entire
`/api/live/*` surface with `404`.

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| POST | `/api/live/sessions` | Key/Session (producer) | Create `{ title?, scriptId?, destinations? }`. Record-only sessions start `ready`; Room/RTMP sessions start `draft`. Returns `{ session }`, never a raw key. |
| GET | `/api/live/sessions` | Key/Session | List workspace sessions. Optional `?status=` and `?limit=1..100`. |
| GET | `/api/live/sessions/:id` | Key/Session or bound device | Read status, destinations, script binding, durable events, and `recordingPackage`; no raw credentials. |
| POST | `/api/live/sessions/:id/keys` | Key/Session (producer) | Rotate a show-once media credential. This is intentionally human-only in MCP. |
| POST | `/api/live/sessions/:id/start` | Key/Session (producer) | Start `ready→live`. Body requires `{ consent: { noticeShown: true, mode } }`; Room/RTMP returns `503` while the media plane is unavailable. |
| POST | `/api/live/sessions/:id/mark` | Key/Session or scoped device | Append one bounded, sparse event. Event rows are capped; high-frequency cursor/mixer data does not belong here. |
| POST | `/api/live/sessions/:id/stop` | Key/Session (producer) | Stop and return one editable handle: `{ documentKind, documentId, editorHref, recordingPackage, error? }`. |
| POST | `/api/live/sessions/:id/cancel` | Key/Session (producer) | Cancel a `draft` or `ready` session; active broadcasts cannot be cancelled. |
| GET | `/api/live/sessions/:id/playback` | Key/Session or bound device | Mint a short-lived, room-bound, subscribe-only LiveKit grant. Returns `{ sessionId, room, wsUrl, token, expiresAt, participant }`; `503` when LiveKit is unconfigured/down. |
| WS | `/api/live/sync?session=:id` | Key/Session or bound device | Authenticated prompter state, presence, sparse marks, and reconnect hints. Session cookies and `Authorization` are supported; browser device tokens use the versioned WebSocket subprotocol described below. Credentials are never accepted in the query string. |
| GET | `/api/client/capabilities` | Key/Session | Versioned shell/form-factor feature matrix for web, PWA, Tauri, tablet, and phone. |
| POST·GET | `/api/teleprompter/scripts` | Key/Session | Create or list scripts. Create accepts `{ source, sourceId?, text?, compositionId? }`. |
| GET·PUT·DELETE | `/api/teleprompter/scripts/:id` | Key/Session; GET also bound device | Read, edit, or delete a workspace script. Device reads must resolve through their bound live session. |
| POST | `/api/teleprompter/scripts/:id/bind` | Key/Session (producer) | Bind `{ sessionId }` so operator/talent surfaces resolve the same script. |

The JavaScript SDK exposes the frozen live twins `liveCreate`, `liveList`,
`liveGet`, `liveStart`, `liveMark`, `liveStop`, `liveCancel`, and `livePlayback`,
plus full teleprompter CRUD/bind twins. MCP exposes the same non-credential
operations with `corereflex_…` names. Every MCP write requires `confirm:true`;
key rotation and device pairing/revocation remain SDK-reachable but are listed
in `MCP_EXCLUDED` because credentials stay human-controlled.

Browser device clients offer both `corereflex-live-v1` and
`corereflex-device.<base64url-token>` in `Sec-WebSocket-Protocol`. The server
authenticates the decoded device token but selects and echoes only
`corereflex-live-v1`, so the credential is never placed in the URL or returned
as the negotiated protocol. Non-browser clients may continue to use
`Authorization: Bearer …`.

---

## 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. Body: `{ recipeId, params? }`, `{ spec }`, or `{ intent }`. Returns `{ estimate: { creditsLow, creditsHigh }, billingEnabled, balance, affordable, estimateBasis, budgetMode, enforced, note }`. `estimateBasis` is `submitted-spec` \| `saved-recipe` \| `intent-default`. `enforced` is true only when `budgetMode` is `cap`. Note leads with the free client-export lane on paid plans. |
| GET | `/api/recipes/step-kinds` | Session | Every step kind: consumes, publishes, params, closed enums (`beatRole`, `beatVisual`). |
| POST | `/api/recipes/validate` | Session | Dry-run a spec. Infers `out`, reports per-step wiring, lists unbound vars. No side effects. |
| 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`** `{ runId, status: "running", poll: "/api/recipes/{id}/runs" }`. |
| 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 or API key | `{ brandKit: { name, voice, voiceText, colors, typography } \| null }`. |
| PUT/POST | `/api/brand-kit` | Session or API key | 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 or API key (Pro inside) | List the account's brands. Includes `currentBrandId` and per-row `linked` for the active workspace. |
| POST | `/api/brands` | Session or API key (Pro inside) | Create a brand. Body: `{ name, link? }`. `201`. |
| GET | `/api/brands/storage` | Session or API key | Account storage usage (plan allowance + purchased bundles vs. bytes used). |
| POST/PUT | `/api/brands/link` | Session or API key | Select the brand this workspace uses. Body: `{ brandId }` (`null` unlinks). |
| POST | `/api/brands/:id/generate` | Session or API key (Pro inside) | Generate a draft brand asset in-platform. **`202`**. |
| POST | `/api/brands/:id/assets` | Session or API key (Pro inside) | Add an asset (draft unless `approved:true`). `201`. |
| PATCH/PUT | `/api/brands/:id/assets/:assetId` | Session or API key (Pro inside) | Update an asset in place (logo replace, label, data merge). |
| PUT/POST | `/api/brands/:id/logo` | Session or API key (Pro inside) | Add or replace the primary logo. Body: `{ data: { imageUrl, objectKey, … }, replace? }`. |
| POST | `/api/brands/:id/assets/:assetId/approve` | Session or API key (Pro inside) | Approve a draft → it cascades to linked projects. |
| DELETE | `/api/brands/:id/assets/:assetId` | Session or API key (Pro inside) | Archive an asset. |
| GET | `/api/brands/:id` | Session or API key (Pro inside) | Brand detail incl. assets + approval state. |
| PATCH/PUT | `/api/brands/:id` | Session or API key (Pro inside) | Rename or archive a brand. |

### Workspaces (sub-accounts)

One billing account can own many workspaces. A session is bound to one. Owners
can create more, turn session binding on or off, and switch the current session.

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| GET | `/api/workspaces` | Session or API key | List memberships on this account (`sessionEnabled`, linked brand, `current`). |
| POST | `/api/workspaces` | Session or API key (owner) | Create a sub-account. Body: `{ name }`. `201`. |
| PATCH/PUT | `/api/workspaces/:id` | Session or API key (owner) | Rename or set `{ sessionEnabled }`. |
| POST | `/api/session/workspace` | Session or API key | Select a workspace. Session callers get a new JWT + cookie; API keys get `{ mode: "header" }` and should send `X-CoreReflex-Workspace`. |

---

## 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`. Body (all optional): `{ contactId, name, email }`. `contactId` links a workspace CRM contact (foreign contact → `404`): name/email autofill from the contact, the share is logged to the contact's activity timeline, and the client's later decision lands there too (`proof_approved` / `proof_changes_requested`). |
| 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. A review link minted for a CRM contact also logs the decision on that contact's timeline. |
| 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/formats` | API key or session | Sizes + templates. Copy is laid out as editable text nodes. |
| POST | `/api/social/layout` | API key or session | Pure layout of an `ImageStudioDesign` from a brief (no persist). |
| POST | `/api/social/compose` | API key or session (10/min) | Brief → editable design + `editorHref`. `source`: `pexels` (meters `usage:stock`) · `ai` (image credit) · `none` (free). Photo is an image node; type is text nodes. `201`. |
| GET | `/api/social/connection` | API key or session | Connection status (`{ connected, … }`). |
| POST | `/api/social/connection` | API key or session (10/min) | Connect. Body: `{ apiKey, locationId, label? }`. |
| DELETE | `/api/social/connection` | API key or session | Disconnect. |
| GET | `/api/social/accounts` | API key or session (30/min) | The connected platforms/accounts available to publish to. |
| GET | `/api/social/posts` | API key or session | List relayed posts. |
| POST | `/api/social/posts` | API key or session (20/min) · meters `usage:publish` | Publish now or schedule a **flattened** `mediaUrl` + caption. 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. |

### Paid seats (P2)

Stripe owns how many seats exist (the subscription quantity); these routes own
who occupies them. This is the invite lane for people **without** a CoreReflex
account: the invitee gets an emailed link (`/app?seat=<token>`) that creates
their membership on accept. The dashboard Team panel offers it automatically
when a team invite returns `needs_account`.

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| GET | `/api/seats` | Session (account owner) | Seat ledger: `{ usage: { seats, members, pending, used, available }, invites, minTeamSeats }`. |
| POST | `/api/seats/invite` | Session (account owner, 20/min) | Email a token invite that fills a paid seat. Body: `{ email, role }` (`producer`/`reviewer`/`client`). No free seat → `402` (a solo account must buy the seat minimum first). Returns the invite including the raw `token` and `sent`. When mail is down, hand the `/app?seat=<token>` link over yourself. A re-invite replaces the open one. |
| DELETE | `/api/seats/invite` | Session (account owner) | Revoke the open invite for an email. Body: `{ email }`. |
| POST | `/api/seats/accept` | Session (invitee) | Accept an invite. Body: `{ token }`. Bound to the invited email; a different signed-in user gets a `403`. Re-checks seat availability and enterprise domain rules at accept time. |

---

## 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`). Includes family **Shot language** (exploded, cyanoprint, turnaround, knolling, packshot, …). Leading `/codes` on `POST /api/imagen` and `POST /api/social/compose` compile against those ids (`src/visual-modes.js`). "labeled" implies callouts. Enhance restores leading codes. |

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` | User (session or `cr_live_` key) | 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` | User · 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` | User · 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` | User · 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, actions, grounded }`, or `error`. A volunteered `email` is captured as a lead best-effort. **Signed-in `contextType: "dashboard"` turns** additionally answer from a live account snapshot (plan, credits, recent generation jobs, open tickets) and may return `actions`: vetted same-origin deep-link chips from a fixed server-side catalog (the model can only pick from it, never invent one). Marketing visitors never receive `actions`. |
| 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 }`. |
| POST | `/api/support/report` | Key/Session | Report a failed generation job and/or workflow run. Body: `{ jobId? , runId?, message? }` (at least one id; both must belong to your workspace). Opens a ticket with diagnostic meta attached for staff. `201`. |
| GET | `/api/support/tickets` | Key/Session | Your own tickets, newest first. `?limit=` (default 30, max 200). Returns `{ tickets }`, the Settings → Support tickets panel. |
| POST | `/api/support/tickets` | Key/Session | File a ticket directly. Body: `{ subject (required), description?, priority?, category?, contactEmail? }` (defaults to your account email). `201` `{ ticketId, status, subject }`. |
| GET | `/api/support/tickets/:id` | Key/Session | Your own ticket with its message thread: `{ ticket, messages }`. A ticket that is missing **or belongs to someone else** is an identical `404`. |
| POST | `/api/support/tickets/:id/messages` | Key/Session (30/min) | Reply on your own ticket. Body: `{ message }` (≤ 8,000 chars). Replying to a `resolved`/`closed` ticket reopens it. `201` `{ message, status }`. |

---

## 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 for the God Console (`audit_events`, migration 038), e.g. `disable_paid_confirmation`. `?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 |
| POST | `/api/compositions/:id/presence` | S (240/min) |
| 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) |
| POST | `/api/hyperframes` | K (10/min · content-own [+voice, +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 |
| POST·GET | `/api/live/sessions` | K |
| GET | `/api/live/sessions/:id` | K or bound device |
| POST | `/api/live/sessions/:id/keys` · `/start` · `/stop` · `/cancel` | K (producer) |
| POST | `/api/live/sessions/:id/mark` | K or scoped device |
| GET | `/api/live/sessions/:id/playback` | K or bound device |
| WS | `/api/live/sync?session=:id` | K or bound device |
| GET | `/api/client/capabilities` | K |
| POST·GET | `/api/teleprompter/scripts` | K |
| GET·PUT·DELETE | `/api/teleprompter/scripts/:id` | K (GET also bound device) |
| POST | `/api/teleprompter/scripts/:id/bind` | K (producer) |
| 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/step-kinds` | S |
| POST | `/api/recipes/validate` | 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 or key |
| POST | `/api/brand-voice` | S |
| GET·PUT·POST | `/api/theme` | S |
| GET·POST | `/api/brands` | S or key (Pro inside) |
| GET | `/api/brands/storage` | S or key |
| POST·PUT | `/api/brands/link` | S or key |
| POST | `/api/brands/:id/generate` | S or key (Pro inside) |
| POST | `/api/brands/:id/assets` | S or key (Pro inside) |
| PATCH·PUT | `/api/brands/:id/assets/:assetId` | S or key (Pro inside) |
| PUT·POST | `/api/brands/:id/logo` | S or key (Pro inside) |
| POST | `/api/brands/:id/assets/:assetId/approve` | S or key (Pro inside) |
| DELETE | `/api/brands/:id/assets/:assetId` | S or key (Pro inside) |
| GET·PATCH·PUT | `/api/brands/:id` | S or key (Pro inside) |
| GET·POST | `/api/workspaces` | S or key |
| PATCH·PUT | `/api/workspaces/:id` | S or key (owner) |
| POST | `/api/session/workspace` | S or key |
| 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 | `/api/social/formats` | S |
| POST | `/api/social/layout` | S |
| POST | `/api/social/compose` | S (10/min · stock or image meter) |
| 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 |
| GET | `/api/seats` | S (account owner) |
| POST | `/api/seats/invite` | S (account owner, 20/min) |
| DELETE | `/api/seats/invite` | S (account owner) |
| POST | `/api/seats/accept` | 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` | U (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) |
| POST | `/api/support/report` | K |
| GET·POST | `/api/support/tickets` | K |
| GET | `/api/support/tickets/:id` | K |
| POST | `/api/support/tickets/:id/messages` | K (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.*
