Admin page management
Pages are scoped to a team and hub:icon. Send it on create or PATCH as either a name from the hub icon set (the same names spaces accept, for example "calendar") or emoji: followed by exactly one emoji (for example "emoji:🎉"). Any other value returns 422 with source.pointer /data/attributes/icon, and nothing is stored. On PATCH, null clears the icon and omitting it leaves it unchanged. Admin page reads return icon (null when unset); member-facing page reads do not include it.
Managing pages in the admin
The admin Pages list lets you create and delete pages. To view a supported page, select its title or choose View page from the row’s More options menu; it opens the matching page on the member-facing hub in a new tab. Some rows do not have a View page action. A new, unpublished page is not visible to public visitors or members; its signed-in hub owner can open the draft in the inline editor, while other visitors may see not found. The hub workspace sidebar’s View hub link opens the member-facing hub. These external links use the hub origin configured for the current admin environment and are not shown when that origin is unavailable. Hub owners can edit the homepage, custom pages, and sales pages in mio-hub’s inline editor. Open a supported page while signed in to the hub as its owner and select the floating Edit button. The editor opens over the current page and adds?edit=1 to its URL (for example, /{hubSlug}?edit=1 for the homepage or /{hubSlug}/{pageSlug}?edit=1 for a custom page). The editor is part of the mio-hub release, so its availability depends on the hub environment. Opening a page from the platform admin does not sign you in as a hub contact: public pages can be viewed without a hub session, while a private hub or member-only page may require sign-in through the hub’s existing sign-in flow. The CLI and API operations below remain available for page-tree authoring.
DELETE .../pages/{page_id} refuses to delete a system page — login, register, or payments — with 409 system_page_protected, checked before the homepage-orphan guard below. The hub only falls back to that page type’s built-in default form while no row of that type exists at all; deleting an existing one would instead 404 a private hub’s anonymous auth route.
Each item in the admin page list carries has_unpublished_changes, so API clients can identify pages with work that is not live yet without reading every tree. It is true when either:
- the page is new — it has never been published and no legacy section rows serve it, so it is not live anywhere, and its type is one the editor actually renders (
genericorsales); a never-publishedlogin,register,payments, or other non-editor page type readsfalsehere, since there is nothing an owner could publish for it from the page builder; or - the page has a draft that differs from its published tree (on the server the comparison ignores JSON key order and whitespace) — this applies regardless of page type.
tree_draft is null) never reads as changed on its own once it is published. The flag appears only on the admin list — single-page admin reads and the member-facing page list do not carry it. See Publishing or discarding a whole hub for how the flag lines up with hub-wide publish.
Admin page-list items also include the required boolean has_published_tree. It is true only when tree_published exists; legacy section rows do not count. The field is list-only. A page must have a published tree before it can become the hub homepage. Admin page creation with is_homepage: true, page PATCH with is_homepage: true, and hub PATCH with homepage: {"kind":"custom","page_id":"..."} return 409 homepage_page_not_published otherwise. The error’s source.pointer identifies the rejected field (/data/attributes/is_homepage for page create/PATCH, /data/attributes/homepage/page_id for hub PATCH). The MCP create_page tool also rejects is_homepage: true with a conflict result because it creates a draft without publishing it. Publish the page first, then select it as the homepage.
Trash and restore
DELETE .../pages/{page_id} moves a page to the hub’s trash. List the trash with GET .../pages?filter[deleted]=true: deleted pages, most recently deleted first. Each item carries a read-only purge_at (deleted_at plus 30 days): when the page becomes eligible for permanent deletion. It is null on live pages. filter[deleted]=false or omitting it lists live pages, and any other value returns 422 with source.parameter filter[deleted].
200 with the page. It comes back in the state it was deleted in (privacy, draft and published tree), so a published page is served to members again, but it never comes back as the hub homepage. The sections deleted along with it come back too, and so do comments that the delete hid; a section or comment deleted on its own earlier stays deleted. Restore returns:
409 slug_taken(source.pointer/data/attributes/slug) when a live page already uses the slug.409 auth_page_duplicateor409 content_page_duplicate(source.pointer/data/attributes/type) when the hub already has a live page of a type it allows only once.404when the page is live, does not exist, or belongs to another hub or team.
409 or 404.
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 in CLI or API authoring tools to read the fully-transformed draft tree. The hub inline editor also uses authoring endpoints to load and save page drafts.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 authoring tools can inspect the resolved draft; 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
This endpoint mints a short-lived JWT for an editor integration to use with mio-hub’s/preview/{hubSlug}/{pageId} iframe route. This route is a preview canvas; the hub’s inline editor opens over the regular page route instead.
aud="preview", a 15-minute TTL, and embeds page_id, hub_id, and the author’s platform user id. The draft-preview route can render a tree pushed over its preview message channel; it is separate from the inline editor, which runs on the regular hub page. The current preview host does not validate this token. Do not treat the token as 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.
Publishing or discarding a whole hub
An editor that lets an author change several pages plus hub-level settings needs one “Publish all” and one “Discard all”. Two endpoints do that in a single request each:403; a hub that does not exist in the team (or that the caller has no standing in) answers 404.
The pending-ops version is the retry token
The hub editor keeps staged hub-level changes (branding, navigation, settings, media and playlist changes) in the hub’s pending-ops queue:If-Match (the value of the queue’s ETag), and both replace the queue with the ops you did not apply (pending_ops_remaining), bumping its version. That makes a retry safe: if the response to a successful publish is lost and you resend the same request, the queue version has already moved, so the retry fails with 409 stale_pending_ops and nothing is published twice. Re-read the queue and the page list to see where you are. The new queue version comes back in the response’s ETag.
Publish all
pages— the pages to publish, each with thedraft_versionyou last read (the same token the single-page publish takes asIf-Match). A page whose draft already equals its published tree is skipped, not republished, and a page with no draft is never published. Skipped ids come back inskipped_page_ids. Pages you leave out of the list are untouched, including pages created after you read the list.hub— hub-level fields, written exactly asPATCH /api/v1/teams/{team_id}/hubs/{hub_id}would write them:brandingandnavigationreplace the stored object;settingsmerges the top-level keys you send.clear_settings_keysremoves top-level settings keys and accepts onlyappearance,customCssandfooter, and no key may be both set and cleared.expected_etagis required wheneverhubis present: one strong entity tag (the hub’sETagfromGET …/hubs/{hub_id}, quoted or bare).*, a weakW/tag and a comma-separated list are rejected. If navigation newly links to a page that is not live and is not published in the same request, the publish is refused, so a menu never points at a page members cannot open.media— files to publish to the hub.published_atis required: send a timestamp to schedule, or an explicitnullto leave the file as a draft. There is no default, so a scheduled file can never go live because a field was left out.playlists—publish(withvisibility) orunpublish. Re-publishing a playlist that is already live or scheduled keeps its existingpublished_at, so changing its visibility never re-dates it. Unpublishing a playlist that is not published is a no-op, reported inskipped_playlist_ids.pending_ops_remaining— the queued ops you did not apply. They stay staged. Send[]to empty the queue.
201 Created and records the publish:
hub_etag as the hub’s new entity tag and the response ETag as the new queue version. pending_ops_version_consumed is the If-Match you sent, which lets a client that lost a response recognise its own publish. Afterwards, the pages you published read has_unpublished_changes: false in the admin list.
Publishing holds a lock on the hub for the length of the request. If another publish or edit holds it too long, the request fails fast with 503 publish_timed_out; nothing was changed and it is safe to retry.
Discard all
- Reset — a published page whose draft differs from its published tree gets its draft set back to the published tree and its
draft_versionbumped. It is listed inpageswith the newdraft_version. - Remove — a never-published page of a type the page builder actually renders (
generic,sales) has nothing to go back to, so discarding it deletes the page, exactly asDELETE …/pages/{page_id}would. Its id is listed inremoved_page_ids. - Clear — a never-published
login,register,payments, or other non-editor-owned page is never removed, but if it carries a draft — an editable “slot” inside its otherwise system-rendered form — that draft is cleared (tree_draftset tonull,draft_versionbumped) and it is listed inpageslike a reset. If it has no draft at all, it is left untouched and reported inskipped_page_ids. - Skip — a page whose draft already equals its published tree (or that has no draft) is left untouched and listed in
skipped_page_ids.
pages, removed_page_ids and skipped_page_ids never share an id. An empty pages list is valid: it only replaces the queue, which discards staged hub-level changes. Draft trees are not echoed back; re-read the open page’s draft with the author draft read.
409 no_published_tree. Removing the page that is the hub’s only homepage is refused with 409 homepage_unset_would_orphan_hub; point the hub at another homepage first.
Errors
Every error names the failing item:source.pointer points at it in request order (for example /data/attributes/pages/1), and item errors carry meta.item with its kind, id and index. After any error, nothing in the request was applied.
Both endpoints are rate limited per client IP address (publish: 30 requests per hour; discard: 60 per hour; over the limit answers
429) and accept request bodies up to 1 MB (larger bodies answer 413).
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:
Which id a dataSource takes
A data-bound node names what it renders in dataSource: {"type": …, "id": …}, and the namespace of id depends on type. The tree write checks only that dataSource is an object, and publish accepts an id it cannot resolve, so an id from the wrong namespace publishes cleanly and renders an empty section.
A
file binding that holds a playlist id or a file id makes the renderer request GET /api/v1/hub/{hub_id}/content/{id} on every view, which answers 404, so the section renders as an empty shell. A playlist binding with "resume": true (the “continue watching” hero) still takes the playlist id.
To get a file’s content-node id, reconcile its playlist into content items, then pick the lesson whose file_id is the file:
content reconcile adopts content items that already exist, so it is safe to run on a playlist that was reconciled before. content children returns one page of at most 100 rows, and the last line stops the script rather than leaving an empty id. publish does check one thing about a content node: binding a members or paid node with no gate on the node or any ancestor fails with 422 (exit 2) and an R3 invariant violation message.
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:
Set a page as home in Admin V3
A team owner can set a published page as the hub homepage from the Admin V3 Pages list by choosing Set as home on that page. Pages without a published page-builder tree are not eligible. Saving the selection replaces the current custom homepage or discussions index; the Home indicator moves after the save succeeds. If the save fails, the existing homepage remains in place. Always readhomepage 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:GET /api/v1/hub/{hub_id}/pages/{identifier}?resolve=true (and /pages/content?resolve=true) also returns attributes.page_links: a map of the hub’s pages the requesting viewer may open, keyed by page id, so a renderer can turn an Opens › Page action ({"type": "page", "value": "<page id>"}) into a link without another request:
public pages only, and members (or access-override holders) also see members pages. Any other privacy value, soft-deleted pages, and pages that aren’t published are never listed. content, login, register and payments pages are listed without a published tree, because the hub serves them on their own routes. An id that isn’t in the map should stay unresolved. The map is {} when there’s nothing to list, and it never includes privacy.
GET /api/v1/hubs/{slug}/pages/{page_type} is the anonymous, unauthenticated render — page_type is restricted to the anon whitelist (login, register, payments); anything else, or an unknown slug, 404s without disclosing which. Its response carries an additive tree attribute alongside the legacy branding/sections fields — sourced from the fresh anon-tree artifact when available, live-pruned from the published tree otherwise, and null when the page has never been published. tree is served only when the page’s privacy is public — the auth page types default to public on create, but an admin can still set one to members, in which case this endpoint still returns 200 with branding/sections populated as usual and tree: null. Every tree node is additionally filtered through the same catalog anonSafe boundary the sections half already enforces — a node whose resolved type isn’t anon-safe is dropped, subtree included, before the response is built, so the two halves can never disagree about what an anonymous caller may see. See the auth brand panel guide for the full authoring contract this tree field feeds on login/register/payments 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. Page templates come in three kinds. An outline (page-homepage, page-generic) carries no copy: scaffold each section from its own template and splice it into the page root’s children. A complete page (page-sales, page-homepage-starter) is finished sections with placeholder copy: edit its values in place. A system page (page-login, page-file-detail, and the other pages the hub routes itself) holds fixed regions: fill in their values and add no sections. See Create a hub for how to tell them apart and for a sales-page recipe.