MOLTED EMAIL

Approval Queues

Review, approve, reject, edit, and escalate held messages using the override queue API.

When a mailbox is set to autonomy level L1 or L2, outbound sends are held for review before delivery. The approval queue API lets your agents or human operators inspect held messages and take action on each one.

How queues work

After the policy engine approves a send, the mailbox's autonomy level is evaluated:

  • L3 - send executes immediately
  • L2 - first message to a new contact is held; subsequent messages to known contacts execute immediately
  • L1 - every send is held regardless of contact history

Held messages appear in the approval queue. Inbound messages flagged by the safety classifier (spam, phishing, malware, abuse, impersonation -- any verdict whose action is quarantine or reject) are also routed to needs_approval, tagged with metadata.safety_verdict/safety_action. There is no separate spam-verdict routing at classification time (#1644): the dedicated spam queue below is filled only by a manual report-spam, which itself refuses to move a thread already under review (needs_approval, high_risk, blocked_by_policy) into spam. A classifier false positive is corrected via POST /v1/agent/override/:threadId/not-spam, which accepts a spam thread or a spam-flagged needs_approval thread and refuses anything else. It answers 409 quarantine_verdict for a thread with a phishing, malware, abuse or impersonation verdict, whatever its queue: that is Release (approve with quarantine: true), which carries its own warning. Report-spam refuses a trashed thread (409); restore it first -- and for an agent, releases directly ONLY for a thread currently in spam that the classifier never held (a plain inbox thread manually reported as spam). Anything the classifier held is gated exactly like approve/edit/release below, including a needs_approval thread whose verdict happens to read spam: a thread's safety_verdict reflects only the LATEST classified message on it, so it can't be trusted to tell a corrected false positive apart from a thread that's still dangerous. See Inbound Email for full spam-triage documentation.

In the portal, a person reviews these on two pages: spam-verdict threads (and reported spam) on Spam (/app/spam), every other safety verdict (phishing, malware, abuse, impersonation, and prompt injections, which the classifier records as phishing) on Quarantine (/app/quarantine). Quarantine never renders the message as HTML or offers its links or attachments; a person can read a plain-text copy with links neutered, then Release (the human approve) or Delete (the human reject, to the trash). A Quarantine decision passes quarantine: true to approve/reject: it is refused (409) unless the thread is still a non-spam safety quarantine with no draft waiting for approval, and a release moves the verdict to metadata.released_safety_verdict/released_safety_action, so the agent still sees what the thread was flagged as. Until a person releases it, an agent key without operator scope gets a placeholder for every inbound message on the thread and can't list, download or send its attachments (see Inbound); release opens them (attachments the file scan blocked stay blocked), delete never does. A message quarantined for prompt injection stays quarantined after release: the agent gets the conversation with a placeholder for that message's text.

Home (/app) shows the same mail inline: every conversation is labelled (spam, held as phishing or another verdict, or in the trash), a filter shows All, Clean, Spam, Quarantine or Trash, and a person can mark Not spam, Release (with the same warning) or Restore a trashed conversation right on the row. Restore puts a conversation back where it was: deleted spam back in Spam, a deleted quarantine back in Quarantine, still gated. Like the pages, all of it is for a person: an agent-held session can't load it. Any approve that takes a thread out of review (Release, a plain approve, an approved decision) moves its spam or safety verdict to released_safety_verdict, so nothing puts a thread a person approved back in review.

Queue types

Four queues carry held items; needs_approval is split across two count fields so dashboards can show inbound vs outbound pressure separately.

Queue (for drill-down)Count fieldsContents
needs_approvalneeds_approval_outbound, needs_approval_inboundOutbound sends held by autonomy level + inbound messages quarantined/rejected by the safety classifier (any verdict, including spam). Filter with ?direction=inbound|outbound.
blocked_by_policyblocked_by_policySends blocked hard by the policy engine
high_riskhigh_riskMessages flagged as high-risk
spamspamThreads manually reported spam (POST .../report-spam) -- never populated automatically by classification

The left column is the queue name you pass to GET /v1/agent/override/queues/:queue (or molted overrides queue <name>); the right column lists the field names you'll see in counts.

Endpoints

MethodPathDescription
GET/v1/agent/override/queues/countsGet counts per queue
GET/v1/agent/override/queues/:queueList items in a queue
GET/v1/agent/override/held-messagesList held messages
POST/v1/agent/override/held-messages/:messageId/releaseRelease a held message
POST/v1/agent/override/held-messages/:messageId/rejectReject a held message
POST/v1/agent/override/:threadId/approveApprove a thread for sending
POST/v1/agent/override/:threadId/rejectReject a thread
POST/v1/agent/override/:threadId/editEdit a held message before release
POST/v1/agent/override/:threadId/escalateEscalate to a human reviewer
POST/v1/agent/override/:threadId/not-spamMark a thread not spam (gated for an agent unless it's a plain, never-classifier-held spam-queue thread, see below)
POST/v1/agent/override/:threadId/report-spamMove a thread into the spam queue (refuses needs_approval/high_risk/blocked_by_policy; refused on a taken-over thread)
POST/v1/agent/override/:threadId/delete-spamResolve and trash a spam-queue thread, or a classifier-held spam-verdict thread (that case is gated like reject; refused on a taken-over thread)
GET/v1/agent/override/spam-feedback/statsTenant-wide spam feedback stats
GET/v1/agent/override/sender-reputation?domain=XReputation for a sender domain
GET/v1/agent/override/allowlistList sender allow-list entries (admin or scope-all-mailboxes key only)
DELETE/v1/agent/override/allowlist/:idRemove a sender allow-list entry (admin or scope-all-mailboxes key only)

All endpoints require Bearer authentication and operator API key scope; the allow-list additionally requires an admin or scope-all-mailboxes key -- a mailbox-scoped key gets 403 there, since the list isn't mailbox-scoped data. Report-spam, delete-spam on a spam-queue thread, the feedback/reputation reads, and the allow-list are not human decisions -- none of them loosen a control an agent couldn't already affect (though report-spam/delete-spam still refuse an agent on a taken-over thread). Not-spam is a partial exception (see below).

Decisions need a human

Reading the queues, escalating, report-spam, delete-spam (on a spam-queue thread), spam-feedback stats, sender-reputation, and the allow-list (list + remove) are all open to agents. Deciding is not: approve, reject, edit, release and held-message reject are human decisions, and so are PATCH /v1/send/approvals/:id, POST /v1/inbound/:id/approve, POST /v1/inbound/:id/reject, POST /v1/inbound/bulk-approve and PATCH /v1/templates/approvals/:id. A person makes them in the portal.

Inbound and template approvals in the portal

Two more kinds of decision show up as cards on the portal's Home (/app), next to held sends:

  • Inbound approvals. An incoming email the classifier routed to a person: an email it wasn't sure about (low confidence, or one that reads as more than one request), one that looks written to steer your agent, one the safety check flagged, and billing, legal or security mail your safety settings let through anyway. The card says why, and names the email's platform label in plain words (for example "Asked to stop receiving mail"). Approve records that a person signed off; for a request to stop receiving mail it also stops your emails to that sender. Reject records that a person said no: the email stays where it is and nothing runs. Either way the decision records who made it and when, and a rejected approval keeps the status rejected, so you can filter on it (GET /v1/inbound?status=rejected). Approving one at a time is the portal's job; approving every email with one platform label at once (POST /v1/inbound/bulk-approve?label=) is only available through the API. Home shows the ones routed in the last 30 days; an older one that is still pending stays listed (GET /v1/inbound?status=pending) and can still be decided.
  • Template approvals. A template with approval required waits here after it is published. An older version can't be approved once a newer one is live or waiting for review; reject it to clear it. Preview opens the version as written (its {{variables}} in place) in the email viewer, with its lint results above it. A lint error is a warning, not a block: the person approving can still approve it. Approve makes that version the template's live one; Reject keeps the template as it was.

Only a pending approval can be decided. Deciding one that was already decided the other way returns 409 with { "error": "already_decided", "status": "..." } (a rejected template approval can never be flipped to approved later); repeating the same decision is a no-op.

Not-spam splits the difference: for an agent, it releases directly ONLY for a thread currently in the spam queue that the classifier never held (a plain inbox thread a human or agent manually reported as spam). Anything the classifier itself held -- needs_approval, high_risk, blocked_by_policy, or any safety verdict at all, even one that currently reads spam -- is gated exactly like approve/edit/release, same 403 human_decision_required response below. A thread's safety_verdict reflects only the LATEST classified message on it (an earlier phishing message followed by a later spam-only one flips the thread's own verdict to spam while the phishing message is still there), so a per-verdict exemption would be bypassable; this drops it rather than analyze per message. Also refused (403) whenever a human has taken the thread over, regardless of verdict or gate mode. Humans are unaffected either way.

POST /v1/threads/restore (manage scope) is conditionally gated the same way: restoring a thread a human rejected, or that a spam-triage decision (delete-spam) removed, is a triage decision, gated exactly like approve/edit/release (kind thread). Otherwise a rejected phishing message a human reviewed, or a message a spam decision removed, could go straight back into the agent's inbox with no queue and no further review -- and delete-spam then restore would be a second way around the spam-triage gate above. An agent may restore, without a human decision, only a thread that an agent trashed itself, via the plain POST /v1/threads/trash action or a mailbox deletion; a thread a human trashed (by any of those same paths), rejected, or deleted as spam always needs a human. Re-trashing an already-trashed thread never changes its recorded reason -- an agent can't launder a human's rejection into a plain trash by calling trash on it again -- and archive never touches a trashed thread at all, so it can't be used to slip around the gate either. Every thread trashed before this gate shipped has no recorded reason and reads as needing a human too, even one an agent trashed itself: a real, one-time behaviour change on upgrade. Humans are unaffected. See Threads.

What an agent key gets depends on the account's mode:

ModeAgent callsWho has it
enforce403 human_decision_required with a decisionUrl to the portal page for that item. Nothing is decided.Every account created from now on.
warnThe call still works. The response carries a Molted-Deprecation: human-gate header, and the account owner gets one email a day listing the decisions agents made.Accounts that existed before this change, for 30 days. After that they behave as enforce.
offThe call works as before.Only when set explicitly.
403 Response
{
  "error": "human_decision_required",
  "message": "This decision needs a human. Share the decisionUrl with a person on your team; they can approve or reject it in the Molted portal.",
  "decisionUrl": "https://molted.email/app/decide/thread/THREAD_ID",
  "kind": "thread",
  "id": "THREAD_ID",
  "reason": "enforce"
}

decisionUrl always has the form <portal>/app/decide/<kind>/<id>, where kind is send (a send approval), thread (a thread in a review queue), held (a held message), inbound (an inbound routing action), inbound-intent (bulk approval by intent) or template (a template approval).

What your agent should do: stop, don't retry, and hand the decisionUrl to a person (chat, ticket or email). To pass a whole conversation to a person, use a handoff.

CLI and MCP never call these endpoints on your behalf, whatever the account's mode. molted overrides approve, edit, release and reject-held (alias: reject) build the same decisionUrl locally and print it, unconditionally: they don't try the API first. molted overrides reject-send is the one command that still calls through, for the own-withdrawal exception below. The MCP server has no approve tool; call request_decision with the item's kind and id (and an optional note) to get the link, and every other MCP tool surfaces decisionUrl in its result if it happens to hit a gated route. See CLI Quickstart and MCP Server for the full command/tool reference. This is the sanctioned exception to "every customer feature goes through the mailbox API" (D3): decisions and loosening controls stay in the API/portal and need a human; the CLI and MCP only ever hand back a link.

Two rules apply in every mode:

  • An agent may withdraw its own send. PATCH /v1/send/approvals/:id with {"decision": "rejected"} works when the pending send was created with the same API key (or a key linked to the same agent). Approving it is still a human decision.
  • A taken-over thread is off limits. While a person has taken a thread over, agents can't approve or release anything on it (403, reason: "thread_taken_over").

List send approvals

A plain, read-only list of this tenant's send approvals, scoped to the mailboxes the calling key can read. This is distinct from the override queues above: a send approval is the record PATCH /v1/send/approvals/:id decides, not an override-queue item.

GET https://api.molted.email/v1/send/approvals
curl
curl "https://api.molted.email/v1/send/approvals?tenantId=TENANT_ID&status=pending" \
  -H "Authorization: Bearer YOUR_API_KEY"

Query parameters

ParameterTypeDescription
tenantIdstringYour tenant ID
statusstringFilter by approval status
mailboxIdstringFilter to a specific mailbox (403 outside the calling key's scope)

CLI

molted approvals list
molted approvals list
molted approvals list --status pending
molted approvals list --mailbox mbx_abc123

This command is list-only: deciding an approval (approve or reject) is human-only (D3) and is not exposed here, matching molted overrides approve/edit/release/reject-held above. Withdrawing your own pending send still goes through molted overrides reject-send (D4).

Queue counts

Get the current item count for each queue. Use this as a lightweight check before listing queue contents.

GET https://api.molted.email/v1/agent/override/queues/counts
curl
curl "https://api.molted.email/v1/agent/override/queues/counts?tenantId=TENANT_ID" \
  -H "Authorization: Bearer YOUR_API_KEY"

Query parameters

ParameterTypeDescription
tenantIdstringYour tenant ID
mailboxIdstringScope counts to a specific mailbox

Response

Response
{
  "needs_approval_outbound": 3,
  "needs_approval_inbound": 1,
  "blocked_by_policy": 0,
  "high_risk": 0,
  "spam": 5
}

CLI

molted overrides counts
molted overrides counts

List queue items

List the items in a specific queue.

GET https://api.molted.email/v1/agent/override/queues/:queue
curl
curl "https://api.molted.email/v1/agent/override/queues/needs_approval?tenantId=TENANT_ID&direction=outbound&limit=25" \
  -H "Authorization: Bearer YOUR_API_KEY"

Path parameters

ParameterDescription
queueQueue name: needs_approval, blocked_by_policy, high_risk, spam. The needs_approval_outbound and needs_approval_inbound counts both roll up under the single needs_approval queue — use ?direction=inbound|outbound to filter.

Query parameters

ParameterTypeDescription
tenantIdstringYour tenant ID
mailboxIdstringFilter to a specific mailbox
directionstringFilter needs_approval by direction: inbound, outbound
limitintegerMax items (1-50, default 25)
offsetintegerPagination offset

CLI

molted overrides queue
molted overrides queue needs_approval --direction outbound --limit 10

List held messages

List messages that are held pending approval for a tenant or thread.

GET https://api.molted.email/v1/agent/override/held-messages
curl
curl "https://api.molted.email/v1/agent/override/held-messages?tenantId=TENANT_ID" \
  -H "Authorization: Bearer YOUR_API_KEY"

Query parameters

ParameterTypeDescription
tenantIdstringYour tenant ID
threadIdstringFilter to messages in a specific thread

A held message that is also quarantined for high prompt-injection risk has its body replaced with "[quarantined -- injection risk]", the same placeholder used on get a thread. One on a thread in a safety quarantine (phishing, malware, ...) gets "[quarantined -- flagged as unsafe, held until a person releases it]". Only a human release or reject (below) sees or acts on the real content.

CLI

molted overrides held-messages
molted overrides held-messages
molted overrides held-messages --thread-id thr_abc123

Release a held message

Send the message as originally requested.

POST https://api.molted.email/v1/agent/override/held-messages/:messageId/release
curl
curl -X POST "https://api.molted.email/v1/agent/override/held-messages/msg_abc123/release?tenantId=TENANT_ID" \
  -H "Authorization: Bearer YOUR_API_KEY"

CLI

Releasing is a human decision (D3). The CLI does not call this endpoint, it prints the decisionUrl and exits non-zero:

molted overrides release
molted overrides release msg_abc123
# → { "error": "human_decision_required", "decisionUrl": "https://molted.email/app/decide/held/msg_abc123", ... }

Reject a held message

Discard the message without sending it.

POST https://api.molted.email/v1/agent/override/held-messages/:messageId/reject
curl
curl -X POST "https://api.molted.email/v1/agent/override/held-messages/msg_abc123/reject?tenantId=TENANT_ID" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "reason": "Content not appropriate for this contact" }'

Request body

FieldTypeDescription
reasonstringOptional reason for rejection

CLI

Rejecting a held message is a human decision (D3). The CLI does not call this endpoint, it prints the decisionUrl and exits non-zero:

molted overrides reject-held
molted overrides reject-held msg_abc123
# alias: molted overrides reject msg_abc123
# → { "error": "human_decision_required", "decisionUrl": "https://molted.email/app/decide/held/msg_abc123", ... }

Approve a thread

Approve a held outbound send on a thread. The message is released for delivery.

POST https://api.molted.email/v1/agent/override/:threadId/approve
curl
curl -X POST "https://api.molted.email/v1/agent/override/thr_abc123/approve?tenantId=TENANT_ID" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "reason": "Content reviewed - looks good",
    "recipientEmail": "alice@example.com",
    "templateId": "welcome",
    "dedupeKey": "welcome-alice-approved-1",
    "payload": { "name": "Alice" }
  }'

