ghostkey-server API

The optional cloud sync server for the Ghostkey stack — auth, file sync, per-document and per-asset project sync, and a push WebSocket. Clients that don't opt in keep working unchanged.

The machine-readable contract lives at /openapi.yaml. This page is the human-readable companion for workflow notes, examples, and caveats. If you're an agent working in a Ghostkey client repo, fetch one of these server-owned specs instead of relying on a local skill cache.

The server has two surfaces:

Choose Projects for any manuscript you intend to edit and sync; choose Files for everything else. Clients can use both.

Compatibility today. Phases 1 (auth), 2 (files CRUD + streamed blob upload/download), 3 (folders), 4 (projects/documents/assets), and 5 (realtime WebSocket) are live. IP rate limits are enforced; plan access is enforced (see Billing), and per-account usage quotas are landing alongside it.

Last-write-wins everywhere. Neither surface does conflict detection. A second PUT /api/files/:id/blob overwrites bytes and bumps version; a second PUT /api/projects/:pid/documents/:did overwrites a chapter and bumps its version. There is no base_version precondition and no conflict-sibling protocol on either surface.

Conventions

Common error codes

CodeStatusMeaning
invalid_json400Body wasn't valid JSON.
invalid_body400Body didn't match the expected schema.
invalid_id400Path parameter wasn't a valid UUID.
invalid_name400Filename failed validation (length, forbidden chars, leading/trailing whitespace, ./..).
missing_body400A request expected a request body and didn't get one.
missing_content_type400Sent without a Content-Type header (on POST /api/files or asset upload).
empty_patch400PATCH body had none of the patchable fields.
invalid_folder400Referenced folder_id doesn't exist, is deleted, or isn't a UUID.
invalid_parent400Referenced parent_id doesn't exist, is deleted, equals :id, or would create a cycle.
invalid_kind400Document kind was not chapter or note — or a pitch kind was not blurb or query_letter.
invalid_filename400Document/asset filename failed validation (empty, contains /, \, .., or starts with .).
invalid_chapters400PATCH project chapters wasn't an ordered list of chapter objects/folders.
invalid_notes400PATCH project notes wasn't an ordered list of note objects.
invalid_saved_prompts400PATCH project saved_prompts didn't match the expected shape.
invalid_editing_plan400PATCH project editing_plan didn't match the expected shape (and wasn't null).
invalid_cover400PATCH project cover_filename isn't a valid filename (or empty for none).
invalid_asin400A comps entry was neither a 10-character ASIN nor an Amazon product URL.
audiobook_editionPer-ASIN comps failure: an audiobook with no Kindle/print sibling to hop to (Audible listings carry no blurb). Audiobooks that do have one are silently stored under the readable edition's ASIN.
no_matchPer-book comps resolve failure: Amazon search returned nothing whose title and author both verified against what was asked for.
no_brief409The comps shelf step and the pitch guidance/analyze routes need a positioning_brief on the project, and there isn't one yet — pick an angle first.
no_comp_blurbs409Pitch guidance reads the comp board's blurbs as its corpus, and no comp on this board carries blurb text (a listing with no book_description stores an empty one). Refresh or replace the comps — retrying only buys a sheet written off the shelf label.
empty_draft422Pitch analyze was asked to read an empty draft — there is nothing to analyze.
no_covers409The cover-brief route was asked to look at a board where no comp has a stored cover — there is nothing to look at yet.
invalid_artist400A cover-artist create/update body was malformed: the name is required, and every field has a length bound.
content_too_long400A pitch draft exceeded its 10,000-character cap.
invalid_grounding400Agent grounding used outside the default Gemini stream, or combined with json.
unauthorized401Missing/invalid/expired access token.
invalid_credentials401Wrong email or password.
invalid_refresh_token401Refresh token unknown, expired, or revoked.
not_found404No such resource for the calling user.
name_taken409Display name collision in the same folder (files or projects).
filename_taken409Document or asset with the same filename already exists in the project.
email_taken409Signup with an already-registered email.
deleted410Resource is soft-deleted.
content_too_large413Document content exceeded 2 MB.
asset_too_large413Asset blob exceeded 25 MB.
origin_not_allowed403Browser sent an Origin this server doesn't serve. See CORS.
rate_limited429Rate limit hit — ours or an upstream provider's. See Rate limits.
upload_failed502Server couldn't write the bytes to backing storage.
storage_unavailable502Server couldn't read the bytes from backing storage.

Rate limits

