- The server owns the clock and the provenance.
produced_at,producerandrun_idare 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
freshorstale, 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.
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
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
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
slug — the page’s slug.
Response 200 — the page, its panels in spec order, and each panel’s current
data:
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
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
Push panel data
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.
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:
push:<panel>:<seq> in place of a run id.
Statuses:
List grants
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
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
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
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:
Dispatch an action
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
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
write grant.
Request body:
to is required — a rollback names the version to restore.
Response 200:
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
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:
Import a page
page.create.
Request body: the bundle, the slug to create, and a binding for every
reference the bundle declares:
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:
List public links
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
Response 201 — the token is returned once:
Withdraw a public link
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
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:
token field: it is stored as a digest and cannot be re-read.
Statuses:
Mint a webhook token
page set instead — one write path,
and no credential to leak.
Authentication: session cookie. The page owner or a workspace ADMIN/OWNER.
Request body:
Revoke a webhook token
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
PUT …/data takes, judged by the same schema.
state — ok or failed, as on the main write path.
Response 200: {"accepted": true, "panel": "cron", "seq": 4413}.
Statuses:
View a public page
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:
Unlock a public page
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:
Related
- 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