Skip to the documentation
CLI

For AI agents

Drive `openemail` from Claude Code, Codex or a CI job: unattended sign-in, help as data, dry runs, missing scopes and verification codes.

The built-in guide

openemail agents prints a short guide in Markdown for an AI agent such as Claude Code or Codex, or for a script in CI: how to sign in without a person, read the output, find commands, change things safely and page through lists, what to do when a verification code or a scope is missing, and five recipes to copy. openemail agent is the same command.

Terminal
openemail agentsopenemail agents --json | jq -r '.recipes[].commands[]'

With --json the guide is one document with schemaVersion, title, intro, sections of { id, title, points }, exitCodes and recipes of { id, title, commands }. Rather than pasting these rules into every prompt, tell the agent once, in the instructions file your project already gives it, to run openemail agents before it uses the CLI.

Signing in without a person

  • Use an API key. Set OPENEMAIL_API_KEY, or pass --api-key to one command. Create it in Settings → API keys (openemail open api-keys) with only the scopes the agent needs. A key never opens a browser and never needs a verification code.
  • Or reuse a browser sign-in that a person made once on this machine with openemail login, and pick it with --profile <name>. The CLI renews its tokens by itself.
  • Nothing prompts without a terminal. Under --json, --no-input or CI, or with no terminal attached, a value the CLI would have asked for stops with exit code 2 and names the flag to pass.
  • A browser sign-in needs a person to approve it, so an unattended openemail login stops with exit code 2 and code unattended before it registers anything, and points at openemail login --with-token.
  • openemail whoami --json shows the workspace, the kind of sign-in and its scopes.

Reading the output

Pass --json to every command. stdout then holds exactly one JSON document, or one object per line with --ndjson, and progress stays on stderr. A failure prints one {"error":{...}} line on stderr: branch on the exit code and on its code, show next to a person, and never parse message, whose wording can change. The Scripting page lists every field and every exit code.

Commands as data

--help --json prints help as one JSON document, built from the same command registry the CLI parses with, so it always matches the version installed. It works on the root, a group or a command, and openemail help <command> --json prints the same.

Terminal
openemail send --help --jsonopenemail domains delete --help --json | jq '.commands[0] | {scopes, destructive}'openemail help domains --json | jq -r '.commands[0].subcommands[].command'openemail --help --json | jq -r '.commands[].command'

The document

schemaVersionnumber
Moves when a field changes meaning
cli, versionstring
Always `openemail`, and the version that printed it
pathstring[]
The command asked about, empty for the root
commandsobject[]
Every top-level command for the root, otherwise the one asked about, each with its subcommands
globalFlagsobject[]
The flags every command takes, in the same shape as a command’s flags
subcommandAliasesobject
Each shared alias, such as `ls` or `rm`, and the verbs it stands for
exitCodesobject[]
Every exit code as `{ code, name, meaning }`

A command

namestring
The last word of the command
commandstring
The whole command, such as `openemail domains delete`
path, aliasesstring[]
The words after `openemail` that reach it, and its other names
summary, descriptionstring
What it does, in one line and in full
usagestring[]
How to call it
categorystring | null
Its section in `openemail --help` for a top-level command, otherwise `null`
group, runnable, hiddenboolean
Whether it has subcommands, whether it runs on its own, and whether the help leaves it out
authstring
The sign-in it needs: `required`, `browser` for a browser sign-in only, `optional` or `none`
scopesstring[]
The API scopes every run of it needs
destructiveboolean
Whether it asks to confirm first, which `--yes` answers
argumentsobject[]
`name`, `description`, `required` and `variadic` of each argument
flagsobject[]
`name`, `short`, `kind`, `required`, `repeatable`, `choices`, `placeholder`, `description` and `hidden` of each flag
notes, examplesobject[]
The extra help blocks as `{ title, lines }`, and the examples as `{ command, note }`
resourceobject | null
For a resource command, the SDK method and the REST call behind it, otherwise `null`
subcommandsobject[]
The commands under a group, in the same shape

A resource

namespacestring
The SDK namespace, such as `domains`
sdkMethodstring
The SDK method, such as `openemail.domains.delete`
sdkMethodAllstring | null
For a list, the `listAll` method that `--all --json` walks
httpMethod, httpPathstring
The REST call, such as `DELETE` and `/domains/{id}`
scopesstring[]
The scopes the method needs
authstring
`apiKey`, or `none` and `inboxToken` for a method that sends no API key
returnsobject
`{ shape, type }`: the shape of the answer, such as `object` or `page`, and its SDK type
paginatesboolean
Whether it returns one page of a list

The whole tree is about a megabyte, almost all of it the 198 resource commands, so ask for the command you need, or filter the tree with jq. Text keeps its backticks and carries no colour codes, and hidden commands such as security are included with hidden set to true.

Dry runs

--dry-run works on every command but mcp serve. Reads run as usual, then the first request that would change something is printed instead of sent, and the command exits with code 0 without doing anything else. Confirmations are skipped, since nothing is sent, so an agent can see what a destructive command would do without passing --yes.

