Pages
A Page is a workspace-scoped, slug-addressable record holding an ordered list of panels. Each panel declares:- a schema — one of a closed set of five (below),
- an owner — a crew, which is the panel’s permission anchor,
- a producer — the routine, script, agent or webhook allowed to write its data,
- a freshness contract — how often data is expected, and what happens when it stops.
- Layer 1 — the page definition. Human-authored YAML,
apiVersion: crewship/v1,kind: Page. Declares which panels exist, who owns them, and who may produce their data. - Layer 2 — the panel payload. Machine-produced JSON, one push per
update, validated against the panel’s declared schema. See
crewship page setfor how a producer pushes it.
The five panel schemas
The set is closed — a new panel kind is a server release, never a user-supplied string.Panel icons
A panel’s icon is derived from its schema, which is right until a page carries threestatus.v1 panels — “is it running”, “who is on call”, “what deployed
today” wear one face, and the header stops telling the reader which panel they
are looking at. Declare icon: to say what the panel is about:
Thirteen, and deliberately short enough to read in one go: a vocabulary nobody
can hold in their head is one you pick from by grepping, which is an open
string with extra steps.
There is no
check and no warning, and no per-icon colour. The panel
already renders a verdict — status.v1 draws ✓ / ! / ✕ per item, the frame
draws the freshness word, and colour on this surface means state
(ok/warning/critical). A tick in the header would be a second verdict on
the same card and you could not tell whether it described the subject or the
state. alert is admitted because an incident board is a real subject; it
draws a siren, never the triangle a broken panel uses. The icon carries
identity; the state carries colour.
A metric.v1 delta is directionless by default — it renders with a sign and
an arrow in a neutral tone, because green-up on a rising error rate would be a
lie. An optional delta_good: "up" | "down" opts into success/destructive
colour. target is a ceiling, rendered as a ratio meter. sparkline points
are evenly spaced by contract — a producer that pushes irregularly should use
series.v1 instead, not stretch metric.v1’s sparkline to fit.
The x axis of a series.v1 panel — name the ticks that matter
A series.v1 payload carries one label per category, and a label may be
null: a tick you are deliberately not naming. The category is still there —
it keeps its place in every series’ values, its bars and its tooltips — it
just carries no word under it.
null for
the rest.
The panel then thins what is left if even that does not fit: it drops whole
labels at an even stride, anchored on the last named tick, rather than
truncating every one of them into -1…. It never thins a data point. Every
category you send is drawn, and the name of a tick that lost its place on the
axis is still on that tick’s own bars, in the tooltip.
Two rules survive from before:
""is refused, and so is a label of nothing but spaces. An empty string is what a broken format expression produces, so a blank you meant has to look different from a blank you shipped — the same reasonnulland0are different claims invalues. (A label of spaces used to slip past the length check and draw an empty tick. It is now refused with a message that names the field;nullis how you ask for an unnamed tick.)- At least one label must be named. The panel may thin the names down to one; it may not be handed zero, because an axis with no name anywhere states no x meaning at all.
Tabs — several screens, one page
A page is a fixed structure you return to, and past about six panels it stops being glanceable and becomes a scroll: the thing you came for is below the fold. Declare atab: and the page grows a bar under the breadcrumb, one
screen at a time — the shape an ordinary website has had for thirty years.
tabs: block. Adding a tab is one word on the
panel that needs it rather than a new section plus an entry in it — and there
is no second list that can disagree with the panels, so a tab with no panels,
a panel listed twice and a panel nobody listed are all states this format
cannot represent.
Three rules, and they are all you need to predict what renders:
- Bar order is first appearance. The spec is read top to bottom and the page is drawn the way it reads, exactly as panel order is the layout.
- A panel with no
tablands on the first tab. So declaring one on a single panel of an existing page gives you a working page rather than an error. If you want the first tab to be named after those panels, name it on the first panel — the first tab is the tab of the first panel that declares one. - A page where nothing declares a
tabhas no bar at all and renders exactly as it did before tabs existed.
A tab hides panels, so the bar carries state
This is the part that is not layout. The claim this whole feature rests on is that a panel which stops reporting says so — and a critical panel sitting on the third tab, behind a click nobody made, would be perfectly silent. So:- Every tab carries the worst state of its own panels —
failedoverstaleovernever_producedoverfresh— drawn as a glyph beside the name, never as colour alone. The tab that is broken looks broken from the tab you are standing on. - The page’s freshness summary is computed over every tab, never the visible one. The word top-right does not change when you switch tabs. A page that read FRESH while a hidden tab was failing would be the old-numbers failure with one extra click in front of it.
- A tab whose panels are all sealed to you still appears, with its placeholders under it. The page has the same shape for everyone: a bar that reflowed per reader would, by what it left out, say whose data was on it. Such a tab carries no state glyph — the server sends no state for a panel you may not see, and the bar does not guess one.
Deep links, print and phones
The selected tab is in the URL —/pages/sit?tab=odezva — so a link opens on
the screen you meant. An unknown tab falls back to the first one rather than
rendering nothing.
Print ignores tabs. Paper cannot be clicked, so printing a page renders
every tab’s panels, in bar order, each under its tab name, and the bar itself
is not printed.
On a phone the bar scrolls sideways rather than wrapping into a stack of rows,
and the panels below it are the same single column they always were.
The bar is one tab stop, and every tab names the panels it reveals. Tab
enters the bar once and the arrow keys move along it — Home and End jump to
the ends — rather than costing a keypress per tab to walk past. Each tab
points at its own group of panels and each group points back at its tab, so a
screen reader announces which panels a tab revealed instead of leaving them to
be hunted for elsewhere on the page.
What is refused
A tab name is a word on a bar, so the authoring gate refuses what cannot be drawn as one, with the reason:tab: "" is treated as “no tab”, the same way an empty title: or icon: is —
omitting the key and declaring it empty mean the same thing.
tab: " " is not the same as tab: "", which is why the table above
refuses one and this paragraph accepts the other. An empty string says “this
panel has no tab”. A string of spaces says “this panel has a tab” and then names
it nothing — the shape a broken template expression produces — so it is refused
rather than quietly read as the first. Same distinction the payload layer draws
between a null and a measured 0. Names are trimmed
but never case-folded: the bar draws the word you wrote.
Where a producer pushes from
A producer is whatever runs closest to the thing being measured. Crewship does not care what language it is written in or where it lives; it cares that the push arrives with an identity it can check. There are four doors — and the door you come through decides whichproducer: declarations you can satisfy,
because each door carries a different identity. Pick the door from where your
code runs, then declare the producer that door can prove.
In every row a
produce grant a human issued is the alternative to the
declaration — that is what the grant is for.
The four doors are one write. They share the schema validation, the 64 KiB
payload cap, the push-rate floor, and the same refusal sentences — the entry
point and the identity it carries are the only things that differ.
script is the odd one, and the difference is worth understanding. The
other three name a principal the server can check: a run, an agent, a token.
script/watch-services.sh names nothing — it is a label saying “a script
writes this”, and the real principal is whichever human’s CLI token invoked it.
So the server admits it on the CLI door, where a human is present, and gates it
on ownership of the page instead. That is also what makes a script panel the
one kind a person can correct by hand while a producer keeps writing it.
A routine’s push is governed twice. The producer check is the first gate;
the author crew’s autonomy level is the second. page.write maps to the
page_write policy action, and under strict autonomy it is held for operator
approval — which on an unattended run (a schedule, an automation, a wake gate)
is a refusal, so the panel is not written. guided, trusted and full
all write and journal. See Governance.
A panel that never fills on a strict crew is this, not a broken producer.
From inside a crew container
An agent does not get a CLI token, and there is nocrewship binary inside
the sandbox image — it ships curl, jq, git and little else, so
crewship page set is not a command an agent can run. It pushes to its sidecar
on localhost instead, and the sidecar attaches the identity:
fd-3 form is how every sidecar call carries its token: a bearer token
passed as -H is visible in the container’s process table to anything else
running in it.
The body is the payload, exactly as it is for crewship page set. Add
?state=failed when the producer ran and could not measure — that is the only
part of its own state a producer gets to assert.
What the agent cannot do is claim to be someone else. workspace_id,
crew_id and agent_id are written by the sidecar from the container’s own
configuration and the token on the request; the same fields inside the payload
are inert. A sibling agent sharing the sidecar pushes as itself, not as the
agent the container booted with, and a forged token pushes as nobody. This is
why the door exists at all: handing a container a CLI token would work, and
would put the one credential the sidecar is there to hold inside the process it
is holding it against.
The panel has to be declared for this door. The sidecar stamps an
agent_id and deliberately no run id, so the panel this push lands on must
declare the agent that is pushing:
producer: script/watch.sh cannot be written from a
container, even though the script is sitting right there in it. On this door
the server asks two questions and only two: is the acting agent the panel’s
declared producer, or did a human issue it a produce grant covering the
panel? A script producer answers neither — it names no principal — so the
push is refused, and the refusal says so:
producer: agent/<slug> and push as
that agent, or leave the declaration alone and have a human issue that agent a
grant — crewship page grant <page> --agent <slug> --level produce --panels <id>.
Filling such a panel by hand, to see it work
The seed cannot do it, and that is not a gap to be closed. The sidecar is not a service that waits: it is started when an agent RUN is dispatched, and$CREWSHIP_AGENT_TOKEN is minted on the same path and injected into that
agent’s process — not into the container. So a docker exec from outside lands
in a shell with no token, and the only way to get one there would be to read
another process’s command line. A seed that needed a model to cooperate, a
container to boot and a token to be scavenged is not a seed.
What works is asking the agent, which is the designed path and the same thing a
wake gate asks for:
curl with -H "Authorization: Bearer $CREWSHIP_AGENT_TOKEN" will refuse it —
correctly, because that puts the token in the process table — and then do the
same push the safe way from its own guidelines.
What a container producer gets, and what it does not
This is the part people get wrong, so it is a table rather than a sentence.
The consequence: a container is not where you put a polling producer. If a
panel should refresh on a cycle, its producer is a routine on a schedule, or a
script on a host you control with a real cron and a CLI token. The container
door is for the agent that has just concluded something and wants it on the
board — which is exactly what a wake gate asks it to do.
See Producer authority
for the full rule.
What a Page is not
This is the part worth reading before you build on top of one. There is no ad-hoc time-range selection. You cannot re-query a panel for “what did this look like last March.” A page answers what is happening now, and what did the system do about it — it does not answer show me last March. When someone needs that, the honest answer is a routine that queries and pushes aseries.v1 panel on its own schedule, which is more reviewable
than a dashboard’s query string ever was.
There is no query language, no datasource plugin, and no credential of any
kind in the page layer. A page cannot be pointed at a database. Every
number on it arrived because something with real access pushed it.
The panel vocabulary is closed at five schemas, and there is no plugin
system for adding your own. A single sandboxed embed panel (an iframe,
cross-origin, CSP-scoped) is reserved as the escape hatch for everything that
does not fit — maps, calendars, custom HTML — but it ships in a later stage
than the five schemas above and is not available from day one.
History, alerting, and correlation do not live in the panel. They live
where Crewship already keeps them:
A bounded ring of previous payloads (newest 200, hard age cut at 7 days) is
kept per panel — enough for a sparkline and for “what did this look like
right before it broke,” not enough to be a time-series database.
The wake gate is the payoff for living with those limits. A panel can
declare a threshold that wakes an agent:
page.panel.*
journal entries and lets the existing matcher → debounced run pipeline do the
rest.
The freshness contract
A dashboard that silently shows old numbers is worse than no dashboard. Pages takes its behavior from push-based uptime monitors (Uptime Kuma, Healthchecks.io, Gatus), not from a metrics pushgateway that keeps a value forever until something remembers to delete it. Every panel declaressla. There is no default that means “never mind” —
a panel without one does not validate.
Four states, computed server-side, never by the producer:
The boundary is
age >= sla → stale, tested at exactly that instant. Freshness
is never stored — there is no column a producer could write a timestamp into
— it is computed from the server’s own produced_at on every read, so a
producer cannot claim to be fresher than it is.
The newest payload always survives the retention cut. Without this rule,
a producer dead for eight days would have nothing left inside a seven-day
retention window, and the panel would flip from “stale, last value 12:40” to
“never produced” — the system forgetting a failure instead of reporting it,
which is the one thing this contract exists to prevent.
on_failure: {issue: crew/<slug>} opens an issue on that crew. A page that
quietly stops updating must generate work for a human, not just a dimmed tile
nobody happens to look at.
What notices is a sweeper, once a minute. It has to be a sweeper: freshness is
computed when a page is READ, and the case this rule exists for is the page
nobody is reading. So the check runs whether or not anybody is watching, and
the largest delay it adds is one interval — a panel with a 30s SLA is reported
between 30 and 90 seconds after it goes quiet.
Each lapse is reported once, not once per check:
- The edge is recorded in the database, so a panel dead for a week produces one issue and one notification rather than ten thousand. A restart, a deploy or a missed tick delays the report by one interval; it cannot duplicate or drop it.
- The record is cleared when data arrives inside the SLA again, and a
page.panel.recoveredentry says so. The NEXT outage is a new edge and opens a new issue — a monitor that reports only your first outage is worse than none. - A panel with no
on_failurestill reports its lapse: the page owner gets thepages.stalenotification, nobody gets an issue. Awareness and work are different things, and only the second one needs an owner. - The issue is opened on the crew
on_failurenames and assigned to that crew’s lead agent, so it can be started without being edited first. A crew with no lead agent cannot be given work — the lapse stays unreported and loudly logged until one exists, rather than being silently swallowed.
Wake gates — the panel as a sensor
This is the part that makes a Page more than a dashboard. A cheap script pushes, a threshold wakes an agent, and the agent writes its analysis back onto the same page:when is not an expression language. It is two forms:
A predicate the panel’s schema cannot satisfy —
value > 90 on a status.v1
panel — is refused when you save the page, not accepted and then silently never
matched. So is a state outside the closed set (ok, warning, critical), and
so is a writes: naming a panel that is not on this page.
for requires the condition to hold. It is measured against the panel’s
own stored payloads: the gate fires when the oldest unbroken run of matching
pushes is at least for old. One good scrape in the middle restarts the
window, and a panel that pushed once and went quiet never satisfies a non-zero
for — that is on_failure’s case, not this one. Omit for and the gate
fires on the first push that crosses the threshold.
What actually happens when it fires. The gate opens an issue on the crew
named by agent:, assigned to that crew’s lead agent, saying which panel
crossed which threshold and — if writes: is set — where the answer goes.
writes is a declaration, not a grant: the woken agent still needs produce
authority on that panel, and if its push is refused, that is the ACL working.
It fires once per crossing. Two things make that true, and both matter:
- a burst of pushes inside the rule’s debounce window is coalesced into one issue — 200 events, one issue, which is what makes a gate safe to point at a panel pushed every five seconds;
- while the issue is open, the same gate does not open a second one. When the condition clears, the gate re-arms and the next crossing opens a new issue.
automations named page <slug>/<panel> wake <n>, matching on the
page.panel.updated journal entry that every accepted push already emits.
There is no second eventing path, which means gates inherit the debounce, the
hourly burst brake and the visibility that
automations already have — crewship automation list
shows them.
Those rows are owned by the page spec. Every save rewrites them, deleting the
page deletes them, and editing one by hand does not stick.
refresh — the panel that pulls itself
A wake gate tells a human something happened. refresh: tells a routine.
It names the event that runs this panel’s producer:
critical, the gate opens an issue on crew/devops,
and incident-rozbor runs — so the analysis is already on the page when the
human arrives, instead of starting when they get there.
It is a trigger, not a hint. A page holds no query and no datasource, so
nothing polls: the only way a panel’s contents change is a producer pushing to
it. A refresh: that did not run the producer could not refresh anything, so
it runs it.
The vocabulary is closed — two values:
on:wake is page-scoped rather than gate-scoped because the panel being
refreshed is rarely the panel being watched — in the example above they are two
different panels. on:panels-changed is for a producer whose output is about
the page (“what is on this board”), which is stale the moment somebody edits it.
A rename is not a panel change: correcting a title does not run anything.
Under the covers it is an automation, exactly like a wake gate. Each
declaration compiles at save time to a row in automations named
page <slug>/<panel> refresh <value>, with action_kind: routine pointing at
the panel’s own producer. So a refresh inherits everything that table already
does — debounce, coalescing, the hourly burst brake, chain-depth pricing against
composition loops — and crewship automation list shows it. The rows are owned
by the page spec: every save rewrites them, removing refresh: deletes the
rule, and deleting the page deletes it.
The routine is handed the context it needs as inputs: page, panel (the
panel it is expected to write), refresh, reason, and — on on:wake —
trigger_panel and issue.
Four things are refused when you save the page, each because it would
otherwise be a declaration that is stored, believed and never acts:
- a value outside the two.
refresh: on:pushis a routine that never runs and never says why. - a producer Crewship cannot run.
refresh:requiresproducer: routine/<slug>. Ascript/producer is a path inside somebody’s container and Crewship never executes it — the script pushes to us, we do not call it. Awebhook/producer is a door into Crewship with nothing on the other side to call. Anagent/producer is woken by a gate’swrites:, which is the mechanism for asking an agent to write a panel. The refusal names all three. on:wakeon a page that declares no gate. Nothing on the page could ever fire it.on:wakeon a panel that declares its ownwake:gate. That is a loop: the producer runs, pushes to the panel, the push arms the panel’s own gate, the gate fires, and the producer runs again. Put the gate on the panel being watched and the refresh on the panel being written.
refresh: does not travel in an export bundle, alongside wake:,
actions: and on_failure: — and it could not travel alone, because
refresh: on:wake is refused on a page with no gate on it. Re-declare it
after importing, or apply the page from a manifest, which does carry it.Actions — the interactive half
A page that cannot do anything is a report. A panel may declare actions: buttons an operator clicks to run a routine, open an internal entity, or fold a panel away.crewship page action,
or from the panel itself.
The button posts an id, not a routine
This is the load-bearing decision, and it is what makes “actions come from the page’s declared allow-list only” verifiable rather than aspirational. A click posts toPOST /api/v1/pages/{slug}/panels/{panelId}/actions/{actionId}, and the body
carries only the collected inputs. The server resolves the action id against
the page’s stored spec and dispatches the routine named there. A compromised
client, an injected narrative panel, or an agent cannot name a routine at click
time, because the wire format has no field for one.
An agent may write a panel’s data. It can never author the button underneath it.
A link carries an entity, never a URL
Alink action names an internal entity by kind and id — issue, run,
page, agent — and the renderer builds the address. There is no URL field
anywhere in the action schema, deliberately: Slack AI’s private-channel
exfiltration in 2024 was a rendered link, and CamoLeak (CVE-2025-59145) proved
that proxying through a trusted first-party host is not a defence. The schema
simply has nowhere to put one.
Confirmation is drawn by the host, and only where it earns its place
Aconfirm block is rendered by the application chrome, never by panel content,
so an injected panel cannot fake or skip it. And it is calibrated to blast
radius rather than applied universally: a read-only or reversible action
declares none. A dialog on every click is a rubber stamp, not a control.
For the same reason, a narrative.v1 panel — the one whose content an agent
writes — may not declare actions in this release. A panel that both displays
untrusted prose and can trigger an action is the shape that turns a prompt
injection into an operation.
Clicking returns when the run is queued
The endpoint answers202 with a pending id, never the run’s result. A button
on a ten-minute routine must not hold a connection open, and no surface in the
product waits on one.
Three answers follow from that, and all three are the same construction rather
than three features:
- A double-click is one run. Every dispatch carries an
Idempotency-Key; the same key with the same inputs resolves to the original dispatch. - A replayed key with different inputs is refused (
409) rather than silently resolved onto the first run. A key that quietly returned somebody else’s result would report a click that never happened as a success. - “Already running” is an answer, not a queue. A second dispatch while the
first is still in flight gets
429withRetry-After.
What the browser does with those answers
The button is drawn by the page, not by the panel’s payload, and every state it can be in comes from one of the answers above:
A
toggle folds the panels it targets away and back. It is local to your
browser: no request is made, nothing is saved, and a reload brings them back.
Who may click
Two halves, and both must hold: you can see the panel (crew membership, or a workspace admin role), and you hold what the routine itself requires (MANAGER+, and the routine must beactive). A page button is never a cheaper
way to run a routine than the routine’s own surface.
A caller who cannot see the panel gets 404 — the same answer as an action that
does not exist. A 403 would confirm that the action exists for somebody else,
which is precisely what the sealed placeholder is designed not to disclose.
Every dispatch is journalled with who clicked, which action, and which routine
the server resolved it to.
Permission model
Permissions are per panel, not per page — this is the property most dashboard products (Grafana, Looker, Tableau, Power BI, Metabase, Superset) deliberately stop short of, and it is why one page can render differently for different viewers.Who reaches a page
Two questions, decided separately, and the confusion between them is the one thing worth reading this section slowly for:- Can you open the page? You can if you own it, if you are a workspace
ADMIN/OWNER, if one of your crews owns a panel on it, or if a grant names
you. Otherwise the page is not in your
crewship page listand opening it by slug answers not found — the same answer as a page that was never created, so the endpoint cannot be walked to learn what exists. - Can you read a panel on it? Only if you are in that panel’s owning crew (or are an admin). Nothing else opens a panel — not ownership of the page, and not any grant.
Panel visibility is crew membership
A panel’s visibility is its owning crew’s visibility.owner on a panel
is not decoration; it is the access-control list. If you are not a member of
that crew, the panel arrives as a sealed placeholder — its id, its slot
and its owning crew’s name, and nothing else. No schema, no producer, no SLA,
no data. It is filtered server-side, before serialisation; it never renders as
an error, and it is never delivered to a client that then hides it — a
hidden-but-delivered panel would be a data leak.
A page therefore has the same shape for everyone — the placeholder holds the
slot, the grid never reflows — even when its content differs by viewer. “Look
at the panel top right” has to mean the same thing to two people on a call.
The three verbs
A grant is not a single “can touch this page” bit. It is one of three independent verbs:produce can be scoped to specific panel ids, so an agent granted produce
on one panel cannot overwrite another agent’s panel on the same page.
Every level opens the page, so read is the floor the other two are built on
rather than a separate thing you also have to issue. The alternative reads
absurdly in both directions: a write grantee who may rewrite a page but
cannot open it, or a produce grantee who must be told its panel ids out of
band because the page they push to is invisible to them.
A grant widens reach to the page, never to a crew’s data
Grants live in one table (page_grants), not scattered across panel
properties, and they compose with — never override — crew-based visibility.
A grantee still only sees the panels their own crew membership and
workspace role already permit. A page owner cannot use a grant to leak
their crew’s panel to somebody outside it. (A workspace ADMIN sees the crew’s
panels anyway, since an admin’s effective role is already the max of their
workspace and crew role — so this rule costs an admin nothing, and it closes
the escalation path for everyone else.)
The visible consequence: grant somebody read on a page whose panels all
belong to crews they are not in, and they get the page with every panel
sealed. That is the grant working. They were given the board, not the numbers
on it — and if you meant them to read the numbers, the answer is crew
membership, not a wider grant. Where even the existence of a panel is
sensitive, move it to its own page.
Producer authority is separate from viewer authority
Being able to see a panel does not mean you can write it. Only the declared producer for a panel may push its payload — seecrewship page set.
Only a human issues a grant
An agent holdingwrite may rebuild a page’s layout freely, but it can never
widen who reaches the page — not even to another agent in its own crew. When
an agent needs another agent’s help on a page, it asks a human, and that
request shows up as a normal inbox item.
Two consequences worth knowing:
- Layout and data are separate authorities. An agent with
writemay place a panel owned by a crew it cannot see — that is what lets one agent assemble a cross-crew page for a team whose numbers it is not entitled to read. It does not receive that panel’s data: the server filters it exactly as it would for any other viewer, and the agent sees the same sealed placeholder a human without access would see. - An unauthorised
produceattempt is treated as a signal, not noise. It returns403, writes a journal entry, and notifies the page owner — it is equally likely to be a misconfiguration or an injection, and either way it deserves attention on the first occurrence, not the hundredth.
Ownership
A page has exactly one owner: a user or a crew, never both. An agent-created page records the authorising human as owner and the agent as its creator — an agent never owns permissions, it only ever acts under someone’s. If the owning user leaves the workspace, the page transfers to a crew rather than being deleted or orphaned, and a workspace ADMIN/OWNER is notified so a role that does not leave can reassign it. Two things are calledowner and they answer different questions. A
panel’s owner: is written in the page document and is the ACL for seeing
that panel — not a member of that crew, and you get a sealed placeholder. The
page’s owner is not in the document at all; it is a create-time argument
(crewship page create --owner crew/ops) and it decides who may edit the
spec and who may hand-write a script- or webhook-produced panel when
the producer got a number wrong. A crew-owned page counts every member of that
crew for the second question, which is what turns a personal page into a team’s
board.
Neither owner grants the other’s authority. Owning the page does not open a
panel belonging to a crew you are not in, and owning a panel’s crew does not let
you edit the page’s spec.
Ownership is decided once. There is no transfer command: it moves only when the
owning user is erased from the workspace, and then only to a crew. A re-applied
spec never moves it either — page update replaces panels, not ownership.
Re-applying a spec
crewship page update — and a re-run of crewship seed — re-applies a spec
onto the page that is already there rather than replacing the page:
- A panel that survives keeps its payload ring, its history and its freshness clock. Panels are reconciled by id.
- A panel the new spec dropped is deleted, and its payloads go with it.
- Grants, webhook tokens and version history are untouched. They belong to the page, not to the spec.
- Every save appends a version (the last 50 are kept) and rewrites the
page’s
wake:andrefresh:automation rows.
update --file replaces the spec, it does not merge into
it. A panel field left out of the file is deleted — including tab:, icon:,
public:, actions:, wake:, on_failure: and refresh:.
Keep the document you authored, because nothing gives it back to you verbatim:
page get --format json returns the read shape (sla_seconds, plus the state
and data the server attached) which update refuses, and page export returns
a bundle in a third format that drops the gates and the publication flag on
purpose. Rebuilding a lost spec from page get means converting it by hand.
Managing grants
“Inert” now means exactly one thing: the human who issued the grant no longer
has the standing to issue it. It is not a comment on the level —
read,
produce and write all decide something for as long as their issuer stands
behind them.What a page is, and what changed it
The same Settings surface answers the other half: the page’s owner (a user or a crew), its slug, description and panel count, when it was created and when its spec last changed — and the retained version history, each version credited to the human or agent that saved it. Any version can be restored from there, with the same confirmationcrewship page rollback deserves: a
panel a rollback brings back arrives with no data, because old payloads are
never resurrected and shown as current.
Public pages
A page can be published for someone with no Crewship account at all — an accountant, a client, an external auditor — optionally behind a password. This is the highest-risk surface in the feature, and it is a different product, not a looser permission level: a public page is served from a separate URL space (/p/{token}) that shares no session, no cookie, and no
workspace context with the app. Nothing about it is “the same page with a
wider grant” — it is a distinct rendering path, which is what makes it
auditable.
Six rules govern it:
- Read-only. No actions, ever. A public page renders no buttons. A button behind a public link would be remote code execution with a URL for a credential, so action definitions are stripped server-side before the response is built — not merely hidden in CSS.
- Opt-in per panel, not per page. Publishing a page only publishes the
panels an author explicitly marked
public: true. The default is deny; publishing is never a bulk action over panels nobody has looked at. - Only a human publishes. An agent can build the page, but it cannot make it public, and it cannot add a panel to an already-public page without a human separately marking that panel public too. “Public” is the widest reach a page can have, and only a human may grant the widest reach.
- Every public link expires. A required expiry, default 30 days, maximum 1 year. Tokens are high-entropy and individually revocable, and a page may have several — revoking the accountant’s link never breaks the client’s.
- Provenance is stripped by default. Run ids, agent slugs, crew slugs, and producer names are internal vocabulary; a public panel shows the value, the unit, and the age, nothing that maps your org chart for an outside reader. An author can opt provenance back in per page.
- Not indexable, rate limited, and logged.
noindex, no referrer leakage, a per-token request cap, and a journal entry on each token’s first view of the day, so the owner can tell the link is being used and roughly by whom.
Staleness on a public page: show the age, never the reason
A public panel always carries when its data was produced — an outsider acting on a stale number is the worst version of the failure the whole freshness contract exists to prevent; internally someone would catch it, externally they will invoice on it. But the reason a panel is stale or failed stays internal: container names, routine slugs, and crew names are not for an outside reader. A public failed panel reads as “data not current, last value at 12:40” and nothing more; the detail stays on the internal page for the people who can act on it.Password
Optional, per token. Stored hashed, never reversible, never carried in the URL. A wrong password is rate limited per token, and the failure response does not distinguish “wrong password” from “unknown token.”What stays out of a public page
No public actions, no public writes, no public streaming, and no embedding a public page inside a third-party site.The embed panel — the escape hatch, and everything it refuses
Five schemas cannot draw everything, so there is exactly one hatch:
embed.v1, a cross-origin sandboxed iframe. There is no plugin system, and
there is not going to be one — Grafana’s open panel-plugin ecosystem produced
130 plugins in fifteen years, and Perses’ cleaner runtime-pluggable design
produced about twenty.
It is off unless an operator turns it on. An instance with no vetted
destinations refuses an embed.v1 panel in the page spec, refuses every push
to one, and renders one that already exists as a sentence explaining why. That
is the default, and it is the state every Crewship is in until somebody
changes it.
https only, no
user:pass@, no fragment, no literal address in a private or link-local range
— and never this Crewship’s own origin, because a same-origin frame shares
cookies and storage with the page that framed it and would be a sandbox in
name only. One bad entry refuses the whole list rather than being skipped.
The payload names a destination; it never carries one
url, src, html, srcdoc,
sandbox, allow, width or height field, and none is coming — they are
absent from the schema rather than stripped from the payload, the same way
narrative.v1 has no image field.
The reason is worth stating plainly, because “it’s just a link to a dashboard”
is the intuition it contradicts. An iframe src is fetched by the reader’s
browser, from the reader’s network, at the moment the page is opened. A URL a
producer could change on every push is therefore an outbound channel: encode a
panel’s numbers into the path, push, wait for someone to open the page, and the
data has left the workspace with a colleague’s IP on the request. That is the
shape of the two incidents this product’s panel rules are written from — a
leak through a trusted image proxy, and a leak through a rendered link — and an
iframe is worse than either, because it fetches and it executes. No sandbox
attribute and no CSP closes it; the only frame-src that does is one naming
origins a human already approved, which is the same statement as “a human
authored the URL.”
So a human authors it and a machine selects from it — the same split the
action allow-list already uses. A producer’s whole influence over what the
browser fetches is which of the approved destinations is showing. An
undeclared name is refused and named back to you, never fetched to find out
what it was:
How the frame is drawn
sandbox="allow-scripts" and nothing else. Not allow-same-origin — the two
together are the documented way for a framed document to reach its own frame
element and delete the sandbox attribute. Not allow-top-navigation, so an
embed cannot steer the tab somewhere else; not allow-forms or
allow-popups, so a credential prompt cannot be drawn inside Crewship’s
chrome; not allow-modals, allow-downloads, allow-pointer-lock or
allow-presentation.
Alongside it: allow="" denies every delegated permission (camera,
microphone, geolocation, payment), referrerpolicy="no-referrer" keeps the
page’s own URL — which carries an internal slug — out of the embedded site’s
logs, and credentialless keeps the reader’s cookies for that site out of the
frame. Size is the panel’s, never the payload’s: a producer-supplied height is
a panel that can cover the page it sits in.
An embed never renders on a public page. A public link that framed a third
party would report every anonymous reader — their IP, their timing, the fact
that the link exists — to a site the reader never chose to visit. Public pages
already strip provenance for a smaller version of the same reason.
The frame also has to be permitted by the app’s own
Content-Security-Policy. Crewship’s UI policy has no frame-src
directive, so it falls back to default-src 'self' and the browser blocks
every cross-origin frame — including a correctly configured embed. Until
that policy is widened to the configured origins, CREWSHIP_PAGES_EMBED_SOURCES
enables the panel on the server side and the frame is still blocked in the
browser.Making one of your own
A page does not have to belong to a routine, and most do not. This is the whole flow, and every step of it is verified against a running server. 1. Decide who owns the page. The default is you. Hand it to a crew and every member of that crew can edit the spec and hand-write itsscript-produced
panels — which is what you want for anything a team looks after.
2. Decide who may SEE each panel. That is the panel’s own owner:, and it
is always a crew. A reader outside that crew gets a sealed placeholder rather
than an omission, so the page has the same shape for everyone.
3. Decide who may WRITE each panel. That is producer:, and this is the
choice people get stuck on. Four kinds, and only two of them have to exist
before you can save the page:
So a page you want to fill yourself is a
script/ page. The name is a label —
script/whatever.sh is fine even before the script exists — and it is the only
kind whose panel the page’s owner may also write by hand.
Letting other people in
Reach is closed by default: someone outside the owning crews gets a 404, not a 403 — the endpoint is not an existence oracle, and the page is absent from theirpage list entirely.
--user, --crew, --agent):
Two properties worth knowing before you rely on them, because both surprise
people:
readis not a skeleton key. A grantee who is in none of the owning crews reaches the page and sees a grid of sealed placeholders. Panel visibility is crew membership and a grant does not widen it.producedoes not let you SEE what you write. Every level opens the page —readis the floor the other two are built on, not a separate grant you also have to issue — but opening it is not the same as unsealing a panel. A subject can be the writer of a panel whose contents it may not look at, and that is deliberate: it lets a producer feed a board it has no business reading.
write can rebuild the page freely and can never widen who
reaches it — otherwise “grant my agent access” would be a privilege-escalation
primitive.
The same three verbs are in the page’s settings panel in the app, with each
grant showing who issued it and whether it is still live.
Seeing one without building one
crewship seed creates a page called Operations: five panels, one per
schema, each on its own tab, and a payload pushed into every one — so a fresh
workspace opens on a page that shows something rather than on five panels
reading “never produced”.
One panel per tab is unusual and deliberate. Tabs exist because a page past
about six panels becomes a scroll, so a real page groups several panels under
one tab; this one is a catalogue of the five payload shapes, and what a reader
learning the format wants is to look at exactly one shape at a time.
Its panels are owned by different seeded crews on purpose. Log in as a member of
only one of them and the rest arrive as sealed placeholders, which is the
per-panel permission model doing its job rather than a rendering fault.
The services panel is also where the seed shows the other half of the
feature. It declares a wake: gate (any(state == "critical"), held for five
minutes), an on_failure: route that opens an issue on ops when the producer
stops, an action that runs the seeded feed-watch-probe routine, and
public: true. So crewship page publish operations works against a fresh
seed with nothing to edit first, and the gate is a real compiled automation you
can see with crewship automation list — not a line of YAML that has never been
exercised.
The definition is cmd/crewship/seeddata/builtin/pages.yaml, and it is a
readable example of the authoring format: five schemas, a sparse series axis, an
em-dash cell, a delta_good, tabs, and the icons. Every payload in it is
validated against its panel’s schema at build time, and every page is re-parsed
as though a human had authored it, so a demo that would refuse to load fails the
test suite instead.
Three writers, one panel
The seeded page is handed to crew/ops rather than kept by the account that ran the seed, and its panels all declare ascript producer. Those two choices
together are what let three different writers fill the same panel without a
single grant being issued:
- the seeder, once, at seed time;
- a script, whenever you run it —
examples/pages/operations.shmeasures whatever host it runs on withfree,uptime,dfand one HTTP probe, and pushes all five panels throughcrewship page set; - any member of ops, by hand, at any time.
script- or webhook-produced panel, and ownership of a crew-owned
page counts every member of that crew. So a number a producer got wrong can be
corrected by whoever is looking at it, without stopping the producer and without
anyone issuing a permission.
--owner the page belongs to whoever ran the command, which is right
for a personal page and wrong for a team’s board. Ownership is a create-time
decision — page update re-applies a spec and never moves it, because a
re-apply that silently transferred a page would be a permission change nobody
asked for.
A hand-written payload is still attributed to the panel’s declared producer
in the footer, because
producer: is a name rather than a principal. If you
need to tell the two apart, the journal records who actually pushed.The second seeded page shows the loop, not the snapshot
Operations is filled and then it ages: nothing re-pushes it unless you run the script, so it goes stale exactly as a real page whose producer stopped would. The seed also creates Watch, and its two panels declareproducer: routine/page-watch. That one word changes who may write them: a
routine producer is not the page owner, and producer
authority admits only
the declared producer, so the seeder cannot fill these panels the way it fills
the others — a demo payload authored for them would come back a 403. They are
filled by running their producer, which the seeder does once at the end of
seeding.
So the four seeded pages together show every door a producer can push
through — a script on a host (Operations), an agent inside a crew container
(From a container), a third-party system holding a token (From outside), and a
routine through the dispatcher (Watch) — and Operations carries the sensor,
action and publish halves on top of that. Re-running it is the shortest way to watch a panel
refresh:
crewship seed --nuke removes pages before crews, because a panel’s owning crew
carries ON DELETE RESTRICT — a workspace still holding pages cannot have its
crews deleted.
Related
- Page commands — the full CLI reference, including the producer
script pattern for
page set. - Automations — what a panel’s
wakegate becomes. - Crew Journal — where a page’s history actually lives.
- Roles & Permissions — the workspace-role layer a page’s grants sit on top of.