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: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.
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.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.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):
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-urlsetsbranding.favicon_url;--logo-urlsetsbranding.logo_url. Both are read-modify-write, so sibling branding keys are preserved.--registration-enabledsetssettings.registration.enabled. It is tri-state: pass--registration-enabledto enable,--registration-enabled=falseto disable explicitly, or omit it to leave it untouched.mio hubs retrievesurfaces a derivedregistration_enabledfield so you can confirm it.- On
hubs update,--navigation-jsonreplaces the whole navigation blob. For item-by-item edits usemio 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. Gettingsecondary wrong is the single most common branding mistake.
*_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 instead.
Who controls light and dark mode
The hub’s own mode lives atsettings.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.
Menu items and hrefs
- Menu items must be typed. The hub frontend’s parser silently drops any
header/footeritem that lacks a non-emptytype(url,page,playlist, ordiscussions), so a menu shipped without types renders empty. The CLI rejects untyped items up front.header/footermust each be an array of objects; themobilebucket uses a different{id,label,route,icon}shape. - Every menu
hrefmust be a root-relative path, and hub-scoped. The API validates this on write: anhrefmust 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 absolutehttps://…menu href is rejected with a422— 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 needslabel,href, andposition. Omittingpositionis a422, not a default.labelis 1–120 characters, and each bucket holds at most 50 items.
Navigation icons — two different vocabularies
The API checks that anicon 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.
headeraccepts any id in the hub frontend’s icon sprite (~205 glyphs), and is the only bucket that actually renders one.iconis 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.infoandglobeare not in the sprite — they render blank with no error. Useinformation/information-circleandearth/globalinstead.footeraccepts and storesicon, but the footer never renders it — only the header does. A footer icon is not an error, just inert.mobiletabs accept an eight-value whitelist only, andiconis 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, andcontent(that last one is deliberately lowercase). Copying a header value such ashomeloses 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.
-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 thehub_media row that surfaces the playlist on the hub’s /content browse grid and homepage content-grid.
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. Thevariants 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_variantsentry 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.
--publishdoes that inline (visibility public, published now); otherwise runmio media hub-media publish --hub hub_abc123 --file-id file_hero --visibility publicfirst. --presetemits 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.pages tree set --if-matchis optional and defaults to0. Omit it only for the first tree on a page that has never had a draft:pages tree get404s until a draft exists, so there is no version to read back. The0default does not bypass the concurrency guard — sending0(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 thedraft_versionfrom a priorpages tree get. A page created bymio hubs scaffoldalready has a draft at version1, so your first write against it needs--if-match 1.- Scaffolding is offline-capable and prints an informational
catalog: …line to stderr, so> tree.jsoncaptures only the JSON. Apage-*template emits a complete{"root": …}tree ready fortree set; a section template emits a bare subtree to drop into a root’schildren.
Scaffold the page, then splice in real sections
Apage-* 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: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.7. Publish the hub
--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 returns200 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, notsettings.value. This is the single biggest silent-drop trap — see the node envelope. weightmust be numeric. A nodeweightis a number like700, never a CSS keyword like"bold"— an unrecognized weight is discarded and the node renders at whatever weight it would have had with noweightset at all. (pages tree setcatches any non-numeric weight client-side, before any HTTP.)- A section must carry its
template. The catalog scaffold setstemplateon every section node ("hero","carousel","row", …). If you hand-build a tree, a section without itstemplatewill not render. A blank or non-stringtemplateis rejected client-side. - Button nodes need the correct
actionshape. A malformed or missingactionon a button node leaves a button that renders but navigates nowhere. - An off-enum
surface.background.typerenders a transparent band with no error at all — see surfaces and backgrounds. section_countis your “did it apply” signal.mio pages publishreturns apage_publishesresource withsection_countandgate_count. Ifsection_countis lower than the number of sections you authored, the renderer rejected some — inspect the tree, don’t infer success from the200.- Homepage content-grids need STATIC cards, not a data-source binding. The homepage route prefetches only
type:"playlist"sources and runshub_playlistswithenabled:false, so a content-grid bound todataSource:{type:"hub_playlists"}renders empty on the homepage. (hub_playlistsfeeds the/contentbrowse 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:Raw API reference
Direct integrators can create a hub without the CLI. The CLI--name flag maps to the JSON:API title attribute:
mio hubs policies update: