Skip to main content
A page is a declared arrangement of panels. It contains no query, no connection string and no credential for anything it displays: a producer — a script, a routine step, an agent, or an outside system holding a webhook token — pushes a typed payload, and the panel renders the last one that arrived. Read the Pages guide first for what a page is and is not, the freshness contract, and the permission model. This page is the wire contract. Three properties hold across every endpoint below, and most of the error responses exist to keep them true:
  • The server owns the clock and the provenance. produced_at, producer and run_id are attached server-side. A payload carrying any of them is refused; a producer cannot claim to be fresher than it is.
  • Freshness is computed on read, never stored. There is no column holding fresh or stale, so nothing can be written into one.
  • Identity comes from the token. Whichever of the four write doors a push arrives through, the caller’s identity is resolved from its credential, never from the body.
Implementation: internal/api/pages_*.go, internal/pages for the schemas and validation.

Endpoints

A page may carry a custom application; its source, build, review and publication routes are the custom application contract at the end of this page: Routes are workspace-unscoped; the workspace comes from the session context or the workspace_id query parameter, the same shape saved views and routines use.

Unreachable pages are not found

When a management endpoint on a page — update, delete, push, grants, access, versions, rollback, export, project sources, public links or webhook tokens — refuses a request, it answers 404 if the caller cannot reach the page. Successful actions by authorized producers are unaffected. The 403 rows in the status tables below apply only to a caller who reaches the page — as its owner or a member of its owning crew, a workspace ADMIN/OWNER, a member of a crew that owns a panel on it, through the sharing of the folder it is in, or by a grant; the reach vocabulary under List pages — and lacks the standing the endpoint needs. A 403 therefore always says “this page exists and you may open it, but not do this”; a 404 never says which of “does not exist” and “not yours to see” it is, so no management endpoint can be walked to learn which pages exist. The same rule covers a wrong panel id on the push path: page “x” has no panel “y” is answered only to a caller who reaches the page. The concealment is decided after the endpoint’s own gate has refused, so it never gates a successful action: a declared producer or an agent whose authority is crew-bound pushes or writes sources without holding any human read path to the page. An agent working through its page_project tool (the internal path, not these routes) is refused with 404 whenever its crew-bound gate fails, since no human viewer stands behind it.

List pages

Authentication: session cookie or CLI token. Any workspace member may list; the response holds the pages the caller can reach — their own, their crews’, and the ones a grant names (Issue a grant). A page outside that set is absent, not present-and-locked. Query parameters: workspace_id (required when the caller’s context does not supply one). Response 200 — an array of page summaries:
panel_count counts every panel including sealed ones, because the grid draws a placeholder for those and a count that skipped them would disagree with what the reader sees. last_produced_at is the newest arrival across visible panels — deliberately not updated_at, which is when the spec last changed: a page edited an hour ago whose data last arrived a week ago must not read as “updated today”. icon and color are the page’s own avatar — a crew icon name and a crew palette key, the same vocabulary a folder uses — and they are always present, "" for none. They are presentation, stored on the page row and not in the spec: a rollback does not restore an older one and an export bundle does not carry it. The freshness state is a separate fact and stays beside the avatar everywhere it is drawn. reach says how you, the caller, reach the page, in a fixed order: It is never empty and never omitted, because a page is in the list only when you reach it somehow. It describes you and nobody else: a page reached only through grant is one “shared with you”, and the other subjects on a page are the ACL, which List grants returns behind its own gate. A panel_crew entry names a crew whose panel you can already read; it discloses nothing a sealed placeholder does not carry. Statuses:

Create a page

Authentication: session cookie or CLI token, plus the page.create capability. A MEMBER holding that capability passes; the workspace role alone is not the answer. Request body — the parsed spec. The CLI parses the YAML and sends JSON; the server never parses YAML:
owner is user/<id> or crew/<slug> — exactly one of the two. span is a column count on a 12-column grid; there is no height field, a panel is as tall as its content. icon and color are optional: the page’s avatar, a crew icon name and a crew palette key (blue, emerald, violet, amber, rose, cyan, lime, fuchsia). A value outside either set is refused with a 400 that names the set; the page is not created. A panel may also carry refresh — "on:wake" or "on:panels-changed", and nothing else. It is the event that runs that panel’s producer, not a rendering hint, so it requires producer: "routine/<slug>": the server cannot execute a script/, call a webhook/ producer, or dispatch an agent/. It compiles to an automations row with action_kind: routine. "on:wake" is additionally refused on a page that declares no wake gate — it could never fire — and on a panel that declares its own gate, which is a loop. Every one of those is a 400 naming the rule; see the Pages guide. Response 201 — the created page, in the same shape GET /api/v1/pages/{slug} returns. Statuses:

Get a page

Authentication: session cookie or CLI token. Visibility of individual panels is decided per panel, server-side, before serialisation. Path parameters: slug — the page’s slug. Response 200 — the page, its panels in spec order, and each panel’s current data:
Every page object — here and in the list — also carries two booleans about its custom application, and they answer different questions. has_project is true when the page has application source at all, published or not. has_application is true only while a publication is actually running. A page awaiting its first publication has has_project: true and has_application: false; that is the case the publication review exists for, so a client deciding whether to offer the review reads has_project. A withdrawn application leaves has_project true and has_application false as well. Neither field is omitted when false. A panel owned by a crew the caller does not belong to arrives as a sealed placeholder: it keeps its id, schema and span so the page has the same shape for everyone, and carries no data and no provenance. This is decided server-side — nothing is hidden in the client. An editor additionally receives the authored half of each panel — its declared actions, wake gates, on_failure and refresh — because that is what an edit form has to round-trip. A reader does not, and an absent authored half is absent, not an empty array. Statuses:

Update a page

Replacing panels requires visibility of every panel in both the current and submitted definition; otherwise the server returns a neutral 403. Metadata-only edits remain available under write authority and return sealed placeholders for unreadable panels. A concurrent live definition change returns 409 instead of being overwritten. Authentication: session cookie or CLI token. The page owner, a workspace ADMIN/OWNER, or the holder of a write grant. Request body: the same shape as create. Panels are reconciled against the submitted list — a panel absent from it is removed, and its payload history goes with it. Every field is optional and an omitted one is left alone, so {"icon": "chart"} changes the icon and nothing else. icon and color follow the pointer rule exactly: omitted keeps, "" clears, a name outside the crew icon registry or the crew palette is a 400 that stores nothing. A request that leaves the document as it was — an avatar-only change, or an empty body — records no version and does not move updated_at, which is the spec’s modification time; the optimistic lock (409) still applies. Response 200: the updated page. Statuses:

Delete a page

Authentication: session cookie or CLI token. The page owner or a workspace ADMIN/OWNER. Request: no body. Response 204: no content. Statuses:

Push panel data

The single write path. Everything that produces a number, a status, a table or a narrative ends here — whether it arrives directly, through a routine’s page.write verb, through the sidecar from inside a container, or through a webhook. Authentication: session cookie or CLI token. Authority is the producer’s, not a workspace role: the caller must be the panel’s declared producer, or hold a produce grant covering it. Request body: the payload itself, with no envelope. It must satisfy the panel’s declared schema (metric.v1, status.v1, table.v1, series.v1, narrative.v1) and is capped at 64 KiB.
Query parameters: state — the producer’s own verdict, ok (the default) or failed. Any other value is a 400, fresh and stale included: those two are the server’s arithmetic, and accepting them here would let a producer declare itself fresh. Response 200:
A producer with no run — a script, a container agent, a webhook — carries the push’s own server-side reference push:<panel>:<seq> in place of a run id. Statuses:

List grants

Path parameters: slug. Query parameters: workspace_id when the caller’s context supplies none. No request body. Authentication: session cookie or CLI token. The page owner or a workspace ADMIN/OWNER. Response 200:
live is not stored. A grant is only as wide as the human who issued it: if that human loses access to the crew the grant names, the grant reports live: false with an inert_reason saying so, and stops working at the same moment. That is the only thing that makes a row inert — every level, read included, decides something while its issuer still stands behind it. Statuses:

Issue a grant

Authentication: session cookie only. A grant is issued by a human, never by an agent — an agent holding write may rebuild a page’s layout freely and still cannot widen who reaches it, not even to another agent in its own crew. Request body:

What each level decides

read is what the other two are built on: a principal who may rewrite a page has to be able to open it, so every level reaches the page and the levels differ only in what they add. A grant widens reach to the page, never to a crew’s data: granting a crew produce on a panel does not give the grantee anything else that crew owns. Concretely, a read grant makes a page appear in GET /api/v1/pages and open at GET /api/v1/pages/{slug} for a subject who would otherwise get a 404 — and changes nothing else. Every panel on it stays sealed unless the grantee’s own crew membership already opened it, so a grantee can be shown a page of nothing but sealed placeholders. That is the grant working, not failing: they were given the board, not the numbers on it. Without any grant a page is reached by its owner, by a workspace ADMIN/OWNER, and by members of the crews that own its panels. Statuses:

Revoke a grant

Authentication: session cookie or CLI token. The page owner or a workspace ADMIN/OWNER. Query parameters: subject_type and subject, naming the subject to revoke; subject_type=workspace needs no subject. A revoke naming nobody answers 400 rather than reporting a success it did not perform. Response 200: {"page": "fleet-201", "changed": 1}. Statuses:

Who reaches a page

Path parameters: slug. Query parameters: workspace_id when the caller’s context supplies none; limit (default 100, at most 1000); cursor from a previous page. No request body. Authentication: session cookie or CLI token. The page owner or a workspace ADMIN/OWNER — the same right as List grants, because this answer contains everything the ACL does and more. Response 200:
Every subject that reaches the page, with the paths that carry it, computed on the server from the same facts it enforces — the workspace roles, the owner, the crews that own panels, and the live grants. It is a report of today’s model, not a new one: nothing here decides anything, and an inert grant is not a path. paths uses the same vocabulary as the list’s reach, with the grant arm spelled out per level: Rows are ordered users by email, then crews by slug, then agents by slug, then the withheld row; next_cursor is present when more follow and is passed back as cursor. What is withheld, and why. A page owner is not necessarily a member of every crew that owns a panel on their page, and §7.1 rule 2 keeps such a panel — and the crew behind it — sealed from them. This listing holds to the same line: every path through a crew the caller cannot see is collected on ONE anonymous row (subject_id is withheld, there is no label) and appears on no named row. Attaching panel_crew:withheld to a named user or crew would say which crew owns the hidden panel, one intersection at a time. An administrator sees every crew named. Statuses:

What a subject reaches

Query parameters: subject (required) — user:<email or id>, crew:<slug> or agent:<slug>; or subject_type (user, crew, agent) with a bare subject. workspace_id when the caller’s context supplies none; limit and cursor as above. No request body. Authentication: session cookie or CLI token. A workspace ADMIN/OWNER may ask about anyone; any member may ask about themselves and nobody else. Response 200:
Pages the subject does not reach are absent, not listed with an empty set — the same posture the list takes. Rows are ordered by slug; next_cursor is present when more follow. Statuses:

Folders

