Skip to content

Inboxes

An inbox is an org-owned address — an email address, a webhook URL, or both — that accepts inbound traffic from outside the platform, evaluates it against a versioned rule set, and routes what survives to an agent (as a new chat) or a process (as a new run). It is the front door for work that starts outside AgentDepot: a customer emailing a support address, a SaaS firing a webhook, a form submission.

Everything that arrives is logged, whatever happens to it next. Nothing here runs on its own — an inbox with channels but no deployed revision quarantines every message it receives, because no route ever matches.

Every rule belongs to exactly one stage, and stage order is fixed and structural — it is a property of the rule kind, not something you can reorder:

transform -> gate -> enrich -> route
  • transform mutates the message (e.g. stripping attachments) and can never reject it.
  • gate looks at the message and returns a verdict: continue, drop, or quarantine. Free gates always run before the paid one, so the classifier never has to look at what a free rule would already have dropped.
  • enrich annotates the message without judging it — a failed enrichment never blocks anything, because enrich has no verdict to return.
  • route decides where an accepted message goes; the first match wins, with an optional default that always sorts last.

Within one stage, a rule’s order field controls its position relative to other rules in that same stage — that is the only ordering you control. You cannot move a rule to a different stage, and you cannot make a route rule run before a gate rule. This is deliberate: it is what stops a paid classifier from being dragged in front of a free header check, and what stops a route from claiming a message before the gates had a chance to reject it.

Every message gets exactly one of:

  • accepted — passed every gate and matched a route; a chat or process run was created for it.
  • dropped — a gate rejected it with high confidence (e.g. it matched a sender denylist). Never bounced — a bounce to an autoresponder is exactly what creates a mail loop, so a drop is silent by design.
  • quarantined — something was uncertain (a classifier timed out, a provider errored, the message hit a rate limit, or a gate’s action was set to quarantine instead of drop) and a human needs to look at it.

There is no fourth verdict, and dropped mail is never retried automatically.

The routed agent’s chat gets the message body as its first user turn, every attachment as a chat file, and one more file holding the original email: email.html when the message had an HTML part, email.txt otherwise. That file is stored exactly as it arrived, with an RFC822 header block prepended — From, To, Subject, Date, Message-ID, In-Reply-To, References.

Reach it with list_files / read_file. It is where to look when the agent needs the layout, tables or links of the mail the sender actually composed, or its Message-ID — e.g. to derive a deterministic id for whatever the message creates downstream, so a re-sent email cannot produce a second copy. A webhook message has no such file.

A message shows up in the log the moment it arrives, before the rules have run — gates and routing can spend tens of seconds in model calls, and a log that only showed finished work made that whole window look like nothing had happened. Those rows carry evaluation_state: "evaluating", and their verdict reads quarantined because nothing has been admitted yet.

That is a state, not a verdict, which is why it is a separate field. Two things follow when you read the log:

  • Filtering verdict="quarantined" returns settled messages only — the ones actually held for a human. In-progress messages are reached with evaluation_state="evaluating" instead.
  • If the platform dies mid-pipeline, the row stays evaluating and is genuinely held: it reports is_stranded: true once nothing is working on it any more, and running it again re-evaluates it against the live rules. A message that is merely still in flight refuses that, because evaluating one message twice at once could dispatch it twice.
KindStagePurpose
strip_attachmentstransformRemove attachments matching mime/filename/size/disposition predicates (e.g. inline signature logos).
header_gategate (free)Drop/quarantine on RFC 3834 auto-submitted / bulk-mail headers — the primary, zero-cost loop defence.
phrase_denylistgate (free)Drop/quarantine on a substring match in subject/body.
sender_denylistgate (free)Drop/quarantine by exact sender address or domain.
loop_guardgate (free)Last-resort circuit breaker on the References self-chain — backstop, not the primary loop defence.
injection_screengatePlatform-owned, auto-inserted, prompt-injection screen — see below. Not something you add; push_inbox_revision puts one in every definition automatically.
semantic_gategate (paid)A yes/no question answered by a Prompt via a real classifier call; see below.
prompt_extractenrich (paid)Annotate the message with structured fields extracted by a Prompt.
route_matchrouteRoute to an agent or process if the message’s fields match a condition.
route_semanticroute (paid)Route by meaning: a list of plain sentences, each with its own target, picked between by one model call. See below.
route_defaultrouteCatch-all route for anything no other route rule claimed. Always sorts last regardless of its order.

