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.
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-keyto 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-inputorCI, or with no terminal attached, a value the CLI would have asked for stops with exit code2and names the flag to pass. - A browser sign-in needs a person to approve it, so an unattended
openemail loginstops with exit code2and codeunattendedbefore it registers anything, and points atopenemail login --with-token. openemail whoami --jsonshows the workspace, the kind of sign-in and itsscopes.
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.
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.
openemail domains delete <domain-id> --dry-runopenemail send --from [email protected] --to [email protected] --subject "Hi" --text "Hello" --dry-run --json{ "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
GETandHEAD, an MCP tool call, and the sign-in and sign-out requests ofloginandlogout. Token renewal anddocs askstill run. - The plan shows the method, the full URL, the headers with the
Authorizationvalue cut toBearer [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-tokenor 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:
readprints the thread, then the request that would mark it read. Pass--no-mark-readto leave the second one out. mcp serverefuses--dry-runwith exit code2, because its client decides what to send. Preview one tool call withopenemail mcp call <tool> --dry-runinstead.
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:
{"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,
nextsays to give the app more access in Account → Command line (openemail open cli, then Edit access), or to runopenemail login --forceand choose more access. A browser sign-in is never givenkeys:writeorkeys:manage, so for those it says to use an API key. - For an API key,
nextsays to use a key that has the scope. - A key from
--api-keyorOPENEMAIL_API_KEYis not checked ahead, and the API decides. When the API refuses a call for a missing scope, the error carries the samenext.
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.
openemail verifyopenemail verify --status --jsonverify --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:
{"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
openemail inbox --unread --limit 20 --jsonopenemail inbox --unread --json | jq -r '.items[].id'openemail read CAHk7pQ2x9LmZ4 --no-mark-read --jsonopenemail send --from [email protected] --to [email protected] --subject "Weekly report" --body-file report.md --idempotency-key weekly-report-39 --jsonopenemail domains create --domain example.com --dry-run --jsonopenemail domains create --domain example.com --jsonopenemail whoami --json | jq '.scopes'