Message states & scan verdicts
Every message ReplyLayer stores — inbound mail your agent receives and outbound mail it sends — carries two independent status channels:
state— the delivery-lifecycle status. This is the authoritative signal for what actually happened to the message (readable, held, sent, bounced, deleted). Branch your control flow onstate.scan.verdict— the scanner's own decision about the content. It explains why a message might be held, but it is not guaranteed to equalstate(see Verdict is not delivery state).
This page is the single source for the state machine and the verdict vocabulary.
Related contracts live elsewhere: the send-gate decision tree,
the error-code catalog, the per-field trust taxonomy
(sender_authentication, agent_safety_context), the
webhook event catalog, and tier limits.
The mailbox model
A message always belongs to exactly one mailbox. Every mailbox-scoped path
accepts the mailbox's name or UUID interchangeably (GET /v1/mailboxes/support/messages
and GET /v1/mailboxes/<uuid>/messages are equivalent). Agent-role API keys are
bound to specific mailboxes; admin keys and dashboard sessions see all of them.
Scope masking differs by read shape so an out-of-scope key can't probe for siblings:
- Detail reads (
GET /v1/messages/:id) resolve then mask — an unbound or non-existent message returns404 NOT_FOUND. - Collection reads (
GET /v1/mailboxes/:id/messages,.../messages/wait,.../threads) check mailbox scope before any lookup — an unbound mailbox UUID returns403and an unbound mailbox name returns404.
The lifecycle
inbound: received → scanning → available (delivered to the agent)
↘ quarantined (releasable hold)
↘ pending_review (human-approval hold)
↘ blocked (terminal reject)
firewall_blocked → scanning → … (inbound firewall hold)
outbound: draft → dispatching → available → delivered (accepted, then confirmed)
↘ pending_review (review hold)
→ available → bounced (delivery failed)
↘ quarantined / blocked (scanner outcome)
any state → deleted (soft delete)State reference
| State | Direction | Meaning | Hold? |
|---|---|---|---|
draft | outbound | Composed but not yet sent. A send-time rescan rejection keeps the row in draft and updates the scan result in place — it never flips a draft straight to quarantined/blocked. | — |
received | inbound | Raw message stored, not yet processed. | — |
scanning | both | Being processed by the scanner. Transient. | — |
available | both | Inbound: safe view ready, delivered to the agent. Outbound: accepted for delivery. | — |
quarantined | both | Scanner flagged; held for review. Releasable. | yes |
pending_review | outbound (primary) | Held for an explicit human approve/deny — a mailbox review policy or a scanner require_human_approval decision. Releasable. | yes |
blocked | both | Scanner rejected, or a review was denied. Terminal (except soft delete). | — |
delivered | outbound | Delivery confirmed by the provider. | — |
bounced | outbound | Delivery failed (hard bounce). | — |
firewall_blocked | inbound | Held by the inbound sender firewall (sender blocklist or an allowlist miss). Releasable — release re-enters scanning. | yes |
dispatching | outbound | Transient phase between an optimistic-ack 202 and the terminal outcome of an asynchronous send. | — |
deleted | both | Soft-deleted. Terminal. Stored bytes are purged; the row is retained for audit. | — |
Transition table
A transition not listed here is rejected — the state machine is forward-only and enforced server-side.
| From | Allowed next states | Trigger |
|---|---|---|
draft | available, pending_review, deleted, dispatching | Sync send (clean → available; review hold → pending_review); async send ack → dispatching; delete. |
received | scanning | Processing begins. |
scanning | available, quarantined, pending_review, blocked | Scan verdict resolves. |
available | delivered, bounced, deleted | Outbound delivery confirmation; late/hard bounce; delete. |
quarantined | available, blocked, deleted | Release → available; confirm block → blocked; delete. |
pending_review | available, blocked, deleted | Approve → available (then dispatch); deny → blocked; delete. De-escalation to quarantined is intentionally illegal. |
blocked | deleted | Delete only. |
delivered | bounced, deleted | Late bounce; delete. |
bounced | deleted | Delete only. |
firewall_blocked | scanning, deleted | Firewall release re-enters scanning (then the normal scanning → transitions apply); delete. |
dispatching | available, pending_review, draft, deleted | Async dispatch resolves: provider-accepted → available; review hold → pending_review; scanner block/quarantine, provider rejection, or watchdog timeout rolls back to draft so you can edit and retry; delete. |
deleted | — | Terminal. |
Releasable holds vs terminal outcomes
Three states are releasable holds — a human (dashboard session or admin key) can move them onward:
| Hold state | Release action | Confirm/reject action |
|---|---|---|
quarantined | POST /v1/messages/:id/release → available (inbound readable / outbound sent) | POST /v1/messages/:id/block → blocked |
pending_review | POST /v1/messages/:id/approve → available then dispatch | POST /v1/messages/:id/deny → blocked |
firewall_blocked | POST /v1/messages/:id/firewall-release → scanning (evaluated afresh) | (report-and-block, see below) |
Contract notes an agent must respect:
- A
quarantinedrelease reuses the recorded decision — the scanner is not consulted again. It marks the messageavailable(inbound) or dispatches it (outbound) using the decision already on record. Only a firewall release re-entersscanningfor a fresh evaluation. POST /v1/messages/:id/releasereturns409if the message is notquarantined. Outbound release can still fail toblockedif a hard dispatch gate (recipient policy, deliverability) rejects the send.blocked,bounced, anddeletedare terminal — the only move out of them isdeleted(soft delete). There is no "unblock".POST /v1/messages/:id/reportis an inbound-only atomic report-and-block: it blocks a held message and adds the sender to the account blocklist in one transaction. An outbound target returns422 REPORT_OUTBOUND_UNSUPPORTED.
Inbound containment (firewall_blocked) only occurs when the sender firewall is
enforced for your mailbox. See the full error semantics in
/agents/errors and mutation endpoints in /agents/cli.
Scan verdict vocabulary
scan is a vendor-neutral summary rendered from the persisted scan result:
{ verdict, categories[], findings[] } (nullable when no scan ran).
scan.verdict is one of five values, in increasing severity:
scan.verdict | Underlying decision | Meaning | Releasable? |
|---|---|---|---|
clean | allow | No adverse finding. | — (deliverable) |
warning | allow_with_warning | Passive advisory (e.g. PII noticed). Delivered as-is; the warning is API/operator-facing, never injected into the email. | — (deliverable) |
review_required | require_human_approval | Content the scanner wants a human to approve. | hold |
quarantined | quarantine | Releasable scanner hold. | yes |
blocked | block | Terminal scanner reject. | no |
blocked strictly dominates quarantined. When findings of different
severities co-occur, the worst wins — a message with any block finding renders
scan.verdict: "blocked" even if it also has a quarantine-level finding. The
ordering is clean < warning < review_required < quarantined < blocked.
scan.categories[] and scan.findings[]
categories[] is a per-category rollup — one { category, decision } row per
non-allow category (empty [] on a clean summary). findings[] carries the
per-finding detail. Both use these vendor-neutral enums:
category: prompt_injection, function_call_risk, harmful_content,
liability_risk, pii, phishing_url, image_exfil, malware,
attachment_policy, mime_mismatch, attachment_type_mismatch, spam,
language_policy, recipient_policy, secret_detected, content_similarity,
delivery_warmup, scan_incomplete.
subtype (optional; scoped to its category):
prompt_injection→jailbreak,instruction_injectionharmful_content→toxicity,violence,sexual_content,hate_speech,harassment,self_harm,profanitysecret_detected→secret_value(a detected credential/secret, either direction),outbound_confidentiality_leak
A finding row can also carry: reason (anonymized, human-readable; may be
null), pii_type, attachment_index + attachment_filename (the attachment a
finding refers to, stamped at scan time so it reaches list/wait/send surfaces that
carry no attachments[] array), a failure_class discriminator, an optional
retryable boolean, and an optional agent_instructions[] array.
Infrastructure error vs content judgment
failure_class distinguishes a real content decision from an infrastructure
failure:
model_judgment— the scanner evaluated the content and reached a verdict. Fix the content and resend.inference_error— a scanner failed for an infrastructure reason (timeout, transport error, fallback exhaustion). The hold is not a content judgment. These findings carryretryable: trueand theirreasonis a static fallback string. The correct agent action is retry later, not edit-content.
agent_instructions[] is stable, machine-readable handling guidance computed
structurally from the typed finding fields (never by parsing reason). It is
vendor-free, and is omitted on allow findings and on inference_error findings
(an infrastructure error is not a content judgment). Treat these strings as
authoritative handling guidance.
delivery_warmup (a new-sender warm-up hold on the shared sending pool) is a
deliberate release-after-review hold, not a content judgment: it renders as a
releasable quarantine with a customer-safe reason and agent_instructions
naming the verify-your-own-domain escape hatch. It is never an inference_error
and is distinct from scan_incomplete.
Directional coverage. Scanning is asymmetric by design: the secrets scanner
runs outbound-only (exfiltration prevention), while inbound scanning targets
agent-input threats (prompt-injection / jailbreak). An inbound secret is the
sender's own data, not an exfil risk, so it is delivered clean.
Verdict is not delivery state
scan.verdict is the scanner-decision channel; state is the delivery
channel. They can diverge because a mailbox review policy can promote or demote
the scanner's decision:
- A clean scan can be held:
scan.verdict: "clean"andstate: "pending_review"when the mailbox holds every outbound send for human review. - On an account without the review-queue entitlement, a scanner
require_human_approvalis downgrade-safely demoted toquarantined(never silently allowed).
Always branch on state (or the send response status) for the authoritative
delivery/releasable signal, not on scan.verdict. When they diverge, the read and
send responses carry a hold_context object explaining the policy hold:
{ trigger_source: "mailbox_policy" | "scanner" | "both", summary_reasons: string[] },
and the wait/list/detail reads carry a review_trigger_source
("mailbox_policy" | "scanner" | "both" | null). hold_context is null when the
scan verdict already explains the hold (a real scanner block/quarantine) or when
the message was sent. Outbound reads additionally carry email_effect
({ effect_status, releasable, terminal, retryable }) — a one-field discriminator
over the send outcome; see /agents/send-gates.
The full decision tree for how a send resolves to a state is owned by /agents/send-gates.
Thread-level verdict
GET /v1/threads/:id and the thread list surface a worst-across-thread
scan summary that folds delivery state in: a blocked member controls the whole
thread verdict, a quarantined/firewall_blocked member reduces to quarantined,
and a clean-but-pending_review member reduces to review_required — so an older
held or blocking message is never hidden behind a newer clean reply.
Reading a message
GET /v1/messages/:id returns a safe view only — body is the sole body
field; raw MIME is never exposed. body is a projection:
{
"format": "text",
"content": "Hello, my order #1234 hasn't arrived...",
"char_count": 42,
"returned_char_count": 42,
"truncated": false
}- Long bodies are capped at the body-delivery cap (20,000 characters by
default);
body.truncatedis thentrue(no marker text is appended insidecontent). - Bearer (API-key) callers always get plaintext. Requesting
?body_format=htmlwith a Bearer key returns400 BODY_FORMAT_HTML_SESSION_ONLY; sanitized HTML is a dashboard-session affordance. - When the mailbox's
pii_modeisredacted, each detected PII span in the body is replaced with a<TYPE>tag (e.g.<EMAIL_ADDRESS>). Subject lines and attachment filenames are not redacted.
Inbound reads also carry agent_safety_context (an always-untrusted-content
behavioural layer — untrusted_content is true even on a clean scan) and
sender_authentication (a domain-authenticity signal). These belong to the trust
taxonomy — see /agents/security-model. An agent cannot
enable, grant, or loosen instruction trust for itself; any relaxation is
configured by a human via a dashboard session with fresh re-authentication.
Attachment rows carry a preview-status projection (preview_status ∈
pending | ready | blocked | failed | null, plus preview_kind,
preview_char_count, preview_page_count, preview_truncated,
preview_generated_at). Only fetch the preview when preview_status is ready.
See /agents/attachments.
Read state is explicit
GET /v1/messages/:id is side-effect-free — reading a message never changes
its read_at. Mark messages read deliberately:
POST /v1/messages/:id/read— single message. Eligible only for inbound, visible rows (direction='inbound'andstate NOT IN ('deleted', 'firewall_blocked')). Outbound, deleted, and firewall-blocked targets return200with the row's existingread_at(usuallynull) as a no-op. Idempotent: the first mark pins the timestamp, and a second call returns the same value.POST /v1/mailboxes/:id/threads/:thread_id/read— bulk-mark every visible inbound unread message in a thread. Returns{ thread_id, marked_count };marked_countis rows newly stamped this call (a fully-read thread returnsmarked_count: 0, not404).
body_preview
The list (GET /v1/mailboxes/:id/messages) and wait reads return a compact
summary row with body_preview — the first 200 characters of the message
body, plaintext (never HTML), and PII-redacted under the mailbox's pii_mode.
Each row also carries has_attachment (boolean), starred, read_at,
state, and mailbox_name, so an agent can triage without a detail read.
Waiting for new mail
GET /v1/mailboxes/:id/messages/wait is a long-poll for new messages. It holds
the connection open until a message arrives or the timeout elapses.
Query params:
timeout— seconds to hold, 1–30, default 30 when omitted. A numeric value> 30or< 1returns400 VALIDATION_ERROR(fail-loud — an out-of-range hold is not silently clamped). An omitted or non-numerictimeoutfalls back to 30 — a non-parseable value is treated as absent, not rejected.since=<iso-datetime>— switches the endpoint into monitoring-loop mode: it surfaces only messages withcreated_at > since, in chronological (ASC) order. Pass thecreated_atof the last returned message assinceon the next poll to advance the cursor. Withoutsince, the endpoint returns the single newest matching message.
On a hit, message is a compact summary row (the same shape as the list row —
id, direction, state, sender, recipient, subject, mailbox_name,
body_preview, starred, has_attachment, created_at, read_at), plus
review_trigger_source for held rows.
On timeout with no new message, the response is 200 with:
{ "message": null }Reconnect immediately to continue the loop. Because a mailbox with human review enabled can hold a
clean message, branch on message.state, not scan.verdict — a row can be
state: "pending_review" with scan.verdict: "clean".