This is the complete vocabulary — every rule config is validated with no extra fields allowed, so a typo in a field name is refused rather than silently ignored.

header_gate’s two easy-to-get-wrong headers

Section titled “header_gate’s two easy-to-get-wrong headers”
  • Auto-Submitted is not a presence check. RFC 3834 defines Auto-Submitted: no as “a human sent this” — an explicit exemption, never a drop match. The default config already applies this exemption; if you write a custom header_gate, do the same, or you will drop mail from every sender scrupulous enough to set the header correctly.
  • Return-Path only matches the literal empty <> form (a bounce) — never a presence check either.

injection_screen — the platform-owned screen you don’t create, only toggle

Section titled “injection_screen — the platform-owned screen you don’t create, only toggle”

Every inbox is screened for prompt injection on every inbound message by default — the reason: intake is the platform’s untrusted-input door. Rather than a hidden setting, this is a real rule row (injection_screen) that push_inbox_revision inserts into a definition automatically if it isn’t already there, enabled. You can push a definition naming it explicitly with "enabled": false to switch screening off for that inbox; a later push that omits the rule entirely reinstates a fresh, enabled one rather than leaving the org silently unscreened. It cannot be reordered — its position in the gate stage (after every free deterministic gate, before any paid stage) is structural, not the rule’s order field. Disabling it costs nothing (no provider call), but it means inbound mail from unknown senders reaches the destination agent unscreened — the message-detail trace records that plainly (outcome: "skipped", detail.reason: "disabled") rather than silently.

Set question to one line of text — “Is this a new work request?” — and that is the whole prompt. The JSON schema the model must answer in is generated around it, so there is no output schema to define, no {{placeholder}} to fill, and no separate object to create. The message itself is appended for you as a fixed rendering: channel, sender, recipient, subject, attachment names (never their content), then the body truncated to body_chars.

Two consequences of that rendering are worth designing around. Attachments are names, mime types and sizes only — “does this carry a schedule file?” is answerable, “does the attached PDF cover week 38?” is not. And headers are absent, because the free header_gate rules already ran on them before this point; asking a paid model to re-read them buys nothing.

continue_when decides which answer lets a message through, so one question serves both polarities: “is this a work request?” continues on true, “is this spam?” on false. action decides whether the other answer drops or quarantines.

Rules written before the question moved inline name a library prompt in prompt_ref instead. Those keep working, and question wins when a rule carries both — but do not point a new gate at a library prompt, and never at an extraction prompt: it validates clean, then arrives as thousands of characters of “question”, overruns the answer cap, and quarantines everything. Enrich (prompt_extract) is the opposite case and deliberately keeps the library, because an extraction prompt has a real output schema and is reused.

For traffic where the destination depends on what the message means rather than on any word it contains. Each branch is one plain sentence describing a kind of message, plus the agent or process that kind goes to:

{
"id": "intents",
"name": "Accommodation intents",
"kind": "route_semantic",
"config": {
"branches": [
{"when": "This is a request to create a new accommodation",
"target": {"kind": "agent", "ref": "accommodation-create"}},
{"when": "This is a request to update an existing accommodation",
"target": {"kind": "agent", "ref": "accommodation-update"}},
{"when": "This is a request to cancel an accommodation",
"target": {"kind": "process", "ref": "accommodation-cancel"}}
]
}
}

All the branches go into one rule, and that rule costs one model call per message however many branches it holds. Do not split them across several route_semantic rules: you would pay per rule, and each call would see only its own sentence, so nothing could weigh “create” against “update” — which is usually the whole distinction you are trying to draw.

Four things to design around:

  • Write the branches to tell each other apart. The discriminating word has to be in the sentence: “create a new accommodation” against “update an existing one”. Sentences that nest (“This is about accommodations”) swallow the ones beside them.
  • Order breaks ties. The model returns one branch. If two could fit, it picks one and nothing arbitrates, so keep them mutually exclusive and put the narrower one first when they are not.
  • “None of these” is a normal answer, not an error. It falls through to the next route rule, so give the inbox a route_default (or an inbox default handler) pointing at triage or a generalist. Without one, every message the branches do not describe is held for review.
  • The model never sees your agent names, only the numbered sentences, and it answers with a number. Inbound mail asking to be sent to a particular agent therefore cannot route itself there.