A folder is a named group of pages owned by one crew, with an icon from the crew icon set and a colour from the crew palette — the same picker a crew uses. One level, a page in at most one folder; a page outside any folder is unfiled. Every folder read is filtered to the pages the caller reaches; page_count counts those, never the folder’s true size. A folder can be shared, and what it is shared with applies to every page in it right now. A folder’s permissions name a subject — a user, a crew, or everyone in the workspace — as can view or can edit, and they apply continuously to whatever is in the folder: filing a page in a shared folder shares it, taking it out unshares it. A page’s own grants add to that and never take from it. Can edit implies can view; the server stores both and refuses a request that says otherwise. Can edit is page write for every page in the folder, with no workspace-role floor — a VIEWER-role member named by it edits those pages, subject to the same whole-document check as a page write grant. Neither ever opens a panel owned by another crew: a panel is sealed by its owning crew’s membership whatever the folder says. Agents are never a folder subject; an agent reaches a page only by a grant on that page. An entry belongs to the folder, not to the person who set it: it stays until a manager changes it or the folder is deleted, and set_by is audit only. This is deliberately the opposite regime from a page grant, whose issuer’s standing is re-checked at every use. Every page row and page document carries folder ({slug, name, icon, color} or null, always sent) and pages_version, which changes with every change to the page’s folder membership. A move sends it back as a fence, together with the folder’s acl_version, which changes with every change to the folder’s permissions; a stale fence is refused with 409 carrying conflict (pages_version or acl_version) and the current pair, so the client re-reads and confirms again rather than retrying blind. The 409 carries the target’s permissions only to a caller who may read them; everyone else gets the versions and a sentence. Every folder document carries shared — none, people (shared with named people), crews (shared with named crews), people_and_crews (both) or workspace — for readers who may not see the entries themselves. The label never names anyone. Who may do what: Folder changes are journalled as page.folder_changed; moves and removals as page.folder_membership_changed; permission changes as page.folder_acl_changed. Folder create, rename, delete and permission changes broadcast page.folder.updated ({slug, folder_id}); a move or removal broadcasts the page’s own page.updated.

List folders

Query parameters: workspace_id when the caller’s context supplies none. No request body. Authentication: session cookie or CLI token. Response 200: the folders the caller may read, by name.
Statuses:

Create a folder

Authentication: session cookie or CLI token. A workspace ADMIN/OWNER, or a MANAGER (or higher) who belongs to the owning crew. Request body:
Response 201: the folder, page_count 0. Statuses:

Get a folder

Path parameters: slug. No request body. Authentication: session cookie or CLI token. The owning crew, an admin, anyone the folder’s permissions name, or anyone who reaches at least one page in the folder. Response 200: the folder, plus pages — the pages in it the caller reaches, as index rows. page_count is their number. Statuses:

Update a folder

Authentication: session cookie or CLI token. A workspace ADMIN/OWNER, or the owning crew’s MANAGER (or higher). Request body: any of name, icon, color. An empty string clears the icon or the colour; an omitted field keeps it. The slug and the owner are fixed at creation.
Response 200: the folder as it now is. Statuses:

Delete a folder

Authentication: session cookie or CLI token. A workspace ADMIN/OWNER, or the owning crew’s MANAGER (or higher). No request body. Response 204, no body. Statuses:

File a page in a folder

Files a page in this folder, moving it out of whichever folder it was in. The caller must hold both authorities: the page’s owner or an admin initiates, and an admin, this folder’s owning-crew MANAGER (or higher), or a holder of can edit on this folder accepts. A caller lacking either is refused with a message naming who can. Filing a page in a shared folder shares it with whoever the folder is shared with. Authentication: session cookie or CLI token. Request body:
Response 200: {"page": …} — the page’s index row as the listing now renders it, with its new folder and bumped pages_version. Filing a page where it already is changes nothing and bumps nothing. Statuses:

Remove a page from a folder

Takes the page out of this folder; it becomes unfiled. The page’s owner, an admin, or a holder of can edit on the folder — the last deliberately, since taking a page out withdraws the access everyone else inherited from the folder. Taking your own page back never waits for anyone. Authentication: session cookie or CLI token. Request body: {"pages_version": 4} — the page’s pages_version as last read. Required. Response 200: {"page": …} — the page’s index row, folder now null. A can edit holder who reached the page only through the folder gets the row with an empty reach: the removal took their own path away. Statuses:

File several pages in a folder

Files several pages in this folder in one transaction. Every page is checked on its own — reach, ownership, its pages_version — under the same rules as a single move, and a single refusal writes nothing and names the page. Authentication: session cookie or CLI token. As for a single move, for every page. Request body:
Response 200: {"pages": [...], "acl_version"} — the moved pages’ index rows. Statuses:

Read a folder’s permissions

Authentication: session cookie or CLI token. A workspace ADMIN/OWNER, or the owning crew’s MANAGER (or higher). Everyone else who can see the folder gets 403 with the rule; the folder’s shared label and …/access/me are theirs. Response 200:
can_read is always true. set_by is empty once the setter’s account is gone; the entry stays. The owning crew and admins are not listed: they hold the folder without an entry. Statuses:

Share a folder

Sets one entry: can view, or can view and edit. PUT because the row is identified by (folder, subject) and repeating the request is the same state. Bumps acl_version, so a move confirmed against the previous permissions is refused. Authentication: session cookie or CLI token. A workspace ADMIN/OWNER, or the owning crew’s MANAGER (or higher). Request body:
Response 200: the permissions document after the change, plus entry, the row that was set. Statuses:

Stop sharing a folder

Removes one entry and bumps acl_version. A user or crew is matched by the stored id or by the reference a person types (an email, a slug), so an entry for somebody who has since left can still be removed. The workspace subject has no id: its path is …/acl/workspace/workspace. Authentication: session cookie or CLI token. A workspace ADMIN/OWNER, or the owning crew’s MANAGER (or higher). No request body. Response 204, no body. Statuses:

My access to a page

The caller’s own paths to the page, in the vocabulary of the index’s reach (owner, role, crew:<slug>, panel_crew:<slug>, folder:<slug>, grant), plus the page’s folder and its shared label when it is filed. Anyone who can open the page may ask; the answer describes them and nobody else. Authentication: session cookie or CLI token. Response 200:
Statuses:

