mio CLI for creator-side automation, scripted admin workflows, and quick API exploration from a terminal.
Install
On macOS or Linux, install the latest released binary with the official installer:mio into /usr/local/bin.
Install somewhere you own with PREFIX:
VERSION:
Update
Release installs can update themselves from the CLI:mio update reruns the official release installer into the directory containing the current executable. Use --version to pin a release, or --prefix to install into another directory:
go install, update through Go instead:
mio update also keeps the agent skill in sync: the new binary refreshes each unmodified copy to the version it bundles, and leaves a hand-edited copy untouched. Which copies it refreshes depends on the release that runs the update:
- From
v0.24.0(the release afterv0.23.0): the user copies (~/.claude/skills/mio/SKILL.md, and$CODEX_HOME/skills/mio/SKILL.mdfor Codex), plus the project copies in the current directory (./.claude/skills/mio/SKILL.md,./.codex/skills/mio/SKILL.md). Runmio updatefrom a project to refresh that project’s copy; no other directory is searched. Every refresh warning, including the one naming a hand-edited copy and themio skills install … --forcecommand that would overwrite it, goes to stderr. - On
v0.23.0and earlier: the user copies only. A project copy is never refreshed.
v0.23.0 to v0.24.0 still leaves project copies at their old version. Refresh each one once, from its project directory:
--force added, which overwrites your edits. From v0.24.0, mio skills install also warns on stderr when the other scope holds an older copy of the skill for the same agent, and names the command that refreshes it.
Install the agent skill
If you drivemio from a coding agent (Claude Code or Codex), install the bundled agent skill so the agent knows the CLI-only hub recipe and the render-contract traps before it starts calling commands:
mio binary, so it never needs a separate download and mio update always ships the current version. The install is idempotent and version-stamped: re-running is a no-op, an unmodified install is refreshed in place, and a skill you hand-edited is never overwritten unless you pass --force. Inspect or pipe the skill body with mio skills print.
Authenticate
Interactive users can log in once:Where the stored key lives
mio login never writes the key to the config file. Where it goes depends on the platform and on how mio was built:
The encrypted file lives under the config directory, so a shell or agent whose
XDG_CONFIG_HOME or HOME differs from the one you logged in from reads a different, empty store. A command that finds no key exits 3, and its error names the store it looked in:
mio whoami reports where the key it used came from as key_source: flag (--api-key), env (MIO_API_KEY), or for a stored key the store that held it — file keyring (<path>), keychain, secret service, kwallet or wincred. The one other value, none (--anonymous), is what --anonymous resolves to, but whoami prints its output only after the API accepts GET /api/v1/auth/me, which needs a credential, so against the Membership.io API mio whoami --anonymous exits 3 instead.
Export the stored key for a session
To hand the stored key to a script or agent session once, instead of every command reading the store:mio auth token prints the stored key, and nothing else, to stdout. It never prints the key from --api-key or MIO_API_KEY: if MIO_API_KEY is already set to a different key, it still prints the stored one and adds a note on stderr, because the environment key takes precedence everywhere else. It sends no request and does not check that the key is still valid — run mio whoami for that. With no stored key it exits 3 with empty stdout, and its error names the store it looked in, as above. Keep the assignment and the export as two statements: export MIO_API_KEY="$(…)" returns export’s own status, 0, so set -e never sees the exit 3 and the script carries on with an empty key.
mio auth token, the store named in the exit-3 error, and the per-store key_source values arrived in v0.23.0. On v0.22.0 and earlier, mio auth token exits 2 (unknown command "auth" for "mio"), and every earlier release that has whoami reports a stored key as keychain, whatever actually holds it.
Moderation with an API key
By default a team API key cannot run hub moderation — ban/unban/warn/soft-ban a member, resolve reports, or remove/restore content all return403 for a key. A key gains that power only when it is explicitly granted the can_moderate flag:
api_key_id so it is never attributed solely to the key’s creator — on the moderation audit log, on the durable moderation events, on the banned-members list (banned_by_api_key_id), and on every report the action auto-dismisses. Member-management (adding members, role changes) and report-reason management already work with any team key and do not require can_moderate. Team-security routes (roles, api-keys, verified domains, providers, OAuth clients) remain web/JWT-only.
API Base
The CLI talks to the production API athttps://api.member.dev by default. Override it per command with --api-base <url> or globally with MIO_API_BASE_URL.
Current API requests are sent to /api/v1 paths. The backend still accepts older /api paths as a compatibility alias during the alpha period, but /api/v1 is the documented contract.
Output for scripts
Off a terminal the CLI prints JSON.--jq filters it, and -o plain prints a string without JSON quotes, so capture ids with both: ID=$(mio pages create … -o plain --jq .id).
From v0.24.0 (the release after v0.23.0):
-
-o plain --jqprints one bare value per line, whatever the count. A result that is a stream or an array of strings, numbers or booleans prints one value per line, and a filter that matches nothing prints nothing, so a loop works the same for 0, 1 or 20 ids. A string that itself contains a newline, such as a policy’scontent, spans several lines; use-o jsonfor those. Objects still print askey=valueblocks. -
A list that has more rows says so on stderr. A list command returns one page, usually 20 rows. When the API reports more, the CLI prints one
note:line on stderr with the flag and cursor that fetch the next page, and leaves stdout unchanged:Paste the cursor as printed: it is single-quoted when a shell would split it.segments searchnames--page-afterinstead, andmedia search, which returns no cursor, suggests a larger--limit. The media lists send only a next-page link, so their note says a link came and the next page may be empty. The fourcheckout orders|subscriptions|payments|webhooks listcommands report nothing, so their note appears on any full page, says more rows may exist, and offers the last row’s id as the cursor.--rawprints no note, because its output carries the API’s ownmetaandlinks.
v0.23.0 and earlier, -o plain --jq prints two or more values as value=X records separated by blank lines (one value prints bare, and no match prints an empty line), and nothing reports that a list stopped at one page: read meta and links under --raw.
Build a hub end to end
The CLI can author a full, render-faithful hub — hub branding, menus, discussion spaces, playlists, media, and the homepage tree — without touching the raw API. Hub authoring is worth doing through the CLI because it validates blob keys, typed menu items, and hub-scoped hrefs before firing a request, and it surfaces the silent render-contract traps that a bare200 hides. See Create a hub for the ordered recipe, and Media workflow for the media command group.
Contact id vs. contact_id (the id-namespace trap)
mio surfaces two contact identifiers, and mixing them up 404s a live contact:
- The
contactsverbs — pluscontact-attributesandtags— operate on the team-contact id, surfaced as.id(route param{team_contact_id}). - The member-shaped verbs operate on the global contact id, a separate field surfaced as top-level
.contact_id(.data.attributes.contact_idunder--rawoncontacts retrieve). These arehub-memberships(add / set-role / ban / unban / warn),activity contact, community members (ban / unban / warn / soft-ban),email enrollments(create, list-by-contact), andaccess-rulesoverrides create.
.id into a {contact_id} route 404s. Read the global id from the flattened output first:
v0.24.0 the hint gives the capture above. On v0.23.0 and earlier it names .attributes.contact_id, which is where the field sits in the API’s response; as a --jq path it prints nothing, so capture .contact_id as shown above.
Recently fixed
These commands regressed or were incomplete and now work as documented:media files update(previously returned400).tags assignby tag id (--tag-id) and by name/slug (--tag).contact-attributes values set/get, andcontact-attributes hub-config create. Hub-config gained per-hub flags--in-profile,--in-onboarding,--required,--read-only, and--searchable(replacing the old--visible).values setnow routes each value into the attribute’s typed field (number/boolean/date/text) instead of always sendingvalue_text, so number, boolean, and date attributes are settable from the CLI; an unknown slug or a value that doesn’t parse for its type is rejected up front with no write.content reorder.automations test(dry-run enrollment, no side effects).