Request body

FieldTypeRequiredDescription
reasonstringYesWhy you are approving this send
templateIdstringNoOverride the template for this send
recipientEmailstringNoOverride the recipient
dedupeKeystringNoOverride the deduplication key
payloadobjectNoOverride the template payload

CLI

Approving is a human decision (D3). The CLI does not call this endpoint, it prints the decisionUrl and exits non-zero:

molted overrides approve
molted overrides approve thr_abc123
# → { "error": "human_decision_required", "decisionUrl": "https://molted.email/app/decide/thread/thr_abc123", ... }

Reject a thread

Reject a held send on a thread. The message is discarded, and any pending send approval(s) linked to the thread are rejected along with it, so a rejected send can no longer be approved and dispatched afterward.

POST https://api.molted.email/v1/agent/override/:threadId/reject
curl
curl -X POST "https://api.molted.email/v1/agent/override/thr_abc123/reject?tenantId=TENANT_ID" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "reason": "Wrong template selected" }'

Request body

FieldTypeRequiredDescription
reasonstringYesWhy you are rejecting this send

CLI

This is the one override decision the CLI still makes for you (D4: withdrawing your own pending send). Rejecting anyone else's pending send returns 403 human_decision_required with a decisionUrl instead:

molted overrides reject-send
molted overrides reject-send thr_abc123 --reason "Wrong template"

