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.
The four-stage pipeline
Section titled “The four-stage pipeline”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.
The three verdicts
Section titled “The three verdicts”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.
What an accepted email leaves on the chat
Section titled “What an accepted email leaves on the chat”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.
While it is still being decided
Section titled “While it is still being decided”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 withevaluation_state="evaluating"instead. - If the platform dies mid-pipeline, the row stays
evaluatingand is genuinely held: it reportsis_stranded: trueonce 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.
Rule kinds
Section titled “Rule kinds”| Kind | Stage | Purpose |
|---|---|---|
strip_attachments | transform | Remove attachments matching mime/filename/size/disposition predicates (e.g. inline signature logos). |
header_gate | gate (free) | Drop/quarantine on RFC 3834 auto-submitted / bulk-mail headers — the primary, zero-cost loop defence. |
phrase_denylist | gate (free) | Drop/quarantine on a substring match in subject/body. |
sender_denylist | gate (free) | Drop/quarantine by exact sender address or domain. |
loop_guard | gate (free) | Last-resort circuit breaker on the References self-chain — backstop, not the primary loop defence. |
injection_screen | gate | Platform-owned, auto-inserted, prompt-injection screen — see below. Not something you add; push_inbox_revision puts one in every definition automatically. |
semantic_gate | gate (paid) | A yes/no question answered by a Prompt via a real classifier call; see below. |
prompt_extract | enrich (paid) | Annotate the message with structured fields extracted by a Prompt. |
route_match | route | Route to an agent or process if the message’s fields match a condition. |
route_semantic | route (paid) | Route by meaning: a list of plain sentences, each with its own target, picked between by one model call. See below. |
route_default | route | Catch-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-Submittedis not a presence check. RFC 3834 definesAuto-Submitted: noas “a human sent this” — an explicit exemption, never a drop match. The default config already applies this exemption; if you write a customheader_gate, do the same, or you will drop mail from every sender scrupulous enough to set the header correctly.Return-Pathonly 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.
Writing a semantic_gate
Section titled “Writing a semantic_gate”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.
Writing a route_semantic
Section titled “Writing a route_semantic”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.
Choosing the model a paid rule runs on
Section titled “Choosing the model a paid rule runs on”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 realrate_limit_per_sender_per_hourand 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.jsonfile the agent canread_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 —sendertriessender/from/email, andsubjecttriessubject/title. Neither is required. This matters for gates:sender_denylistquarantines 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’snot_applicableoutcome if that happens. Aphrase_denylistgate 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:
push_inbox_revision— parse a definition against the rule vocabulary and save it as a newdraft. 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.validate_inbox_revision— deep-validate a draft (or any revision): everyroute_match/route_defaulttarget, everyroute_semanticbranch target, and everysemantic_gate/prompt_extractprompt_refmust resolve in this org. Nothing is dispatched or mutated.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.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.
Messages: the log
Section titled “Messages: the log”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.
Replies: answering the sender
Section titled “Replies: answering the sender”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 anaddresssends 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.bodyis 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:
| value | what the rule’s drops do |
|---|---|
default | the reply policy alone decides (the value every rule has until you change it) |
never | this rule’s drops answer nobody, and the message log says rule_opted_out |
always | answer 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.
Where inbox configuration lives
Section titled “Where inbox configuration lives”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.