Same message rendering as semantic_gate — attachment names but not their contents, no headers, body truncated to body_chars — and the same optional per-rule model.

semantic_gate, prompt_extract, route_semantic and injection_screen each take an optional model — a catalog key from your own model list. Empty inherits the org’s intake_classifier_default_model (Usage & Limits → “Inbox Intake Classifier Model”), then the platform’s feature model. So a cheap deterministic gate and a subtle one can run on different models in the same inbox.

A key your catalog cannot serve is refused at validate and deploy time rather than at evaluation time. That is deliberate: an unroutable model raises nowhere a human is looking — it quarantines every message the rule reaches, which reads as a strict filter rather than a broken setting.

semantic_gate quarantines when it cannot classify

Section titled “semantic_gate quarantines when it cannot classify”

A semantic_gate rule calls a real classifier at evaluation time — it is not a stub, and it answers the rule’s question for real. It also never fails open: if the classifier is unbound, unroutable, times out, or the provider returns output in the wrong shape, the message is quarantined, never passed and never dropped, the same fail-closed default every other uncertain outcome in this pipeline gets. So a misconfigured or unroutable classifier shows up as every message reaching the gate being held for review — not as an error anywhere. If a semantic_gate rule is quarantining everything, check that the org’s intake classifier can actually run: either the org’s own model override (Usage & Limits → “Inbox Intake Classifier Model” in the app, or the intake_classifier_default_model org setting) or the platform’s own intake_classifier feature model needs to name a model the org can dispatch to.

Channels: email and webhook, code vs. public

Section titled “Channels: email and webhook, code vs. public”

An inbox may hold more than one channel of each kind.

  • Email — an address of the form in.{inbox}.{org}[.{code}]@.... A coded channel (has_code: true) renders a random code as part of the address, so it is effectively private — nothing without the code can guess it. A public channel (has_code: false, the default) has a codeless, guessable address (derivable from the org and inbox slugs); pair a public inbox with a real rate_limit_per_sender_per_hour and deterministic gates, since anyone can find and mail it. Toggling coded ↔ public is a metadata flip, never a token regeneration — the address is never briefly unreachable.
  • Webhook — an opaque URL a sender POSTs to, secured with an HMAC signature. The signing secret is never readable or rotatable through this MCP surface — that is a deliberate, UI-only, human-admin action. A webhook channel needs no payload configuration. The whole inbound body reaches the agent: it is serialized as the chat’s opening turn, and a payload over ~2 KB is additionally attached to the chat as a payload.json file the agent can read_file. Nothing is sampled out of it and nothing is dropped. Two fields are additionally read off conventional top-level names for display and for the gates — sender tries sender/from/email, and subject tries subject/title. Neither is required. This matters for gates: sender_denylist quarantines a message rather than passing it when the field it needs is unresolved, so a webhook whose payload carries no conventionally-named sender will pile up in quarantine rather than being silently filtered — check the message trace’s not_applicable outcome if that happens. A phrase_denylist gate reads the body, which is now the entire payload, so it matches on any value anywhere in it.

Versioned revisions: draft, validate, deploy, rollback

Section titled “Versioned revisions: draft, validate, deploy, rollback”

An inbox’s rule set is a revision — immutable once created, versioned, never renumbered:

  1. push_inbox_revision — parse a definition against the rule vocabulary and save it as a new draft. Purely syntactic: a reference to an agent or prompt that doesn’t exist yet is not caught here, so you can push a draft while still building out the org resources it will target.
  2. validate_inbox_revision — deep-validate a draft (or any revision): every route_match/route_default target, every route_semantic branch target, and every semantic_gate/prompt_extract prompt_ref must resolve in this org. Nothing is dispatched or mutated.
  3. deploy_inbox_revision — re-runs the same validation and, on success, makes the revision LIVE immediately: the inbox’s current revision swaps atomically and the previously-deployed revision is archived. A message already mid-evaluation when this runs finishes on the revision it started with — nothing changes underneath an in-flight message.
  4. rollback_inbox_revision — restores a previously-deployed (archived) revision as current. This is NOT a re-deploy: no new version number is created, and the revision is not re-validated (it was validated the first time it went live).

Revision status is one of draft / deployed / archived. Inspect the history with list_inbox_revisions, and one revision’s full definition with get_inbox_revision.

