Skip to content

Pages API

All endpoints require authentication and pages tool permissions.

GET /api/pages

Query parameters:

ParamTypeDescription
searchstringFull-text search on title and content (returns up to 50 results)
favouritesstringSet to true to return only the current user’s favourited pages
entity_typestringFilter pages linked to a specific entity type (used with entity_id)
entity_idstringFilter pages linked to a specific entity ID (used with entity_type)
archivedstringSet to true to return archived (trashed) pages

Response: { pages }

Each page includes id, title, icon, parent_id, position, entity_type, entity_id, created_by, updated_by, created_at, updated_at, created_by_name, updated_by_name, children_count, is_favourited (boolean), and content_preview (first 200 chars, only present in search results).

When no filters are applied, returns standalone (non-entity) unarchived pages ordered by parent_id, position, then title — suitable for building a sidebar tree.

GET /api/pages/:id

Returns the full page including content.

Response: { page }

The page object includes all list fields plus content, content_rich, cover_image, is_archived, full_width, small_text, lock_page, toc, text_style, is_published, and published_at.

POST /api/pages

Permission: pages.update

Body:

{
"title": "string (optional, defaults to 'Untitled')",
"content": "string (optional)",
"content_rich": "string | null (optional, rich editor JSON)",
"parent_id": "string | null (optional, parent page ID for nesting)",
"entity_type": "string | null (optional, link to entity type)",
"entity_id": "string | null (optional, link to entity ID)"
}

Position is auto-calculated as the next position under the given parent.

Response: { page } with status 201

PATCH /api/pages/:id

Permission: pages.update

Body: Any combination of:

FieldTypeDescription
titlestringPage title (empty string becomes “Untitled”)
contentstringPlain text content
content_richstring | nullRich editor JSON content (also clears yjs_snapshot)
iconstring | nullPage icon emoji or identifier
cover_imagestring | nullCover image URL
is_archivednumber1 to archive, 0 to restore
full_widthnumber1 for full-width layout
small_textnumber1 for small text mode
lock_pagenumber1 to lock editing
tocnumber1 to show table of contents
text_stylestringText style preset
is_publishednumber1 to publish (sets published_at), 0 to unpublish (clears published_at)
parent_idstring | nullMove page to a new parent
positionnumberReorder position within parent

Response: { page }

DELETE /api/pages/:id

Permission: pages.manage

Soft-deletes the page by setting is_archived = 1. Child pages are orphaned to root level (their parent_id is set to NULL).

Response: { ok: true }

POST /api/pages/:id/favourite

Permission: pages.view

Toggles the favourite status for the current user. If already favourited, removes the favourite; otherwise adds it.

Response: { is_favourited: boolean }


POST /api/pages/ai/command

Permission: pages.update

Streams AI-generated or edited content based on the editor context. Uses Claude Sonnet via the Anthropic SDK with server-sent events.

Body:

{
"ctx": {
"children": "array (Slate editor children nodes)",
"selection": "object | null (Slate selection with anchor/focus)",
"toolName": "string (optional: 'edit' | 'generate' — auto-detected from selection)"
},
"messages": "array of chat messages [{ role, parts: [{ type, text }] }]"
}

Behaviour:

  • With selection + edit tool: Edits the selected text based on the user’s instruction
  • With selection (other tool): Transforms/rewrites the selected text
  • Without selection: Generates new content based on the instruction

Response: Server-sent event stream (text/event-stream) using the Vercel AI SDK UIMessageStream format. Includes a data-toolName event indicating the tool used.

POST /api/pages/ai/copilot

Permission: pages.update

Generates short inline completions (max 50 tokens) for the copilot/autocomplete feature.

Body:

{
"prompt": "string (the text context for completion)",
"system": "string (system prompt for the model)"
}

Response: Vercel AI SDK generateText result object.

Returns 408 if the request is aborted by the client.


GET /api/pages/:pageId/discussions

Permission: pages.view

Returns all discussions for a page, each with nested comments.

Response: { discussions }

Each discussion includes id, page_id, user_id, document_content, document_content_rich, is_resolved (boolean), created_at, updated_at, user_name, user_avatar, and a comments array. Each comment includes id, discussion_id, user_id, content, content_rich, is_edited, created_at, updated_at, user_name, user_avatar.

POST /api/pages/:pageId/discussions

Permission: pages.update

Creates a new discussion thread, optionally with a first comment.

Body:

{
"id": "string (optional, client-generated UUID)",
"document_content": "string (the highlighted text)",
"document_content_rich": "string (optional, rich content JSON)",
"comment": "string (optional, first comment text)",
"comment_rich": "string (optional, first comment rich content)"
}

Response: { discussion: { id, page_id } } with status 201

POST /api/pages/:pageId/discussions/:discussionId/comments

Permission: pages.update

Body:

{
"id": "string (optional, client-generated UUID)",
"content": "string (required)",
"content_rich": "string (optional)"
}

Response: { comment: { id } } with status 201

PATCH /api/pages/:pageId/discussions/:discussionId/comments/:commentId

Permission: pages.update

Body:

{
"content": "string (required)",
"content_rich": "string (optional)"
}

Sets is_edited = 1 on the comment.

Response: { ok: true }

DELETE /api/pages/:pageId/discussions/:discussionId/comments/:commentId

Permission: pages.manage

Hard-deletes the comment.

Response: { ok: true }

POST /api/pages/:pageId/discussions/:discussionId/resolve

Permission: pages.update

Toggles the is_resolved status of a discussion.

Response: { is_resolved: boolean }

DELETE /api/pages/:pageId/discussions/:discussionId

Permission: pages.manage

Hard-deletes the discussion and all its comments.

Response: { ok: true }


POST /api/pages/upload

Permission: pages.update

Uploads a media file to R2 storage. Accepts multipart/form-data.

Form fields:

FieldTypeDescription
fileFileThe file to upload (required)
pageIdstringPage ID for organizing storage (optional, defaults to general)

Constraints:

  • Maximum file size: 10 MB
  • Allowed MIME types: image/jpeg, image/png, image/gif, image/webp, image/svg+xml, video/mp4, video/webm, audio/mpeg, audio/wav, audio/ogg, application/pdf
  • File content is validated against magic numbers for image types

Response:

{
"url": "/api/pages/uploads/pages/{pageId}/{uuid}.{ext}",
"appUrl": "/api/pages/uploads/pages/{pageId}/{uuid}.{ext}",
"name": "original-filename.png",
"size": 123456,
"type": "image/png",
"key": "pages/{pageId}/{uuid}.{ext}"
}
GET /api/pages/uploads/*

Permission: None (public)

Serves files from R2 storage. Returns the file with appropriate Content-Type, one-year Cache-Control header, and ETag for caching.

Returns 404 if the file does not exist.


GET /api/pages/:pageId/versions

Permission: pages.view

Returns up to 50 most recent version snapshots for a page, ordered newest first.

Response: { versions }

Each version includes id, page_id, user_id, title, created_at, and user_name.

POST /api/pages/:pageId/versions

Permission: pages.update

Captures the current page state (title and content_rich) as a version snapshot.

Response: { version: { id, page_id } } with status 201

Returns 404 if the page does not exist.

GET /api/pages/:pageId/versions/:versionId

Permission: pages.view

Returns the full version content including content_rich.

Response: { version }

The version object includes id, page_id, user_id, title, content_rich, created_at, and user_name.

POST /api/pages/:pageId/versions/:versionId/restore

Permission: pages.update

Restores a page to a previous version. Before restoring, the current page state is automatically saved as a new version snapshot (so restores are non-destructive).

Response: { ok: true }

Returns 404 if the version does not exist.