List a panel’s actions

Path parameters: slug, panelId. No request body. The buttons a panel declares, and what each one runs. The panel author declares these in the spec; a caller cannot invent one. Authentication: session cookie or CLI token; the caller must be able to see the panel. Response 200:
Statuses:

Dispatch an action

Authentication: session cookie or CLI token, plus the right to dispatch on this page. The request names an action id, never a routine: what runs is what the panel author declared, so a caller cannot redirect a button at something else. Request body: the declared inputs, if the action declares any.
Request headers: Idempotency-Key is honoured. The key is bound to the resolved inputs, so retrying is safe and replaying with different inputs is refused with 409 rather than silently dispatching a second run. Response 202 — the run is queued, not finished:
status is SCHEDULED, or DEDUPED when an identical dispatch was already in flight. Statuses:

List versions

Path parameters: slug. Query parameters: workspace_id when the caller’s context supplies none. No request body. The page’s structural history — which panels existed, their layout, owners and producers. Panel data is not versioned. Authentication: session cookie or CLI token; page edit permission — the owner, a workspace ADMIN/OWNER, a write grantee, or a holder of can edit on the page’s folder. A version row carries the whole arrangement, panels sealed to a reader included, so seeing the page is not enough. Response 200:
retained is how many versions this build keeps. Statuses:

Roll a page back

The caller must also be able to read every panel in both the current and archived definitions. A partial reader receives a neutral 403; a concurrent live definition change receives 409 and leaves the newer document intact. Authentication: session cookie or CLI token. A rollback is an edit of the arrangement, so it runs the same gate as PATCH: owner, workspace ADMIN/OWNER, or a write grant. Request body:
to is required — a rollback names the version to restore. Response 200:
A rollback never resurrects old panel data. A panel it brings back renders in the “waiting for first data” state even if rows for it survive in the payload ring; showing an old payload as current is exactly the dishonesty the freshness contract exists to prevent. dimmed names those panels in the response, so the operator who ran the rollback learns it here rather than from a blank panel five minutes later. Statuses:

Export a page

Path parameters: slug. Query parameters: workspace_id when the caller’s context supplies none. No request body. Authentication: session cookie or CLI token; a caller with page edit permission — the owner, a workspace ADMIN/OWNER, a write grantee, or a holder of can edit on the page’s folder — who can also read every panel in it. Response 200 — a portable bundle: the spec, plus every reference it makes to something outside itself, listed so the importer must bind them explicitly:
The bundle carries no data, no grants and no tokens — an export is a shape, not a snapshot of what the page was showing. A page with a custom application exports as crewship-page-bundle/v2. The bundle then carries project — the current draft’s source project, the same crewship-page-source/v1 document PUT …/project accepts: source files, lockfile and small assets (base64 entries) — and page is the draft’s authored definition rather than the live one, with each panel’s actions, wake, on_failure and refresh and with every public flag cleared. Every routine a call action names joins references as routine/<slug>, beside the crews, so the importer binds them the same way. A draft that declares a link or custom action is not yet portable and answers 422, as does a bundle whose encoded size exceeds the transfer cap (the project document limit plus the spec cap plus 64 KiB). The v2 bundle does not carry builds, publications, Git history, grants, credentials or data; it is one revision’s source and definition, never the application’s trusted state. An export does not need the compiler. Statuses:

Import a page

Authentication: session cookie or CLI token, plus page.create. Request body: the bundle, the slug to create, and a binding for every reference the bundle declares:
An unbound reference is a 422, not a guess. Importing a bundle that names crew/ops into a workspace that happens to have a crew called ops would otherwise hand a page to whoever holds that name. A crewship-page-bundle/v2 body — format set to that and project present — is accepted by the same endpoint and goes through the same binding: every crew and every routine the actions name must be bound to one that exists here, or the import is a 422 listing them. It creates an inert draft: the page is created with its panels, the bundle’s project is stored as source revision 1 with a Git checkpoint, and the bundle’s authored definition (actions, wake gates, on_failure, refresh) becomes the draft’s definition — but the live page is written without them, nothing is built and nothing is published. Whoever imports it builds, reviews and publishes it here, as for any candidate. A v1 body that carries project, and a v2 body without one, are refused with 400; public flags are never read from either. The v2 size cap is the project document’s plus the spec cap plus 64 KiB, in place of the v1 envelope. Response 201: the created page. Statuses:
Path parameters: slug. Query parameters: workspace_id when the caller’s context supplies none. No request body. Authentication: session cookie or CLI token. The page owner or a workspace ADMIN/OWNER. Response 200: the links, when each expires, whether it carries a password, and whether it shows provenance. It cannot show the link itself — see publish. Statuses:

Publish a page

Authentication: session cookie. Publishing widens reach beyond the workspace, so it is a human’s decision. Request body — every field optional:
Response 201 — the token is returned once:
The column holds a SHA-256 digest, so nothing can show the link again. Only panels explicitly marked public appear on it. Statuses:
Path parameters: slug, tokenId (from list public links). No request body. Authentication: session cookie or CLI token. The page owner or a workspace ADMIN/OWNER. Response 200: {"id": "pgl_…", "revoked": true}, plus "already": true when it was already revoked — revoking twice succeeds, because it is the state that matters. Withdraws one link and leaves the others working — several links per page is the intended shape. Statuses:

List webhook tokens

Path parameters: slug. Query parameters: workspace_id when the caller’s context supplies none. No request body. Authentication: session cookie or CLI token. The page owner or a workspace ADMIN/OWNER. Response 200:
No token field: it is stored as a digest and cannot be re-read. Statuses:

Mint a webhook token