Every /api/* route except /api/health and the Stripe webhook is metered per client IP. A throttled request never reaches the handler: it comes back 429 with { "error": "rate_limited" }, a Retry-After in seconds, and RateLimit-Limit / -Remaining / -Reset. Honour Retry-After — an immediate retry just spends the next window.

BucketBudgetApplies to
auth_signin20 / 10 minPOST /api/auth/signin
auth_write10 / 10 minSignup, forgot/reset password, verification resend + confirm
auth_refresh60 / 10 minPOST /api/auth/refresh
llm60 / 5 min/agent, /ocr, /transcribe, /semantic-search, /memory/reflect, POST /jobs
export20 / 10 min/export/*, /import-docx, /thumbnail
api600 / minEverything else, including the autosave hot path

Two limits are keyed by email rather than IP, so they hold across a distributed attempt. Signin allows 10 failures per address per 15 min — spent on failures only, so a correct password always clears it — and the mail-sending routes (forgot-password, resend-verification) allow 5 sends per address per 30 min, answering 200 either way so they never reveal whether an address is registered.

These are abuse controls, not per-account quotas: they key on IP, so a shared address shares a budget. If the limiter's Redis is unreachable the server fails open and logs — availability over strictness.

CORS and security headers

Only browsers are affected. The desktop app makes its cloud calls from the Electron main process and mobile uses React Native fetch — neither sends an Origin header, and requests without one are never origin-checked.

A request that does carry an Origin must carry an allowed one, or it is rejected with 403 origin_not_allowed before the route runs — not merely stripped of its CORS headers, because a simple cross-origin POST executes even when the browser hides the reply. Allowed origins are the server's ALLOWED_ORIGINS list, plus the server's own origin (so a same-domain web build needs no configuration), plus any localhost port outside production. Credentialed requests are permitted, so the allowlist is exact-match and never *. The /ws upgrade runs the same check — WebSockets are not covered by the browser's same-origin policy.

Browser clients can read x-request-id, Content-Disposition, Content-Range, Accept-Ranges, Retry-After, and the RateLimit-* headers; anything else is hidden by CORS. Preflights are answered directly by the server (204, cached 24 h) and allow authorization, content-type, x-request-id, and range.

Every response also carries Content-Security-Policy (default-src 'none' on API responses), X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy, and the Cross-Origin-* set. Strict-Transport-Security is added on HTTPS requests only.

Auth

Email + password only. Two-token model:

Token reuse = theft. If a previously-rotated refresh token is presented, the server revokes the user's entire active session chain. The response is the same invalid_refresh_token 401, but the user will have to sign in again on all devices.

Storage in clients

Transport: bearer vs cookie

Everything above describes bearer transport — tokens in the header and the body. It is the default and the only option for the native clients.

A browser served from this server's own origin can instead use cookietransport: send x-gk-auth: cookie on every call, and the session routes answer with Set-Cookierather than real tokens —

Both are HttpOnly; Secure; SameSite=Strict. The access_token / refresh_token fields still appear in responses but hold an opaque per-response marker with no credential value — echo it back where a token is asked for, or omit the field. The point is that page script can't read the refresh token, so an XSS bug can't walk off with a 30-day credential.

Same-site only. The cookie is honoured only on requests whose Sec-Fetch-Site is same-origin or same-site — so a web app on a sibling subdomain (ghost-key.app calling api.ghost-key.app) qualifies, while a different registrable domain never does. That header is the one thing browsers send on every request — including the ones with no Origin at all (<img src>, top-level navigations), which is exactly the gap the CORS check can't see — and page script cannot forge it. A request missing it gets 401 rather than a silent downgrade, so nobody can ask for the weaker transport.

Because the opt-in is a custom header, a cross-origin browser preflights before every authed call — which is why x-gk-auth is on the Access-Control-Allow-Headers list. Same-origin callers never preflight at all.

Refresh-on-401 pattern

Wrap your HTTP client with this loop:

  1. Make the request.
  2. If status ≠ 401, return the response.
  3. Call POST /api/auth/refresh with the stored refresh token.
    • On success: replace the stored refresh token, retry the original request once with the new access token.
    • On failure (invalid_refresh_token): clear local auth state and surface a re-login prompt.

Do not retry more than once per call — a second 401 means something's wrong beyond expiry.

POST /api/auth/signup

Request:

{ "email": "user@example.com", "password": "at-least-8-chars" }

Validation: email is RFC-valid (max 254 chars), password is 8–256 chars.

Success (200):

{
  "access_token": "<jwt>",
  "refresh_token": "<opaque>",
  "expires_at": "2026-06-23T10:00:00.000Z",
  "user": { "id": "<uuid>", "email": "user@example.com" }
}

expires_at is the refresh token expiry. The access token expires in ~15 minutes (clients don't need to track this — just refresh on 401).

Errors: invalid_body, email_taken.

The user object carries email_verified: false and a verification code is emailed (best-effort). The account can't sign in again until it's verified — see verify-email below.

POST /api/auth/signin

Same request shape as signup. Same success shape. Errors: invalid_body, invalid_credentials, and email_not_verified (403) when the password is right but the address hasn't been confirmed — send the user to the verification step, not the password-error path. Response time is constant whether the email exists or not — don't try to infer account existence from latency.

POST /api/auth/refresh

Request:

{ "refresh_token": "<opaque>" }

Success (200): same shape as signup/signin, with a new access token and a new refresh token. The submitted refresh token is now revoked.

Errors: invalid_body, invalid_refresh_token. On 401, clear local state.

Also email_not_verified (403), for an account that signed up and never confirmed its address — signup issues a session, so this is where such a session lands once its first access token expires. The submitted refresh token is not consumed here: send the user to the verification step and retry the same token afterwards to resume the session. Don't clear local state on this one.

POST /api/auth/signout

Request:

{ "refresh_token": "<opaque>" }

Always returns 204 No Content, even if the token was unknown. Don't inspect the body. After signout, clear local auth state.

POST /api/auth/verify-email

Request:

{ "email": "user@example.com", "code": "<from the email>" }

Confirms the address with the code mailed at signup (or by a resend). Codes are single-use and expire after 24 h. Success (200): { "ok": true }. Errors: invalid_code (wrong, used, or issued for another address — the response never says which), already_verified (409), code_expired (410 — request a resend).

POST /api/auth/resend-verification

Request:

{ "email": "user@example.com" }

Issues a fresh code and emails it. Answers an empty 200 whether or not the address belongs to an unverified account — don't infer account existence. A repeat within 60 s of the last send returns rate_limited (429); past the per-address mail budget the route answers 200 without sending.

POST /api/auth/forgot-password

Request:

{ "email": "user@example.com" }

Emails a reset link carrying a single-use token valid for 1 h. Always an empty 200, with constant response time, whether or not the account exists. Same per-address mail budget as the resend route.

POST /api/auth/reset-password

Request:

{ "token": "<from the email link>", "password": "new-password" }

Success (200): { "ok": true }, and every active session is revoked — the user signs in again everywhere with the new password. Error: invalid_token (unknown, used, or expired).

Admin (backoffice)

The backoffice is an ordinary account with is_admin set on its row — same signin, same tokens, same refresh rotation. Nothing in the app writes that flag: an admin is made by an UPDATE run by hand against the database, so there is no privilege-escalation path to audit.

GET /api/admin/me

Success (200): { "id": "<uuid>", "email": "…" }. A signed-in account without the flag gets forbidden (403); no session at all gets unauthorized (401).

This is the gate rather than a description of the caller. It runs the same check every future admin route runs, so a backoffice client that renders nothing until this answers 200 can never show a page the API would refuse. Admin-ness is read from the row on each request, not carried in the access token — revoking it takes effect on the next call instead of up to 15 minutes later.

Billing (Stripe)

Subscriptions are handled by Stripe-hosted pages — the client never touches card data. The checkout and portal routes require auth and return a URL to open in the system browser; when Stripe isn't configured on a deployment they answer billing_not_configured (503). Reading prices is public — see /api/billing/plans below.

GET /api/billing/plans

What each rung costs: { currency, plans: [{ plan, amount, currency, interval }] } amount in minor units, exactly as Stripe reports it (499 is $4.99). The one route under /api/billing that needs no auth, because a price is a public fact and the marketing page has no session.

No amount is stored in this codebase. The figures are read from the Stripe prices the server holds the ids for, so what a page quotes is what a card is charged. Clients must not keep a local copy as a fallback — two surfaces quoting different numbers is the failure this replaced, and a stale hardcoded price is worse than a missing one because it reads as authoritative.

Null amount means unknown, not free. An unset or archived price answers null and the client draws no number. Only the entry plan reports 0 as a fact.

Watch the currencies. Stripe pins a customer's currency at their first subscription, so a rung priced in a different currency than the top-level currency is unreachable for anyone who started elsewhere on the ladder — not merely inconsistent.

GET /api/billing/access

Which surfaces the caller's plan can reach at all. Returns { plan, capabilities: [{ id, label, granted, required_plan }] } — the whole table, granted and denied, with server-owned labels. Clients draw their locks from this and must never hardcode the plan ladder, so a capability added after a client shipped locks and labels itself with no client deploy.

Availability, not limits. An entry here is a boolean fact about whether a surface exists for a plan. How much of it a plan gets is a separate question with its own vocabulary — the two are deliberately not merged.

This endpoint is cosmetic. Every lock it feeds is also enforced at the surface that does the work, so a stale snapshot degrades to a 403 and never to a bypass. Do not treat it as the gate.

Access gates actions, never reads. An account that downgrades keeps everything already generated and can still open it — the GET cache routes carry no capability check. What it loses is the button that makes another.

A refused surface answers plan_insufficient (403) with { capability, label, current_plan, required_plan }. On POST /api/projects/:id/agent the gate reads the required surface field — a closed server-owned enum, and not the free-text feature label, which the server never validates. That route serves basic surfaces (proofreading, line editing, the style sheet, change notes) and standard ones (chat) on overlapping models, so neither the model id nor a client-picked label can tell them apart. The surface must also be allowed to run the requested model, or the request is surface_model_mismatch (400) — that pairing is what makes the declaration unspoofable.

POST /api/stripe/checkout

Request:

{ "plan": "basic" | "standard" | "pro" }

Creates a Checkout session for a subscription and returns { "url": "<stripe-hosted checkout>" }. The Stripe customer is created on first use and remembered on the account. An account that has never held a subscription starts with the free trial GET /api/billing/plans advertises as trial_days (card collected now, charged when it ends); the request cannot ask for one, and GET /api/billing/subscription says beforehand (trial_eligible) which it will be.

POST /api/stripe/portal

Returns { "url": "<stripe customer portal>" } for managing the payment method, switching plans, or cancelling. no_subscription (400) if the account has never been through checkout.

POST /api/stripe/webhook

Stripe's receiver, not a client surface: signature-verified via Stripe-Signature, exempt from IP rate limiting. Clients learn about plan changes from their own account state, not from this route.

Files (opaque)

Use this surface for arbitrary opaque uploads (PDFs, drafts, exports). For project sync, use /api/projects instead — that surface stores chapters, notes, and assets as rows and gives you per-document granularity.

Concepts

Response envelopes

Single-file endpoints return { "file": <FileDTO> }. List endpoints return { "files": [<FileDTO>, …] }. Folder endpoints follow the same convention ({ "folder": … } / { "folders": [...] }). Clients must unwrap before consuming.

File metadata shape

{
  "id": "<uuid>",
  "owner_id": "<uuid>",
  "folder_id": "<uuid|null>",              // null = root
  "name": "novel.ghost",
  "content_type": "application/zip",
  "size_bytes": 423921,                    // bytes received on the latest upload
  "version": 7,                            // monotonic, server-controlled
  "etag": "<sha256 hex>",                  // sha256 of the latest bytes
  "status": "ready",
  "deleted_at": null,                      // ISO 8601 when soft-deleted
  "created_at": "2026-05-24T10:00:00.000Z",
  "updated_at": "2026-05-24T11:14:22.000Z"
}

Client gotcha — nullable fields are really null. folder_id and deleted_at arrive as literal JSON null (not absent, not empty string). Statically typed clients must declare these as nullable / Option.

Endpoints (live)

MethodPathPurpose
POST/api/filesAtomically create a file row + upload its bytes (streamed).
GET/api/filesList the caller's files in one folder.
GET/api/files/:idGet one file's metadata.
PATCH/api/files/:idRename and/or move between folders.
DELETE/api/files/:idSoft-delete.
PUT/api/files/:id/blobOverwrite bytes on an existing row (streamed).
GET/api/files/:id/blobDownload bytes (supports Range).

Name validation

Violations return invalid_name (on POST /api/files) or invalid_body (on PATCH /api/files/:id).

POST /api/files — atomic create + upload

Request:

The server generates the file UUID itself, writes the bytes to storage, and only then INSERTs the row in status="ready" with the resulting size_bytes and etag populated. If the upload fails, no row is created. If the INSERT fails (name collision, invalid folder), the storage object is best-effort deleted — so a failed create never leaves an orphan behind.

Success (201):

{ "file": { /* FileDTO — status="ready", version=1, size_bytes>0, etag=<sha256 hex> */ } }

