> ## 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.

# Create a hub

> Build a branded, render-faithful hub end to end with the mio CLI.

A hub is the member destination. It owns pages, content, community spaces, policies, checkout display, and member-specific settings.

This guide is the **CLI-only recipe** for building a full, render-faithful hub. Start with [`mio hubs scaffold`](#start-here-scaffold-the-whole-hub), which builds an entire hub in one command; drop to the [step-by-step recipe](#1-create-the-hub) when you need something the template does not cover. It also spells out the [render-contract gotchas](#render-contract-gotchas) that cause *silent* failures: the API returns `200`, but the hub renderer drops malformed input, so a card or section never appears and nothing tells you why. These silent drops are the number-one pain point in real CLI-only builds.

<Note>
  Every command below is verified against the `mio` CLI. Prefer the CLI for hub authoring — it validates blob keys, typed menu items, and hub-scoped hrefs *before* firing a request. The [raw API](#raw-api-reference) is documented at the end for direct integrators.
</Note>

## Before you start

Set your team (and, once created, your hub) context so you can drop the repeated flags:

```bash theme={null}
export MIO_API_KEY=mio_sk_live_xxxxx
mio config set current_team 019e204f-9ea0-7601-ac0f-ab522eece374   # or pass --team
mio config set current_hub  019f18d9-8ce8-7be0-a7fe-364159d0ecbb   # or pass --hub
```

<Warning>
  Two ways to get this wrong, both of which exit `2` and write nothing:

  * **The only writable keys are `current_team`, `current_hub`, and `api_base`.** There is no `team` or `hub` key — `mio config set team …` fails with `unknown config key "team" (valid: [current_team current_hub api_base])`.
  * **`current_team` and `current_hub` must be a UUID.** A prefixed or slug-style id is rejected: `invalid current_team "team_abc123": must be a UUID (e.g. 019e204f-9ea0-7601-ac0f-ab522eece374)`. Read the real value from `mio teams list` and `mio hubs list`.

  Run `mio config list` to see the current values and the config file path. The API key is a secret managed by `mio login` / `MIO_API_KEY`, not by `config set`.
</Warning>

Ids elsewhere in this guide are written as readable placeholders (`hub_abc123`, `page_home123`); substitute the real UUIDs your own commands return.

The CLI flattens JSON:API resources by default, so `mio hubs create ... --jq .id` gives you the new id for the next step.

<Warning>
  **Capturing an id into a shell variable needs `-o plain`.** `--jq` alone renders its result as JSON, and a JSON string keeps its quotes — so `ID=$(mio pages create … --jq .id)` sets `ID` to `"page_abc123"` *including the quote characters*, and the next command 404s on an id that does not exist. Add `-o plain`:

  ```bash theme={null}
  PAGE_ID=$(mio pages create … -o plain --jq .id)   # page_abc123
  ```

  Numbers are unaffected (`--jq .draft_version` gives a bare `1`), and **`-o plain` is wrong when you want JSON on disk** — it flattens an object to `key=value` lines, so keep the default output for anything you redirect into a `.json` file.
</Warning>

## Start here: scaffold the whole hub

`mio hubs scaffold` collapses the entire sequence below into a single **idempotent** command, and it is the recommended way to reach a working hub. It creates the hub and applies a full-experience template — brand colours, navigation, registration, discussion spaces, onboarding schema, policies, playlists, and a homepage — by orchestrating the CLI's own request-builders and client layer (never raw REST), so every template value passes the same validation as the individual commands below.

<Note>
  The shipped `community` template applies **no imagery** — no logo, favicon, social image, or page images. Set your own with `--logo-url` / `--favicon-url` ([below](#2-branding-favicon-registration-and-menu)) and by filling the homepage's image nodes with [durable URLs](#5-images-inside-page-trees--use-durable-urls).

  A hub with no `logo_url` renders its title as text rather than a broken image, so a freshly scaffolded hub looks intentional — just unbranded.
</Note>

```bash theme={null}
# What the backend you are pointed at actually offers
mio hubs templates

# Preview the ordered plan without making any changes
mio hubs scaffold --template community --name "My Community" --slug my-community --dry-run

# Create a new hub from the community template
HUB_ID=$(mio hubs scaffold --template community --name "My Community" --slug my-community -o plain --jq .hub_id)

# Apply onto an existing hub — also how you resume after a mid-pipeline failure
mio hubs scaffold --template community --hub hub_abc123
```

Templates are read from the page-builder catalog served by the backend the CLI is pointed at, so `mio hubs templates` needs credentials and lists exactly what a scaffold against that backend would apply.

It is safe to re-run: existing spaces, onboarding attributes, and playlists are skipped rather than duplicated. The hub is left **private** by default — add `--publish` to make it public. Self-registration comes from the template (the `community` template sets `settings.registration.enabled: true`), not from publishing; see [what publishing does and does not turn on](#what-publishing-does-not-turn-on). Two pieces still need backend endpoints and are skipped with a note: the welcome discussion post and auto-assigning the creator as hub admin.

<Note>
  **A scaffolded page already has a draft.** Its `draft_version` is `1`, not `0`, so the first tree write *you* make against it needs `--if-match 1` — see [step 6](#6-build-the-homepage). Never guess the token; read it back with `mio pages tree get <page_id> --jq .draft_version`.
</Note>

The rest of this guide is the same pipeline, one command at a time. Follow it to customize a scaffolded hub, to build one that diverges from every template, or to understand what the scaffold does under the hood.

## 1. Create the hub

```bash theme={null}
mio hubs create --name "Member Academy" --slug member-academy
```

`--name` maps to the hub `title`; `--slug` is the unique lookup key public routes use. A new hub is **private and unpublished** — not reachable by members yet. The CLI says so and tells you how to publish (this hint prints to stderr, so JSON stdout stays clean):

```text theme={null}
Created hub hub_abc123 — private/unpublished (not reachable by members yet).
  Slug: member-academy
  Publish it with: mio hubs update hub_abc123 --published
  Note: the public hub URL is not returned by the API and cannot be derived by the CLI; combine the slug above with your hub-frontend host.
```

The public hub URL is **not** returned by the API — combine the slug with your hub-frontend host yourself.

## 2. Branding, favicon, registration, and menu

Branding, settings, and meta are opaque JSONB blobs. The `--branding-json` / `--settings-json` / `--meta-json` flags **merge** (read-modify-write, so a partial edit never clobbers sibling keys) and **validate keys**: an unknown key warns on stderr *and is still sent*, and `--strict-keys` upgrades that to a hard error with no request fired.

```bash theme={null}
mio hubs update hub_abc123 \
  --favicon-url "https://cdn.example.com/favicon.png" \
  --logo-url    "https://cdn.example.com/logo.png" \
  --branding-json '{"primary":"#0F766E","secondary":"#0B1F1C","font_heading":"Inter"}' \
  --registration-enabled \
  --navigation-json '{
    "header":[{"type":"url","label":"Home","href":"/member-academy/","position":0,"icon":"home"}],
    "footer":[{"type":"url","label":"Privacy","href":"/member-academy/legal/privacy-policy","position":0}]
  }'
```

* **`--favicon-url`** sets `branding.favicon_url`; **`--logo-url`** sets `branding.logo_url`. Both are read-modify-write, so sibling branding keys are preserved.
* **`--registration-enabled`** sets `settings.registration.enabled`. It is tri-state: pass `--registration-enabled` to enable, `--registration-enabled=false` to disable explicitly, or omit it to leave it untouched. `mio hubs retrieve` surfaces a derived `registration_enabled` field so you can confirm it.
* On `hubs update`, `--navigation-json` **replaces** the whole navigation blob. For item-by-item edits use `mio hubs navigation list|add|remove|reorder`.

### What each branding key paints

Most of the branding blob is freeform, but the keys the hub renderer reads are fixed. Getting `secondary` wrong is the single most common branding mistake.

| Key                                                                   | What it paints                                                                                                                                                                                                                                                                                                |
| --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `primary`                                                             | Buttons, links, CTAs, brand accents                                                                                                                                                                                                                                                                           |
| `secondary`                                                           | The theme anchor, and it does double duty: when a visitor renders **light** it is the **page foreground** — every heading and every line of body copy — and when they render **dark** it becomes the **page background**. It also sets the base tint behind every `surface.background: {"type":"tint"}` block |
| `text`                                                                | The page foreground — **only in `custom` theme mode** ([see below](#who-controls-light-and-dark-mode)). Rendering light, it is ignored and derived from `secondary`; rendering dark, it is ignored and hard-coded to white                                                                                    |
| `background`                                                          | The page background — **only in `custom` theme mode**. Rendering light, it is ignored (white); rendering dark, it is ignored and taken from `secondary`                                                                                                                                                       |
| `dark_mode` (boolean)                                                 | Does **not** pick light or dark. It selects the fallbacks for `text` and `background` when those are unset — `true` gives a `secondary` background with white text, `false`/absent gives a white background with `secondary` text                                                                             |
| `header_color` / `header_accent`                                      | Top-nav background and accent. Emitted raw, with no contrast correction; when unset the header inherits the page theme                                                                                                                                                                                        |
| `logo_url` / `favicon_url` / `social_image_url`                       | Imagery                                                                                                                                                                                                                                                                                                       |
| `font_heading` / `font_body` / `heading_font_size` / `body_font_size` | Typography                                                                                                                                                                                                                                                                                                    |

<Warning>
  **`secondary` is not a decorative accent.** It is the page foreground for a visitor rendering light — so it paints every heading and paragraph, and tints every `tint` surface — and the page background for a visitor rendering dark. Set it light and your text goes near-invisible on a white page. See [who controls light and dark mode](#who-controls-light-and-dark-mode) for why it has to survive both.
</Warning>

**`*_url` branding keys are the exception to "stored verbatim".** The rest of the blob has no server schema, but every branding key whose name ends in `_url` — `logo_url`, `favicon_url`, `social_image_url`, `custom_font_url`, and any future one — is validated on write: it must be an absolute **`https://`** URL with a real hostname, and must carry no embedded credentials, no percent-encoding in the host, and no control characters. A `data:` URI, a `javascript:` URI, a bare `http://` URL, or a relative path is rejected with a `422`, naming the offending key. So a `data:` SVG logo fails — upload the asset and use a [durable URL](/guides/media-workflow#durable-image-urls-for-page-trees) instead.

### Who controls light and dark mode

<Warning>
  **A hub cannot force light or dark mode.** The rendered mode resolves as: if the hub's mode is `custom`, `custom` wins; otherwise the **visitor's** preference decides — their stored theme choice, defaulting to `system`, which follows the OS `prefers-color-scheme`. So a hub configured as `dark` still renders **light** for every visitor on a light-mode laptop, and no amount of re-writing the setting changes that. If you set it and see no change, the write succeeded; it was never the lever.
</Warning>

The hub's own mode lives at **`settings.background.type`** (`light`, `dark`, or `custom`; default `light`), and it is consulted for exactly one purpose: **`custom` is the only value that overrides the visitor**, and the only mode in which `branding.text` and `branding.background` are read at all.

```bash theme={null}
# The one mode a hub can actually pin — with its own text/background colors:
mio hubs update hub_abc123 \
  --settings-json '{"background":{"type":"custom"}}' \
  --branding-json '{"text":"#111827","background":"#ffffff"}'
```

`background` is on the CLI's settings-key allowlist, so this passes without a warning.

**`branding.dark_mode` does not choose the mode, but it is not inert either:** it selects the *defaults* for `text` and `background` — exactly the two keys a `custom` hub relies on. With `dark_mode: true` and neither key set, `background` defaults to `secondary` and `text` to white; with it `false` or absent, `background` defaults to white and `text` to `secondary`. So on a `custom` hub, `{"secondary":"#0B1F1C","dark_mode":true}` renders a dark page with white text, and dropping `dark_mode` flips the same hub to a white page with dark text. Set it in step with the mode you chose — the renderer logs a warning when the two disagree.

<Warning>
  **This is what makes `secondary` so easy to get wrong.** On any non-`custom` hub the same `secondary` has to work in *both* modes: in light mode it is the page foreground (headings and body copy on a white page), and in dark mode it is the page background (with white text on top). A mid-tone that looks fine in one is unreadable in the other, and you do not control which one a given visitor gets. Pick a **dark, saturated** value — it reads as text on white and as a background under white — and check the hub in both modes before shipping.
</Warning>

### Menu items and hrefs

* **Menu items must be typed.** The hub frontend's parser **silently drops** any `header`/`footer` item that lacks a non-empty `type` (`url`, `page`, `playlist`, or `discussions`), so a menu shipped without types renders empty. The CLI rejects untyped items up front. `header`/`footer` must each be an array of objects; the `mobile` bucket uses a different `{id,label,route,icon}` shape.
* **Every menu `href` must be a root-relative path**, and hub-scoped. The API validates this on write: an `href` must start with exactly one `/`, carry no URL scheme, no protocol-relative `//` prefix, no backslashes, no whitespace, and no control characters — the raw value is checked for whitespace *and* control characters, and its percent-decoded form for control characters only, so an encoded space (`/content/My%20Playlist`) is deliberately fine. Maximum length is 2048. An absolute `https://…` menu href is rejected with a `422` — the CLI's own check still lets it through, so this is one of the few places the CLI is more permissive than the API. Keep the path inside your hub (`/member-academy/about`), or it escapes the hub and 404s.
* **A `type:"url"` item needs `label`, `href`, and `position`.** Omitting `position` is a `422`, not a default. `label` is 1–120 characters, and each bucket holds at most 50 items.

<Warning>
  **Legal links live under `/legal/`.** A footer item pointing at `/{slug}/privacy` or `/{slug}/terms` **404s outright** — `privacy` and `terms` are not routes and are not reserved words, so they fall through to the generic hub-page route, find no page with that slug, and render the 404 page. The real routes are **`/{slug}/legal/privacy-policy`** and **`/{slug}/legal/terms-of-service`**. Fill their content with `mio hubs policies update`.
</Warning>

### Navigation icons — two different vocabularies

The API checks that an `icon` is a string of at most 100 characters, but **not** that the name exists — so a valid-looking wrong name is a silent drop rather than a `422`.

* **`header`** accepts any id in the hub frontend's icon sprite (\~205 glyphs), and is the only bucket that actually renders one. `icon` is **optional** here: an unknown name drops the icon but keeps the menu item. Verified-present picks: `home`, `content`, `chat`, `users`, `search`, `star`, `link`, `earth`, `calendar`, `bell`, `play`, `video`, `settings`, `email`, `folder`, `heart`, `lock`, `tag`, `podcast`, `download`. **`info` and `globe` are not in the sprite** — they render blank with no error. Use `information` / `information-circle` and `earth` / `global` instead.
* **`footer`** accepts and stores `icon`, but **the footer never renders it** — only the header does. A footer icon is not an error, just inert.
* **`mobile` tabs** accept an **eight-value whitelist only**, and `icon` is **mandatory** — an item with a missing or off-list icon is dropped entirely. These are frontend component names, **not** sprite ids, and the casing matters: `Home`, `Bell`, `User`, `Users`, `MessageSquare`, `MessageCircle`, `Search`, and `content` (that last one is deliberately lowercase). Copying a header value such as `home` loses the tab. Two more mobile-only rules: fewer than three valid tabs and the whole list is replaced by the frontend defaults; more than five and it is truncated to five.

To **delete** a key (the `-json` flags are merge-only and cannot remove one — a `null` persists as literal `null`), use `--unset` with a dotted path whose first segment picks the blob:

```bash theme={null}
mio hubs update hub_abc123 --unset settings.registration.enabled --unset branding.gradient
```

`--unset` is repeatable/comma-separated and is applied *after* the `-json` merges and scalar flags.

## 3. Discussion spaces

```bash theme={null}
mio community spaces create --hub hub_abc123 --name "General" --slug general
mio community spaces create --hub hub_abc123 --name "Announcements" --slug announcements \
  --posting-permission admins_only
```

`--access-level` is `public` or `restricted`; `--posting-permission` is `any_member`, `admins_only`, or `segment` (with `--segment-id`).

## 4. Playlists → items → publish to the hub

Build a playlist, curate its items, give it a cover, then publish it to the hub. Publishing writes the `hub_media` row that surfaces the playlist on the hub's `/content` browse grid and homepage content-grid.

```bash theme={null}
# Create the playlist
mio media playlists create --title "Getting Started" --hub-id hub_abc123 --visibility public

# Curate its items (file ids from `mio media files list`)
mio media playlists items add --playlist-id pl_abc --file-id file_intro
mio media playlists items add --playlist-id pl_abc --file-id file_lesson2 --position 1

# Give it a cover (pass the FILE id — the media id is resolved for you)
mio media playlists set-cover pl_abc --file-id file_cover

# Publish it onto the hub
mio media hub-playlists publish --hub hub_abc123 --playlist-id pl_abc \
  --visibility public
```

<Warning>
  **Pass `--visibility public` so anonymous visitors can see the card.** `--published-at` is optional: when you omit it the CLI now defaults it to *now*, so the card publishes immediately. Pass an explicit past/future RFC3339 timestamp only to backdate or schedule. (A `null` `published_at` is treated as a silent draft — the CLI no longer sends null, so an unset flag can't hide the card.)
</Warning>

`playlists items` also supports `list`, `remove <item_id>`, and `reorder <item_id> --position N`. Note that `remove` and `reorder` take the **item id** (the `id` from `items list`), not the file id.

## 5. Images inside page trees — use durable URLs

Page-tree image nodes must reference a URL that does not expire. The `variants` map returned by an upload is **imgproxy-signed and expires in \~24–48h** — inline one of those and the image silently 404s a day later. Use `media files durable-url` instead:

```bash theme={null}
mio media files durable-url file_hero --hub hub_abc123 --preset large-1440 --publish
```

* The durable URL is the file's `durable_variants` entry joined with the **required `?hub_id=`** param (the command adds it) so it resolves for `--hub`.
* The URL 404s until the file is **published public to that hub**. `--publish` does that inline (visibility public, published now); otherwise run `mio media hub-media publish --hub hub_abc123 --file-id file_hero --visibility public` first.
* `--preset` emits one variant (e.g. `thumbnail-160`, `medium-720`, `large-1440`, `webp-medium`); omit it to print every preset. Durable URLs are **image-only** — a non-image file has no durable variants.

## 6. Build the homepage

Create the page, scaffold a node-tree from the page-builder catalog, fill in real values, then set the draft and publish it.

```bash theme={null}
# Create the home page. --slug "home" is RESERVED; --is-home is what designates the homepage.
PAGE_ID=$(mio pages create --hub hub_abc123 --title "Home" --slug welcome \
  --privacy public --is-home -o plain --jq .id)

# Scaffold a homepage tree from the catalog (writes JSON to stdout)
mio pages catalog scaffold --template page-homepage > tree.json

# ... edit tree.json: fill in the headline/text/button nodes, drop in durable image URLs ...

# Set the draft. --if-match is OPTIONAL for the FIRST tree (defaults to 0).
mio pages tree set "$PAGE_ID" --hub hub_abc123 --file tree.json

# Publish it. --if-match is REQUIRED here — use the draft_version from the set/get response.
mio pages publish "$PAGE_ID" --hub hub_abc123 --if-match 1
```

<Warning>
  **`home` is a reserved page slug.** `--slug home` is rejected with a `422` (*"Slug 'home' is reserved and cannot be used for a page. Use the is\_homepage flag instead."*) so that `GET /pages/home` always resolves to the `is_homepage` page. Use a real slug and mark the page with `--is-home`. Omitting `--slug` entirely fails with `Field required`.

  **`pages create --privacy` defaults to `members`**, so a page created without `--privacy public` is login-walled — you ship what looks like a public hub and every anonymous visitor hits the login screen. Valid values: `public`, `members`, `private`.
</Warning>

* **`pages tree set --if-match` is optional** and defaults to `0`. Omit it only for the first tree on a page that has never had a draft: `pages tree get` 404s until a draft exists, so there is no version to read back. 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, so you can never silently clobber an existing draft. For every later write, pass the `draft_version` from a prior `pages tree get`. **A page created by `mio hubs scaffold` already has a draft at version `1`,** so your first write against it needs `--if-match 1`.
* Scaffolding is offline-capable and prints an informational `catalog: …` line to stderr, so `> tree.json` captures only the JSON. A `page-*` template emits a complete `{"root": …}` tree ready for `tree set`; a section template emits a bare subtree to drop into a root's `children`.

### Scaffold the page, then splice in real sections

A `page-*` template is an **outline, not a finished page**. `page-homepage`'s hero child arrives as `{"kind":"row","template":"hero","settings":{}}` — no surface, no values. Scaffolding each section on its own gives you the real, design-conformed recipe (correct `kind`, `settings.surface`, column widths): the same hero scaffolded from the `hero` template is a `container` carrying `{"maxWidth":"content","padding":0,"surface":{"padding":"section","background":{"type":"tint"}}}`.

So scaffold the page for the skeleton, scaffold each section separately, and splice:

```bash theme={null}
mio pages catalog scaffold --template page-homepage    > tree.json
mio pages catalog scaffold --template hero             > hero.json
mio pages catalog scaffold --template row --variant 3eq > cols.json
mio pages catalog scaffold --template grid             > grid.json
# replace tree.json's .root.children with [hero.json, cols.json, grid.json], then fill in values
```

`row` is the unified 1–4 column section — pick the layout with `--variant` (`1col`, `2eq`, `2left`, `2right`, `3eq`, `4eq`, `bound-cards`, `cta-band`, `faq`). `cta-band`, `faq`, and `bound-cards` arrive already filled with placeholder `value`s, which is the fastest way to see the node contract in practice. Discover the rest with `mio pages catalog templates` and `mio pages catalog section-types --writable-only`; there is no `pages catalog list`.

<Note>
  **`pages tree get` and `pages tree set` do not use the same shape.** `tree get` returns the resolved author draft with `tree` set to the **bare root node** — already unwrapped from `{"root": …}` — alongside `draft_version`. `tree set` requires the wrapper. Re-wrap on the way back in:

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

  Feeding a `tree get` response straight back into `tree set` fails: a tree with no top-level `"root"` key is a `422 invalid_tree`. `pages catalog scaffold` already emits the `set` shape, which is why the scaffold → set pipe needs no transform. Note that `tree get` returns the *resolved* draft (actions mapped, `dataSource` expanded), so it is a read-back, not a byte-exact copy of what you wrote.
</Note>

See the [page-builder catalog](/reference/page-builder-catalog) for the full list of templates and section types, and [Pages and the page builder](/guides/pages-page-builder) for the node shape and the draft/publish contract.

## 7. Publish the hub

```bash theme={null}
mio hubs update hub_abc123 --published --registration-enabled
```

The hub is now reachable by members at your hub-frontend host + slug.

`--registration-enabled` is not redundant here. Because this recipe created the hub **private** in step 1, the self-registration default was never injected, and publishing does not add it — so without that flag the hub goes live with no way for members to sign themselves up. See [what publishing does not turn on](#what-publishing-does-not-turn-on).

## Render-contract gotchas

The API validates *structure*, not *renderability*. Every mistake below returns `200` and then goes wrong at render time with no error anywhere — usually by **silently dropping** the node, section, or card, sometimes by quietly rendering something other than what you asked for. Verify the outcome, don't trust the write:

* **A node's content goes in a top-level `value`, not `settings.value`.** This is the single biggest silent-drop trap — see [the node envelope](/guides/pages-page-builder#the-node-envelope).
* **`weight` must be numeric.** A node `weight` is a number like `700`, never a CSS keyword like `"bold"` — an unrecognized weight is discarded and the node renders at whatever weight it would have had with no `weight` set at all. (`pages tree set` catches any non-numeric weight client-side, before any HTTP.)
* **A section must carry its `template`.** The catalog scaffold sets `template` on every section node (`"hero"`, `"carousel"`, `"row"`, …). If you hand-build a tree, a section without its `template` will not render. A blank or non-string `template` is rejected client-side.
* **Button nodes need the correct `action` shape.** A malformed or missing `action` on a button node leaves a button that renders but navigates nowhere.
* **An off-enum `surface.background.type`** renders a transparent band with no error at all — see [surfaces and backgrounds](/guides/pages-page-builder#surfaces-and-backgrounds).
* **`section_count` is your "did it apply" signal.** `mio pages publish` returns a `page_publishes` resource with `section_count` and `gate_count`. If `section_count` is lower than the number of sections you authored, the renderer rejected some — inspect the tree, don't infer success from the `200`.
* **Homepage content-grids need STATIC cards, not a data-source binding.** The homepage route prefetches only `type:"playlist"` sources and runs `hub_playlists` with `enabled:false`, so a content-grid bound to `dataSource:{type:"hub_playlists"}` renders **empty** on the homepage. (`hub_playlists` feeds the `/content` browse page, not the homepage.)

## What publishing does not turn on

Registration and Moderation defaults are injected **only when the hub is created**, never on update — so which flow you use decides what you end up with:

| Flow                                                        | `settings.registration.enabled` | `meta.moderation.enabled` |
| ----------------------------------------------------------- | ------------------------------- | ------------------------- |
| `mio hubs create` (no `--published` — the default, private) | **not set**                     | `true`                    |
| `mio hubs create --published`                               | `true`                          | `true`                    |
| `mio hubs update --published` (step 7 above)                | **untouched**                   | **untouched**             |
| `mio hubs scaffold` (creates private, then publishes)       | whatever the template sets      | `true`                    |

<Warning>
  **Creating a private hub and publishing it later leaves self-registration off.** The registration default is injected on create and only for a hub created public, and `hubs update` never injects it — so the step-by-step recipe above, which creates a private hub and publishes it in step 7, ends with no `settings.registration.enabled` at all. Members cannot sign themselves up. Set it explicitly:

  ```bash theme={null}
  mio hubs update hub_abc123 --published --registration-enabled
  ```

  Moderation is injected on **every** create regardless of privacy, so you rarely need to set it — but it is equally untouched by `update`, so a hub that predates that default needs `--meta-json '{"moderation":{"enabled":true}}'`.
</Warning>

## Raw API reference

Direct integrators can create a hub without the CLI. The CLI `--name` flag maps to the JSON:API `title` attribute:

```bash theme={null}
curl -X POST "$MIO_BASE_URL/api/v1/teams/$TEAM_ID/hubs" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/vnd.api+json" \
  -d '{
    "data": {
      "type": "hubs",
      "attributes": { "title": "Member Academy", "slug": "member-academy" }
    }
  }'
```

Look the hub up by slug on the public route:

```bash theme={null}
curl -s "$MIO_BASE_URL/api/v1/hubs/member-academy" | jq
```

Attach legal policies (terms, privacy) — or use `mio hubs policies update`:

```bash theme={null}
curl -X PATCH "$MIO_BASE_URL/api/v1/teams/$TEAM_ID/hubs/$HUB_ID/policies" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/vnd.api+json" \
  -d '{
    "data": {
      "type": "hub_policies",
      "attributes": { "tos_content": "Terms text...", "privacy_content": "Privacy text..." }
    }
  }'
```
