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. For the full error-code catalog see the agent error reference; for the message state machine and verdict vocabulary see the message-lifecycle reference.

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:
{
  "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.

# 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.

CodeMeaningEmitter
0Success. 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
1Remote / 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
2Local usage / configuration error — bad flags or invalid local input (VALIDATION_ERROR / INVALID_OPTION / UNKNOWN_OPTION). No network call was made.binary
3Authentication 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
4send / 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
5send / 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
6send / 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
124Timeout. Owned by the PyPI rly launcher (subprocess timeout).launcher
127Bundled binary missing / not launchable.launcher
130A 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; the gate decision tree behind the exit-1 codes lives in the send-gates reference.

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 exitMeaning
sent0Delivered.
held_for_review0Accepted into governance, human-releasable — not a failure.
blocked4Terminal content rejection — edit or escalate; never blindly retry.
held_infrastructure5Transient infrastructure hold / indeterminate dispatch — retry later; content was never judged.
(unrecognized)6Fail-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.

# Fail the command on any non-delivered outcome
rly send --from support-bot --to [email protected] \
  --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.

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.

VariableEffect
REPLYLAYER_API_KEYThe API key, equivalent to a stored credential or --api-key.
REPLYLAYER_API_URLOverride-only. Defaults to the production host; set it only to target a non-production API. --api-url wins over it.
REPLYLAYER_MAILBOXDefault mailbox for fresh send --from / inbox list / inbox wait / draft create. An explicit flag wins; not consulted in --thread mode.
REPLYLAYER_AUTH_EXIT_CODESet 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 1300) 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:

ResultExitJSON
A message arrived0{ "message": { ... } }
Polled cleanly, no mail before the deadline0{ "message": null }
The endpoint was unreachable for the entire windownon-zeroerror 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.

# 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 for the trust contract before you feed a body to a model.