> ## Documentation Index
> Fetch the complete documentation index at: https://docs.member.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Pages and page builder

> Build hub pages, sections, and published trees.

Pages are the structured surfaces a hub renders: homepages, sales pages, link-in-bio pages, and other public or member-facing views.

## Admin page management

Pages are scoped to a team and hub:

```text theme={null}
GET  /api/v1/teams/{team_id}/hubs/{hub_id}/pages
POST /api/v1/teams/{team_id}/hubs/{hub_id}/pages
GET  /api/v1/teams/{team_id}/hubs/{hub_id}/pages/{page_id}
PATCH /api/v1/teams/{team_id}/hubs/{hub_id}/pages/{page_id}
DELETE /api/v1/teams/{team_id}/hubs/{hub_id}/pages/{page_id}
```

Use pages when you need a hub-rendered layout, not just a content lesson.

## Sections

Sections are the building blocks inside a page. They can represent text, calls to action, grids, media, search, and other structured page blocks.

```text theme={null}
GET   /api/v1/teams/{team_id}/hubs/{hub_id}/pages/{page_id}/sections
POST  /api/v1/teams/{team_id}/hubs/{hub_id}/pages/{page_id}/sections
PATCH /api/v1/teams/{team_id}/hubs/{hub_id}/pages/{page_id}/sections
PATCH /api/v1/teams/{team_id}/hubs/{hub_id}/pages/{page_id}/sections/{section_id}
DELETE /api/v1/teams/{team_id}/hubs/{hub_id}/pages/{page_id}/sections/{section_id}
```

## Draft-tree authoring

The draft tree is the mutable, unpublished state of a page's node hierarchy. Write it with:

```text theme={null}
PUT /api/v1/teams/{team_id}/hubs/{hub_id}/pages/{page_id}/tree
```

**Team-owner only.** The request body is a JSON:API envelope; the `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:

```http theme={null}
PUT /api/v1/teams/{team_id}/hubs/{hub_id}/pages/{page_id}/tree
Authorization: Bearer <token>
Content-Type: application/vnd.api+json
If-Match: "3"

{
  "data": {
    "type": "page_draft_trees",
    "attributes": {
      "tree": {
        "root": {
          "id": "019f8c3a-1f4e-7a10-9d21-3a2b5c6d7e8f",
          "kind": "stack",
          "settings": { "gap": 0 },
          "children": [
            {
              "id": "019f8c3a-1f4e-7b22-8c40-6d7e8f901a2b",
              "kind": "headline",
              "value": "Welcome to Member Academy",
              "settings": { "level": 1, "weight": 700 }
            }
          ]
        }
      }
    }
  }
}
```

**The tree must be wrapped in a top-level `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](#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`:

```http theme={null}
HTTP/1.1 200 OK
ETag: "4"
Content-Type: application/vnd.api+json

{
  "data": {
    "id": "<page_id>",
    "type": "page_draft_trees",
    "attributes": {
      "draft_version": 4,
      "tree": { "root": { "id": "019f8c3a-1f4e-7a10-9d21-3a2b5c6d7e8f", "kind": "stack", "children": [] } }
    }
  }
}
```

Store the new `draft_version` (or echo the `ETag` value verbatim) as your `If-Match` token for the next write or publish call.

**Error codes**

| Status  | Code                    | Cause                                                                     |
| ------- | ----------------------- | ------------------------------------------------------------------------- |
| 428     | `precondition_required` | `If-Match` header absent                                                  |
| 400     | `invalid_if_match`      | `If-Match` value is not an integer (weak validators `W/"n"` are rejected) |
| 409     | `stale_draft`           | A concurrent write bumped the version; re-read and retry                  |
| 422     | `invalid_tree`          | Tree failed structural validation (node-type rules, 500-node cap)         |
| 413     | —                       | Request body exceeds 1 MB                                                 |
| 404     | `page_not_found`        | Page, hub, or team not found or mismatched                                |
| 401/403 | —                       | Missing or insufficient credentials                                       |

## Reading the author draft (resolved)

Use the author draft-resolve endpoint to read the fully-transformed draft tree for the page-builder editor.

```text theme={null}
GET /api/v1/teams/{team_id}/hubs/{hub_id}/pages/{page_id}/tree?audience=author&resolve=true
```

**Authentication:** platform bearer + team owner.

**Both query parameters are required.** `audience` must be `author` and `resolve` must be `true`; omitting either is a `400` (`invalid_audience` / `invalid_resolve`). Note that the [generated endpoint reference](/reference/endpoint-inventory) 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`

```json theme={null}
{
  "data": {
    "id": "<page_id>",
    "type": "page_trees",
    "attributes": {
      "tree": {
        "id": "019f8c3a-1f4e-7a10-9d21-3a2b5c6d7e8f",
        "kind": "stack",
        "settings": { "gap": 0 },
        "children": []
      },
      "draft_version": 5,
      "resolved_at": "2026-06-26T09:00:00Z"
    }
  }
}
```

<Warning>
  **The read and write shapes differ.** This endpoint returns `tree` as the **bare root node**, already unwrapped from the `{"root": …}` wrapper, so the hub renderer can consume it directly. `PUT .../tree` requires the wrapper. Re-wrap the node as `{"root": <tree>}` before writing it back, or the write fails with `422 invalid_tree` — *"Tree must have a 'root' key at the top level."*

  This is also a *resolved* projection — action transforms and `dataSource.type: "page"` expansions have already been applied — so it is a read-back for editing, not a byte-exact copy of what you last wrote.
</Warning>

**Error codes**

| Status | Code               | Cause                                                          |
| ------ | ------------------ | -------------------------------------------------------------- |
| 400    | `invalid_audience` | `audience` param absent or not `"author"`                      |
| 400    | `invalid_resolve`  | `resolve` param absent or not `"true"` while `audience=author` |
| 403    | —                  | Caller is not the team owner                                   |
| 404    | `page_not_found`   | Page, hub, or team not found or mismatched                     |
| 404    | `no_draft`         | Page exists but `tree_draft` is NULL (never written)           |

## Preview tokens

Mint a short-lived JWT so the page-builder editor can pass an unforgeable token to the mio-hub draft-preview iframe.

```text theme={null}
POST /api/v1/teams/{team_id}/hubs/{hub_id}/pages/{page_id}/preview-token
```

**Authentication:** platform bearer + team owner.

The token carries `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`

```json theme={null}
{
  "data": {
    "id": "<uuid>",
    "type": "preview_tokens",
    "attributes": {
      "token": "<signed-jwt>",
      "expires_at": "2026-06-26T09:15:00Z"
    }
  }
}
```

**Error codes**

| Status | Cause                                             |
| ------ | ------------------------------------------------- |
| 403    | Caller is not the team owner                      |
| 404    | Page does not exist or belongs to a different hub |

## Publish

Publishing compiles the editable tree into the served page state.

```text theme={null}
POST /api/v1/teams/{team_id}/hubs/{hub_id}/pages/{page_id}/publish
```

Send the current `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, returns `200`, and then renders as nothing — with no error anywhere. Everything in this section is a case where that happens.

### The node envelope

```json theme={null}
{
  "id": "019f8c3a-1f4e-7b22-8c40-6d7e8f901a2b",
  "kind": "headline",
  "value": "Welcome to Member Academy",
  "settings": { "level": 1, "weight": 700 }
}
```

A section node is the same envelope plus a `template` and children:

```json theme={null}
{
  "id": "019f8c3a-1f4e-7c33-b551-7e8f901a2b3c",
  "kind": "container",
  "template": "hero",
  "settings": {
    "maxWidth": "content",
    "padding": 0,
    "surface": { "padding": "section", "background": { "type": "tint" } }
  },
  "children": []
}
```

* **`value` is a sibling of `settings`, never `settings.value`.** This is the single biggest silent-drop trap: the API stores `settings.value` happily, the renderer reads `node.value`, and you get an empty heading, button, or image with a `200` and no error. The one exception is `progress-ring`, which reads a numeric `settings.value`.
* **The renderer dispatches on `kind`, never on `type`.** `type` is a JSON:API resource-type word and has no meaning inside a node.
* **`template` marks 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-homepage` emits a root of `{"kind":"stack","template":"page-homepage"}`. Inner containers legitimately carry none.
* **`settings` should 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).
* **`weight` must be a number** (`700`), never a CSS keyword (`"bold"`). An unrecognized weight is discarded rather than dropping the node: a `headline` lands on its `400` default, while `text` and `field` simply render with no weight class — which for a `field` means losing the weight its `role` would have applied. A numeric *string* (`"700"`) happens to resolve, but `pages tree set` rejects it client-side — write the number.
* **Exactly one `level: 1` headline 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 use `root` and `hero`), but `pages catalog scaffold` mints 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.

<Note>
  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](/reference/page-builder-catalog#consumer-profiles). `GET /api/v1/page-builder/catalog` serves it; the [catalog reference](/reference/page-builder-catalog) tracks the current `catalogVersion` and content digest.
</Note>

| Kind            | `value`                                                                          | Settings that matter                                                                                                                                                                                |
| --------------- | -------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `headline`      | The text                                                                         | `level` 1–6 (default 2), `weight` 400/500/600/700, `align`                                                                                                                                          |
| `text`          | The text                                                                         | `size`, `weight`, `align`, `clamp` 1–6, `marginBottom`, `muted`                                                                                                                                     |
| `image`         | The image URL                                                                    | `alt`, `aspectRatio`, `objectFit`, `radius`, `outline`                                                                                                                                              |
| `video`         | The video URL                                                                    | `embed_type` `native`\|`iframe` (note the snake\_case), `controls`, `autoplay`, `loop`, `muted`. There is no `provider` setting                                                                     |
| `button`        | The label                                                                        | `action`, `variant`, `size`, `icon`/`iconRight`, `newTab`, `disabled`                                                                                                                               |
| `icon`          | **The glyph name**                                                               | `size`, `color`, `strokeWidth`. The name goes in `value`, not `settings.name`                                                                                                                       |
| `divider`       | —                                                                                | `spacing`                                                                                                                                                                                           |
| `progress-ring` | —                                                                                | `value` 0–100 (the exception: this one *is* in `settings`), `label`, `size`, `variant`                                                                                                              |
| `quote`         | An **object**: `{"quote": …, "name": …, "profession": …, "avatarUrl": …}`        | `showAvatar`. The content unit inside a `testimonials` section                                                                                                                                      |
| `countdown`     | An ISO-8601 deadline string (e.g. `2026-09-01T00:00:00Z`)                        | `title` (deadline sentence override), `expiredLabel`, `ctaLabel`/`ctaAction` (rendered only when `size: "small"`), `size`, `align`, `hideOnExpire`. An empty or unparseable `value` renders nothing |
| `logo`          | — (settings-only)                                                                | `height` `normal`\|`hero`                                                                                                                                                                           |
| `theme-toggle`  | — (settings-only)                                                                | No settings                                                                                                                                                                                         |
| `plan-card`     | An **object**: `{name, price?, currency?, period?, description?, badge?, save?}` | `defaultSelected`. `period` is `month`\|`year`, rendered as `/mo`/`/yr`. A missing or empty `name` renders nothing                                                                                  |
| `plan-price`    | — (settings-only)                                                                | `settings.groupId` (see the plan-group warning below), `settings.fallback`                                                                                                                          |
| `doodle`        | — (settings-only)                                                                | `variant` `arrow-plain`\|`arrow-straight`\|`underline`\|`knot-curl`, plus `size`, `flip`, `rotate`, `draw`                                                                                          |
| `featured-icon` | — (settings-only)                                                                | `icon` (sprite name) or `label` (short text, e.g. `"1"`) — `label` wins if both are set. Plus `variant`, `tone`, `size`                                                                             |

<Warning>
  **A `plan-price` node only resolves through a `plan-group` it can actually find.** `settings.groupId` first tries an exact match against a `plan-group` node's id; if none matches, it falls back to the page's *sole* registered `plan-group` — but with zero or two-or-more `plan-group` nodes and no exact id match, nothing links up and the node renders `settings.fallback` forever, even after a visitor picks a plan. Keep `fallback` byte-identical to the group's `defaultSelected` `plan-card`'s own `price`/`period` so the pre-hydration and never-linked states don't visibly disagree.
</Warning>

<Warning>
  **A freshly scaffolded `testimonials` section renders an empty carousel.** `scaffold --template testimonials` emits its three `quote` children with `settings: {}` and **no `value` at all**, and a `quote` node whose `value.quote` is missing or empty renders `null`. Nothing errors — you publish, `section_count` is correct, and the band is blank. Fill each child's `value` object before publishing:

  ```json theme={null}
  { "id": "019f…", "kind": "quote",
    "value": { "quote": "Worth every penny.", "name": "Dana R.", "profession": "Member since 2024" },
    "settings": {} }
  ```
</Warning>

<Warning>
  **`subheadline`, `paragraph`, `embed`, `html`, `spacer`, `stat`, `input`, `section`, and `sidebar` are not node kinds.** They were removed from the renderer or never existed, and authoring one gets you a blank node with a `200`. Use `headline` with `level: 3` instead of `subheadline`, `text` instead of `paragraph`, a `stack`/`row` `gap` instead of `spacer`, and a `row` whose `stack` children carry `settings.width` (`1/3` + `2/3`) instead of a sidebar. `content-grid` is a *template* id, not a node kind. The [catalog reference](/reference/page-builder-catalog) lists every kind the catalog knows.
</Warning>

A button's `action` is an object, and its `value` is always canonical — no scheme prefix, no `#`:

```json theme={null}
{
  "kind": "button",
  "value": "Browse the library",
  "settings": { "size": "lg", "action": { "type": "url", "value": "https://example.com/library" } }
}
```

`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's `settings.surface` takes `background`, `gradient`, `padding`, `borderRadius`, `shadow`, `visibility`, and `minHeight`. `background` is a discriminated union of exactly six `type` values:

| `background`                                                               | Renders                                                                                                                                                                                   |
| -------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `{"type":"none"}`                                                          | No override — the most common value                                                                                                                                                       |
| `{"type":"color","token":"primary\|secondary\|muted\|accent\|background"}` | A solid theme color. `primary` also stamps `data-bg="primary"`, which produces the bold band **and** auto-inverts primary buttons inside it. An unrecognized token renders no background  |
| `{"type":"custom-color","value":"#rrggbb"}`                                | A solid inline color (invalid hex renders nothing)                                                                                                                                        |
| `{"type":"tint"}`                                                          | A subtle tint of the page foreground over the page background — so it tracks `branding.secondary` for a visitor rendering light. This is the value every scaffold emits                   |
| `{"type":"image","url":"…","blur":true}`                                   | An image layer. The renderer **always** composes a scrim over it — that is not authorable. An invalid `url` renders no image layer; `blur` adds a backdrop-blur layer on top of the scrim |
| `{"type":"gradient"}`                                                      | A gradient, configured by the **sibling** `surface.gradient`                                                                                                                              |

<Warning>
  Two traps live here.

  **Any `background.type` outside those six is accepted on publish and renders nothing.** The renderer falls through to a default case that returns no class and no style, with no warning in production. If a band looks see-through, check the type string first. `thumbnail` appears in the renderer's own types but is implemented nowhere and used by no recipe — treat it as one of the values that silently renders nothing.

  **The gradient config is a sibling of `background`, not nested inside it.** Nesting it is silently ignored and you get the default gradient:

  ```json theme={null}
  "surface": {
    "padding": "section",
    "background": { "type": "gradient" },
    "gradient": { "type": "monochrome" }
  }
  ```

  `gradient.type` is `monochrome`, `analogous`, `complementary`, `triadic`, `split`, `warm-shift`, or `custom`. `custom` requires valid hex `customStart` and `customEnd`; if either is missing or malformed it degrades to `split`, which is also what an absent `gradient` resolves to.
</Warning>

## 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`:

```text theme={null}
{"kind": "custom", "page_id": "<uuid>"} | {"kind": "discussions_index"} | null
```

**Always read `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:

| `homepage`                    | What to do                                                                                |
| ----------------------------- | ----------------------------------------------------------------------------------------- |
| `{kind: "custom", page_id}`   | Call `GET /pages/home` as before — it resolves this same page.                            |
| `{kind: "discussions_index"}` | Do not call `/pages/home`. Render the discussions index as the homepage surface directly. |
| `null`                        | Legacy behavior — call `/pages/home`; a `404` means "no homepage configured" (unchanged). |

**`/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

```text theme={null}
PATCH /api/v1/teams/{team_id}/hubs/{identifier}
```

Accepts the same `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**

| Status | Code                             | Cause                                                                                                                                                                 |
| ------ | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 422    | `homepage_page_invalid`          | `page_id` doesn't exist, belongs to another hub, or is soft-deleted (`source.pointer`: `/data/attributes/homepage/page_id`)                                           |
| 409    | `homepage_discussions_disabled`  | Setting `kind: "discussions_index"` while discussions are not enabled on the hub, **or** disabling discussions while `homepage.kind` is currently `discussions_index` |
| 409    | `homepage_system_pointer_active` | Setting `is_homepage: true` on a page (admin page create/update, see above) while the hub's homepage descriptor already points at `discussions_index`                 |

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 read `homepage: 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:

```text theme={null}
GET /api/v1/hub/{hub_id}/pages
GET /api/v1/hub/{hub_id}/pages/home
GET /api/v1/hub/{hub_id}/pages/{identifier}
GET /api/v1/hubs/{slug}/pages/{page_type}
```

Access rules can prune or hide page nodes for a contact. Anonymous render endpoints return pre-pruned public artifacts when available.

## Authoring from the CLI

The `mio` CLI wraps this whole draft → publish contract so you don't manage the `If-Match` header by hand:

```bash theme={null}
PAGE_ID=$(mio pages create --hub hub_123 --title "Home" --slug welcome \
  --privacy public --is-home -o plain --jq .id)                     # "home" is a reserved slug
mio pages catalog scaffold --template page-homepage > tree.json     # starter node-tree from the catalog
mio pages tree set "$PAGE_ID" --hub hub_123 --file tree.json        # first tree: --if-match defaults to 0
mio pages publish "$PAGE_ID" --hub hub_123 --if-match 1
```

`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:

```bash theme={null}
V=$(mio pages tree get "$PAGE_ID" --hub hub_123 --jq .draft_version)
mio pages tree get "$PAGE_ID" --hub hub_123 --jq '{root: .tree}' > tree.json
# ... edit tree.json ...
mio pages tree set "$PAGE_ID" --hub hub_123 --if-match "$V" --file tree.json
```

Catalog discovery has three verbs — `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](/guides/creating-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](/reference/page-builder-catalog), generated from the live catalog. A `page-*` template scaffolds a page **outline**, not finished sections — scaffold each section from its own template and splice it into the page root's `children`.
