Skip to main content
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, which builds an entire hub in one command; drop to the step-by-step recipe when you need something the template does not cover. It also spells out the 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.
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 is documented at the end for direct integrators.

Before you start

Set your team (and, once created, your hub) context so you can drop the repeated flags:
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.
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.
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:
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.

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.
The shipped community template applies no imagery — no logo, favicon, social image, or page images. Set your own with --logo-url / --favicon-url (below) and by filling the homepage’s image nodes with 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.
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. Two pieces still need backend endpoints and are skipped with a note: the welcome discussion post and auto-assigning the creator as hub admin.
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. Never guess the token; read it back with mio pages tree get <page_id> --jq .draft_version.
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

--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):
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.
  • --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.
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 for why it has to survive both.
*_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 _urllogo_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 instead.

Who controls light and dark mode

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.
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.
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.
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.
  • 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.
Legal links live under /legal/. A footer item pointing at /{slug}/privacy or /{slug}/terms 404s outrightprivacy 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.
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:
--unset is repeatable/comma-separated and is applied after the -json merges and scalar flags.

3. Discussion spaces

--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.
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.)
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:
  • 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.
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.
  • 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:
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 values, 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.
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:
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.
See the page-builder catalog for the full list of templates and section types, and Pages and the page builder for the node shape and the draft/publish contract.

7. Publish the hub

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.

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.
  • 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.
  • 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:
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:
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}}'.

Raw API reference

Direct integrators can create a hub without the CLI. The CLI --name flag maps to the JSON:API title attribute:
Look the hub up by slug on the public route:
Attach legal policies (terms, privacy) — or use mio hubs policies update: