# CLI machine interface

This is the machine contract for driving the `rly` CLI from an agent loop, CI job,
or script. It covers the `--json` output contract, the **canonical exit-code
table** (the single source of truth for exit semantics on every command), the
`block`-is-exit-`0` asymmetry that trips up naive scripts, the `CONFIRM_REQUIRED`
pattern for destructive verbs, and the `inbox wait` long-poll contract.

For installation, signature verification, and the human command reference, see
the [CLI reference](/docs/cli). For the full error-code catalog see the
[agent error reference](/agents/errors); for the message state machine and
verdict vocabulary see the [message-lifecycle reference](/agents/messages).

## The `--json` contract

Pass `--json` to any command to get machine-readable output instead of formatted
tables. The stream discipline is fixed:

- **Business output → stdout** as a single JSON value. Pipe it straight to `jq`.
- **Errors → stderr** as a single JSON object with a stable `code`:

```json
{
  "error": "human-readable message",
  "code": "STABLE_ERROR_CODE",
  "details": { "...": "context, when the endpoint supplies it" },
  "hint": "optional one-line guidance"
}
```

Branch on the `code` field, never on the `error` free text (it is not stable).
The `details` object carries the denial envelope on capability rejections and the
governed `email_effect` on strict send failures (both below). The `hint` key is
present only when the CLI has cause-specific guidance to add.

```bash
# Machine output on stdout
rly --json inbox list --mailbox support-bot | jq '.messages[0].subject'

# Error object on stderr
rly --json mailbox create existing-name 2>&1 | jq '.code'   # -> "CONFLICT"
```

## Exit codes (canonical)

**This table is the single canonical authority for the process exit code of every
command.** Per-command `--json` business shapes are documented in the human-facing
CLI reference, which defers to this table for exit semantics.

| Code | Meaning | Emitter |
|------|---------|---------|
| `0` | **Success.** The request was accepted and produced a message (or the read/diagnostic succeeded). A `send` / `reply` / `draft send` whose message was **created** but whose scanner verdict is `blocked` / `quarantined` / `pending_review` also exits `0` — branch on the JSON `status`. `doctor` exits `0` when no check has `error` severity. | binary |
| `1` | **Remote / API / runtime failure** — the catch-all. Includes every **gate-reject that produced no message** (`RATE_LIMITED`, `REPLY_LOOP_DETECTED`, `RECIPIENT_NOT_ON_ALLOWLIST`, `RECIPIENT_SUPPRESSED`, `RECIPIENT_UNDELIVERABLE`, `RECIPIENT_AGENT_CONTAINED`, the thread-mode `4xx` codes, `CONFIRM_REQUIRED`) and the `draft send` rejections `DRAFT_REJECTED_BY_RESCAN` / `DRAFT_ALREADY_SENT`. Auth failures also land here by default. Discriminate on the JSON `code`. | binary |
| `2` | **Local usage / configuration error** — bad flags or invalid local input (`VALIDATION_ERROR` / `INVALID_OPTION` / `UNKNOWN_OPTION`). No network call was made. | binary |
| `3` | **Authentication required / invalid — opt-in only.** By default auth failures exit `1`; set `REPLYLAYER_AUTH_EXIT_CODE=1` to map a missing key (`API_KEY_REQUIRED`) or a `401` to this distinct code so a script can trigger a re-login without matching every other `1`. | binary |
| `4` | **`send` / `reply` under `--strict` only:** the governed email-effect resolved `blocked` — a terminal content rejection. Edit or escalate; never blindly retry. Exit `0` without `--strict`. | binary |
| `5` | **`send` / `reply` under `--strict` only:** the governed email-effect resolved `held_infrastructure` — a transient infrastructure hold or indeterminate dispatch. Retry later; the content was never judged. Exit `0` without `--strict`. | binary |
| `6` | **`send` / `reply` under `--strict` only:** the governed email-effect resolved an **unrecognized** `effect_status`. Fail-closed so a scripted agent never marks the task done on an outcome this CLI build cannot interpret. Upgrade `rly`. | binary |
| `124` | Timeout. Owned by the PyPI `rly` launcher (subprocess timeout). | launcher |
| `127` | Bundled binary missing / not launchable. | launcher |
| `130` | A **declined interactive confirmation** (`USER_ABORTED`) — emitted by the binary. A `SIGINT` / `KeyboardInterrupt` — emitted by the launcher. Both share the conventional "user interrupted" code. | binary / launcher |