Every message that reaches an inbox is logged — list_inbox_messages (filterable by verdict, channel, and a sender/subject substring search) and get_inbox_message (adds the per-stage evaluation trace, in the order the evaluator actually ran it).

A row carries two cost figures, and they answer different questions. cost_credits is the intake gate calls alone — a per-message estimate, never a ledger figure, and never to be summed across messages. total_credits (or total_cost_usd, if the org bills its own provider keys — one key is returned, never both) is what the message cost in total: intake plus the chat it dispatched, that chat’s descendants, and any process runs they invoked. A question about what a message cost means the total: cost_credits reads 0.0 on plenty of messages whose agents burned real money. total_spend_chat_count says how many chats it covers, and total_spend_truncated marks the figure as a floor when the lineage walk hit its cap. For an exact org-wide total use the credit ledger, which nets the grants and expiries these per-message figures cannot see.

A revision can carry a reply block beside its rules, and it is how an inbox talks back:

{
"version": 1,
"rules": [ ... ],
"reply": {
"triggers": [
{"id": "ack", "on": "accepted", "body": "Thanks — we're on {{subject}}."},
{"id": "done", "on": "work_done", "body": "Finished:\n\n{{summary}}"}
]
}
}

Six events, on two clocks. accepted, dropped and quarantined are decided by the rules, so a reply to one goes out as soon as the message is decided. work_done, work_failed and awaiting_human describe what the agent or process the message was routed to actually did, so they go out when that work stops moving — which for an agent that started other agents means once the whole chain has finished, without anything having to wait or be polled.

Pick the events you want; that is the timing choice. Acknowledge on accepted and answer on work_done is the usual pair.

  • to: "sender" (default) answers whoever wrote in; "fixed" with an address sends somewhere else, and is the only mode that works for a webhook message, which has no sender. Deploy validation refuses the impossible combination rather than letting it silently never fire.
  • body is markdown with {{subject}}, {{sender}}, {{inbox_name}}, {{verdict}}, {{rule}}, {{message_url}}, {{summary}} (work clock) and {{chat_url}}. Nothing is written by a model — the body is your text.
  • One enabled trigger per event.

Some replies are refused whatever the policy says, and the message records why: mail that identifies itself as automated (Auto-Submitted, Precedence: bulk, List-Id), a bounce, our own address, a message the loop guard or a throttle stopped, a chat the agent already answered, an event already replied to, a re-run, and anything past the hourly per-recipient ceiling. Answering rejected mail is how mail loops are built, so a dropped trigger is available but never on by default.

notify_on_drop: which rejections are worth answering

Section titled “notify_on_drop: which rejections are worth answering”

The dropped trigger is one message for every rule that rejects, and the rules do not all deserve the same answer: “this is not a work request” is worth telling somebody, and “your bulk mail matched no-reply@” is worth telling nobody. Every rule takes a notify_on_drop, read only when that rule is the one that decided the message:

valuewhat the rule’s drops do
defaultthe reply policy alone decides (the value every rule has until you change it)
neverthis rule’s drops answer nobody, and the message log says rule_opted_out
alwaysanswer even a sender the bulk headers call automated
{"id": "no-replys", "name": "Skip No-Replys", "kind": "phrase_denylist",
"notify_on_drop": "never",
"config": {"phrases": ["no-reply@"], "action": "drop"}}

always exists for one shape: a staffed mailbox that forwards. Its forwards carry the headers of whatever they forward, so a colleague who sends you a vendor newsletter arrives looking like the newsletter, and the automated sender check — which is a proxy, not a fact — stays silent at the one person who is waiting to hear back. always waives that check and nothing else: a bounce, our own address, the loop guard, a throttle trip and the outbound ceiling are not configurable and no value here reaches them.

It applies to dropped only. A quarantine is held for a human, and its reply is about the holding rather than about the rule.

get_inbox_message reports reply_state, reply_event, reply_to, reply_at and the full reply_log.

Configuring inboxes (creating one, wiring channels, pushing and deploying rule-set revisions) is an org-admin / MCP-client action — there is no in-chat tool that lets a running agent configure its own or another inbox. If you want an agent to react to what an inbox routes to it, that happens naturally once the inbox’s route_match/route_default targets that agent — the agent just receives a chat like any other; it does not need (and cannot get) inbox-configuration tools of its own.