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. Per-user quotas and rate limits (phase 6) are not yet enforced.

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.
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).
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

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.

POST /api/auth/signin

Same request shape as signup. Same success shape. Errors: invalid_body, invalid_credentials. 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.

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.

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/pdfRender all chapter/note bodies to PDF (headless Chromium). One-way.
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).
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, chapters, notes, saved_prompts, editing_plan, folder_id must be present.

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_chapters, 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.

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 project's chapter_tree / 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, pdf renders the same HTML the desktop client prints (via headless Chromium), 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.

Errors: invalid_id, not_found, unauthorized; PDF only: pdf_render_failed (502) if Chromium fails.

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 project's chapter_tree, and bumps metadata_version. Additive into this existing project — it never creates a project or touches assets. Heading-derived filenames are de-duplicated against documents already in the project.

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

Errors: invalid_id, empty_body, invalid_docx (400), content_too_large (413, > 100 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. The client is responsible for separately PATCHing the project to insert the new document into chapters or notes (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

Rename a document. Body { "filename": "..." }. Updates the filename display title in place and bumps version. The document keeps its id, content, and version history (unlike a delete + re-create), and since filenames are duplicatable a rename never collides.

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

Errors: invalid_id, invalid_json, invalid_body, invalid_filename, 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 PATCHing the project's chapters or notes to drop the reference.

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
}

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.
sourcesarrayOptional. Exactly one id (opus-4-8, fable-5, gpt-5-5, gpt-5-6-sol, gemini-3-1-pro, gemini-flash, 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 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).

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", "force": false }. 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, /book-map). 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.

World bible

A manuscript-derived entity canon: the world_bible job reads the whole manuscript in one pass and extracts characters, places, and terms — exact spellings, aliases (including misspellings, surfaced so the author can mark them as typos), and book-level facts. Seeing every chapter at once is what makes identity resolution reliable: a nickname in chapter 2 and a full name in chapter 30 land on one card. Author overrides (rename / add aliases / mark typos / hide) are applied deterministically 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 / add aliases / mark typos / hide) and re-derive the cards — no model call. 404 no_bible before the first current-format run.

spellings is the flat canonical-spellings list (visible cards' names and aliases, typo-marked forms excluded, 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.

Book map

The arc-first structural map PhantomMemory's Book Map page renders: the story's main arc (protagonist, want, conflict, growth — the plotline whose resolution requires the protagonist to change) plus its acts, each carrying the five structural elements, the external plot's per-act pressure, and its scene list. The book_map job derives it from the cached AI story outline — never from the raw manuscript — so the outline's interior tracks and relational beats anchor the acts and the external threat plot reads as pressure on the arc rather than being mapped as the story. A cold outline cache is generated on the way (which also warms it for chat and continuity). A run when nothing changed is a cache hit (force re-maps regardless).

Endpoints

MethodPathPurpose
GET/api/projects/:id/book-mapThe map (main_arc + acts, scenes embedded per act), or { "book_map": null } 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