Errors:

Migration note. The pre-2026-05 flow split this into two calls (POST /api/files for metadata, then PUT /api/files/:id/blob for bytes). Clients still on the two-call flow must switch to the combined shape — the old JSON-body create returns invalid_name / missing_body now.

GET /api/files — list

Optional query parameter: folder_id=<uuid> to filter to a specific folder. Omit for the root listing.

Success (200):

{ "files": [ /* FileDTO[], sorted by name ascending */ ] }

Excludes soft-deleted rows. Returns the caller's files only (owner-scoped server-side).

Errors: unauthorized.

GET /api/files/:id — metadata

Success (200): { "file": <FileDTO> }.

Errors: invalid_id, unauthorized, not_found.

Soft-deleted rows are still returned by this endpoint (their deleted_at will be set); the blob endpoints reject them.

PATCH /api/files/:id — rename and/or move

Request (at least one of name, folder_id must be present):

{ "name": "renamed.ghost", "folder_id": null }

folder_id: null moves the file to the root. A non-null folder_id must be a UUID of a non-deleted folder owned by the caller.

Success (200): { "file": <FileDTO> } (updated row).

Errors: invalid_id, invalid_body, empty_patch, name_taken, invalid_folder, unauthorized, not_found.

DELETE /api/files/:id — soft-delete

Success: 204 No Content.

Errors: invalid_id, unauthorized, not_found (also returned if the row was already soft-deleted).

Soft delete only — bytes stay in object storage until the phase 6 sweeper hard-deletes them. The row stays around for restore tooling but is excluded from listings.

PUT /api/files/:id/blob — overwrite bytes on an existing row

Use this to push new bytes for a file that's already in the system. For a brand-new file, use POST /api/files (atomic create + upload) instead.

Request:

The server streams the request body straight through to backing storage while computing sha256 and counting bytes. On a successful storage write, it updates the row:

Success (200): { "file": <FileDTO> }.

Errors: invalid_id, unauthorized, not_found, deleted (410, the row is soft-deleted), missing_body (no request body), upload_failed (502, storage rejected the write).

No conflict check. A second PUT overwrites the bytes and bumps version. For projects, use per-chapter LWW via /api/projects — there is no conflict-resolution protocol on this surface.

GET /api/files/:id/blob — download bytes

Request:

Success:

Errors: invalid_id, unauthorized, not_found, deleted (410), storage_unavailable (502).

Projects (per-document/-asset sync)

The structured, cloud-only sync surface. The server stores chapters, notes, and assets as individual Postgres rows. This is the right surface for any manuscript you intend to edit and sync — incremental saves push one document (or one asset) at a time and never re-upload the whole project.

Concepts

Caps

Exceeding either of these returns 413 with the relevant content_too_large / asset_too_large error code.

Project shape

{
  "id": "<uuid>",                          // matches project.json.id
  "owner_id": "<uuid>",
  "folder_id": "<uuid|null>",              // null = root
  "name": "My Novel",                      // display name (file-listing)
  "title": "My Novel",                     // project.json.title
  "description": "",
  "author": "",
  "format_version": 2,                     // manifest schema version mirrored on the row
  "cover_filename": "cover.jpg",           // "" if no cover
  "cover_thumbnail": {                     // null if no usable cover image
    "status": "ready",                     // "available" before first thumbnail request
    "url": "/api/projects/<uuid>/thumbnail",
    "width": 250,
    "height": 375,
    "content_type": "image/webp",
    "size_bytes": 8192,
    "etag": "<sha256 hex>",
    "updated_at": "2026-05-25T11:14:22.000Z"
  },
  "saved_prompts": [],                     // opaque to server, preserved round-trip
  "editing_plan": null,                    // opaque Editing Plan object, or null
  "metadata_version": 14,                  // bumps on any PATCH
  "original_created_at": "2025-09-12T14:03:11.000Z",
  "deleted_at": null,
  "created_at": "2026-05-25T08:00:00.000Z",
  "updated_at": "2026-05-25T11:14:22.000Z"
}

Document shape

{
  "id": "<uuid>",
  "project_id": "<uuid>",
  "owner_id": "<uuid>",
  "kind": "chapter",                       // "chapter" | "note"
  "filename": "01-prologue.md",
  "content": "# Prologue\n\n...",
  "version": 42,                           // bumps on every successful PUT
  "deleted_at": null,
  "created_at": "...",
  "updated_at": "..."
}

GET /api/projects/:id returns ordered chapters and notes made of document summaries (no content). Fetch the full document via GET /api/projects/:pid/documents/:did.

Chapter version shape

{
  "id": "<uuid>",
  "project_id": "<uuid>",
  "document_id": "<uuid>",
  "owner_id": "<uuid>",
  "document_version": 42,
  "content": "# Prologue\n\n...",
  "word_count": 1240,
  "created_at": "..."
}

Chapter snapshots are saved on create, then on PUT only when the latest saved snapshot differs by at least 100 words. The server keeps the newest 10 snapshots per chapter.

Asset shape

{
  "id": "<uuid>",
  "project_id": "<uuid>",
  "owner_id": "<uuid>",
  "filename": "cover.jpg",
  "content_type": "image/jpeg",
  "size_bytes": 87421,
  "etag": "<sha256 hex>",
  "deleted_at": null,
  "created_at": "...",
  "updated_at": "..."
}

Endpoints

MethodPathPurpose
POST/api/projectsCreate an empty project (no documents/assets).
GET/api/projectsList the caller's projects in one folder (lightweight).
GET/api/projects/:idFull project: metadata + document index + asset index (no document content).
GET/api/projects/:id/thumbnailGenerate-on-demand or stream the cached cover thumbnail.
PATCH/api/projects/:idUpdate metadata, ordering structures, or folder. Bumps metadata_version.
DELETE/api/projects/:idSoft-delete (cascades documents + assets in one tx).
GET/api/projects/:id/export/docxAssemble a Word (.docx) document from all chapter/note bodies. One-way.
GET/api/projects/:id/export/htmlAssemble a print-ready HTML document from all chapter/note bodies. One-way. This is the PDF path — the client renders it.
GET/api/projects/:id/export/epubAssemble an EPUB (chapters + notes + cover). One-way.
POST/api/projects/:id/import-docxBulk-create chapters from a parsed .docx into this project.
GET/api/projects/:id/outlineCached reverse outline, or null if not yet cached.
PUT/api/projects/:id/outlineStore the client-generated reverse outline (survives manuscript edits; the outline pipeline reconciles incrementally via per-entry doc_version stamps).
GET/api/projects/:id/ai-outlineCached AI-facing story outline, or null if not yet cached.
PUT/api/projects/:id/ai-outlineStore the client-generated AI-facing story outline (survives manuscript edits; reconciled incrementally, same as the reverse outline).
PATCH/api/projects/:id/ai-outlineCorrect one chapter entry (and/or the central relationship) in place when the outline's reading is wrong — pipeline stamps untouched, so the fix lasts until that chapter's prose really changes.
GET/api/projects/:id/continuityCached continuity report, or null if not yet cached.
PUT/api/projects/:id/continuityStore the client-generated continuity report (invalidated on manuscript change).
POST/api/projects/:pid/documentsCreate a chapter or note.
GET/api/projects/:pid/documents/:didRead a document body.
PUT/api/projects/:pid/documents/:didOverwrite a document body (autosave hot path).
PATCH/api/projects/:pid/documents/:didRename a document (change its filename display title) in place.
GET/api/projects/:pid/documents/:did/versionsList saved chapter snapshots, newest first.
DELETE/api/projects/:pid/documents/:didSoft-delete a document.
POST/api/projects/:pid/assets?filename=<urlencoded>Streamed asset upload (atomic create + upload).
GET/api/projects/:pid/assets/:aidDownload asset bytes (supports Range).
DELETE/api/projects/:pid/assets/:aidSoft-delete an asset.
POST/api/word-countCount words in caller-provided text.

POST /api/projects

Create an empty project.

{ "name": "Draft", "title": "Draft", "folder_id": null }

title is optional and defaults to name. The server generates the project id.

Success (201): { "project": <ProjectDTO> }.

Errors: invalid_json, invalid_body, invalid_name, invalid_folder, name_taken, unauthorized.

GET /api/projects

Optional folder_id=<uuid> filter. Returns { "projects": [<ProjectDTO>, …] } sorted by name ascending, soft-deleted excluded. The list resolves the current cover asset and cached thumbnail metadata only; it never downloads assets or generates thumbnails.

Errors: unauthorized.

GET /api/projects/:id

Full project including ordered chapter/note summaries and the asset index (no document content; fetch /documents/:did for that):

{
  "project":   <ProjectDTO>,
  "chapters": [
    { "id": "<uuid>", "kind": "chapter", "filename": "01-prologue.md", "version": 7, "updated_at": "..." },
    { "name": "Act 1", "chapters": [ <DocumentSummaryDTO>, ... ] }
  ],
  "notes": [ <DocumentSummaryDTO>, ... ],
  "assets": [ <AssetDTO>, ... ]
}

Errors: invalid_id, not_found, unauthorized.

GET /api/projects/:id/thumbnail

Authenticated endpoint for the current cover thumbnail. If a cached thumbnail exists for the project's current cover asset, the server streams it. Otherwise it downloads the source cover, generates a 250px-wide WebP thumbnail without upscaling, stores the derived bytes, upserts thumbnail metadata, and streams the new thumbnail.

Projects without a usable image cover return not_found. Thumbnail generation is isolated to this endpoint; project listing and project reads only expose thumbnail availability.

Success (200): WebP bytes with Content-Type: image/webp,ETag, X-Thumbnail-Width, and X-Thumbnail-Height.

Errors: invalid_id, not_found, storage_unavailable (502), upload_failed (502), unauthorized.

PATCH /api/projects/:id

At least one of name, title, description, author, cover_filename, notes, saved_prompts, editing_plan, folder_id must be present.

Chapter ordering is not here. It belongs to a draft — write it with PATCH /api/projects/:id/drafts/:draftId. Notes are project-level and stay on this route.

Every successful PATCH bumps metadata_version (whether or not any field actually changed value — the call itself is the signal). Changing or clearing cover_filename clears any cached derived thumbnail; a new one is generated only when the thumbnail endpoint is requested.

Success (200): { "project": <ProjectDTO> }.

Errors: invalid_id, invalid_json, invalid_body, invalid_name, invalid_cover, invalid_notes, invalid_saved_prompts, invalid_editing_plan, invalid_folder, empty_patch, name_taken, not_found, unauthorized.

DELETE /api/projects/:id

Soft-delete. Cascades deleted_at to documents and assets in the same transaction. Storage objects stay until the phase 6 sweeper.

Success: 204. Errors: invalid_id, not_found, unauthorized.

Drafts

A draft is a named, ordered set of chapter documents. Exactly one per project is active, and only the active one is writable — every other surface in this API (GET /api/projects/:id, exports, jobs, word counts) reports the active draft alone. Notes, assets, boards and every authored artifact are project-level and shared by all drafts.

The chapter ordering lives on the draft row, not the project. That is load-bearing: the manuscript loaders walk the ordering and then append every chapter document it didn't name, so nothing is silently dropped — with one project-level ordering across several drafts' documents, that rule would append the entire archive to every export and every analysis.

GET /api/projects/:id/drafts

Every draft, oldest first, each with chapter_count, word_count, is_active, and outline — a summary of that draft's kept AI story outline. Inactive drafts keep their outline (nothing regenerates a frozen manuscript), so it is the only description of what an old draft contained; null means the draft was never analysed while it was active.

POST /api/projects/:id/drafts

