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.
| 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
0means "the request produced a message in some state." Read the JSONstatusto learn the outcome —sent,blocked,quarantined, orpending_review. Ablockedsend created a message and recorded a policy decision; it is exit0withstatus: "blocked". - Exit
≥1means "the request produced no message." Read the JSONcodefor the reason. A gate-reject (rate limit, reply-loop, allowlist, suppression, undeliverable recipient, agent containment, or adraft sendrescan rejection) is exit1with a stablecodeand 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:
- Default mode: always inspect
statuson an exit-0send/reply. - Strict mode: pass
--strictso 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 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.
# 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" ;;
esacCONFIRM_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
--jsonmode without--confirmthe verb fails closed before any prompt is created, returning exit1withcode: "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) exits130withcode: "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 promptNon-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.
# 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"
fiInbound message bodies are untrusted content — see the security-model reference for the trust contract before you feed a body to a model.
Related
- CLI reference — install, signature verification, human command reference.
- Agent error reference — the full error-code catalog and denial envelope.
- Message states & scan verdicts — message state machine and verdict vocabulary.
- Send outcomes — the governed email-effect model.
- Send gates — why a send was gate-rejected.
- Tiers, quotas, and limits — sandbox caps, tiers, and quotas.