For a producer that cannot run the CLI: a cron on someone else’s box, a step in an automation service, a PLC gateway, a CI job. Anything that can run the CLI should use page set instead — one write path, and no credential to leak. Authentication: session cookie. The page owner or a workspace ADMIN/OWNER. Request body:
A token is bound to one panel. Several tokens per panel is the intended shape, so revoking the PLC’s does not break the CI job’s. Response 201 — the URL is returned once and never again:
Statuses:

Revoke a webhook token

Path parameters: slug, webhookId (from list webhook tokens). No request body. Authentication: session cookie or CLI token. The page owner or a workspace ADMIN/OWNER. Response 200: {"id": "pgwh_…", "revoked": true}, plus "already": true when it was already revoked. Statuses:

Fire a webhook

Authentication: the token in the path is the authentication — no header, no session. It is matched against a SHA-256 digest and is bound to exactly one panel, so a leaked token can write that panel and nothing else. Request body: the panel’s payload, with no envelope — the same bytes PUT …/data takes, judged by the same schema.
Query parameters: state — ok or failed, as on the main write path. Response 200: {"accepted": true, "panel": "cron", "seq": 4413}. Statuses:

View a public page

Path parameters: token — the published link’s token. No request body, no query parameters. Authentication: the token in the URL. No account, no workspace, no session. When the link carries a password, this answers 401 until unlock has been called. Response 200 — only the panels marked public, and only what a stranger may see:
A stale panel on a public page shows its age, never the reason: “last updated 3 days ago” is useful to a stranger, “producer script/watch-services.sh has not run since Tuesday” describes your infrastructure to them. Statuses:

Unlock a public page

Authentication: the token in the URL, plus the password in the body. The password is never in the URL — a URL ends up in browser history, in referrer headers, and in whatever chat app the link was pasted into. Request body:
Response 200: the public page itself — the same body GET /api/v1/public/pages/{token} returns. There is no separate “unlocked” acknowledgement to round-trip for, and a correct password spends a view from the link’s cap, because it is the serving that the cap bounds. Statuses:
  • Pages guide — what a page is, the freshness contract, tabs, actions, wake gates and the permission model
  • Page commands — the CLI, including the producer form
  • Internal IPC — the routine and container write path

Custom application contract

Custom Pages store a portable React/TypeScript source project separately from live panel data. Draft edits and builds do not publish. Source operations below require session/CLI authentication, workspace context and Page edit permission; publication additionally requires Page ownership or workspace administration. Application reads/actions enforce the current viewer’s Page/panel reach. Errors use JSON {"error":"…"}. Common statuses are 401 without authentication, 403 without authority, 404 for an unavailable Page/resource, 500 for an internal storage error, and 503 when required project/build/runtime services are not configured. These endpoints are opt-in and experimental. The supported source format is crewship-page-source/v1 with runtime react-vite-typescript/v1. files contains {path, encoding, content} entries; encoding is utf8 or canonical base64. Required files are package.json, pnpm-lock.yaml and index.html. Limits: 256 files, 512 KiB decoded per file, 2 MiB decoded source, 4 MiB serialized transfer. Traversal, reserved paths, case collisions and symlinks are rejected. The pinned compiler profile fixes supported dependencies; project-defined install/build scripts are not executed.

Read custom source

GET /api/v1/pages/{slug}/project Request: path slug, workspace context; no body. Response 200: {revision, digest, git_commit, definition, project}. The project contains exact file bytes; the definition is the authored Page document. This is an editor-only response, never the reader’s application payload. Statuses: 404 if no draft exists, plus the common authentication/storage errors above. The response is no-store.

Save custom source

PUT /api/v1/pages/{slug}/project Request: {expected_revision, project, definition?}. Revision 0 creates an initial draft. An optional complete definition replaces the draft Page spec; omitting it preserves the previous spec (or copies the existing Page on initialization). Public-panel flags cannot be enabled in a portable draft. Response 200: {revision, digest, git_commit, state:"draft"} after a Git checkpoint and atomic revision update. Live publications are unchanged. Statuses: 400 malformed request, 409 revision conflict, 413 oversized transfer, 422 invalid source/definition/references, 507 exhausted quota, plus common errors. Authentication requires Page edit permission.

Build custom source

POST /api/v1/pages/{slug}/project/build Request: {expected_revision}; authenticated Page editor. Response 202: build record {id, source_revision, source_digest, state, created_at}. Completion adds artifact_digest or error and completed_at. States are running, ready, failed, interrupted. One offline compiler runs per server; this response is admission, not completed compilation. Statuses: 400 malformed revision, 409 draft changed, 413 oversized request, 429 compiler busy, 507 build-record quota, plus common errors.

Read build preview

GET /api/v1/pages/{slug}/project/preview Request: path slug; authenticated Page editor, no body. Response 200: {revision, build, runtime_url, development_same_origin, artifact?}. build is null before the first build; development_same_origin is true only when the operator has enabled CREWSHIP_PAGE_RUNTIME_DEVELOPMENT_SAME_ORIGIN. A ready build includes the immutable artifact {format:"crewship-page-preview/v1", javascript, css, toolchain}. This response is no-store; execute only through the configured separate-site bootstrap. Statuses: 404 missing draft, common errors including 503 if the worker or required runtime isolation configuration is unavailable. Preview does not permit SDK actions against the published application.

List source history

GET /api/v1/pages/{slug}/project/history Request: authenticated Page editor; optional positive before revision. Response 200: {revisions, next_before} with at most 50 entries. Each entry contains revision, digest, git_commit, actor, actor_kind, created_at, restorable. An agent-authored entry uses the agent ID as actor; it is not a user ID. actor_kind is user, agent, crew or unknown and is the only field that says which of those the opaque actor string holds. next_before: 0 means no further page. Metadata is no-store. Statuses: 400 invalid cursor, plus common errors.

Read historical source

GET /api/v1/pages/{slug}/project/history/{revision} Request: authenticated Page editor; positive path revision. Response 200: the same shape as the current source response, verified against its Git checkpoint and saved Page definition. Statuses: 400 invalid revision, 404 missing revision, 409 legacy revision without a saved definition, plus common errors.

