mio
The official command-line interface for Membership.io.
mio is agent-first: JSON output by default when piped, environment-variable auth, and stable exit codes so scripts and AI agents can branch deterministically.
This README tracks main, not the released binary. If you installed mio from a release, a feature described here may not be in it yet — run mio version, and prefer mio <cmd> --help, which always comes from the binary you actually have. Behaviours newer than the latest release are called out inline.
Prerequisites
- Prebuilt binary / curl install: none — the binary is self-contained and statically linked.
go install or building from source: Go 1.25+ (the module targets Go 1.25).
Install
mio is released. Pick whichever you prefer.
curl (recommended — macOS / Linux)
curl -fsSL https://raw.githubusercontent.com/Searchie-Inc/mio-cli/main/scripts/install.sh | sh
Detects your OS/arch, verifies the SHA-256 checksum, and installs mio into /usr/local/bin (you'll be asked for sudo). Install somewhere you own instead with PREFIX:
curl -fsSL https://raw.githubusercontent.com/Searchie-Inc/mio-cli/main/scripts/install.sh | PREFIX="$HOME/.local/bin" sh
# then make sure that dir is on your PATH, e.g. add to ~/.zshrc:
# export PATH="$HOME/.local/bin:$PATH"
Pin a specific version with e.g. VERSION=0.2.0.
Download the archive for your OS/arch from the latest release, extract it, and put mio on your PATH:
- macOS / Linux:
mio_<version>_<os>_<arch>.tar.gz
- Windows:
mio_<version>_windows_amd64.zip
where <version> is the release tag, e.g. 0.2.0.
checksums.txt is published alongside for verification.
go install (Go 1.25+)
go install github.com/Searchie-Inc/mio-cli@latest
Note: go install names the binary mio-cli (after the module path), not mio, and installs it to $(go env GOPATH)/bin (make sure that's on your PATH). Rename it if you like:
mv "$(go env GOPATH)/bin/mio-cli" "$(go env GOPATH)/bin/mio"
Or just use the curl / prebuilt-binary install above for a ready-to-go mio.
Homebrew — coming soon
brew install searchie-inc/tap/mio isn't enabled yet (the tap is being provisioned). Use the curl installer or a prebuilt binary for now.
From source
git clone https://github.com/Searchie-Inc/mio-cli
cd mio-cli
go build -o mio .
./mio version
Verify any install:
mio version # → mio 0.2.0 (commit …, built …)
Update a release install later from the CLI:
mio update # latest release
mio update --version 0.2.1
mio update installs into the directory containing the current executable; use
--prefix <dir> to install somewhere else.
- macOS / Linux — reruns the official installer.
- Windows — updates natively:
mio.exe downloads the release zip itself,
verifies its SHA-256 against checksums.txt, and swaps the binary. No Unix
shell (sh) and no curl are needed, so it works in stock PowerShell and
cmd.exe. Windows cannot delete a running executable, so the previous
version is renamed to mio.exe.old next to the new one and removed
automatically on the next mio run.
First run
# 1. Install (see above) — e.g. the curl one-liner.
# 2. Authenticate (pick one)
mio login # interactive — stores a key in your OS keychain
# …or, for scripts / CI / agents:
export MIO_API_KEY=mio_sk_live_xxxxx # no login step needed
# 3. Confirm setup
mio whoami # prints resolved user, team, hub, api-base, key source
# 4. You're ready
mio contacts list
Auth works against https://api.member.dev by default. Point elsewhere with --api-base <url> or MIO_API_BASE_URL (see Authentication).
If you belong to multiple teams, mio login will prompt you to pick one. You can switch later with mio teams switch <team-id>, which updates your local context. For single-team accounts the team is resolved automatically — no manual config step needed.
Authentication
mio login (interactive)
Run mio login and choose one of two paths:
- Paste an existing API key — a
mio_sk_live_… key, validated against the API and stored.
- Email + password — logs you in (JWT), then auto-mints an API key named
mio-cli@<host> on your team and stores it. Your password is never saved — only the minted key is kept.
The key is stored in your OS keychain (with an encrypted-file fallback on headless/CI environments where no keychain is available). mio logout deletes it.
mio register (create an account)
Don't have an account yet? mio register creates one and logs you in in a single step — it mints and stores a key just like mio login, so you end up authenticated as the new account (this replaces any key already stored).
# Interactive: prompts for email, password (typed twice), optional name
mio register
# Headless (CI / scripting)
mio register --email you@example.com --password 's3cr3tpass' --first-name Ada --last-name Lovelace
# …or via env vars
MIO_EMAIL=you@example.com MIO_PASSWORD='s3cr3tpass' mio register
Your password is never echoed or saved. --first-name/--last-name are optional; email-format and password rules (min 8 characters) are enforced by the API.
API key (CI / scripts / agents)
Skip mio login entirely — just set the environment variable:
export MIO_API_KEY=mio_sk_live_xxxxx
Resolution order
The key is read in this order (first match wins):
--api-key <key> → MIO_API_KEY env → key stored in the OS keychain
Pointing at a non-production backend
mio --api-base https://api.staging.membership.io contacts list
# or
export MIO_API_BASE_URL=https://api.staging.membership.io
Discovering commands
mio is fully self-documenting:
mio --help # top-level: all resources + global flags
mio <resource> --help # e.g. mio contacts --help — actions for a resource
mio <resource> <action> --help # e.g. mio contacts create --help — flags for an action
mio gen-docs --dir ./docs # generate the full Markdown reference (one file per command)
When in doubt, append --help to any command.
Quickstart
# Confirm auth + active team
mio whoami
# Contacts
mio contacts list
mio contacts create --email alice@example.com --first-name Alice --last-name Smith
mio contacts retrieve <id>
mio contacts update <id> --last-name "Jones"
mio contacts delete <id> --yes
# Hubs
mio hubs list
# A new hub is PRIVATE by default. In table mode create prints the slug and how to
# publish on stderr; output also carries a derived `published` (= !is_private).
# No public URL is echoed — the API returns no domain/url field, so the CLI cannot
# derive one; combine the slug with your hub-frontend host.
mio hubs create --name "My Hub" --slug my-hub
mio hubs create --name "My Hub" --slug my-hub --published # publish immediately
# Author the presentation layer at create time (opaque JSONB blobs; inline JSON or @file).
# --logo-url and --favicon-url merge into --branding-json rather than replacing it.
mio hubs create --name "My Hub" --slug my-hub \
--logo-url https://cdn.example.com/logo.png \
--favicon-url https://cdn.example.com/favicon.ico \
--branding-json '{"primary":"#6747E3","heading_font_size":32}' \
--navigation-json @nav.json \
--settings-json '{"policies":{"enabled":true}}' \
--meta-json '{"discussions":{"enabled":true}}'
# retrieve surfaces derived registration_enabled + published booleans.
# The hub id is an OPTIONAL positional on every hubs verb except `delete`:
# positional > --hub > current_hub in config. All three of these are equivalent.
mio hubs retrieve <hub-id>
mio hubs retrieve --hub <hub-id>
mio hubs retrieve # uses current_hub from config
# Enable member self-registration (settings.registration.enabled) — read-modify-write,
# sibling settings keys are preserved. Pass =false to disable explicitly.
mio hubs update <hub-id> --registration-enabled
mio hubs update <hub-id> --registration-enabled=false
# Set the favicon on an existing hub (branding.favicon_url; RMW-merges into branding).
mio hubs update <hub-id> --favicon-url https://cdn.example.com/favicon.ico
# DELETE a key from a blob. The *-json flags are merge-only (a null persists as
# literal null, {} is a no-op); --unset is the only real delete. The first dotted
# segment selects the blob (branding/settings/meta); nested paths and comma-lists
# and repeats are supported. Applied AFTER --*-json merges and scalar flags.
mio hubs update <hub-id> --unset settings.registration.enabled
mio hubs update <hub-id> --unset branding.favicon_url,settings.customCss
# Author the header/footer menu on an existing hub. Each item needs a "type"
# (url|page|playlist|discussions) — untyped items are rejected. Whole-blob replace.
mio hubs update <hub-id> --navigation-json '{"header":[{"type":"url","label":"Home","href":"/my-hub/","position":0}]}'
# Build a whole branded hub in ONE command from a template (see `mio hubs templates`).
# VERSION GATE: the --*-color palette flags and the machine-readable --output json
# result are NOT in v0.12.1 — on that binary they exit 2 with "unknown flag".
# The branding flags MERGE over the template's palette — a key you don't name keeps
# the template's value. --primary-color also fills header_color unless you give a
# header color yourself (--header-color, or a header_color key in --branding-json).
mio hubs scaffold --template community --name "Acme" --slug acme \
--primary-color '#B91C1C' --secondary-color '#F59E0B' --text-color '#0F172A' \
--logo-url https://cdn.example.com/logo.png --publish
# Already have a branding blob? Pass it whole; the scalar flags win over its keys.
mio hubs scaffold --template community --name "Acme" --slug acme \
--branding-json @branding.json --primary-color '#B91C1C'
# Preview first — the plan names the palette it would apply, and changes nothing.
mio hubs scaffold --template community --name "Acme" --slug acme --primary-color '#B91C1C' --dry-run
# Pages & page trees
# --privacy defaults to `members`: omit it and the page is behind the login wall.
# `home` is a RESERVED slug — use a real one and mark the homepage with --is-home.
mio pages create --hub <hub-id> --title "Welcome" --slug welcome --privacy public --is-home
# Discover the page-builder catalog. (There is no `pages catalog list`.)
mio pages catalog templates --page-type homepage
mio pages catalog section-types --writable-only
# Scaffold a tree, fill in the values, set the draft, publish.
mio pages catalog scaffold --template page-homepage > tree.json
mio pages catalog scaffold --template row --variant 3eq > cols.json # a section to splice in
mio pages tree set <page-id> --hub <hub-id> --file tree.json # first tree: --if-match defaults to 0
mio pages publish <page-id> --hub <hub-id> --if-match 1
# `tree get` hands back the BARE root node; `tree set --file` wants {"root": ...}.
# The round trip is a RE-wrap, not an unwrap.
V=$(mio pages tree get <page-id> --hub <hub-id> --jq .draft_version)
mio pages tree get <page-id> --hub <hub-id> --jq '{root: .tree}' > tree.json
# Products & prices (the product id is a positional argument, not a flag)
mio products list
mio products create --name "Pro Plan" --description "Full access"
mio products prices list <product-id>
# --amount, --currency and --type are required; --interval AND --interval-count are
# required when --type=recurring. The label flag is --name (there is no --nickname).
mio products prices create <product-id> --amount 4900 --currency usd \
--type recurring --interval month --interval-count 1 --name "Monthly"
mio products prices create <product-id> --amount 19900 --currency usd --type one_time
# Segments — preview who matches a condition tree (does not save)
mio segments search --conditions '{"version":1,"groups":[{"logic":"AND","conditions":[{"type":"email","operator":"contains","value":"@example.com"}]}]}'
mio segments members <segment-id>
segments search takes the full condition tree (matching the backend write shape), not a flat list. You can also read it from a file and paginate:
mio segments search --conditions @conditions.json --page-size 50 --page-after <cursor>
Global Flags
Every command inherits these flags.
| Flag |
Short |
Default |
Description |
--api-key |
|
env/keychain |
API key. Overrides MIO_API_KEY and the stored key. |
--anonymous |
|
false |
Ignore MIO_API_KEY and the stored key; send the request with no Authorization header and let the API answer (diagnostics). An explicit --api-key still wins. |
--team |
|
config |
Team ID for team-scoped resources. |
--hub |
|
config |
Hub ID for hub-scoped resources. |
--output |
-o |
json (piped) / table (TTY) |
Output format: json, table, plain. |
--jq |
|
|
Filter JSON output with a gojq expression. |
--raw |
|
false |
Emit the raw JSON:API envelope instead of the flattened resource. |
--yes |
-y |
false |
Skip confirmation prompts on destructive operations. |
--profile |
|
default |
Named config profile. |
--api-base |
|
config |
Override the API base URL (MIO_API_BASE_URL). |
Output and --jq Examples
By default, mio flattens JSON:API responses into plain resource objects. Use --output to control format:
# Pipe-friendly JSON (also the auto-default when piped)
mio contacts list --output json
# Aligned table for humans
mio contacts list --output table
# key=value pairs (great for grep)
mio contacts retrieve <id> --output plain
# Raw JSON:API envelope (meta, links, included)
mio contacts list --raw
# Extract a field with jq
mio contacts retrieve <id> --jq '.email'
# Pluck IDs from a list
mio products list --jq '.[].id'
# Chain into shell variable
# -o plain when CAPTURING a string id: --jq alone renders it JSON-quoted (MIO-2792).
HUB_ID=$(mio hubs list -o plain --jq '.[0].id')
mio content list --hub "$HUB_ID"
Exit Codes
Scripts and agents can branch on these stable codes.
| Code |
Constant |
Meaning |
0 |
— |
Success |
1 |
ExitGeneric |
Unexpected / generic error |
2 |
ExitUsage |
Bad flags, missing required argument, or rejected input (400/409/422) |
3 |
ExitAuth |
Missing or invalid credentials (HTTP 401/403) |
4 |
ExitNotFound |
Resource not found (HTTP 404) |
5 |
ExitNeedsConfir |
Destructive op in a non-interactive shell — pass --yes |
6 |
ExitRateLimited |
Rate limited (HTTP 429) |
7 |
ExitServer |
Upstream server error (HTTP 5xx) |
Command Reference (by resource)
| Resource |
Available actions |
login / logout |
Interactive auth |
register |
Create a new account (email + password, optional first/last name) and auto-login — interactive, or headless via --email/--password (or MIO_EMAIL/MIO_PASSWORD). Replaces any stored key. |
whoami |
Print resolved identity — user, team, hub, api-base, profile, key source |
config |
set, get, list |
api-keys |
create, list, retrieve, delete |
teams |
create, list, retrieve, update, delete, switch (server-side switch + updates local context); members list/add/remove |
users |
me, list, retrieve, update |
roles |
create, list, retrieve, update, delete; permissions list |
hubs |
create, list, retrieve, update, delete; navigation list/add/remove/reorder (edit the menu item-by-item); policies update; scaffold (one-command full-experience hub from a template live-fetched from the backend catalog; provenance-guarded resume on re-runs), templates (list the backend catalog's hub templates) |
contacts |
create, list, retrieve, update, delete, restore |
contact-attributes |
create/list/retrieve/update/delete defs; options sub-group; hub-config sub-group; values get/set |
tags |
create, list, retrieve, update, delete, assign, assign-bulk, remove |
segments |
create, list, retrieve, update, delete, search, members, count |
content |
create, list, retrieve, children, update, delete, restore, reorder |
pages |
create (--privacy public|members|private, default members), list, retrieve (add --tree for raw published node tree), update, delete, home, publish (requires --if-match <draft_version>); tree get/set (author a page's draft node-tree; set takes --file + optional --if-match — omit it for the first tree on a draft-less page, it defaults to 0); sections create/list/update/delete/reorder; catalog templates/section-types/scaffold (the page-builder catalog — no catalog list verb exists) |
media files |
list, retrieve, durable-url (non-expiring hub-scoped image URL for page trees), update, delete; ingest from the CLI: upload (create → presigned S3 PUT → finalize; auto-multipart), replace, finalize, transcode, register-synthetic; cards get/set, chapters get/set |
media folders |
list, create, retrieve, update, delete, move (--parent-id/--to-root) |
media search |
hybrid search over the team's transcripts (--query, --hub-id, --limit) |
media playlists |
list, create, retrieve, update, delete, set-cover (--file-id); items add/list/remove/reorder — populate a playlist with media files (--playlist-id, --file-id, --position) |
media hub-media |
publish, list, unpublish — publish standalone files to a hub (--file-id) |
media hub-playlists |
publish, list, unpublish — publish playlists to a hub (--playlist-id) |
media attachments |
list, show, update, delete |
media transcripts |
get, vtt, content, versions, edit, revert |
products |
create, list, retrieve, update, delete; prices create/list/retrieve/update/delete |
checkout |
orders list/retrieve; subscriptions list/retrieve/cancel; payments list/retrieve/refund; webhooks list/retrieve/replay; accounts list/retrieve/onboarding-link (onboarding-link is web/JWT-only — see Troubleshooting); stripe-sync import/import-status/adopt-product |
email |
drip-campaigns create/list/retrieve/update/delete/activate/pause (create/update now accept --enrollment-mode, --trigger-event-type, --segment-id, --segment-check-interval-minutes, --allow-reenrollment); steps create/list/update/delete; templates create/list/retrieve/update/delete/preview; config set/get/delete/test; enrollments list/exit; stats get |
access-rules |
rules create/list/retrieve/update/delete; overrides create/list/retrieve/update/delete |
activity |
contact, top-engaged |
Run mio <resource> --help or mio <resource> <action> --help for flag details on any command, or mio gen-docs --dir ./docs to generate the complete reference.
Config File
mio stores its config as TOML at ~/.config/mio/config.toml (or $XDG_CONFIG_HOME/mio/config.toml when XDG_CONFIG_HOME is set). It holds non-secret context only — current team/hub, API base, and named profiles. Your API key is never written here; it lives in the OS keychain (managed by mio login / mio logout).
Manage it with the config command (writable keys: current_team, current_hub, api_base). Values are validated at the setter — current_team/current_hub must be a UUID and api_base must be an http(s) URL, so a typo is rejected here (exit 2) instead of failing later on an unrelated command:
mio config set current_team <team-id> # must be a UUID
mio config set current_hub <hub-id> # must be a UUID
mio config set api_base <url> # must be an http(s) URL
mio config get current_team
mio config list # shows all values and the config file path
Multiple named profiles are supported via --profile.
Building a hub — and the silent-drop traps
mio hubs scaffold builds a whole branded hub in one command and is the recommended
starting point. When you customize it — or build a hub by hand — you are authoring
against the hub frontend's render contract, and the API validates a page tree's
structure, not its renderability. These all return 200 and then render
nothing, with no error:
- A node's text lives in
value at the TOP LEVEL of the node, not settings.value.
This is the most common one by a distance. (progress-ring is the only kind that
genuinely reads settings.value; quote's value is an object, not a string.)
settings.weight must be a number (700), never "bold".
- A section node must carry a non-empty
template (hero, carousel, row, …).
- An off-enum
surface.background.type renders a transparent row. The valid set
is tint · color (+token) · custom-color (+value) · gradient · image
(+url) · none — thumbnail is deliberately not one of them.
- The gradient config is a sibling of
background — surface.gradient, not
surface.background.gradient. Nesting it silently yields the default gradient.
- A
header/footer menu item without a type is dropped; a mobile tab whose
icon is outside the 8-name whitelist is dropped.
mio pages publish returns section_count — compare it against the number of
sections you authored. That, not the 200, is the "did it apply" signal.
mio pages tree set catches the numeric-weight and blank-template cases
client-side (exit 2, no HTTP). The rest are documented in full in the bundled agent
skill — mio skills print, or mio skills install to drop it into Claude Code /
Codex — and in AGENTS.md.
Troubleshooting
| Symptom |
Likely cause |
Fix |
Exit code 3 (ExitAuth) |
No key, or the key is invalid/expired |
Run mio login, or export MIO_API_KEY=mio_sk_live_…. Run mio whoami to confirm the resolved key and active team. |
Exit code 2 (ExitUsage) |
Bad flag, missing argument, unknown subcommand, or rejected input |
Check your flags against mio <resource> <action> --help. Flags are kebab-case (--first-name). |
Exit code 5 (ExitNeedsConfir) |
A destructive op (delete, cancel, refund, or a member-moderation verb: ban/unban/warn/soft-ban, moderation reports resolve) in a non-interactive shell |
Re-run with --yes / -y. |
Exit code 6 (ExitRateLimited) |
Too many requests (HTTP 429) |
Back off and retry. |
Exit code 7 (ExitServer) |
Upstream 5xx |
Transient — retry; if it persists, the backend is down. |
| Calls fail against production |
Wrong API base or stale/revoked key |
Verify with mio whoami. Point at a different backend with --api-base <url> or MIO_API_BASE_URL. |
checkout accounts onboarding-link always errors (exit 3) |
The route is web/JWT-only — the backend rejects API-key principals so a leaked team API key can't attach an attacker's Stripe payout account, and this CLI authenticates exclusively via API keys |
Expected — connect a Stripe account through the member.dev dashboard instead of the CLI. |
Errors are rendered TTY-aware: on an interactive terminal you get a friendly one-line Error: <detail> plus a dimmed (exit code N); when stderr is piped or otherwise non-interactive you get the machine-readable JSON:API errors array with the exit code echoed in meta.exit_code. Either way the process exit code is the same, so scripts can branch on the exit code (and, off a TTY, on the body).
In that envelope — from the release after v0.12.1; on v0.12.1 and earlier status is reconstructed from the exit code, so a 403 reads "401" and a 409 or 422 reads "400" (MIO-2656) — errors[].status is the real HTTP status the API returned — a 403 reads "403", a 409 "409", a 422 "422", and so on. Exit codes stay deliberately coarse (401 and 403 both exit 3; 400, 409 and 422 all exit 2), so use status when you need the precise distinction and meta.exit_code when the coarse class is enough. For failures that never reached the network (bad flag, unreadable file, no API key) there is no HTTP status, and status falls back to the exit code's class.
See Also
- AGENTS.md — guide for AI coding agents (non-interactive auth, exit codes, command table)
- llms.txt — machine-readable one-line-per-command index
mio --help — top-level help
mio <resource> --help — resource-level help
mio gen-docs --dir ./docs — generate the full Markdown reference