---
name: glance-publish-sites
description: >-
  Publish a website — landing page, marketing site, docs-style pages — to Glance
  over MCP, served on the organization's own domain. Use when the user asks for a
  landing page, a website, a home page, a pricing page, or wants to "put this
  online" on their own domain.
homepage: https://glance.watch
mcp:
  name: glance
  transport: http
  url: https://glance.watch/api/mcp
  auth: Bearer <publisher API key from /dashboard>
---

# Glance — publish a site

Glance serves **pages** as well as dashboards. This is the same MCP server, the same
tools and the same declarative layout format as
[glance-publish-dashboards](https://glance.watch/skill.md) — read that first if you
have not connected the server yet. This document covers only what is different about
a website.

Connection setup uses the **universal publisher connection contract** in
<https://glance.watch/skill.md>: Streamable HTTP to `https://glance.watch/api/mcp`
with `Authorization: Bearer <publisher key>`. Client config syntax still varies, so use
the named client entry there rather than adapting another client's snippet.

`get_dashboard_recipe` is specifically for compact live **dashboards**. Do not stretch
one of those recipes into a marketing page. For a site, read
`glance://creative-directions`, `glance://page-recipes`, and `glance://site-starter`
(or call `get_creative_direction` and `get_page_recipe`), keep
`layout.presentation: "page"`, and run `check_layout` with the chosen or custom
`creativeDirection` before publishing. These are suggestions, not an allowlist: a
custom direction is first-class. Publish and patch responses can still include advisory
`quality` feedback, but the dashboard-only density and KPI hierarchy advisories do not
replace the site composition rules below.

## 1. What a page is, and what a site is

A **page** is an ordinary dashboard with two extra things:

- a **`slug`** — the path it is served at, e.g. `"pricing"`;
- **`layout.presentation: "page"`** — website framing (full-bleed sections, no
  dashboard chrome) instead of the one-screen live dashboard.

A **site** is a first-class collection of public slugged pages with its own handle,
custom domains, and analytics settings. Every organization starts with an immutable
default site. Free and Pro include 1 active site, Business 5, and Scale 25; archiving a
non-default site frees capacity. A publisher uses **`list_sites` to discover active sites**
and which one is the default, then carries the selected `siteId` into publishing calls. A
publisher key cannot create or manage sites. An owner/admin uses the `sites:manage` OAuth
tools to create, rename, set a handle, or archive sites; existing OAuth clients must reconnect
once to approve that scope. Sites must be empty before they can be archived, so move or remove
their dashboards first. Publisher calls accept `siteId`; omitting it targets the default site.

The OAuth viewer connection exposes the site's reusable direction through
`get_site_brand`; authorized owners/admins can change it with `set_site_brand`, while an
editor may read it to keep pages coherent. Site brand defaults are **materialized into a
page when you publish or patch**, and explicit page values win. Changing the site brand
does not silently repaint existing stored or live pages. Give each existing page a
reviewed republish or patch to adopt new defaults, preserving version history and
avoiding surprise public changes.

| URL | What it serves |
| --- | --- |
| `https://<domain>/` | the page slugged **`home`** (not `/home`) |
| `https://<domain>/<slug>` | that page |
| `https://<domain>/<anything else>` | the page slugged **`404`** if there is one, else a plain not-found |
| `https://glance.watch/p/<token>` | every page, always — its share link never goes away |

**Slug rules:** lowercase letters and digits joined by single hyphens, 3–64
characters, exactly ONE path segment. No slashes, dots, underscores, spaces, uppercase
or trailing hyphens. Unique within the selected site. `slug: null` takes a dashboard
off the site and frees the word.

A page is a dashboard, so it **counts against the plan's dashboard limit** — there is
no separate page allowance. Re-publishing an existing page is never blocked by that
limit.

## 2. The three things you cannot do

Say these out loud to the user rather than trying and failing:

1. **You cannot verify a domain.** The custom domain is attached and DNS-verified once
   by an organization admin in the Glance dashboard (Pro plan and above). You cannot
   read it, set it, or check it. Publish anyway — the pages go live once the domain is
   verified (verification announces nothing to the page cache, so allow the same window
   as any other change), and every page has a working `/p/<token>` link meanwhile.
2. **You may not be able to publish someone else's visibility.** Through the
   role-gated surface (a user's own OAuth connection), changing `visibility` — and
   changing the `slug` of an already-public page — requires an **admin** role. An
   editor gets `SHARING_NOT_ALLOWED`: retry the same call without `visibility`/`slug`
   and ask an admin to finish it. A **publisher API key** is the whole organization
   and is never gated this way.
3. **You cannot add scripts, analytics, tracking pixels, custom CSS or raw HTML.** The
   layout is validated data, not code. Anything outside the node catalog is rejected
   at the trust boundary. Do not try to smuggle markup through a `text` value — it
   renders as escaped text, exactly as written.

## 3. The recipe

1. **Choose the site.** Call publisher **`list_sites`** to discover the active sites. Use
   the default when the user did not request a separate site; if a new one is needed, an
   owner/admin creates it through the `sites:manage` OAuth tools. Carry the selected
   `siteId` through publishing calls.
2. **Publish `home` first.** It is the site root, and every other page's nav links
   back to it.
3. **Publish the rest** — `pricing`, `about`, `contact`, whatever the user actually
   needs. Inherit the selected site's saved brand direction where it helps, and use
   explicit page overrides only when the content needs them.
4. **Review privately before public launch.** Open each draft's authenticated
   `/dashboard/{id}/preview` workspace and inspect the real renderer at desktop 1440
   and mobile 390 widths. Write one critique, make one revision for material hierarchy,
   contrast, CTA, proof, overflow, or data-story issues, then preflight and review again.
5. **Publish a `404` page** if the site has more than two or three pages.
6. **Check with `list_dashboards`** that every slug you referenced exists and is
   public. Each publish/patch also returns a `site` block — read its `siteStatus`,
   `siteUrl` and `unresolvedLinks`; they tell you exactly what is still missing.
7. **Hand off to the human** with the lines they need to act on:
   - which pages you published, and at which slugs;
   - any page still **private** (an admin must publish it — you may not be able to);
   - whether the organization has an **address** for its site yet. If `siteUrl` is
     null and the status is `awaiting-address`, the site has nowhere to serve from.
     The fast fix is free and needs no plan: an admin claims a **site handle** under
     **/dashboard → Sites**, and the site is then live at
     `https://<handle>.<the platform apex>`. Connecting a custom domain
     on that site's detail page is the other route, and needs Pro or above — say
     both, and lead with the free one;
   - the `/p/<token>` links, which work right now regardless.

Publish the structure once, then keep any live numbers fresh with `update_data`.

**Pages are cached, so a publish is not instant.** A change you publish — a new page, an
edit to an existing one, a forward link lighting up — takes **at least** about a minute to
reach visitors on the site address. That is a floor, not a deadline: the stored copy is
handed out one more time in each region while the new one renders, so seeing the old page
even then is normal. Wait at least that long before re-reading, and never read the previous
copy as a failed publish: publishing again fixes nothing and can undo work. (Dashboards and
`/p/<token>` share links are not cached this way — they are rendered fresh on every
request, and `update_data` streams live to anyone already watching.)

## 4. Links — exactly three kinds

```jsonc
{ "kind": "external", "href": "https://docs.acme.example" }  // absolute https only
{ "kind": "internal", "to": "pricing" }                      // a slug of YOUR site
{ "kind": "tel", "number": "+1 720 613 2301" }               // a phone number, dialable
```

- **Never hardcode a URL to your own site** — not `https://acme.com/pricing`, and
  above all **never a `/p/<token>` URL**. An `internal` target is resolved at view
  time, so it follows the page when the domain changes and stays correct on every
  surface the page is viewed through (own domain, share link).
- **Forward references are the normal workflow.** Publish `home` linking to `pricing`
  before `pricing` exists: the link renders **inert** (visible, not clickable) and
  lights up on its own once you publish that page — no re-publish of `home` needed,
  though `home` is cached, so allow at least about a minute. An unresolved internal link is
  NEVER a publish error — unresolved targets come back in the `site` block so you can
  see which pages are still missing.
- A link to a page that exists but is **private** is inert too, for the same reason: a
  visitor cannot see it, so it must not pretend to be a link.
- **A phone number is a `tel` target, never an `external` one.** Write it the way a
  visitor should read it — `"(720) 613-2301"`, `"+44 20 7946 0958"` — because the
  punctuation and spaces are kept as the label; the dialable `tel:` href is built from
  its digits and a leading `+` alone (3–15 digits, 32 characters of text at most). A
  `tel` target resolves against nothing, so it is **never inert**. It is the only
  non-http scheme the catalog has: a `tel:`, `mailto:` or `sms:` string inside an
  `external` `href` rejects the whole layout, because that field is https-only.
- `http:`, `data:`, `javascript:` and relative URLs are rejected at the trust boundary
  (the same guard `image.url` uses).

## 5. The site nodes

Four nodes exist for pages; everything in the dashboard catalog still works inside
them.

- **`navBar`** — `items` (1–7 links), optional `brand { text, logoUrl? }`, optional
  `cta`. Repeat it verbatim on every page.
- **`hero`** — `headline` (≤120 chars) plus optional `headlineSpans`, `eyebrow`,
  `subhead`, `subheadSpans`, `cta`, `secondaryCta`, `peek`,
  `media { url, alt?, annotations?, ring? }`,
  `mediaPlacement: 'below' | 'side' | 'backdrop'`, `align: 'start' | 'center'`,
  `size: 'default' | 'display'`. Exactly one per page, first after the navBar. It owns
  the page's `<h1>`.
  - `size: "display"` is the landing-page type scale — drawn big and uppercase, so the
    headline has to be SHORT (under ~90 characters; the rest belongs in `subhead`).
  - `headlineSpans` is the headline split into marked runs,
    `[{ text, marks?, block? }]`, exactly like a `text` node's `spans`: the texts must
    concatenate to `headline`
    EXACTLY. Mark the run that matters with `marks: ["highlight"]` and the page's own
    accent paints it — you never pick the colour. `headline` stays the plain string a
    link preview, an OG card and a search result read. A run marked `block: true` starts
    its own LINE, which is how a display headline reads one sentence per line instead of
    whatever the column width decides; it changes the layout and never the words, so the
    concatenate rule is unaffected.
  - `subheadSpans` is the same thing for `subhead`, under the same rule — the texts must
    concatenate to `subhead` EXACTLY, and it needs a `subhead` to mark up. Mark the ONE
    phrase the sentence exists for ("$958,000, one block away") with
    `marks: ["strong"]` and it reads darker and heavier than the words around it. Without
    it an agent restates that phrase in the headline and the hero says the same thing
    twice.
  - `mediaPlacement: "side"` stands the image beside the words on a wide screen and
    stacks it on a narrow one. Legal without `media`, where it simply does nothing.
  - `mediaPlacement: "backdrop"` puts the image **behind the words**, edge to edge. You
    choose the media's ROLE and never its treatment: keeping the headline readable over
    that picture, in light mode and dark, is ours — there is no overlay, tint, opacity or
    text colour to send, and none is accepted. Give `media.alt` a real description, or
    `""` if the picture is pure decoration. Legal without `media`, where it does nothing.
  - A backdrop picture can be **annotated**. `media.annotations` is up to 8 chips —
    `[{ x, y, label (≤40), kind?: 'subject' | 'comp' | 'poi' }]` — positioned in PERCENT
    of the image box (0–100 on each axis). Each kind has its own seat: a `poi` chip
    centres ON its point, a `comp` chip (and one with no `kind`) perches just ABOVE it
    like a pin, and a `subject` draws its dot AT the point with the address tag hanging
    below — so keep a pin-style chip's `y` at 6 or more, or its lifted box can leave the
    picture (preflight warns you). `media.ring` is one `{ x, y, width, height }` ellipse
    in the same percent units.
    That is how four sold prices and a walking radius end up ON the photograph instead of
    burned into a JPEG nobody can restyle, translate or read out. The kinds are the map's
    three marker roles and we draw all of them. The layer is DECORATIVE: it carries no
    link and no callback — a place worth clicking is a `map` node with a `targetKey` —
    and its labels reach assistive tech through `media.alt` plus a hidden list, so
    annotations discharge no alt obligation and you still write an `alt` describing the
    picture as a whole.
  - `peek` — `{ label (≤40), sublabel?, media?, target }` — is a SCROLL affordance, not a
    third button: a thumbnail and two lines that move the viewer to the node whose `id`
    is `target`, further down the same page. It is a NODE reference resolved in the
    browser, exactly like a map marker's `targetKey`, so an id that names nothing simply
    does nothing and it is never a publish error. Use it instead of repeating your
    primary action in the hero.
- **`linkButton`** — `label`, `link`, `variant: 'primary' | 'secondary' | 'quiet'`.
- **`footer`** — optional `tagline`, `groups` (≤4 of `{ title, items[] }`), `legal`.

A band is not a fifth site node — it is a `section`. On top of `title`, `collapsible`
and `children`, `section` takes `tone: 'muted' | 'inverted'`, and on a page a toned
section runs **full-bleed** edge to edge: `muted` is a quieter surface in the same
theme, `inverted` is the opposite mode (a dark band on a light page, and the reverse)
keeping the SAME accent. Omit `tone` for a plain titled group.

Labels above things are a `text` node of `variant: "overline"` — the small uppercase
line an eyebrow, a meta line or a stat's caption wants. It is deliberately not a
heading, so it never joins the page outline or competes with the hero's `<h1>`.

An annotated backdrop hero, whole — the pattern is easier to copy than to assemble:

```jsonc
{ "type": "hero", "size": "display", "mediaPlacement": "backdrop",
  "eyebrow": "3015 Springmeadow Drive · Colorado Springs 80906",
  "headline": "Broadmoor pocket. $271,000 spread. Full cosmetic.",
  "headlineSpans": [
    { "text": "Broadmoor pocket. ", "block": true },
    { "text": "$271,000 spread. ", "marks": ["highlight"], "block": true },
    { "text": "Full cosmetic.", "block": true } ],
  "subhead": "The closest comp sold for $958,000, one block away.",
  "subheadSpans": [
    { "text": "The closest comp sold for " },
    { "text": "$958,000, one block away", "marks": ["strong"] },
    { "text": "." } ],
  "media": {
    "url": "https://cdn.example.com/hero-map.jpg",
    "alt": "The block, with the five closed comps and the quarter-mile walk marked",
    "annotations": [
      { "x": 50, "y": 55, "label": "3015 Springmeadow", "kind": "subject" },
      { "x": 22, "y": 31, "label": "Sold $958K", "kind": "comp" },
      { "x": 79, "y": 24, "label": "The Broadmoor · 1.3 mi", "kind": "poi" } ],
    "ring": { "x": 50, "y": 55, "width": 20, "height": 36 } },
  "peek": {
    "label": "See the house",
    "sublabel": "Photos · Map · Comps · The Flip Plan, all below",
    "media": { "url": "https://cdn.example.com/house.jpg", "alt": "The house from the street" },
    "target": "gallery" } }
```

The `target` there is the `id` of a node further down the same page — put
`"id": "gallery"` on the section you want the peek to reach.

## 6. Design rules

- **ONE promise per page**, stated in the hero headline in the visitor's words. Two
  promises means two pages.
- **ONE primary action per page** — the hero `cta`, or a single
  `linkButton` with `variant: "primary"`. Everything else is `secondary` or `quiet`.
- **3–5 sections after the hero**, alternating dense (metrics, table, chart) and light
  (a claim, a link) so the page has rhythm instead of being a wall.
- **Give the page ONE display hero and let it be big.** `size: "display"` draws the
  headline at landing-page scale, so write a SHORT one and mark the run that carries
  the promise — the price, the place, the verdict — with `headlineSpans` and
  `marks: ["highlight"]` instead of restating it. Add `mediaPlacement: "side"` when the
  hero has a photo worth standing beside the words, or `mediaPlacement: "backdrop"` when
  the photo IS the promise — a place, a building, a room — and the words belong on it.
  Reach for `"backdrop"` at most once on a page, and only with a picture whose subject
  survives having a headline across it.
- **On a backdrop, the eyebrow carries the locating line.** The address, the city, the
  date — whatever says WHERE and WHEN this page is — belongs in the hero's own `eyebrow`,
  not in an `overline` composed under the picture, where it does no work and repeats what
  the hero should already have said. It is drawn to run WIDER than the headline column on
  purpose: a short eyebrow line, a narrower headline, a wider subhead is a STEPPED
  silhouette, and that step is what stops a hero reading as one flat rectangle of type.
- **A backdrop that makes an argument is annotated, not pre-baked.** `media.annotations`
  puts up to 8 labelled chips ON the picture at percent coordinates (plus one
  `media.ring`), so four comparable prices and a walking radius sit on the map instead of
  being burned into a JPEG nobody can restyle, translate or read out. Label them with
  FACTS the page also states in words ("Sold $958K", "The Broadmoor · 1.3 mi"). When a
  marker needs to be CLICKED, or the scale needs to be true, that is a `map` node with
  `targetKey`, not an annotation.
- **Give the hero a `peek` instead of a second copy of its own button.** A thumbnail, a
  label and a sublabel that scroll the visitor to the section the page is really about
  say "there is more under this"; the same primary CTA printed twice says nothing the
  first one did not.
- **Alternate the BANDS as well as the density.** Give every second section a `tone`
  (`"muted"` for a quiet band, `"inverted"` for the opposite-mode one) so the page
  reads as horizontal strips rather than one column. Two toned bands in a row read as
  one band; every section toned reads as none.
- **Use `variant: "overline"` for the small labels** above a headline, a stat or a
  section — an eyebrow, a meta line, a caption. Reaching for `h3` to get a small line
  puts a fake level in the page outline.
- **Give the page a type PAIRING.** `theme.fonts` takes two roles, `display` and `body`,
  and they should be two DIFFERENT faces doing two different jobs: the display face
  carries every label, number and headline, the body face carries the prose. That
  separation is most of what makes a page read as editorial rather than as a report —
  `outfit` + `barlow` is the classic pairing — and the same family in both roles is the
  page you already have. The families are a closed allowlist (see the theme section of
  the dashboard skill; `barlow` is body-only), and a font URL is rejected.
- **A photograph with several things to say under it is a `card`,
  not an image `caption`.** A caption is ONE string and renders as one flat grey line, so "Sold
  $958,000" reads exactly like "very similar floor plan". A `card` declares the parts
  instead — `overline` (the micro-label), `value` (the number, drawn in the accent),
  `title`, `meta` (the one-line specifics), `body`, `action` — and its `link` wraps the
  WHOLE card, so the words that promise the click are inside the anchor rather than
  beside it. A grid of comparable sales, a team, a set of plans, a gallery with a claim
  under each shot: all cards, with `span: "full"` on the single one that deserves the
  feature slot. When the caption is two different STATEMENTS about one picture, that is
  `facts` — 1–3 labelled lines, `[{ label, text, tone?: 'accent' | 'muted' }]` — not a
  sentence with an em dash in it: "You have: a 1979 kitchen" beside "Buyer plan: paint and
  counters, $14K" reads as two halves a visitor can scan, and the `tone` says which half
  carries the argument (absent is `muted`, the quiet one). Write the labels in sentence
  case; we uppercase them. A gallery card is exactly a picture plus `facts`, with no
  `title` at all.
- **A criteria block is a headless table, and its source is a `cite` column.** A
  label/value grid — dimension, grade, why — has no use for column heads reading
  "Dimension / Grade / Why", so send `headless: true` and the head row is not drawn (the
  columns are still declared; they select the fields and carry the emphases, and there is
  no head left to click, so no sort affordance either). Give the grade column
  `emphasis: "pill"` and the attribution column `emphasis: "cite"`, which draws the source
  as a quiet tracked micro-label so it never competes with the claim it backs.
- **Use a `visitorMap` when the audience itself is the proof.** Bind it to fixed
  `{ countryCode, views, visitorDays }` rows returned by `get_visitor_geography`; on a
  public page with built-in analytics active, Glance overlays a rolling country snapshot
  and live view pulses without changing the authored dashboard or creating versions.
  This is country-level evidence, never surveillance: the node accepts no IP address,
  visitor digest, city, exact coordinate, tile URL or inline marker. Historical visits
  with no country stay absent, so an empty or newly filling map is honest. Use it as one
  vivid proof section, not as decoration on every page.
- **Two things a reader is COMPARING go side by side, never one behind a tab.** A `tabs`
  widget keeps a dense dashboard readable; it is the wrong shape for a comparison, because
  half the argument is off-screen at all times and the visitor has to hold the other half
  in their head. Put both plans, both scenarios, both quotes in one `grid` and let the
  difference between them be visible — that difference IS the section.
- **A `ticker` is a band of evidence, used ONCE, high up.** 2–12 short claims, each ≤60
  characters, that the rest of the page then proves ("5 closed comps inside 0.8 mi",
  "Ask $679K", "No structural"). It is not a marquee of slogans, and everything about how
  it moves is ours — we draw the separators, own the loop and stop it entirely for a
  viewer who asked for reduced motion.
- **A `leadForm` is a private collection boundary, not a data widget.** It is available
  only on published site pages for Business and above. Give the form, action, every field,
  and required consent statement stable ids; increment the positive integer `version` when
  fields, consent, privacy URL, success copy, or action changes. Every submission is saved
  to the private inbox; email delivery is optional and an owner/admin configures it
  separately. A `formId` is site-wide: forms on different pages need distinct ids unless
  they share the exact definition and version, plus one optional delivery setting.
  Submissions are encrypted, retained for 30 days, and never become dashboard data or
  publisher-readable PII. With built-in analytics enabled, an eligible tagged landing may
  attach one encrypted, tenant/site-bound attribution snapshot; exact UTMs and allowlisted
  click IDs remain owner/admin inbox data, while publishers see only aggregate attribution
  sources. Untagged and unknown-only URLs do not replace the snapshot, and disabling
  built-in analytics makes an existing attribution cookie inert at submission. A successful
  native lead emits generic GA4 `generate_lead` and Meta `Lead` browser events only when the
  provider is configured and its runtime is loaded after any required consent. These calls
  contain no form or action id, PII, attribution, or other parameters; the provider can
  correlate them only through its own browser session. Business/Scale owners and admins can
  separately authorize Google Data Manager or Meta Conversions API and create outbound goals for
  `lead_submitted`, `qualified`, or `won`. The click identifier is encrypted at rest and decrypted
  only for the outbound TLS request; Glance sends it with a
  stable event id, configured action, and optional value—never form fields or email. Glance imports no provider spend/campaign metrics.
  Tokens stay in Vercel Connect. Creating a goal requires the human to confirm that every affected
  native form&apos;s consent text and linked tenant privacy notice cover the
  feedback; the analytics banner is not server-upload consent. Never fabricate that
  acknowledgement. Pause/disable remains reversible, full lead data expires after 30 days, and
  the minimal encrypted provider source expires after 90 days.
  Only an eligible captured Meta touch plus an active deliverable Meta `lead_submitted` goal
  suppresses the duplicate browser Meta Lead. Organic or expired touches retain the browser event,
  and GA4 is unchanged.

## Agent-managed experiments (Business and Scale)

- Use the OAuth viewer MCP, not a publisher key, for experiment authoring. Stage candidate content
  through `stage_experiment_candidate`: it writes a non-serving candidate version and an unchanged
  control checkpoint atomically, so a candidate is never accidentally the live page. Do not
  simulate staging by publishing and restoring. Reconnect with all four required scopes before
  staging: `dashboards:read`, `dashboards:write`, `dashboards:manage`, and `experiments:manage`.
- Create one experiment for immutable versions of the same public page. Use 2–5 unique variants;
  integer weights must total exactly 10,000. Start, pause, or resume explicitly. Public assignment
  is sticky in the visitor&apos;s browser, so refreshing keeps that visitor on the same variant, and
  frozen snapshots do not open an SSE stream. Experiments run only at the canonical site URL; the
  generic `/p/` share viewer does not participate in experiments.
- Inspect each immutable variant from the signed-in experiment page&apos;s Preview links. A preview does
  not change public assignment or record an exposure. There is no public query-string override for
  variants; never invent one or append a variant parameter to the canonical site URL.
- Reports are `descriptive_observed` with `inference: none`: visitor-day exposures, leads,
  qualified, won, and values. Do not call a difference causal or statistically significant, and
  never claim Glance selected a winner. Promotion requires an explicit `winnerVariantId` from the
  user or their authorized agent.
- `experiments:read` covers retained list/get/report. `experiments:manage` participates in staging
  and covers other lifecycle mutations; staging still requires all four scopes listed above. After
  downgrade, reads and pause remain available while new work does not.
- **Spend the accent like money.** On a page it should mean ONE thing — the number in
  the reader's favour — and it stops meaning anything the moment everything carries it.
  `kpiCard` takes `emphasis` set to `plain`, which turns the accent off on the supporting
  stats so one value can keep it; a `dataTable` column takes `emphasis: "accent"` for the
  one column that carries the argument. Four accented values in a row rank exactly as
  well as none. The same rule governs a backdrop's annotations: give the ONE chip the page is
  about `kind: "subject"` and let the others be `comp` or `poi`, because eight loud chips
  on a picture rank exactly as well as eight quiet ones.
- **The page's jump navigation is DERIVED, not composed.** A page with three or more
  top-level `section`s gets its own on-this-page strip automatically, built from those
  sections' titles — so **do not build one out of `linkButton`s**; title the sections the
  way you want them listed and the strip follows. When the title that reads well as a
  heading is too long to be a nav item, give that section a `navLabel` ("Photos", "Comps",
  "The Flip Plan") rather than shortening the heading. A section the strip should list has
  to be a real `section` node: a `stack` is INVISIBLE to it, so the item a visitor expects
  silently lands on the section after it. On a narrow screen a page that carries
  a `tel` link also gets a fixed call bar at the bottom, so the phone number stays
  reachable without a pinned section of your own. Neither is a node, and neither needs
  anything from you.
- **Numbers, not adjectives.** "1,284 dashboards published this week" beats
  "incredibly popular" — and back exactly ONE live strip with real `kpiCard` bindings
  fed by `update_data`, so the page proves it is alive without becoming a dashboard.
- **NEVER invent testimonials, customer names, logos, awards or statistics.** Publish
  only what the user gave you. An unverifiable claim on a public site is a liability.
- **Budget ~150 words of prose** for the whole page. If you are writing paragraphs,
  you are writing a document — link to it instead.
- Keep the same `theme` across the site; one page with a different accent reads as
  broken.

## 7. A minimal two-page site

```jsonc
publish_dashboard {
  "dashboardId": "site-home",
  "slug": "home",
  "visibility": "public",
  "layout": {
    "title": "Acme", "presentation": "page",
    "theme": { "mode": "dark", "accent": "#6366f1",
               "fonts": { "display": "outfit", "body": "barlow" } },
    "root": { "type": "stack", "direction": "column", "children": [
      { "type": "navBar", "brand": { "text": "Acme" },
        "items": [{ "label": "Pricing", "link": { "kind": "internal", "to": "pricing" } }],
        "cta": { "label": "Start free", "link": { "kind": "external", "href": "https://app.acme.example/signup" } } },
      { "type": "hero", "headline": "Ship status pages your agents keep current",
        "subhead": "Publish once. The numbers update themselves.",
        "cta": { "label": "See pricing", "link": { "kind": "internal", "to": "pricing" } } },
      { "type": "grid", "columns": 3, "children": [
        { "type": "kpiCard", "title": "Dashboards published", "source": "published" },
        { "type": "kpiCard", "title": "Teams", "source": "teams" },
        { "type": "kpiCard", "title": "Uptime", "source": "uptime", "unit": "%" } ] },
      { "type": "footer", "tagline": "Built by Acme.", "legal": "© Acme" } ] }
  }
}
```

```jsonc
publish_dashboard {
  "dashboardId": "site-pricing",
  "slug": "pricing",
  "visibility": "public",
  "layout": { "title": "Pricing", "presentation": "page",
    "theme": { "mode": "dark", "accent": "#6366f1",
               "fonts": { "display": "outfit", "body": "barlow" } },
    "root": { "type": "stack", "direction": "column", "children": [
      { "type": "navBar", "brand": { "text": "Acme" },
        "items": [{ "label": "Home", "link": { "kind": "internal", "to": "home" } }] },
      { "type": "hero", "headline": "Simple pricing" },
      { "type": "footer", "legal": "© Acme" } ] } }
}
```

```jsonc
// keep the live strip fresh
update_data { "dashboardId": "site-home",
  "values": { "published": 1284, "teams": 96, "uptime": 99.98 } }
```

## 8. Errors, and what to do about them

| Code | Meaning | Do this |
| --- | --- | --- |
| `SLUG_INVALID` | The slug does not fit the grammar | The message quotes the whole rule — rewrite the slug and retry |
| `SLUG_RESERVED` | The word belongs to the application (`api`, `login`, `docs`, …) | Pick another word |
| `SLUG_TAKEN` | Another page in this organization holds it | Patch THAT dashboard instead, pick a different slug, or free it with `slug: null` on the holder |
| `SHARING_NOT_ALLOWED` | Role-gated surface only: your role may not change what the public can reach | Retry without `visibility`/`slug`; ask an organization admin |
| `WRITE_NOT_ALLOWED` | Role-gated surface only: your role cannot author at all | An owner/admin must act |
| `PLAN_LIMIT_EXCEEDED` | At the plan's dashboard limit (pages count) | Delete one, upgrade, or re-publish an existing page (never blocked) |
| `LAYOUT_INVALID` | A node or field is not in the catalog | Read the message; it names the path and the rule |