Terminal
openemail domains delete <domain-id> --dry-runopenemail send --from [email protected] --to [email protected] --subject "Hi" --text "Hello" --dry-run --json
stdout
{  "dryRun": true,  "request": {    "method": "POST",    "url": "https://api.openemail.uk/emails",    "headers": {      "accept": "application/json",      "authorization": "Bearer [redacted]",      "content-type": "application/json",      "idempotency-key": "58e6fb61-ad2e-401e-b141-7a0546c7c749",      "user-agent": "openemail-cli/0.0.1 openemail-sdk/0.0.5"    },    "body": {      "from": "[email protected]",      "to": [        "[email protected]"      ],      "subject": "Hi",      "text": "Hello"    },    "raw": null  }}
  • A change is any request but GET and HEAD, an MCP tool call, and the sign-in and sign-out requests of login and logout. Token renewal and docs ask still run.
  • The plan shows the method, the full URL, the headers with the Authorization value cut to Bearer [redacted], and the JSON body with secret fields, such as a Resend key, redacted. An upload shows only its size and content type.
  • A change that stays on this machine, such as profile use, login --with-token or forgetting a saved API key, prints {"dryRun":true,"local":{"action","profile"}} and saves nothing.
  • A command that prints what it read before its first change shows that first: read prints the thread, then the request that would mark it read. Pass --no-mark-read to leave the second one out.
  • mcp serve refuses --dry-run with exit code 2, because its client decides what to send. Preview one tool call with openemail mcp call <tool> --dry-run instead.

Missing scopes

Every command knows the API scopes it always needs, and its help lists them. When a saved sign-in lacks one, the command asks the API once for the current list, so access given on the website after the sign-in counts straight away. If the scope is still missing, it stops with exit code 4 and code insufficient_scope before it asks anything or sends a request:

stderr
{"error":{"type":"cli_error","code":"insufficient_scope","message":"This sign-in does not have the emails:send permission, which openemail send needs.","hint":null,"next":"Give this app more access in Account settings, Connected apps (openemail open apps, then Edit access), or run openemail login --force and choose more access.","status":null,"requestId":null,"param":null,"docUrl":null,"exitCode":4}}
  • For a browser sign-in, next says to give the app more access in Account → Command line (openemail open cli, then Edit access), or to run openemail login --force and choose more access. A browser sign-in is never given keys:write or keys:manage, so for those it says to use an API key.
  • For an API key, next says to use a key that has the scope.
  • A key from --api-key or OPENEMAIL_API_KEY is not checked ahead, and the API decides. When the API refuses a call for a missing scope, the error carries the same next.

Verification codes

An API key never needs a verification code. A browser sign-in needs one before a sensitive change, such as adding a webhook, creating a rule, changing a member or removing a domain, and an agent cannot type it. So before the agent runs, a person either runs openemail verify in a terminal with the same profile, or chooses Allow changes for 60 minutes on that sign-in in Account → Command line. Either covers the next 60 minutes.

Terminal
openemail verifyopenemail verify --status --json

verify --status --json tells the agent whether the profile is verified, in elevated, and until when, in elevatedUntil. Without a verification the change stops with exit code 4 and code step_up_required, and nothing is changed:

stderr
{"error":{"type":"cli_error","code":"step_up_required","message":"This action needs a verification code, and there is no interactive terminal to ask for one.","hint":null,"next":"Run openemail verify in an interactive terminal first, then run this again within 60 minutes. An API key never needs a code.","status":403,"requestId":"req_9Qm4tV","param":null,"docUrl":"https://openemail.uk/docs/api/errors#step_up_required","exitCode":4}}

Over MCP

An agent that speaks MCP can use the OpenEmail MCP server instead. openemail mcp config --client claude-code, or codex, cursor and the other clients it lists, prints the setup, and openemail mcp serve is a local bridge that reuses this CLI’s browser sign-in. API keys cannot reach the MCP server. The AI and MCP page has the details.

Recipes

Unread threads as JSON
openemail inbox --unread --limit 20 --jsonopenemail inbox --unread --json | jq -r '.items[].id'
Read a thread without marking it read
openemail read CAHk7pQ2x9LmZ4 --no-mark-read --json
Send from a file, safe to retry
openemail send --from [email protected] --to [email protected] --subject "Weekly report" --body-file report.md --idempotency-key weekly-report-39 --json
Add a domain after a dry run
openemail domains create --domain example.com --dry-run --jsonopenemail domains create --domain example.com --json
Check what the credential may do
openemail whoami --json | jq '.scopes'

Your inbox,
on your own terms.

Email infrastructure for businesses, AI, agents and personal email. Built for scale, privacy and control. Everything email should have had from day one.

OpenEmail

Email infrastructure for businesses, AI, agents and personal email. Built for scale, privacy and control. Everything email should have had from day one.

© 2026 OpenEmail. All rights reserved.