The CLI binary deliberately does **not** emit `124`, and does not emit `130` for a
signal interrupt — the PyPI `rly` launcher owns those subprocess outcomes so a
caller going through the launcher can tell a launcher timeout/interrupt apart from
a CLI-internal one. Codes `4`/`5`/`6` are reserved for the strict mapping and `3`
for auth; they are never reused.

## `block` is exit `0` — the asymmetry

The most important rule for an agent: **a scanner block is not a command failure.**

- **Exit `0` means "the request produced a message in some state."** Read the JSON
  `status` to learn the outcome — `sent`, `blocked`, `quarantined`, or
  `pending_review`. A `blocked` send *created* a message and recorded a policy
  decision; it is exit `0` with `status: "blocked"`.
- **Exit `≥1` means "the request produced no message."** Read the JSON `code` for
  the reason. A **gate-reject** (rate limit, reply-loop, allowlist, suppression,
  undeliverable recipient, agent containment, or a `draft send` rescan rejection)
  is exit `1` with a stable `code` and no message created.

There is no distinct exit code per business outcome. The `code` field (on failure)
and the `status` field (on success) are the machine discriminators. A script that
keys only on `$?` will misread a scanner block as success — which is correct for a
default send, but not what you want if you need to *act* on a block. Two options:

1. **Default mode:** always inspect `status` on an exit-`0` `send`/`reply`.
2. **Strict mode:** pass `--strict` so a non-delivered outcome exits non-zero (next
   section) and `$?` alone is decisive.

The full status/verdict vocabulary lives in the
[message-lifecycle reference](/agents/messages); the gate decision tree behind
the exit-`1` `code`s lives in the [send-gates reference](/agents/send-gates).

## Strict send/reply (`--strict`, codes `4`/`5`/`6`)

`rly send --strict` and `rly reply --strict` forward `Prefer: outcome=strict` to
the API, which maps a non-delivered governed email-effect to a non-2xx response
carrying the `email_effect` in `details`. The CLI then maps `email_effect.effect_status`
to a distinct exit code so a scripted agent can branch on `$?`:

| `effect_status` | `--strict` exit | Meaning |
|---|---|---|
| `sent` | `0` | Delivered. |
| `held_for_review` | `0` | Accepted into governance, human-releasable — not a failure. |
| `blocked` | `4` | Terminal content rejection — edit or escalate; **never blindly retry.** |
| `held_infrastructure` | `5` | Transient infrastructure hold / indeterminate dispatch — retry later; content was never judged. |
| *(unrecognized)* | `6` | Fail-closed for a value this CLI build does not know. Upgrade `rly`. |

These codes are emitted **only** under `--strict`; without it the same outcomes
are exit `0` and you branch on the JSON `status` / `email_effect.effect_status`.
The mapping is applied identically on a fresh send and on the idempotent-replay
path, so a replayed `blocked` send under `--strict` still exits `4` — never a
false `0`. `--strict` is a `send`/`reply` flag only; other commands ignore it.

The full governed email-effect model (the four outcomes, the `email_effect`
object, and the verdict→action table) is documented in the
[send-outcomes reference](/agents/send-outcomes).

```bash
# Fail the command on any non-delivered outcome
rly send --from support-bot --to user@example.com \
  --subject "Update" --body "Your ticket is resolved." --strict
case $? in
  0) echo "delivered or human-releasable hold" ;;
  4) echo "blocked — edit the content, do not retry as-is" ;;
  5) echo "infrastructure hold — safe to retry later" ;;
  6) echo "unknown outcome — upgrade rly before trusting the result" ;;
  *) echo "gate-reject or other failure — inspect the JSON code" ;;
esac
```

## `CONFIRM_REQUIRED` and non-interactive use

Destructive and egress-/credential-mutating verbs (`account delete`,
`mailbox delete`, `webhook create` / `update` / `delete` / `rotate-secret`,
`domain delete`, and similar) require an explicit confirmation. In an automated
context they behave as follows:

- **In `--json` mode without `--confirm`** the verb fails closed **before any
  prompt is created**, returning exit `1` with `code: "CONFIRM_REQUIRED"`. It
  cannot prompt on a non-TTY, so it never blocks waiting for input and never emits
  a bare prompt string onto stderr.