Edit a held message

Modify a held message before releasing it. Use this to adjust routing or metadata without discarding the message entirely.

POST https://api.molted.email/v1/agent/override/:threadId/edit
curl
curl -X POST "https://api.molted.email/v1/agent/override/thr_abc123/edit?tenantId=TENANT_ID" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "reason": "Adjusted tone before sending",
    "metadata": { "reviewed_by": "supervisor-agent" }
  }'

Request body

FieldTypeRequiredDescription
reasonstringYesWhy you are editing the message
assignedAgentIdstringNoReassign the thread to a different agent
metadataobjectNoUpdate thread metadata

CLI

Editing is a human decision (D3). The CLI does not call this endpoint, it prints the decisionUrl and exits non-zero:

molted overrides edit
molted overrides edit thr_abc123
# → { "error": "human_decision_required", "decisionUrl": "https://molted.email/app/decide/thread/thr_abc123", ... }

Escalate a thread

Route a thread to a human reviewer or a named team when the agent cannot decide. Escalating does not reject anything: a pending send approval on the thread stays pending for that human to decide, and the thread stays visible in its original queue (and in queue counts) with status escalated.

POST https://api.molted.email/v1/agent/override/:threadId/escalate
curl
curl -X POST "https://api.molted.email/v1/agent/override/thr_abc123/escalate?tenantId=TENANT_ID" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "reason": "Complex legal question - needs human review",
    "assignTo": "legal-team"
  }'

Request body

FieldTypeRequiredDescription
reasonstringYesWhy you are escalating
assignTostringNoName or identifier of the team or person to escalate to

CLI

molted overrides escalate
molted overrides escalate thr_abc123 \
  --reason "Complex legal question" \
  --assign-to "legal-team"

Supervisor agent pattern

A common pattern for agents managing L1 or L2 mailboxes is a periodic supervisor loop:

# 1. Check queue depth
molted overrides counts

# 2. Review pending items (needs_approval covers both inbound and outbound)
molted overrides queue needs_approval --direction outbound --limit 25

# 3. For each item: share the decisionUrl with a human, withdraw your own
#    pending send, or escalate
molted overrides approve thr_abc123          # prints a decisionUrl for a human
molted overrides reject-send thr_def456 --reason "Off-brand"  # your own send: D4
molted overrides escalate thr_ghi789 --reason "Needs legal sign-off"

# 4. Repeat on interval

To integrate programmatically, poll the queues/counts endpoint and trigger your review logic when counts exceed a threshold. Use webhooks or events to get notified without polling.