{ "name": "Second draft", "source": { "draft_id": "…", "include_prose": true } }

Creates the draft and activates it — a draft that exists but isn't active is a state this product doesn't have. Omit source for a blank draft. With it, the named draft's chapters and folders are copied, and include_prose decides whether their text comes too. Either way each copy's reference_document_id points at the chapter it came from, so a structure-only draft opens with the old chapter already beside the empty one.

A prose copy is byte-identical, so the derived caches are carried across (chapter version is copied with the content, and outline entries are re-stamped with the new document ids) and the new draft costs no regeneration. Copying never records writing activity or chapter-version snapshots: duplicating a book is not a day's work.

GET/PATCH/DELETE /api/projects/:id/drafts/:draftId

GET returns that draft's chapters in reading order, in the same shape as chapters on GET /api/projects/:id. Reading an inactive draft's chapter body needs no special route — GET /api/projects/:id/documents/:did fetches any document of the project by id.

PATCH takes name and/or chapters; the latter is the chapter-ordering write that used to live on the project PATCH. DELETE soft-deletes the draft and its chapters, and refuses the active draft (draft_is_active) and the last remaining one (last_draft), both 409.

PUT /api/projects/:id/active-draft

{ "draft_id": "…" }

Asks every running job on the project to stop, moves the pointer, bumps metadata_version, and drops the machine-derived caches that described the old manuscript: reverse outline, continuity, entity dossiers, derived story maps.

Deliberately untouched: the world bible, authored story maps, the authored plan, tasks, chat memory, editing plan and saved prompts. The rule is that the machine's reading of a manuscript may be discarded because it can be rebuilt from the prose; the author's work may not, because it cannot. The AI story outline needs no invalidation at all — it is keyed per draft, so the outgoing draft keeps its own row.

GET /api/projects/:id/export/{docx,pdf,epub}

Publish-format exports — one-way, not a .ghost round-trip. Each assembles all chapter and note bodies in reading order (the active draft's chapter ordering and the project's note_order), applying markdown bold/italic/headings and smart typography. Chapters start on new pages; notes follow at the end. docx returns a Word document, html a complete print-ready document, and epub builds an EPUB 2.0.1 with a title page, inline TOC, and — if cover_filename resolves to an asset — the cover image (re-encoded to JPEG). All respond with a Content-Disposition attachment header.

There is no pdf format. PDF is html plus a renderer, and every client already has one (Electron's printToPDF, or the browser's print dialog) — so the server hands over the document rather than keeping a headless Chromium resident to do it for them.

Errors: invalid_id, not_found, unauthorized.

POST /api/projects/:id/import-docx

Request body: raw .docx bytes (no JSON wrapper). Splits the document on heading levels into chapters (and optional one-level folders) exactly as the desktop client does, creates each as a chapter document, appends them to the active draft's chapter ordering, and bumps metadata_version. Additive into the active draft — it never creates a project or touches assets. Heading-derived filenames are de-duplicated against that draft's chapters only; a name reused from an older draft is not a collision, since the two never appear in one manuscript.

Success (201): { "project": <ProjectDTO>, "documents": [<DocumentSummary>, …] }.

Errors: invalid_id, empty_body, invalid_docx (400), content_too_large (413, > 20 MB), filename_taken (409, concurrent import race), not_found, unauthorized.

POST /api/projects/:pid/documents

{ "kind": "chapter", "filename": "02-arrival.md", "content": "" }

content is optional and defaults to empty. A new chapter joins the active draft; notes belong to no draft. The client is responsible for separately inserting it into the ordering — chapters via PATCH /api/projects/:id/drafts/:draftId, notes via PATCH /api/projects/:id — referencing it by id; the server doesn't touch the ordering structures on create. A document's identity is its id; filename is a duplicatable display title, so a create never collides on name.

Success (201): { "document": <DocumentDTO> }.

Errors: invalid_id, invalid_json, invalid_body, invalid_kind, invalid_filename, content_too_large (413), not_found (parent project missing or deleted), unauthorized.

GET /api/projects/:pid/documents/:did

Returns { "document": <DocumentDTO> } with the full content.

Errors: invalid_id, not_found, unauthorized.

PUT /api/projects/:pid/documents/:did

The autosave hot path. Body { "content": "..." }. Server overwrites and bumps version. Last-write-wins, no base_version check.

For chapters, the server also saves a history snapshot when there is no prior snapshot or the latest saved snapshot differs by at least 100 words. The newest 10 snapshots are retained.

Success (200): { "document": <DocumentDTO> }.

Errors: invalid_id, invalid_json, invalid_body, content_too_large (413), not_found, unauthorized.

PATCH /api/projects/:pid/documents/:did

Body { "filename": "...", "reference_document_id": "…" | null } — at least one. Renaming updates the display title in place; the document keeps its id, content, and version history (unlike a delete + re-create), and since filenames are duplicatable a rename never collides.

reference_document_id names a chapter in another draft of the same project — the one Apparition shows beside this chapter while it is written. Send null to clear it. A reference into another project, another account, or the same draft is rejected.

Neither field bumps version. That column is the document's content revision and the staleness key for every derived artifact — the embedding index, dossiers, world bible, book map, both outlines, and Poltergeist's plan anchors. A retitle and a pointer at a different document invalidate none of them, and bumping would cost a re-embed plus an outline regeneration for nothing. The row's realtime event still fires, so clients learn it changed.

Success (200): { "document": <DocumentDTO> }.

Errors: invalid_id, invalid_json, invalid_body, invalid_filename, invalid_reference, reference_same_draft, not_found, unauthorized.

GET /api/projects/:pid/documents/:did/versions

Returns saved chapter snapshots, newest first: { "versions": [<ChapterVersionDTO>, ...] } . Notes do not have snapshots.

Errors: invalid_id, not_found, unauthorized.

DELETE /api/projects/:pid/documents/:did

Soft-delete. Client is also responsible for dropping the reference from the ordering — the draft's chapters or the project's notes.

Success: 204. Errors: invalid_id, not_found, unauthorized.

POST /api/word-count

Request body: { "text": "..." }, { "content": "..." }, or { "project_id": "<uuid>" }. The project form counts all non-deleted chapters server-side without returning document content. Returns { "word_count": 123 }.

Errors: invalid_json, invalid_body, invalid_id, not_found, content_too_large (413), unauthorized.

POST /api/projects/:pid/assets?filename=<urlencoded>

Streamed atomic create + upload. Content-Type header is the asset's MIME. Body is the raw bytes. Same shape as POST /api/files but scoped to a project. Assets are immutable — replace via delete + new upload.

Success (201): { "asset": <AssetDTO> }.

Errors: invalid_id, invalid_filename, missing_content_type, missing_body, asset_too_large (413), filename_taken (409), not_found (parent project missing or deleted), upload_failed (502), unauthorized.

GET /api/projects/:pid/assets/:aid

Streams the bytes. Range supported (200 / 206 response codes). Content-Type from the row.

Errors: invalid_id, not_found, deleted (410), storage_unavailable (502), unauthorized.

DELETE /api/projects/:pid/assets/:aid

Soft-delete. Storage object stays until the phase 6 sweeper. If the asset was the source for a cached cover thumbnail, the server clears the derived thumbnail cache. If the asset was the project cover, the client should also PATCH cover_filename = "".

Success: 204. Errors: invalid_id, not_found, unauthorized.

Folders

