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

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.

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”. 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. 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:
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

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. 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: exactly one of user, crew, or agent, naming the subject to revoke. A revoke naming nobody answers 400 rather than reporting a success it did not perform. Response 200: {"page": "fleet-201", "changed": 1}. 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; any caller who can see the page. Response 200:
retained is how many versions this build keeps. Statuses:

Roll a page back

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 who can see the page. 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. 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. 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: stateok 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