Admin page management
Pages are scoped to a team and hub:Sections
Sections are the building blocks inside a page. They can represent text, calls to action, grids, media, search, and other structured page blocks.Draft-tree authoring
The draft tree is the mutable, unpublished state of a page’s node hierarchy. Write it with:If-Match header carries the optimistic-concurrency token (OCC).
Bootstrapping the token: read the page first — the admin read (GET /api/v1/teams/{team_id}/hubs/{hub_id}/pages/{identifier}) returns the current version in both attributes.draft_version and the ETag response header. Echo that value as If-Match on your first write; every subsequent write/publish response hands you the bumped token:
root key, and root must carry a children array. Below the root, every node needs an id (422 invalid_tree otherwise), settings/dataSource must be objects when present, children must be an array, and the whole tree is capped at 500 elements. The root itself is checked more loosely — its id is optional and its settings is not type-checked — but the renderer wants both, so author it exactly like any other node. See Node shape for what the renderer needs on top of that.
On success you get 200 with the bumped version echoed in both the ETag response header and attributes.draft_version:
draft_version (or echo the ETag value verbatim) as your If-Match token for the next write or publish call.
Error codes
Reading the author draft (resolved)
Use the author draft-resolve endpoint to read the fully-transformed draft tree for the page-builder editor.audience must be author and resolve must be true; omitting either is a 400 (invalid_audience / invalid_resolve). Note that the generated endpoint reference currently lists both as optional — the backend declares them as nullable and enforces them in the handler — so a client generated from the spec will compile without them and then fail at runtime. Send both. The endpoint applies the same cascade, action, and dataSource structure transforms as the portal render path so the editor preview matches what members will see — but it reads tree_draft (the live working copy), not the published tree, and it never prunes nodes behind membership gates.
Response type: page_trees
Preview tokens
Mint a short-lived JWT so the page-builder editor can pass an unforgeable token to the mio-hub draft-preview iframe.aud="preview", a 15-minute TTL, and embeds page_id, hub_id, and the author’s platform user id. The hub preview host verifies the signature client-side without a backend round-trip. This token is not an alternate auth gate on the resolve endpoint — use the resolve endpoint directly with a platform bearer when you need to read the draft.
Response type: preview_tokens
Publish
Publishing compiles the editable tree into the served page state.draft_version as If-Match (same OCC contract as the tree write). If a concurrent draft write happened after your last read, publish returns 409 — re-read and re-send.
The backend uses optimistic concurrency for publish operations. If a frontend edits pages collaboratively, preserve and send the versioning headers shown in the generated endpoint reference.
Node shape
The API validates a tree’s structure, not its renderability. A node with the wrong shape is stored, returns200, and then renders as nothing — with no error anywhere. Everything in this section is a case where that happens.
The node envelope
template and children:
valueis a sibling ofsettings, neversettings.value. This is the single biggest silent-drop trap: the API storessettings.valuehappily, the renderer readsnode.value, and you get an empty heading, button, or image with a200and no error. The one exception isprogress-ring, which reads a numericsettings.value.- The renderer dispatches on
kind, never ontype.typeis a JSON:API resource-type word and has no meaning inside a node. templatemarks a node as a section. It opts the node into the surface renderer and tells the publish-time converter which section type to compile. Section roots carry one, and so does the page root —scaffold --template page-homepageemits a root of{"kind":"stack","template":"page-homepage"}. Inner containers legitimately carry none.settingsshould always be present ({}minimum). Hub-level defaults are resolved at render time, but the code and template layers of the cascade are not wired up, so in practice every value that must render has to be inlined on its own node — and each leaf also applies its own built-in defaults (see the table below).weightmust be a number (700), never a CSS keyword ("bold"). An unrecognized weight is discarded rather than dropping the node: aheadlinelands on its400default, whiletextandfieldsimply render with no weight class — which for afieldmeans losing the weight itsrolewould have applied. A numeric string ("700") happens to resolve, butpages tree setrejects it client-side — write the number.- Exactly one
level: 1headline per page. Extra level-1 headlines are demoted to<h2>, keeping their visual size. - Every node needs an
id, and ids should be unique within the tree. Uniqueness is renderer hygiene rather than a server rule — the API only checks that the key is present, so duplicate ids are accepted and then confuse id-based behaviour like the one-<h1>guard. Short symbolic ids are legal (the catalog’s own recipes userootandhero), butpages catalog scaffoldmints fresh UUIDv7 ids, which is the safer habit when you splice subtrees together.
Leaf kinds
These sixteen are the leaf kinds — the nodes that carry content and take no children. They are one family among the catalog’s 33 kinds; the rest are containers (stack, row, grid, carousel, horizontal-scroll, tabs, container, content-card, accordion, banner — a sticky or reveal-after-scroll notice-bar-style band, plan-group — a radiogroup of plan-card children that publishes the selected card), data bindings (field, media-slot, cta-slot) and system blocks (search-bar, file-player, file-attachments). The table names the settings that matter in practice.
The catalog is the authority on settings shapes. Its
settingsSchema map carries the complete vocabulary — types, enums, defaults and per-field notes for all 33 node kinds, plus the shared surface, background, gradient and structural shapes — split into core and presentational buckets matching the consumer profiles. GET /api/v1/page-builder/catalog serves it; the catalog reference tracks the current catalogVersion and content digest.
A button’s
action is an object, and its value is always canonical — no scheme prefix, no #:
type is url, page, email, scroll, or playlist. email takes a bare address (no mailto:), scroll a bare anchor id (no #), url a full URL including its scheme, page a system page type, a page id, or a /-prefixed path, and playlist a playlist id. A malformed or missing action leaves a button that renders but navigates nowhere. The legacy settings.href still works but is deprecated.
Surfaces and backgrounds
A section’ssettings.surface takes background, gradient, padding, borderRadius, shadow, visibility, and minHeight. background is a discriminated union of exactly six type values:
Hub homepage descriptor
The hub bootstrap response (GET /api/v1/hubs/{slug}, and hub admin read/write routes) carries a resolved homepage descriptor on data.attributes.homepage:
homepage from the bootstrap response before calling GET /api/v1/hub/{hub_id}/pages/home. The descriptor is authoritative; /pages/home is a convenience route that only resolves for one of the three states:
/pages/home 404s by design when homepage.kind is "discussions_index". This is not an error state — it means the hub has no custom-kind homepage page, and the homepage surface is the discussions index instead. Gate the call on homepage.kind; don’t call /pages/home unconditionally and branch on its status code, and don’t log the 404 as a failure in that case.
Writing the descriptor
homepage object on data.attributes.homepage. An explicit null clears the descriptor — distinct from omitting the key, which leaves it unchanged (standard PATCH / exclude_unset semantics). On a hub that still carries a legacy is_homepage page, null re-resolves to that page as kind: "custom" on the next read; it does not destructively clear the legacy flag.
Error codes
Both
homepage_discussions_disabled rows are two directions of the same coupling guard — one on the page-side write, one on the hub meta write — and both reject rather than leaving the hub in an inconsistent state.
Rollout note
Existing hubs readhomepage: null until the legacy backfill runs; after backfill, any hub with an existing is_homepage page resolves to {"kind": "custom", "page_id": ...}. Single-hub bootstrap reads (GET /api/v1/hubs/{slug}) are always fully resolved, including the legacy fallback — there’s no stale-null window there. List endpoints (GET /api/v1/teams/{team_id}/hubs) and the PATCH .../hubs/{identifier} write-path echo may show a stale homepage: null for legacy, pre-backfill hubs — both intentionally skip the legacy-fallback lookup to avoid extra queries, and self-heal once the backfill completes.
Member and public rendering
Portal routes return hub-renderable pages:Authoring from the CLI
Themio CLI wraps this whole draft → publish contract so you don’t manage the If-Match header by hand:
pages tree set --if-match is optional and defaults to 0 — omit it for the first tree on a draft-less page (pages tree get 404s until a draft exists, so there is no draft_version to read back yet). The 0 default does not bypass the concurrency guard: sending 0 (or any stale value) against a page that already has a draft is rejected as a conflict. For every later write, pass the draft_version from a prior pages tree get — including the first write against a page created by mio hubs scaffold, whose draft already exists at version 1. pages publish --if-match is always required, and its response section_count tells you how many sections actually compiled — treat a shortfall as a dropped section, not a success.
Round-tripping an existing tree means re-wrapping it, because tree get returns the bare root node and tree set wants the {"root": …} wrapper:
mio pages catalog templates, mio pages catalog section-types, and mio pages catalog scaffold. There is no pages catalog list. All three are emit-only and work offline against the CLI’s embedded, digest-pinned catalog copy.
See Create a hub for the full homepage recipe and the render-contract gotchas that silently drop malformed nodes.
Templates and section types
The full list of available templates and section types is published in the Page-builder catalog, generated from the live catalog. Apage-* template scaffolds a page outline, not finished sections — scaffold each section from its own template and splice it into the page root’s children.