Concepts

Folder shape

{
  "id": "<uuid>",
  "owner_id": "<uuid>",
  "parent_id": "<uuid|null>",              // null = root
  "name": "Drafts",
  "deleted_at": null,                      // ISO 8601 when soft-deleted
  "created_at": "2026-05-24T10:00:00.000Z",
  "updated_at": "2026-05-24T11:14:22.000Z"
}

Endpoints

MethodPathPurpose
GET/api/foldersList the user's folder tree.
POST/api/foldersCreate a folder.
PATCH/api/folders/:idRename or re-parent a folder.
DELETE/api/folders/:idSoft-delete a folder.

GET /api/folders — list

No query parameters. Returns every folder the user owns (flat list; the client reconstructs the tree from parent_id).

Success (200): { "folders": [<FolderDTO>, …] }.

Errors: unauthorized.

POST /api/folders — create

Request:

{ "name": "Drafts", "parent_id": null }

parent_id is optional; omit or send null for root. A non-null parent_id must be a UUID of a non-deleted folder owned by the caller.

Success (201): { "folder": <FolderDTO> }.

Errors: invalid_body, invalid_parent, name_taken, unauthorized.

PATCH /api/folders/:id — rename and/or re-parent

Request (at least one of name, parent_id must be present):

{ "name": "Renamed", "parent_id": "<uuid|null>" }

parent_id: null moves the folder to the root. A non-null parent_id must be a UUID of a non-deleted folder owned by the caller, must not equal :id, and must not be any descendant of :id (no cycles).

Success (200): { "folder": <FolderDTO> }.

Errors: invalid_id, invalid_body, empty_patch, invalid_parent, name_taken, unauthorized, not_found.

DELETE /api/folders/:id — soft-delete (cascading)

No request body. Server stamps deleted_at = now() on the folder, every live descendant folder, and every live file in the subtree, all in one transaction. Storage objects for those files are not removed — that's phase 6.

Success: 204 No Content.

Errors: invalid_id, unauthorized, not_found (also returned if the folder was already soft-deleted).

Realtime / WebSocket

Connect

Lifecycle

Subscribe

A freshly connected socket receives no events until it subscribes to one or more projects. Send a single JSON frame per project you care about:

ws.send(JSON.stringify({ type: "subscribe", project_id: "<uuid>" }))

The server acknowledges with { "type": "subscribed", "project_id": "<uuid>" }. To stop receiving a project's events, send { "type": "unsubscribe", "project_id": "<uuid>" } (acked with unsubscribed). A socket may subscribe to multiple projects; subscriptions are per-socket and are dropped when the socket closes. A malformed frame or a project_id that isn't a UUID is silently ignored. There is no need to re-subscribe after a refresh — only after a reconnect (a new socket starts with no subscriptions).

Messages from server

JSON, one event per frame. A project_changes server-side NOTIFY event is fanned out to a socket only if that socket has subscribed to the event's project_id:

{
  "type":       "project_changes",
  "kind":       "insert" | "update" | "delete",
  "entity":     "project" | "document" | "asset" | "job" | "bible",
  "project_id": "<uuid>",
  "id":         "<uuid>",         // == project_id when entity == "project"
  "version":    7,                 // metadata_version for project, version for document; absent for asset, job, bible
  "job": {                         // entity == "job" only (may be absent on delete / fallback)
    "kind":             "world_bible",
    "label":            "World bible",
    "status":           "running" | "done" | "error" | "cancelled",
    "progress":         { "label": "…", "current": 12, "total": 74, "fraction": 0.16, "indeterminate": false } | null,
    "error":            null,
    "cancel_requested": false,
    "started_at":       "<iso8601>",
    "questions_count":  0
  }
}

The opaque /api/files surface does not emit realtime events today (the old file_changes channel was removed when projects became the primary sync surface). If clients need file-listing updates, poll GET /api/files.

Client behavior

The server never broadcasts file bytes or document content — always re-fetch from the relevant endpoint when you care.

LLM agent

The server owns every model API key (Gemini, Anthropic, OpenAI, OpenRouter) and proxies all generation so no client ever holds a model credential. There is one project-scoped LLM endpoint; the client owns intent and prompt authoring and sends its access token plus a single system + prompt turn. The server optionally fetches project context, assembles the full user prompt, and streams the result.

POST /api/projects/:id/agent

Content-Type: application/json

FieldTypeNotes
systemstringSystem instruction. May be empty, but must be present.
promptstringRequired. The user turn; appended after any assembled context.
contextarrayOptional project-context entries to fetch: {"kind":"manuscript"}, {"kind":"notes"}, or {"kind":"file","filename":"…"}. Each becomes a labeled section.
inlinearrayOptional client-supplied text blocks: {"label":"…","text":"…"}.
max_tokensintegerUpper bound on output tokens. Defaults to 65536; clamped to 65536.
jsonbooleanOptional. Constrain the model to emit syntactically valid JSON (constrained decoding). Only honored on the default Gemini stream (no sources); ignored elsewhere. Not combinable with grounding.
groundingbooleanOptional. Google Search grounding on the default Gemini stream — the model searches the live web before answering. Only valid without sources/messages, and not combinable with json (invalid_grounding otherwise).
sourcesarrayOptional. Exactly one id (opus-4-8, fable-5, sonnet-5, gpt-5-5, gpt-5-6-sol, gemini-3-1-pro, gemini-flash, gemini-flash-lite, glm-5-2, kimi-k3, qwen3-8-max, minimax-m3) streams just that model. More than one entry is rejected with invalid_sources.
toolsarrayOptional. Native Anthropic tool definitions ({"name","description","input_schema"}), passed through verbatim. Only valid with messages.
messagesarrayOptional. Multi-turn conversation for native tool calling ({"role","content"}; content is text or Anthropic content blocks, passed through verbatim). Requires a single Claude source; prompt is ignored. Must start and end with a user turn. The first user turn is the exception to verbatim: the server injects the project header there and owns the prompt-cache breakpoint, replacing any cache_control a client sets on that turn.

The server resolves each context entry against the project and assembles the full user prompt — a ## Project: <title> header, one ## <label> section per context/inline entry, then the client's prompt. Then:

The response is one SSE stream (text/event-stream). Each data: event is a JSON object tagged by source: a token ({"source":"…","text":"…"}), a completion ({"source":"…","done":true}), or a failure ({"source":"…","error":"…"} — it ends the reply, e.g. a model refusal). On the native tool-calling path the turn also ends with {"source":"default","message":{"content":[…],"stop_reason":"…"}} — the raw assistant content to echo verbatim on the next request; stop_reason: "tool_use" means run the requested tools and continue with tool_result blocks in a new user turn. The stream ends with a literal data: [DONE].

Status before stream. Auth and upstream failures are returned as the HTTP status (especially 401) before any streaming begins — the client checks the status before reading the body. Once a 200 stream starts, a mid-stream upstream failure can only be surfaced by an error event ending the stream early.

Errors

A missing/expired/invalid access token returns 401 (the client refreshes once and retries). Validation failures return 400 with a code: invalid_body, invalid_context, invalid_inline, invalid_sources, invalid_tools, invalid_messages, no_chapters (a requested manuscript/notes context found nothing), invalid_id, not_found (404). Upstream Gemini failures (single stream) map as follows:

