Automation contract
mxr is built to be scripted, but not every command supports every automation primitive. This page is the contract — the things you can rely on when piping mxr into a shell pipeline or an LLM agent.
When in doubt, the auto-generated CLI reference is the source of truth. The tables below summarise the patterns.
The four primitives
Section titled “The four primitives”--format <FORMAT>—table(default) for humans,json|jsonl|csv|idsfor machines. The generated CLI reference lists the exact values per command.--dry-run— preview affected ids/labels/threads without mutating provider state. Implemented by core mail mutations and selected lifecycle commands.--yes— skip confirmation prompts on commands that ask before mutating. Required when stdin is not a TTY.- stdin IDs — pass message IDs on stdin, one per line. Equivalent to listing them as positional args. Available on most mutations; not all.
Most mail-facing commands also accept --account <selector>. Use it
to restrict a read, search, list, draft, delivery, invite, saved-search
run, or mutation to one enabled account. Selectors accept account key,
email address, account id, or an unambiguous display name.
When omitted, commands keep their normal behavior. Search, list, read, and batch mutation surfaces operate across all enabled accounts by default. Unknown or ambiguous selectors fail before mxr sends the daemon request, and direct-ID commands validate that the selected account owns the target before acting.
Reads (commands that return data)
Section titled “Reads (commands that return data)”These are the common automation-oriented read surfaces. Exact formats live in the generated CLI reference. JSON shapes per command live in JSON output schemas.
| Command | Returns | Pipeable formats |
|---|---|---|
mxr search |
envelopes (matching messages) | json, jsonl, csv, ids, table |
mxr count |
scalar count | json, jsonl, table/text |
mxr cat |
full message body | json, jsonl, table (and the --view modes for body rendering) |
mxr thread |
thread + messages | json, jsonl, table |
mxr headers |
RFC 822 headers | json, jsonl, table |
mxr labels |
labels with counts | json, jsonl, csv, ids, table |
mxr saved list / mxr saved run <name> |
saved searches / matches | json, jsonl, csv, ids, table |
mxr drafts list |
drafts | json, jsonl, csv, ids, table |
mxr replies list |
reply-later queue | json, jsonl, table |
mxr snippets list |
snippets | json, jsonl, table |
mxr storage / mxr stale / mxr response-time / mxr contacts / mxr subscriptions / mxr wrapped |
analytics summaries | json, jsonl, csv, table |
mxr status |
daemon health | json, jsonl, table |
mxr sync --status |
sync state per account | json, jsonl, table |
mxr events / mxr history / mxr logs |
streaming event/log records | json, jsonl, csv, table |
mxr notify |
unread summary | json, jsonl, text |
mxr accounts |
runtime account inventory | json, jsonl, csv, ids, table |
mxr config show |
resolved config | json, jsonl, csv, ids, table |
mxr config get |
one config value | text |
mxr attachments list |
attachments for a message | table/text |
mxr export |
thread export | markdown, json, mbox, llm |
Account-scoped reads:
mxr search "is:unread" --account work --format idsmxr cat --search "from:alice@example.com" --account personal --firstmxr deliveries --account work --format jsonCreating drafts from an external program
Section titled “Creating drafts from an external program”mxr compose is the supported interface for a program that wants mxr to create
a draft — including HTML drafts with inline images. It reports structured output
under --format json/jsonl, so callers read a draft id rather than scraping
a human string:
mxr compose --account notto --to person@example.com \ --subject "Product Digest" --html-file message.html \ --inline notto-logo=logo.png --draft --format json{ "action": "save_draft", "draft_id": "draft_abc123", "account_id": "acct_...", "subject": "Product Digest", "to": ["person@example.com"], "content_kind": "html", "inline_count": 1, "attachment_count": 0, "local_message_id": null}--draft cannot send: it conflicts with --yes in the parser. Sending stays a
separate mxr send DRAFT_ID call.
This CLI contract is the supported integration path for external tools such as
mxr-mailmerge. The daemon socket exists but is not a
public contract — prefer the CLI unless you have measured a reason not to.
Mutations (destructive or stateful)
Section titled “Mutations (destructive or stateful)”Core mail mutations accept either explicit message IDs as positional args, --search QUERY for batch ops, or piped IDs on stdin. Use the generated CLI reference for non-mail lifecycle commands.
| Command | Targets | --dry-run |
--search |
stdin IDs |
|---|---|---|---|---|
mxr archive |
message(s) | ✓ | ✓ | ✓ |
mxr read-archive |
message(s) | ✓ | ✓ | ✓ |
mxr trash |
message(s) | ✓ | ✓ | ✓ |
mxr spam |
message(s) | ✓ | ✓ | ✓ |
mxr star / mxr unstar |
message(s) | ✓ | ✓ | ✓ |
mxr read / mxr unread |
message(s) | ✓ | ✓ | ✓ |
mxr label NAME / mxr unlabel NAME |
message(s) | ✓ | ✓ | ✓ |
mxr move LABEL |
message(s) | ✓ | ✓ | ✓ |
mxr snooze |
message(s) | ✓ | ✓ | ✓ |
mxr unsnooze |
message(s) or --all |
✓ | — | ✓ |
mxr unsubscribe |
message(s) | ✓ | ✓ | ✓ |
mxr undo MUTATION_ID |
one mutation | ✓ | — | — |
mxr send DRAFT_ID |
a draft | ✓ (--at conflicts) |
— | — |
mxr unsend DRAFT_ID |
a scheduled send | — | — | — |
mxr drafts delete DRAFT_ID |
one local draft | ✓ | — | — |
mxr drafts push DRAFT_ID |
one local draft + provider capability | ✓ | — | — |
mxr rules dry-run |
a rule | n/a (always dry-run) | — | — |
Account-scoped mutations use the same target set for preview and apply:
mxr archive --account work --search "from:noreply@example.com older_than:30d" --dry-runmxr archive --account work --search "from:noreply@example.com older_than:30d" --yesWhat’s not scriptable
Section titled “What’s not scriptable”mxr(no args) — launches the TUI. There is no--format jsonfor “the TUI.”mxr daemon— is a long-running process; structured output is onmxr status/mxr events/mxr logs.mxr compose/mxr reply/mxr reply-all/mxr forward— open$EDITORby default. For scripts, use--body,--body-stdin,--yes, and--dry-run, or use the HTTP bridge’s compose endpoints.mxr setup— interactive first-run account setup.mxr setup --demois legacy; usemxr demofor an isolated fake-provider profile.mxr accounts add— interactive wizard by default, but goes non-interactive when you pass enough flags AND setMXR_IMAP_PASSWORD/MXR_SMTP_PASSWORD/MXR_GMAIL_CLIENT_SECRETenv vars.
Safe agent loop
Section titled “Safe agent loop”For agents driving mutations, follow this pattern:
1. SEARCH — mxr search '<query>' --format json2. CONFIRM — surface the candidates to the user3. DRY-RUN — mxr <verb> --search '<query>' --dry-run4. APPROVE — user signs off on the diff5. MUTATE — mxr <verb> --search '<query>' --yes6. RECORD — capture the printed mutation_id; offer mxr undo within ~60s7. VERIFY — mxr history --category mutation --limit 1 --format jsonThe loop is the same whether the agent is claude, cursor, aider, or a hand-rolled curl-and-jq script. The contract above guarantees every step is composable.
When the user names an account, keep that selector on every step:
mxr search 'from:noreply@example.com older_than:30d' --account work --format jsonmxr archive --search 'from:noreply@example.com older_than:30d' --account work --dry-runmxr archive --search 'from:noreply@example.com older_than:30d' --account work --yesIdempotency
Section titled “Idempotency”mxr archive/read-archive/trash/spam/star/unstar/read/unread/label/unlabel/move/snooze/unsnoozeare idempotent — re-running with the same target IDs leaves state unchanged after the first call.mxr send DRAFT_IDis not idempotent — calling twice will send twice (the daemon schedules the second send). Always checkmxr drafts listfirst.mxr unsubscribemay hit a provider URL once; re-running on an already-unsubscribed message is harmless but emits no useful new state.mxr undo MUTATION_IDworks within a 60-second window; after that it returns an error.- An undoable mutation normally returns a
mutation_id. If that field is absent and the result has"undo_unavailable": true, the mutation succeeded but its undo entry could not be recorded — it can’t be reversed withmxr undo. A plain absentmutation_id(noundo_unavailable) just means the mutation isn’t undoable by design (e.g. star, label, move).
See also
Section titled “See also”- CLI reference — every command, every flag, auto-generated
- JSON output schemas — field names you can pipe into
jq - Recipes — copy-pasteable pipelines using these primitives
- For agents — patterns and safe defaults when an LLM is driving