---
title: "Scripting"
description: "JSON output, streams, exit codes, environment variables, and running unattended or in CI."
url: "https://openemail.uk/docs/cli/scripting"
area: "CLI"
category: "Using it"
---

# Scripting

JSON output, streams, exit codes, environment variables, and running unattended or in CI.

## JSON output

With `--json`, stdout holds only JSON, indented by two spaces, while notes and progress stay on stderr, and nothing prompts. A list prints `{ items, hasMore, nextCursor }`, an API object prints as the API returned it, and a hand-written command prints the object its help describes.

**Terminal**

```
openemail whoami --json | jq -r .workspaceId
openemail emails list --status failed --json | jq -r ".items[].id"
```

An error goes to stderr as one line of JSON, and the exit code is the one a person would get:

**stderr**

```
{"error":{"type":"permission_error","code":"insufficient_scope","message":"This API key does not have the domains:write scope.","hint":"The credential is missing a scope this call needs. Use a key that has it, or sign in again with openemail login.","next":null,"status":403,"requestId":"req_7Hc2kQ","param":null,"docUrl":"https://openemail.uk/docs/api/errors#insufficient_scope","exitCode":4}}
```

| Field | What it holds |
| --- | --- |
| type | The API error type, or `cli_error`, `network_error` or `internal_error` for a failure inside the CLI |
| code | A stable code such as `insufficient_scope`, `not_signed_in` or `unknown_flag` |
| message | What went wrong, in one sentence |
| hint, next | What to try, and the command to run next, or `null` |
| status, requestId, param, docUrl | From the API when the error came from it, otherwise `null` |
| exitCode | The exit code the process ends with |

## Streams

Some output is a stream of JSON objects, one per line, so a pipeline can handle each item as it arrives:

- A resource list with `--all` when stdout is not a terminal, or with `--ndjson`. `--max <n>` stops after that many items.
- `openemail temp watch --json`, one line per new message.
- `openemail mcp serve`, one JSON-RPC message per line in each direction.

**Terminal**

```
openemail contacts list --all > contacts.ndjson
openemail emails list --status failed --all --max 500 | jq -r .id
```

## Exit codes

| Code | Meaning |
| --- | --- |
| 0 | Done |
| 1 | An unexpected failure, a server error, or a send that failed |
| 2 | A usage error: a bad argument, an unknown command or flag, a value or confirmation that could not be asked for, or an origin or path the CLI will not send a credential to |
| 3 | Not signed in, or the sign-in was refused, has expired or was signed out while the command ran |
| 4 | Not allowed: a missing scope or permission, a verification code that could not be asked for or is paused, or an API key where a browser sign-in is needed |
| 5 | Not found |
| 6 | A conflict with the current state |
| 7 | The input was invalid |
| 8 | Rate limited, or the AI allowance is spent |
| 9 | The network failed or timed out |
| 10 | Cancelled: you declined a confirmation or a prompt |
| 130, 143 | Stopped by Ctrl+C, or by SIGTERM |

## Environment variables

| Variable | What it does |
| --- | --- |
| OPENEMAIL_API_KEY | An API key to use instead of any saved profile |
| OPENEMAIL_PROFILE | The saved profile to use |
| OPENEMAIL_BASE_URL | The API origin for `OPENEMAIL_API_KEY`, `--api-key` and commands that send no credential. A saved sign-in only ever goes to the API it signed in to |
| OPENEMAIL_APP_URL | The web app origin, for sign-in, `open` and docs links |
| OPENEMAIL_CONFIG_DIR | Where profiles and inbox tokens are kept, `~/.openemail` unless set |
| OPENEMAIL_NO_UPDATE_CHECK | Never check npm for a newer release. `OPENEMAIL_DISABLE_UPDATE_NOTICE` does the same |
| NO_COLOR, FORCE_COLOR=0 | No colour |
| CI | Never prompt, never open a browser, never check for updates. Most CI services are recognised without it |
| VISUAL, EDITOR | The editor `send` and `reply` open for a body |

## Unattended runs

The CLI prompts only when stdin and stdout are both terminals, and none of `--json`, `--no-input` or CI applies. Otherwise:

- A missing required value stops with exit code `2` and names the flag to pass.
- A destructive command stops with `Refusing to run unattended. Pass --yes to confirm.` and exit code `2`, unless you pass `--yes`.
- A change that needs a verification code stops with exit code `4`, because nobody can type it. Use an API key, or run `openemail verify` first.

## In CI

Give the job an API key with only the scopes it needs, keep it in a secret, and let `OPENEMAIL_API_KEY` carry it. Nothing is saved, nothing prompts, and no update check runs.

**.github/workflows/deploy.yml**

```
- name: Tell the team
  env:
    OPENEMAIL_API_KEY: ${{ secrets.OPENEMAIL_API_KEY }}
  run: |
    npx -y @openemail/cli@0.0.1 send \
      --from deploys@acme.com \
      --to team@acme.com \
      --subject "Deployed ${{ github.sha }}" \
      --text "Build ${{ github.run_number }} is live." \
      --idempotency-key "deploy-${{ github.run_id }}"
```

**Wait for a sign-up email**

```
ADDRESS=$(npx -y @openemail/cli@0.0.1 temp new --ttl 15)
./signup-test.sh "$ADDRESS"
npx -y @openemail/cli@0.0.1 temp watch --first --json | jq -r .snippet
npx -y @openemail/cli@0.0.1 temp delete --yes
```

**Fail on failed sends**

```
failed=$(openemail emails list --status failed --json | jq ".items | length")
test "$failed" -eq 0
```

> Pass `--idempotency-key` on a send that a pipeline may retry, and derive it from what made the send necessary, such as a run id. Running the step again then returns the first send instead of mailing twice.
