Mail rules
Mail rules let you describe what a label means in plain English instead of building a filter out of match conditions. An LLM classifies every inbound message against your rule prompts, and each match can skip the inbox, carry a severity that steers triage, or kick off an agent, all without you touching a regex.
Defining a rule
Section titled “Defining a rule”Rules live on the Rules tab of the Mail page, a header tab next to the account tabs. Each rule is a card:
- Label: the tag applied on a match, shown as a chip with a running “matched N e-mails” counter.
- Prompt: the natural-language description the classifier judges messages against, for example “cold outreach pitching a tool or service we didn’t ask about.”
- Then: a Skip inbox / Keep in inbox toggle, an optional severity picked from the same levels triage uses (silent, normal, important, ultra), and an optional run agent picker naming an agent definition to invoke on a match.
Rules save immediately and broadcast a live-invalidate signal, so the Rules tab and any other open Mail window pick up the change without a refresh.
How matching works
Section titled “How matching works”A background consumer watches every new inbound message and classifies its subject, snippet, and body against every rule’s prompt in one pass. On a match it commits, in a single transaction:
- the label into
comms.mail_labelsand appended to the message’slabel_ids, INBOXremoved if the rule says to skip it (the thread moves to the Mail page’s Skipped the inbox section),- the rule’s match counter incremented,
- an idempotency stamp so redelivery is a no-op.
Sent mail never gets classified (a loop guard against labeling your own outgoing copies), and a newly created rule only classifies mail from that point forward, it doesn’t replay history.
The classifier
Section titled “The classifier”Under a real model, one generateText call returns strict JSON naming every matched
label, parsed and validated before anything commits; a failing call redelivers rather than
silently dropping the message. Under PROVIDERS=fake, matching is deterministic: a rule
matches when any significant keyword from its prompt (four or more letters, common
stopwords excluded) appears in the message text, which makes rules easy to steer in tests
and demos without depending on a live model.
Gmail label write-back
Section titled “Gmail label write-back”Rule-applied labels write back to the real provider best-effort, after the local commit.
Because Gmail rejects label names and wants real label ids, each label name resolves
through comms.mail_labels; a label Subspace hasn’t seen before is created via Gmail’s
users.labels.create and its id persisted (or, if a race means it already exists, matched
by name from users.labels.list). If the write-back fails it’s logged and nothing rolls
back: local, name-keyed labels stay authoritative, and the next history sync reconciles
the provider’s ids back to names.
Severity overrides triage, deterministically
Section titled “Severity overrides triage, deterministically”A rule can carry a severity, and when it does, a match routes the message’s
mail-received event directly to that level instead of asking the LLM classifier to decide.
This is the per-label equivalent of a triage level’s fallback rules, and because the mail
rules consumer runs before triage, its decision short-circuits the classifier for that
message entirely (recorded as decidedBy: 'rule').
A few edge cases worth knowing:
- If an LLM classification somehow raced ahead of the rule, the rule’s severity overrides it, but only as an upgrade to a notifying route still fires the notification; a downgrade can’t unsend one that already went out.
- Leaving severity unset means the classifier decides, same as any message with no matching rule.
- A severity naming a level that’s since been renamed or deleted falls through to the classifier silently rather than erroring.
Agent triggers
Section titled “Agent triggers”A matched rule with an agent attached starts a run on that agent
definition once the transaction commits. The prompt template
receives {{label}} {{threadId}} {{from}} {{subject}} {{snippet}} {{bodyText}}; a prompt
with none of those placeholders still gets the mail context appended as a block, so the
agent always has the message in front of it.
Draft replies
Section titled “Draft replies”The seeded agents/mail-draft-reply definition demonstrates a grounded, gated reply flow:
it searches the knowledge base for relevant context, then calls
mail.proposeReply {threadId, body, sources?}, an ungated tool (proposing a draft is safe,
sending isn’t) that files a needs-confirm card in the task queue.
The thread view on the Mail page surfaces the pending card as a violet proposed reply
frame: the read-only draft body, a sources line where [[page]] tokens link back to the
grounding pages, and two actions.
- Approve & send resolves the task and dispatches the existing send path (correctly
threaded, with
Re:and the rightIn-Reply-To) before marking the card done. A failed send leaves the card pending so you can retry. - Dismiss closes the card without sending anything.
The raw mail.send tool itself is approval-gated (needsApproval: true), so an agent can
never send mail directly, only through this same pause-and-confirm path, and every send
call claims an idempotency key before it fires so a crash-replayed call can’t send twice.