Statuserror codeCause
429rate_limitedGemini rate limit hit (key is shared across all users).
413content_too_largePrompt exceeds the model/context token limit.
400Gemini's reasonBad request / safety block — the upstream message is surfaced.
502upstream_errorGemini 5xx, network failure, or malformed upstream response.

All model keys live only in server config; none are ever returned to or held by any client.

Manuscript fetch

GET /api/projects/:id/manuscript

Returns the full manuscript (every chapter in reading order, with ## filename headers) as text/plain. Used by clients that need the raw text locally (e.g. a chat agent'sread_novel tool) rather than feeding it to a model. Errors: invalid_id, no_chapters (400), not_found, unauthorized.

Semantic search

POST /api/projects/:id/semantic-search

Embedding-based search over the project's chapters (the PhantomMemory chat semantic_search tool) — finds passages by meaning rather than exact wording. Chapters are chunked on paragraph boundaries and embedded with the server-held Gemini key (GEMINI_EMBEDDING_MODEL, default gemini-embedding-001); the index is rebuilt lazily on this path whenever a chapter's version is stale, so the first search after heavy edits is slow and later ones are fast.

{ "query": "the scene where doubt first creeps in", "top_k": 8, "chapter": "ch04.md" }

top_k (1–20, default 8) and chapter (exact chapter filename) are optional. Returns { "hits": [{ "filename", "chunk_index", "text", "score" }], "reindexed": n } best-first by cosine similarity. Errors: invalid_query/invalid_top_k/invalid_chapter/no_chapters (400), not_found, unauthorized, rate_limited (429), and upstream_error (502, including when the key is unset).

Speech-to-text

POST /api/transcribe

Dictation STT, proxied through the server so the ElevenLabs key (ELEVENLABS_API_KEY) never leaves it. The client records, meters, and splits audio into chunks locally, then POSTs each finished chunk as multipart/form-data with a single file field. The server forwards it to ElevenLabs, runs a best-effort, manuscript-safe Gemini Flash cleanup pass over the transcript — the model's output is diffed against the raw transcript and only small mechanical word-level fixes are merged; a rewrite is dropped by construction, and any failure falls back to the raw transcript — and returns the result. Clients insert the returned text as-is (there is no client-side cleanup pass):

An optional project_id form field injects that project's world-bible spellings into the cleanup pass so dictated names come back in their canonical form. Best-effort: an absent, invalid, or foreign id just means no spellings — never an error.

{ "text": "transcribed words for this chunk" }

The transcript may be an empty string for a silent chunk. Errors: file_required/empty_file/invalid_body (400), content_too_large (413, chunk over 25 MB), rate_limited (429), unauthorized, and upstream_error (502, including when the key is unset).

Vision OCR

POST /api/ocr

Photo-to-text OCR, proxied through the server so the Anthropic key (ANTHROPIC_API_KEY) never leaves it. The client captures and base64-encodes the photo locally, then POSTs (with an optional project_id that injects the project's world-bible spellings into the vision prompt, same best-effort semantics as /api/transcribe):

{ "image_base64": "<base64 bytes>", "media_type": "image/jpeg", "project_id": "<uuid, optional>" }

The server sends it to Claude vision (model ANTHROPIC_MODEL, default claude-opus-4-8) and returns the transcript:

{ "text": "the transcribed text" }

media_type must be one of image/jpeg, image/png, image/gif, image/webp. Errors: image_required/invalid_media_type (400), content_too_large (413, base64 over ~12 MB), rate_limited (429), unauthorized, and upstream_error (502, including when the key is unset).

Image generation

POST /api/images

Text-to-image, proxied so the provider key (OPENROUTER_API_KEY) never leaves the server. Stateless like the two proxies above — no project, no database, and no knowledge of what the picture is for. The caller composes the prompt and decides where the bytes go; the desktop's world-bible portraits hand them straight to POST /api/projects/:id/assets.

{ "prompt": "<what to draw>", "style": "<how, optional>", "size": "square" | "portrait" | "landscape" }
{ "image_base64": "<base64 bytes>", "media_type": "image/png" }

style is what makes a set of images look like a set, and it is not invented here — it comes from GET /api/projects/:id/art-direction, one sentence derived once per book from its positioning brief and story bible and cached on the project row. Ask for it, pass it through, and every portrait in a book shares a medium and a palette.

Errors: prompt_required/invalid_size/invalid_body (400), content_too_large (413), rate_limited (429), unauthorized, and upstream_error (502, including when the key is unset). A well-formed response carrying no picture — a refusal, or a model that answered in prose — comes back as 502 no_image.

Jobs (server-side pipelines)

The long-running LLM pipelines — the author-facing reverse outline, the AI-facing story outline, the three-pass continuity check, the world-bible extraction, and the book map — run on the server as jobs. A job survives the client disconnecting: kill the app mid-run and the pipeline keeps going; reopen and re-attach.

Workflow

  1. POST /api/projects/:id/jobs with { "kind": "reverse_outline" | "ai_outline" | "continuity" | "world_bible" | "book_map" | "entity_dossier" | "chapter_breakdown" | "outline_sketch" | "content_pull", "force": false } (entity_dossier also requires "key" — the target bible entity; and book_map requires "template" — the story template to map onto). Returns 202 with the job snapshot, or 409 job_running when a live run already holds that (project, kind) slot — treat 409 as "attach to the running job" (find it via the list).
  2. Watch over /ws: every persisted change (progress, questions, completion) fires an entity: "job" event — re-fetch the snapshot on each. No socket? Poll GET /api/projects/:id/jobs.
  3. On status: "done", fetch the artifact from its existing cache route (/outline, /ai-outline, /continuity, /bible, /story-maps, /dossiers, /content-pull). The job row carries narration, never the artifact.

Questions and answers

A pipeline can raise non-blocking author questions mid-run (continuity's "which is the story?"). They ride the snapshot's questions array; answer with POST /api/projects/:id/jobs/:jobId/answer ({ question_id, option_id, text? }). Answers write canon/intent back to the story bible and persist on the cached report. Unanswered questions rehydrate: when no continuity job row exists, the list synthesizes a done snapshot with id cq-<projectId> carrying them (dismissing it is a no-op; answering clears it).

Cancel / dismiss

DELETE /api/projects/:id/jobs/:jobId — a running job gets a cancellation request (202; the pipeline aborts between model calls and flips to cancelled, caching nothing); a finished one is deleted (204).

Liveness

The running machine heartbeats the row every 30 s. A running row without a heartbeat for 5 minutes is presumed dead (crash, redeploy) and is repaired to error: "interrupted" on the next read — or superseded by the next start. Deploys mark their own in-flight jobs interrupted on shutdown. After a WS reconnect, re-fetch the list — events during the gap are lost by design.

Content pull (Glamour)

The material a finished book gives its author to market it with — quotes, scenes and tropes read out of the manuscript — and the one-line hooks written from that material. Two endpoints, because the two halves cost very different things.

1. Read the book — a job

POST /api/projects/:id/jobs with { "kind": "content_pull" }. One model call per chapter, so it is a job, with per-chapter progress and cancellation. It is incremental by default: a chapter is re-read only when its prose changed. A rename or a reorder costs nothing at all — entries are keyed on the document id, and documents.version only moves on a content write. Read the result at GET /api/projects/:id/content-pull, whose changed_count is exactly how many chapters the next run would pay for.

Every quote is verified server-side as an exact substring of its chapter (under a typography-folding normalisation) before it is stored, and one that fails is dropped, never repaired — the surface exists so a line can be copied straight into an ad, and a tidied quote is not a line from the book. Scenes and tropes are paraphrase and carry the chapter the pipeline was reading, stamped from the document rather than model-authored.

2. Write hooks — a route

POST /api/projects/:id/content-pull/hooks. One call over the cached pull plus the positioning brief, the comp board and the author's kept hooks. It never starts the read and never generates one: a project with nothing read yet is refused 409 no_pull, so run the job first. The author's blurb is optional here (unlike the pitch sheet) — hooks come from the book and the board, and this surface is reachable before a word of blurb exists.

Cached against a hash of every input, so a second call with nothing changed costs no model call and no quota. The author's kept hooks are one of those inputs: keeping a hook is a statement of taste, and the next run should feel it. Every returned hook cites a real pulled item — the model cites by id, the id is resolved server-side, and anything unresolvable is dropped; a run where nothing resolves is 502 upstream_error, not an empty list.

Kept hooks

GET/POST /api/projects/:id/content-pull/hooks/saved and PATCH/DELETE …/saved/:hookId. Saving is authoring, not generating: no plan gate, no spend, and these rows survive everything — a draft switch discards the pull and never touches them. source (author | generated) is provenance for display and decides nothing: no query and no prompt filters on it, because a generated hook the author kept is as much a statement of her taste as one she typed.

World bible

A manuscript-derived entity canon: the world_bible job extracts characters, places, and terms from the cached AI story outline (ensured fresh first — a cold outline is generated on the way, which also warms it for chat, continuity, and the book map). The roster is identity only — canonical names and aliases (used for identity resolution, never for the derived spellings); entity descriptions live in the per-entity dossiers, generated on demand. Author overrides (rename / hide / notes) are applieddeterministically after extraction, so they reattach stably across regenerations. Mention counts and chapter lists are computed server-side by scanning the text, never asked of the model. A run when nothing changed is a cache hit (force re-extracts regardless). Distinct from the chat memory's novel_info story bible.

Endpoints

MethodPathPurpose
GET/api/projects/:id/bibleThe merged cards + overrides + derived spellings, or { "bible": null } before the first run.
PUT/api/projects/:id/bible/overridesReplace the author's overrides (rename / hide / notes / planned chapters) and re-derive the cards — no model call. 404 no_bible before the first current-format run.

spellings is the flat canonical-spellings list (the canonical name of each visible card only — aliases do not contribute — ranked characters → places → terms by mention count, capped at 200). It is derived server-side only — the same list the /api/transcribe cleanup pass and /api/ocr vision prompt inject when the client sends a project_id. Those two proxies stay stateless by default; the optional id adds exactly one best-effort read of this cache and never a write.

Story templates and story maps

A story template is a structure: the beats a tradition asks for, in order, with a line of craft guidance each. The catalog covers the universal frames (Story Grid, three-act, the fifteen-beat sheet, the hero's journey, seven-point) and the genre beat sheets (romance, mystery, thriller, heroic and epic fantasy). A template declares structure only — it knows nothing about who fills it in.

A story map is one of those templates filled in: a header plus a tree of parts, each carrying its named beats, and each beat carrying the chapters it lands in. Two sources share the surface. The book_map job writes derived maps, mapping the cached AI story outline onto the requested template — never the raw manuscript, so the outline's interior tracks and relational beats anchor the structure. authored maps are the writer's own plan. Same shape on both sides, so plan and manuscript are directly comparable.

A cold outline cache is generated on the way (which also warms it for chat and continuity). A run when nothing changed — same manuscript, same template version — is a cache hit; force re-maps regardless. Each beat stores its own label, so a map stays readable after the template it was built against is edited or removed.

Endpoints

MethodPathPurpose
GET/api/story-templatesThe template catalog. Not project-scoped; user-scoped, since the list will carry the author's own templates beside the built-ins (origin).
GET/api/projects/:id/story-mapsEvery template filled in for this project, both sources. Empty list before anything is mapped or planned. Authored rows also carry loose (beats not yet placed) and layout (where the board draws them; null means lay it out again).
PUT/api/projects/:id/story-maps/:templateIdThe author's own map for one template (Mara's Cards board). Always the authored source. Partial per key: map alone or layout alone, so a drag never clobbers a typed beat. A changed map voids the chapter proposal made from it; a layout write does not. 413 over 1 MiB.
POST/api/projects/:id/story-maps/:templateId/adoptCopy the derived map over the authored one, minting ids and keeping each beat's chapters as match hints. 404 no_derived_map before the job has run; 409 authored_not_empty when the board already holds a filled beat, unless {"force": true}.

Entity dossiers

A deep-dive reference page for one world-bible entity, generated by the entity_dossier job from mention-window excerpts rather than a whole-manuscript read: the server scans the manuscript for the entity's surface forms (name and aliases, all known from the bible), takes a paragraph window around each hit, merges overlaps, and runs one model call over just those passages. Token cost scales with how often the entity is on the page, not with book length. Requires a current-format bible (no_bible otherwise); the job's key targets a BibleEntity.key. Each dossier caches under its mentioning chapters' doc versions, so editing a chapter only stales the dossiers whose entity appears in it; a nothing-changed run is a cache hit (force regenerates regardless).

Endpoints

MethodPathPurpose
GET/api/projects/:id/dossiersEvery cached dossier keyed by entity key (overview, a four-cell glance, typed sections, a chapter-cited timeline, and ties to other entities), each with an advisory stale flag. Empty map before the first run. Server-generated only — no client PUT.

Freewrite drafts

A staging pile for drafts written on a Freewrite device. Each user connects their own Dropbox once via OAuth (connect → browser consent → callback; the refresh token is stored encrypted per user). The server then syncs Postbox's Dropbox folder into freewrite_drafts rows on demand — the client POSTs /api/freewrite/sync when its drafts overlay opens; there is no background poller. sync returns 409 not_connected until the user connects. Only the Dropbox app key/secret live in env. Inserting a draft into a book is client-side — the editor pastes the content at the cursor like dictation/OCR — after which the client archives the draft to take it out of the pile. The server never writes draft content into project documents. New writing on the device (a new Dropbox rev) resurfaces an archived/deleted draft as pending. Books opt into a device folder via freewrite_folder ("A" | "B" | "C" | null) on the project PATCH; several books may share a letter.

Endpoints

MethodPathPurpose
POST/api/freewrite/connectStart the Dropbox OAuth flow; returns the consent URL.
GET/api/freewrite/connectionIs this user's Dropbox connected? (Poll after opening consent.)
DELETE/api/freewrite/connectionDisconnect (revoke + forget the token); drafts stay.
GET/api/integrations/dropbox/callbackBrowser-facing OAuth redirect target (HTML page, no bearer).
POST/api/freewrite/syncRun one Dropbox sync cycle (overlay open); 409 not_connected, 502 when unconfigured.
GET/api/freewrite/drafts?folder=A&status=pendingList the pile (no content); both params optional, status defaults to pending.
GET/api/freewrite/drafts/:idOne draft with full content.
POST/api/freewrite/drafts/:id/archiveAfter a client-side insert: flips to archived, records optional { project_id, document_id }. Idempotent.
DELETE/api/freewrite/drafts/:idDiscard from the pile (status flip, 204).

Things to confirm with the live server before shipping client code