---
title: "For AI agents"
description: "Drive `openemail` from Claude Code, Codex or a CI job: unattended sign-in, help as data, dry runs, missing scopes and verification codes."
url: "https://openemail.uk/docs/cli/agents"
area: "CLI"
category: "Using it"
---

# 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 agents
openemail 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 --json
openemail 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**

- `schemaVersion` (number): Moves when a field changes meaning
- `cli, version` (string): Always `openemail`, and the version that printed it
- `path` (string[]): The command asked about, empty for the root
- `commands` (object[]): Every top-level command for the root, otherwise the one asked about, each with its subcommands
- `globalFlags` (object[]): The flags every command takes, in the same shape as a command’s flags
- `subcommandAliases` (object): Each shared alias, such as `ls` or `rm`, and the verbs it stands for
- `exitCodes` (object[]): Every exit code as `{ code, name, meaning }`

**A command**

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

**A resource**

- `namespace` (string): The SDK namespace, such as `domains`
- `sdkMethod` (string): The SDK method, such as `openemail.domains.delete`
- `sdkMethodAll` (string | null): For a list, the `listAll` method that `--all --json` walks
- `httpMethod, httpPath` (string): The REST call, such as `DELETE` and `/domains/{id}`
- `scopes` (string[]): The scopes the method needs
- `auth` (string): `apiKey`, or `none` and `inboxToken` for a method that sends no API key
- `returns` (object): `{ shape, type }`: the shape of the answer, such as `object` or `page`, and its SDK type
- `paginates` (boolean): 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-run
openemail send --from you@acme.com --to ada@example.com --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": "you@acme.com",
      "to": [
        "ada@example.com"
      ],
      "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 verify
openemail 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 --json
openemail 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 you@acme.com --to ada@example.com --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 --json
openemail domains create --domain example.com --json
```

**Check what the credential may do**

```
openemail whoami --json | jq '.scopes'
```