Restore source draft

POST /api/v1/pages/{slug}/project/restore Request: authenticated Page editor; {revision, expected_revision} names the historical source and the current draft revision respectively. Response 200: {revision, digest, git_commit, state:"draft"} for a new checkpoint. Restore does not move the live application or erase later history. Statuses: 400 malformed body, 409 stale revision or unrestorable history, 413 oversized body, 422 invalid current references, 507 quota, plus common errors.

Check a publication candidate

POST /api/v1/pages/{slug}/project/check Request: authenticated Page editor; {build_id, expected_revision}. Response 200: {build_id, source_revision, git_commit, artifact_digest, checks} after source/artifact integrity and current reference checks. browser_review and security_review remain required; this is not an automatic safety certification and does not publish or invoke actions. Statuses: 400 malformed body, or a declared reference — owner crew, producer, call routine, gate target — that no longer exists, named in the sentence, 403 that same reference problem on a panel the caller may not read, where the sentence says only that a withheld part does not validate (or, when this candidate also changes it, that it changes it) and how many panels are withheld, 409 incompatible source/build or failed integrity checks, 413 oversized body, 422 invalid current bindings, plus common errors. Validation always runs over the whole candidate for everyone; only whether the refusal may name what failed depends on the caller, and a workspace administrator reads every panel and always gets the full sentence. The project/check response filters checks.routine_definitions to routines referenced by visible panels. Validation still covers the complete candidate; publication provenance retains the complete map server-side.

Review a publication candidate

GET /api/v1/pages/{slug}/project/review Request: authenticated Page editor; path slug, optional positive publication query parameter, no body. Response 200: {issued_at, candidate, baseline, routines, capabilities, blockers, initial_publication}, where baseline is {publication_version, published, definition_digest, definition, excluded_panels, source_revision, git_commit, source_available, source_unavailable_reason, withheld_changed, definition_diverged} and candidate is {revision, git_commit, source_digest, created_at, actor, build, definition}. candidate is the current draft revision with its actor and the build of exactly that revision, and is null when there is no draft, or when the draft is identical to a publication that is still running. A withdrawn publication is not running, so an identical draft is still a candidate: republishing it is how a withdrawal is undone. Pass publication=<version> to ask what publishing that retained version would be fenced on instead. candidate then describes that publication’s archived source — its source revision, checkpoint, digest, the publisher it recorded and when — with the build it recorded, and routines is computed from its archived Page definition. candidate.actor and candidate.created_at mean the source revision’s author and save time in draft mode, and the publication’s publisher and publication time in this one. That is exactly the key set a rollback_version publication must send as expected_routine_digests; the draft’s routines are a different document’s routines, and no client can recompute the current digest of each one. baseline is identical in both modes, because restoring a version replaces the same live definition. A version that is not retained is reported as no_candidate and one that is already running as candidate_matches_live; both keep the rest of the snapshot, and a retained candidate stays populated so the refusal can name which version it is about. baseline.definition_digest is the sha256 of the live Page definition and is the value a publication is fenced on. baseline.definition is that same live Page document, read from the same stored row in the same request, and candidate.definition is the document this candidate would make live — the draft’s, or in ?publication=N mode the archived one that version recorded. Both come out of this one response, so the two sides of the comparison and the digest the publication attests to describe one instant rather than three reads of three endpoints. Compare these two documents and not a separately fetched Page or project, or a reviewer can approve a comparison the fence does not cover. Panels this caller may not read are removed from both — panel visibility is its owning crew’s, and an edit grant is never a read grant — and the removal is deliberately symmetric: a panel taken out of one document and left in the other would be reported as added or removed by the publication, which is false about a panel that is merely not this reader’s to see. baseline.excluded_panels is how many panels were withheld from the comparison, counting a panel withheld from both documents once, so a partial comparison can say so. Full authoring reads require visibility of every declared panel. The review screen reads /project/source and /project/history/{revision}/source, which omit the definition; the authorized comparison comes from this snapshot. Application source remains shared Page code, not automatically redacted text. baseline.withheld_changed is whether any of those withheld panels actually differs between the live definition and the candidate — added, removed, modified, or re-pointed between a crew this caller may see and one they may not. A count is a warning; this is the answer that settles what the consent underneath the review can honestly cover. False means the comparison on screen covers everything that moves and publishing stays available. True adds the withheld_change blocker and the publication is refused, and nothing about what changed is disclosed either way: the flag, and the count beside it, are the whole of it. routines follows the same rule — a routine is listed only when a panel that survived withholding declares it, so the response does not name what it has just withheld — and routines[].in_candidate is therefore exactly the fence the publication rebuilds for this caller. A routine only a withheld panel calls is not fenced by a publisher who cannot read that panel: the fence asks a human to attest that a value has not moved since they reviewed it, and they never reviewed that one. A workspace administrator reads every panel, so their list and their fence are unchanged. definition_digest is unaffected by any of this, because the fence compares the full stored document. Either document is null only when the stored bytes cannot be read as a Page document, and there is then no basis for a comparison at all; when there is no candidate, candidate itself is null. baseline.definition_diverged says the live Page definition and the definition the live publication shipped with have drifted apart — somebody changed panels outside the application flow. It is a statement, not a refusal. The comparison on this snapshot is derived from the current live definition and the candidate, so it is complete regardless of what the publication once shipped, and publishing the candidate is what ends the divergence; refusing on it would block the only action that does. It is true in the withdrawn state too, where published is false and nothing is running, so a note about it must say “the live publication”, never “the running application”. Do not confuse it with the definition_moved blocker code, which is reserved for the publish fence tripping — a base that moved between the render and the click, where refreshing genuinely is the cure. baseline.source_available:false means the retained source for the live publication cannot be read back — a missing comparison, never “no changes” — and source_unavailable_reason says why in a sentence. routines is a union: the candidate’s own call routines and the ones the live publication recorded, so a routine being dropped stays visible. routines[].in_candidate marks the rows that belong in a publication’s expected_routine_digests — the candidate declares them, so the server rebuilds and compares exactly those. Sending a row without the flag is a key the server does not have and is refused as a routines conflict naming a routine nobody moved. routines[].state is unchanged, changed or unknown; unknown covers a publication that recorded no digest for a routine and a routine that no longer resolves, and is never rendered as agreement. blockers[].code is one of no_candidate, candidate_matches_live, build_missing, build_failed, build_stale, baseline_unavailable, routine_unresolved, definition_moved, withheld_change, not_permitted and storage_unavailable. definition_moved is reserved for the publish fence; the review does not emit it. routine_unresolved is emitted for every in_candidate routine with no current digest: publishing such a candidate is refused, so the review says so rather than reporting the routine as merely unknown beside a ready build. Missing build storage or a missing runtime origin is a blocker, not a 503: a reviewer who cannot publish can still read the snapshot. Statuses: 400 non-positive or malformed publication, plus common errors. Responses are no-store.

