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:
- Files (
/api/files) — opaque blob storage for arbitrary uploads (PDFs, drafts, exported artifacts, etc.). Server never reads the bytes. - Projects (
/api/projects) — structured, cloud-only sync. The server stores chapters, notes, and assets as individual Postgres rows. Clients PUT individual chapters (~5-second autosave cadence) and upload assets one at a time, without re-uploading the whole project.
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/bloboverwrites bytes and bumpsversion; a secondPUT /api/projects/:pid/documents/:didoverwrites a chapter and bumps itsversion. There is nobase_versionprecondition and no conflict-sibling protocol on either surface.
Conventions
- Base URL is configurable per client; in dev it's
http://localhost:3000, in prod it's whatever Fly host the server is deployed at. Clients should store this in user settings. - All bodies are JSON with
Content-Type: application/json, except file blob upload/download (raw bytes) andPOST /api/files(raw bytes; see below). - Authenticated requests carry
Authorization: Bearer <access_token>. - Errors always return
{ "error": "<machine_readable_code>" }plus an HTTP status. Codes are stable; messages are not provided (clients map codes to UI strings themselves).
Common error codes
| Code | Status | Meaning |
|---|---|---|
invalid_json | 400 | Body wasn't valid JSON. |
invalid_body | 400 | Body didn't match the expected schema. |
invalid_id | 400 | Path parameter wasn't a valid UUID. |
invalid_name | 400 | Filename failed validation (length, forbidden chars, leading/trailing whitespace, ./..). |
missing_body | 400 | A request expected a request body and didn't get one. |
missing_content_type | 400 | Sent without a Content-Type header (on POST /api/files or asset upload). |
empty_patch | 400 | PATCH body had none of the patchable fields. |
invalid_folder | 400 | Referenced folder_id doesn't exist, is deleted, or isn't a UUID. |
invalid_parent | 400 | Referenced parent_id doesn't exist, is deleted, equals :id, or would create a cycle. |
invalid_kind | 400 | Document kind was not chapter or note. |
invalid_filename | 400 | Document/asset filename failed validation (empty, contains /, \, .., or starts with .). |
invalid_chapters | 400 | PATCH project chapters wasn't an ordered list of chapter objects/folders. |
invalid_notes | 400 | PATCH project notes wasn't an ordered list of note objects. |
invalid_saved_prompts | 400 | PATCH project saved_prompts didn't match the expected shape. |
invalid_editing_plan | 400 | PATCH project editing_plan didn't match the expected shape (and wasn't null). |
invalid_cover | 400 | PATCH project cover_filename isn't a valid filename (or empty for none). |
unauthorized | 401 | Missing/invalid/expired access token. |
invalid_credentials | 401 | Wrong email or password. |
invalid_refresh_token | 401 | Refresh token unknown, expired, or revoked. |
not_found | 404 | No such resource for the calling user. |
name_taken | 409 | Display name collision in the same folder (files or projects). |
filename_taken | 409 | Document or asset with the same filename already exists in the project. |
email_taken | 409 | Signup with an already-registered email. |
deleted | 410 | Resource is soft-deleted. |
content_too_large | 413 | Document content exceeded 2 MB. |
asset_too_large | 413 | Asset blob exceeded 25 MB. |
origin_not_allowed | 403 | Browser sent an Origin this server doesn't serve. See CORS. |
rate_limited | 429 | Rate limit hit — ours or an upstream provider's. See Rate limits. |
upload_failed | 502 | Server couldn't write the bytes to backing storage. |
storage_unavailable | 502 | Server 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.
| Bucket | Budget | Applies to |
|---|---|---|
auth_signin | 20 / 10 min | POST /api/auth/signin |
auth_write | 10 / 10 min | Signup, forgot/reset password, verification resend + confirm |
auth_refresh | 60 / 10 min | POST /api/auth/refresh |
llm | 60 / 5 min | /agent, /ocr, /transcribe, /semantic-search, /memory/reflect, POST /jobs |
export | 20 / 10 min | /export/*, /import-docx, /thumbnail |
api | 600 / min | Everything 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:
- Access token — JWT, ~15 min TTL. Send in
Authorization: Bearer …on every authed request and on the WebSocket upgrade. - Refresh token — opaque random string, ~30 day TTL. Trade for a new access token via
POST /api/auth/refresh. Rotates on every use — the refresh response contains a new refresh token; discard the old one and store the new.
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
- Native clients (desktop/mobile): refresh token in OS-secure storage. Access token in memory only.
- Never persist the access token to disk — it's short-lived and re-derivable from the refresh token.
Refresh-on-401 pattern
Wrap your HTTP client with this loop:
- Make the request.
- If status ≠ 401, return the response.
- Call
POST /api/auth/refreshwith 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
- Each user owns files (binary blobs with metadata).
- A file is addressed by UUID. The display
name(including extension) is unique within(owner, folder). - Storage is content-agnostic. The server never parses these bytes regardless of MIME.
- All files are
status: "ready"—POST /api/filesis atomic create-plus-upload, so the row only commits after bytes are in storage.
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_idanddeleted_atarrive as literal JSONnull(not absent, not empty string). Statically typed clients must declare these as nullable /Option.
Endpoints (live)
| Method | Path | Purpose |
|---|---|---|
| POST | /api/files | Atomically create a file row + upload its bytes (streamed). |
| GET | /api/files | List the caller's files in one folder. |
| GET | /api/files/:id | Get one file's metadata. |
| PATCH | /api/files/:id | Rename and/or move between folders. |
| DELETE | /api/files/:id | Soft-delete. |
| PUT | /api/files/:id/blob | Overwrite bytes on an existing row (streamed). |
| GET | /api/files/:id/blob | Download bytes (supports Range). |
Name validation
- 1–255 characters.
- No control characters (
\x00–\x1f), no/, no\. - No leading or trailing whitespace.
- Not
.or...
Violations return invalid_name (on POST /api/files) or invalid_body (on PATCH /api/files/:id).
POST /api/files — atomic create + upload
Request:
- Query parameters:
name(required) — URL-encoded display name (see name validation above).folder_id(optional) — UUID of an existing, non-deleted folder. Omit for root.
Content-Typeheader (required): the file's MIME type. Stored on the row and echoed back on download. For.ghostfiles useapplication/zip.- Body: raw file bytes, streamed. No JSON wrapper, no multipart. Chunked transfer encoding works fine; the server streams the body straight through to backing storage while computing sha256 and counting bytes.
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:
invalid_name(400) — missingnamequery param, or it failed validation.invalid_folder(400) —folder_idisn't a UUID, or no such non-deleted folder exists for the caller.missing_content_type(400) — noContent-Typeheader on the request.missing_body(400) — empty request body.name_taken(409) — a non-deleted file with the same name already exists in that folder.upload_failed(502) — storage rejected or dropped the write.unauthorized(401).
Migration note. The pre-2026-05 flow split this into two calls (
POST /api/filesfor metadata, thenPUT /api/files/:id/blobfor bytes). Clients still on the two-call flow must switch to the combined shape — the old JSON-body create returnsinvalid_name/missing_bodynow.
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:
- Body: raw file bytes (no JSON wrapper, no multipart).
Content-Typeheader: optional. If set, the server updates the row'scontent_typeto match. If absent, the existing row'scontent_typeis kept.Content-Length: optional. The server streams the body and counts bytes as it goes, so chunked transfer encoding works fine. Don't pre-buffer large files in memory client-side.
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:
size_bytes→ actual bytes received.etag→ sha256 hex of the bytes.version→ bumps on every successful write.
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:
- Optional
Range: bytes=…header for partial downloads.
Success:
- 200 for a full download.
- 206 if a
Rangeheader was honored. - Body: raw bytes streamed.
- Headers:
Content-Typefrom the file row (not whatever storage echoes), plusContent-Length,Content-Range,Accept-Ranges,Last-Modifiedwhen storage provides them.
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
- A project is a single manuscript stored as rows. The API exposes top-level metadata plus composed, ordered
chaptersandnoteslists. - A document is one chapter or one note — same schema, distinguished by
kind(chapterornote). Filenames (e.g.01-prologue.md) are the stable identity per the format spec; don't rename, recreate. - An asset is a binary attachment (cover image, etc.). Assets are immutable — to replace one, delete and upload a new asset with the same filename.
- Cover thumbnails are derived server artifacts. They are generated lazily from the current cover asset and are never returned in asset indexes.
- Ordering structures live on the project, not the documents. Creating, deleting, or reordering a chapter or note is two calls: create/delete the document, then PATCH the project to update
chaptersornotes. - Last-write-wins.
PUTa document and the server overwrites + bumpsversion; nobase_versioncheck and no conflict siblings. - Three NOTIFY events flow through a single channel (
project_changes): project metadata change, document change, asset change. See Realtime.
Caps
- Document content (chapter or note): 2 MB.
- Asset blob: 25 MB.
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
| Method | Path | Purpose |
|---|---|---|
| POST | /api/projects | Create an empty project (no documents/assets). |
| GET | /api/projects | List the caller's projects in one folder (lightweight). |
| GET | /api/projects/:id | Full project: metadata + document index + asset index (no document content). |
| GET | /api/projects/:id/thumbnail | Generate-on-demand or stream the cached cover thumbnail. |
| PATCH | /api/projects/:id | Update metadata, ordering structures, or folder. Bumps metadata_version. |
| DELETE | /api/projects/:id | Soft-delete (cascades documents + assets in one tx). |
| GET | /api/projects/:id/export/docx | Assemble a Word (.docx) document from all chapter/note bodies. One-way. |
| GET | /api/projects/:id/export/pdf | Render all chapter/note bodies to PDF (headless Chromium). One-way. |
| GET | /api/projects/:id/export/epub | Assemble an EPUB (chapters + notes + cover). One-way. |
| POST | /api/projects/:id/import-docx | Bulk-create chapters from a parsed .docx into this project. |
| GET | /api/projects/:id/outline | Cached reverse outline, or null if not yet cached. |
| PUT | /api/projects/:id/outline | Store the client-generated reverse outline (survives manuscript edits; the outline pipeline reconciles incrementally via per-entry doc_version stamps). |
| GET | /api/projects/:id/ai-outline | Cached AI-facing story outline, or null if not yet cached. |
| PUT | /api/projects/:id/ai-outline | Store the client-generated AI-facing story outline (survives manuscript edits; reconciled incrementally, same as the reverse outline). |
| GET | /api/projects/:id/continuity | Cached continuity report, or null if not yet cached. |
| PUT | /api/projects/:id/continuity | Store the client-generated continuity report (invalidated on manuscript change). |
| POST | /api/projects/:pid/documents | Create a chapter or note. |
| GET | /api/projects/:pid/documents/:did | Read a document body. |
| PUT | /api/projects/:pid/documents/:did | Overwrite a document body (autosave hot path). |
| PATCH | /api/projects/:pid/documents/:did | Rename a document (change its filename display title) in place. |
| GET | /api/projects/:pid/documents/:did/versions | List saved chapter snapshots, newest first. |
| DELETE | /api/projects/:pid/documents/:did | Soft-delete a document. |
| POST | /api/projects/:pid/assets?filename=<urlencoded> | Streamed asset upload (atomic create + upload). |
| GET | /api/projects/:pid/assets/:aid | Download asset bytes (supports Range). |
| DELETE | /api/projects/:pid/assets/:aid | Soft-delete an asset. |
| POST | /api/word-count | Count 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
- Folders nest.
parent_id = nullmeans a folder lives at the root. - A file lives in zero or one folder;
folder_id = nullmeans root. - Names are unique per
(owner, parent_id)among non-deleted folders — same partial-uniqueness model as files. - Folders are pure metadata — moving or renaming never moves any bytes.
- Soft-deleting a folder cascades transitively: every descendant folder and every file pinned to any folder in the subtree gets its own
deleted_atstamped in the same transaction. Storage objects stay until the phase 6 sweeper. - Name validation is identical to files (1–255 chars, no control chars, no
/or\, no leading/trailing whitespace, not.or..).
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
| Method | Path | Purpose |
|---|---|---|
| GET | /api/folders | List the user's folder tree. |
| POST | /api/folders | Create a folder. |
| PATCH | /api/folders/:id | Rename or re-parent a folder. |
| DELETE | /api/folders/:id | Soft-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
- URL:
wss://<server>/ws(orws://in dev). - Auth on upgrade. Send two subprotocols on the WS handshake:
In a browser-style API:Sec-WebSocket-Protocol: ghostkey-v1, bearer.<access_token>
The server validates the bearer token before completing the handshake. On success it echoes back onlynew WebSocket("wss://<server>/ws", ["ghostkey-v1", "bearer." + accessToken])ghostkey-v1as the negotiated subprotocol — thebearer.*value is never reflected, so the token doesn't end up in response logs or caches. - Auth failures are returned as a plain HTTP 401 Unauthorized on the upgrade response. The WS connection is never opened. A missing or unknown
ghostkey-v1subprotocol returns 400 Bad Request the same way.
Lifecycle
- Server pings every 30s; the WS library pongs automatically. A socket that misses a ping/pong cycle is terminated.
- When the access token is near expiry, refresh via
POST /api/auth/refreshand reconnect with the new token. (The server doesn't close sockets on token expiry today — the existing socket keeps its session — but you'll need a fresh token to reconnect after any drop.) - On any connection drop, reconnect with exponential backoff capped at ~30s.
- The only client → server message the server reads is the
subscribe/unsubscribeframe (see below). Any other message is ignored.
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
- If
entity == "document"andversion > local_versionfor a document the client has open or cached: pull it fromGET /api/projects/:pid/documents/:did. - If
entity == "project"andversion > local_metadata_version: pull the full project (chapters, notes, asset list) viaGET /api/projects/:id. - If
entity == "asset": invalidate any cached copy of that asset id. New uploads have a new id; a delete event for the old id is your signal to drop it. - If the change is a folder mutation visible in a listing the user is viewing: refresh the listing. (No realtime event for folders today.)
- If
entity == "job": re-fetchGET /api/projects/:id/jobs/:jobId(or the list) — the event fires on every persisted job change (progress ticks, questions, completion).kind == "delete"means the row is gone; drop the card. See Jobs. - If
entity == "bible": re-fetchGET /api/projects/:id/bibleif the bible is on screen (fires when a run finishes or overrides are saved). See World bible.
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
| Field | Type | Notes |
|---|---|---|
system | string | System instruction. May be empty, but must be present. |
prompt | string | Required. The user turn; appended after any assembled context. |
context | array | Optional project-context entries to fetch: {"kind":"manuscript"}, {"kind":"notes"}, or {"kind":"file","filename":"…"}. Each becomes a labeled section. |
inline | array | Optional client-supplied text blocks: {"label":"…","text":"…"}. |
max_tokens | integer | Upper bound on output tokens. Defaults to 65536; clamped to 65536. |
json | boolean | Optional. Constrain the model to emit syntactically valid JSON (constrained decoding). Only honored on the default Gemini stream (no sources); ignored elsewhere. |
sources | array | Optional. 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. |
tools | array | Optional. Native Anthropic tool definitions ({"name","description","input_schema"}), passed through verbatim. Only valid with messages. |
messages | array | Optional. 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:
- Single stream (no
sources): one Gemini call, re-emitted as Server-Sent Events taggedsource: "default". - Single model (one
sourcesid): that model streams alone, still taggedsource: "default"— the chat model picker. Withmessages(Claude sources only) the call is multi-turn with native tool use.
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 a200stream starts, a mid-stream upstream failure can only be surfaced by anerrorevent 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:
| Status | error code | Cause |
|---|---|---|
| 429 | rate_limited | Gemini rate limit hit (key is shared across all users). |
| 413 | content_too_large | Prompt exceeds the model/context token limit. |
| 400 | Gemini's reason | Bad request / safety block — the upstream message is surfaced. |
| 502 | upstream_error | Gemini 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
POST /api/projects/:id/jobswith{ "kind": "reverse_outline" | "ai_outline" | "continuity" | "world_bible" | "book_map", "force": false }. Returns 202 with the job snapshot, or 409job_runningwhen a live run already holds that (project, kind) slot — treat 409 as "attach to the running job" (find it via the list).- Watch over /ws: every persisted change (progress, questions, completion) fires an
entity: "job"event — re-fetch the snapshot on each. No socket? PollGET /api/projects/:id/jobs. - 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
| Method | Path | Purpose |
|---|---|---|
| GET | /api/projects/:id/bible | The merged cards + overrides + derived spellings, or { "bible": null } before the first run. |
| PUT | /api/projects/:id/bible/overrides | Replace 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
| Method | Path | Purpose |
|---|---|---|
| GET | /api/projects/:id/book-map | The 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
| Method | Path | Purpose |
|---|---|---|
| POST | /api/freewrite/connect | Start the Dropbox OAuth flow; returns the consent URL. |
| GET | /api/freewrite/connection | Is this user's Dropbox connected? (Poll after opening consent.) |
| DELETE | /api/freewrite/connection | Disconnect (revoke + forget the token); drafts stay. |
| GET | /api/integrations/dropbox/callback | Browser-facing OAuth redirect target (HTML page, no bearer). |
| POST | /api/freewrite/sync | Run one Dropbox sync cycle (overlay open); 409 not_connected, 502 when unconfigured. |
| GET | /api/freewrite/drafts?folder=A&status=pending | List the pile (no content); both params optional, status defaults to pending. |
| GET | /api/freewrite/drafts/:id | One draft with full content. |
| POST | /api/freewrite/drafts/:id/archive | After a client-side insert: flips to archived, records optional { project_id, document_id }. Idempotent. |
| DELETE | /api/freewrite/drafts/:id | Discard from the pile (status flip, 204). |
Things to confirm with the live server before shipping client code
- Per-user storage quotas — not enforced day one, will arrive in phase 6 along with upload rate limiting. Until then, the only protections are the per-document and per-asset size caps.
- End-to-end encryption is not available on the projects surface (the server reads chapter content directly to support per-chapter sync). If E2EE is ever needed it would have to ride on top of
/api/filesas opaque ciphertext.