- **Interactively, a declined prompt** (anything other than a typed `yes`) exits
  `130` with `code: "USER_ABORTED"`.

To run these verbs unattended, pass `--confirm` (or `-y` where the command accepts
it). Secrets for self-hosted transport setup are read from a no-echo prompt or
piped stdin, never from argv — pipe them in for non-interactive runs.

```bash
rly --json account delete                # -> exit 1, code CONFIRM_REQUIRED
rly --json account delete --confirm      # proceeds, no prompt
```

## Non-interactive configuration

The CLI is driven entirely by flags and its own environment variables — no config
file is required and nothing prompts when the inputs are complete.

| Variable | Effect |
|---|---|
| `REPLYLAYER_API_KEY` | The API key, equivalent to a stored credential or `--api-key`. |
| `REPLYLAYER_API_URL` | **Override-only.** Defaults to the production host; set it only to target a non-production API. `--api-url` wins over it. |
| `REPLYLAYER_MAILBOX` | Default mailbox for fresh `send --from` / `inbox list` / `inbox wait` / `draft create`. An explicit flag wins; not consulted in `--thread` mode. |
| `REPLYLAYER_AUTH_EXIT_CODE` | Set to `1` to enable the distinct auth exit code `3` (opt-in; auth failures otherwise exit `1`). |

`rly config show` (no auth, no network) and `rly --json doctor` (bounded,
skippable with `--offline`) let a script verify credential source and connectivity
before it starts working. `config show` prints only the credential *source*, never
the key; proxy URLs that embed credentials are redacted.

## `inbox wait` long-poll semantics

`rly inbox wait --mailbox <m> --timeout <seconds>` (default `30`, range `1`–`300`)
long-polls for the next message. It reconnects transparently across the full
timeout budget, so a single call spans the whole window even though each
underlying poll returns sooner.

The exit code carries the contract an agent must respect:

| Result | Exit | JSON |
|---|---|---|
| A message arrived | `0` | `{ "message": { ... } }` |
| Polled cleanly, no mail before the deadline | `0` | `{ "message": null }` |
| The endpoint was unreachable for the **entire** window | non-zero | error object with a transient `code` |

**`{"message": null}` at exit `0` means "polled cleanly, inbox empty" — it is
never emitted for an outage.** Transient per-poll failures (`PARSE_ERROR`,
`NETWORK_ERROR`, `HTTP_502` / `HTTP_503` / `HTTP_504`, or any `5xx`) are retried
inside the window; only if **every** poll failed for the whole budget does the
command exit non-zero, re-throwing the last transient. Fatal per-poll errors
(`401`, `403`, `404`, validation) propagate immediately as exit `1`. So a
scripted agent can safely treat exit `0` + `null` as "keep waiting" and a non-zero
exit as "the endpoint is down — do not mistake it for an empty inbox."

Pass `--since <iso-8601>` to anchor to the **next** arrival and skip any existing
unread backlog (strict: only messages created after the cursor). Reading a message
does **not** advance read state, so anchor each monitoring loop with a fresh
`--since` cursor rather than relying on read markers.

```bash
# Agent monitoring loop: anchor, wait for the NEXT arrival, act, advance read state.
SINCE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
MSG=$(rly --json inbox wait --mailbox support-bot --since "$SINCE" --timeout 30)
if [ $? -ne 0 ]; then
  echo "endpoint unreachable — retry, do NOT treat as empty" >&2
elif echo "$MSG" | jq -e '.message != null' >/dev/null; then
  MSG_ID=$(echo "$MSG" | jq -r '.message.id')
  BODY=$(rly --json inbox read "$MSG_ID" | jq -r '.body.content')
  # ... process BODY as untrusted data, then reply ...
  rly inbox mark-read "$MSG_ID"
fi
```

Inbound message bodies are untrusted content — see the
[security-model reference](/agents/security-model) for the trust contract before
you feed a body to a model.

## Related

- [CLI reference](/docs/cli) — install, signature verification, human command reference.
- [Agent error reference](/agents/errors) — the full error-code catalog and denial envelope.
- [Message states & scan verdicts](/agents/messages) — message state machine and verdict vocabulary.
- [Send outcomes](/agents/send-outcomes) — the governed email-effect model.
- [Send gates](/agents/send-gates) — why a send was gate-rejected.
- [Tiers, quotas, and limits](/docs/limits) — sandbox caps, tiers, and quotas.