Publish or roll back an application

POST /api/v1/pages/{slug}/project/publish Authentication: Page owner or workspace administrator; edit grant alone is insufficient. Request: {build_id, expected_revision, expected_publication, reviewed_code:true, expected_definition_digest, expected_routine_digests}. Use publication 0 for the first publication. For rollback, send {rollback_version, expected_publication, reviewed_code:true} with both fence fields, and omit build_id and expected_revision. acknowledged_unavailable_baseline is optional and defaults to false. When the live publication’s retained source cannot be read back — the same condition the review reports as baseline_unavailable — the publication is refused with 409 and conflict: "baseline" unless it is true. It states that nobody compared this candidate with what is running. It proves nothing about the candidate itself, whose source, checkpoint, artifact and bindings are verified either way, and the answer is recorded in the publication’s checks as baseline_source (verified, unavailable_acknowledged, or initial_publication) so a receipt says which publications shipped without a diff basis. An initial publication has no prior source and is never refused for this reason. The server refuses by default rather than blocking outright because retention and compaction legitimately discard old checkpoints, and a workspace whose history was reclaimed must still be able to publish a sound candidate — by saying so, not by having it assumed. reviewed_code: true attests that the whole change was reviewed, and the server refuses an attestation the caller cannot make. Publishing requires Page ownership or workspace administration; seeing a panel requires membership of that panel’s owning crew or the same administration. A Page owner who is not an administrator therefore may publish a Page carrying panels they cannot read. Nothing there leaks and nothing bypasses authorization — the operation was always theirs — but the claim about what was reviewed was not true of the part that was hidden. So when a panel this caller may not read differs between the live definition and the candidate, the publication is refused with 403 and a sentence that says such a part exists and how many panels are withheld, and nothing about what it is. The rollback path is included: restoring a retained version replaces the live definition too, and the comparison is against that version’s archived declaration. Read baseline.withheld_changed on the review snapshot to know in advance. This is not a state conflict, so the body carries no conflict kind and refetching changes nothing, and unlike acknowledged_unavailable_baseline there is no flag that waives it: that one says nobody made a comparison, this one would say the publisher may not see what they are signing for. A workspace administrator reads every panel, so it never applies to one. expected_definition_digest and expected_routine_digests are required on both paths and are the values the reviewer was shown — read them from the review snapshot above. The digest is 64 lowercase hex characters; the map is keyed by routine slug and is {} when the candidate declares no call actions. Both are compared inside the publishing transaction, before any write, so a Page definition or a routine script that moved between review and publication refuses the publication instead of shipping it. An exact retry short-circuits to its original receipt before the comparison runs: the receipt records what already happened, and the fence guards what is about to. Response 200: immutable receipt {version, build_id, source_revision, source_digest, git_commit, artifact_digest, created_at}. The live pointer and Page declaration change atomically. An exact retry returns its original receipt without rewinding a newer live publication. Rollback creates a new publication from the old artifact and declaration; it preserves the draft and does not undo completed routine effects. Statuses: 400 invalid or conflicting fields, missing review confirmation or a malformed fence — and a declared reference that no longer exists (owner crew, producer, call routine, gate target), which the authoring gate refuses ahead of the fence and therefore without a conflict kind, even when it was deleted concurrently, 403 publishing is not this caller’s to do, a panel they may not read differs between the live definition and the candidate, or a panel they may not read fails that reference validation — the refusal then says only that a withheld part changes or does not validate, and how many panels are withheld, never which reference or which panel, 409 changed draft, publication or declaration, 413 oversized body, 422 invalid bindings, 507 publication quota, plus common errors. A 409 body carries conflict — definition, routines, publication, draft or baseline — naming which base moved or is missing, and a routines conflict also lists the routines that moved.

Read the live application

GET /api/v1/pages/{slug}/application Authentication: current Page reach. Request: path slug; optional If-None-Match header containing the previously returned ETag. Response 200: {publication, publication_version, can_publish, artifact?, runtime_url?, development_same_origin?}. An unpublished Page returns publication:null. A published response includes the immutable receipt and compiled artifact, never the source project. 304 is returned for an unchanged ETag only after current authorization is checked; no artifact bytes are read or retransmitted. The tag includes publication, runtime and publishing authority. Responses are no-store; the page in the browser keeps the active artifact in memory and sends its ETag for revalidation. The response also carries publication_version — the live publication’s number, 0 before any — and, when a publication is running, development_same_origin: true only when the operator has enabled CREWSHIP_PAGE_RUNTIME_DEVELOPMENT_SAME_ORIGIN, so a client can show the isolation warning. A withdrawn application answers publication: null with its publication_version kept. Statuses: common errors; 404 also conceals a Page outside viewer reach. Reading published artifacts works with the compiler disabled.

Dispatch a published action

POST /api/v1/pages/{slug}/application/actions/{panelId}/{actionId} Authentication: existing action-dispatch role, current Page/panel access and routine policy. Request: Idempotency-Key header (1–128 bytes), JSON {publication, inputs} at most 32 KiB. Only a stored call action is accepted; there is no shell, arbitrary URL or routine-name parameter. The SDK broker in the page obtains confirmation in the trusted host before sending this request. Response 202: existing action queue receipt including pending_id; this does not mean the routine completed. Keys/debounce are scoped by viewer and publication. Publication/declaration checks and enqueue share a transaction. Statuses: 400 missing key or invalid inputs, 404 inaccessible action, 409 publication/declaration changed, 413 body too large, 429 rate limit, plus common errors. Refresh and review the new declaration after 409.

Read own application action status

GET /api/v1/pages/{slug}/application/actions/{pendingId} Authentication: only the user who dispatched this pending action, with current access to the originating panel. Request: Page slug and the returned pendingId; no body. Response 200: {pending_id, pending_status, run_id, run_status}. Run fields can be empty before execution. A disconnect or client timeout is not cancellation. Statuses: 401 unauthenticated, 403 forbidden, 404 unavailable Page, panel or another viewer’s pending action, 500 storage failure.

Bootstrap the isolated runtime

GET /api/v1/pages/runtime/bootstrap Authentication: none. Request: no body; serve on the configured origin on a different site from the browser origin (CREWSHIP_PAGE_STUDIO_ORIGIN). Response 200: fixed text/html bootstrap with sandbox/CSP protections. It contains no user data, code artifacts, source or credentials; the authorized page host initializes a private MessagePort. Status 404: runtime origin is not configured. This is the only route that needs exposure on the runtime reverse-proxy alias. A separate site does not provide a portable hard browser CPU/memory limit or complete anti-exfiltration protection; custom source still requires review.

Publication history and withdrawal

GET /api/v1/pages/{slug}/project/publications requires Page edit access and returns at most 50 receipts, next_before, publication_version, published and can_publish. Use ?before=<version> to paginate. Each receipt identifies its exact source revision/build, original publisher, rollback target and any withdrawal time. Source inspection uses the existing project-history endpoint. POST /api/v1/pages/{slug}/project/unpublish requires Page ownership or workspace administration and a body {"expected_publication":3}. A stale version returns 409. Withdrawal hides the application, preserves panels and history, and keeps the version counter for future publication. Repeating the same withdrawal is idempotent; an old successful publish retry cannot reactivate withdrawn code.

Application panel history

GET /api/v1/pages/{slug}/application/panels/{panelId}/history requires readable panel access and publication=<mounted-version>. Optional limit is 1–20; before is a positive sequence cursor. The response contains items (sequence, data, producedAt, state), next_before (0 at the end), and publication. Responses are bounded to 1 MiB and never contain producer/run metadata. A stale, withdrawn or definition-mismatched publication returns 409; an inaccessible panel returns 404. The SDK exposes this as getPanelHistory(panelId, {limit, before}).

POST /api/v1/pages/maintenance

Authentication: workspace OWNER or ADMIN. Request: JSON object; optional discard_history defaults to false. Setting it to true also requires confirm: true and discards optional history across the workspace. Current drafts, live publication pointers and running builds remain protected. Response 200: compacted, discarded_optional_history, and preserved. Statuses: 400 malformed request or missing confirmation; 403 unauthorized; 503 project storage disabled; 500 storage maintenance failure.

GET /api/v1/pages/{slug}/project/fsck

Authentication: Page edit access. Request: Page slug, no body. Response 200: healthy, checked_sources, checked_checkpoints, checked_artifacts, checked_git_objects, and failures (kind, id, error). An unhealthy result is still HTTP 200: callers must inspect healthy. This checks source, checkpoint contents, referenced artifacts and reachable Git objects without changing them. Statuses: 403 no edit access; 404 unavailable Page; 503 storage disabled or maintenance busy; 500 database/read failure.

Whole-document authoring and source review

A Page write grant does not grant visibility of another crew’s panels. GET /pages/{slug}/project, GET /pages/{slug}/project/history/{revision}, and Page export return 403 unless the caller can read every panel in the returned definition. Agent project reads apply the same rule. These endpoints never return a truncated document that could delete hidden panels on save. PUT /pages/{slug}/project (including source restore) requires visibility of all panels in the live definition, existing draft, and proposed definition. A rejected save leaves the draft revision unchanged. For reviewing application source, use GET /pages/{slug}/project/source and GET /pages/{slug}/project/history/{revision}/source. Both retain the Page edit-permission gate and return revision, digest, git_commit, and project, with no definition. The review snapshot supplies the authorized panel comparison. Application source is shared Page code; these reads do not redact panel names or other text an author has written into source files.

Read candidate source evidence

GET /api/v1/pages/{slug}/project/source Request: path slug, workspace context; no body. Requires Page edit permission. Response 200: {revision, digest, git_commit, project} with exact source files and no definition. Panel comparisons belong to the review snapshot. Statuses: 401 unauthenticated, 403 no edit authority, 404 Page or draft missing, 500 storage/integrity error, 503 project storage unavailable. The response is no-store.

Read archived source evidence

GET /api/v1/pages/{slug}/project/history/{revision}/source Request: path slug and positive revision, workspace context; no body. Requires Page edit permission. Response 200: {revision, digest, git_commit, project} verified against the archived source digest, without the archived panel definition. Statuses: 400 invalid revision, 401 unauthenticated, 403 no edit authority, 404 Page or revision missing, 409 unsupported legacy revision, 500 archive/integrity error, 503 project storage unavailable. The response is no-store.