Rules¶
rules.json is where you teach the assistant your inbox. Anything a rule can
decide is never sent to a model. It is read on every run, so edits take effect on
the next run without reinstalling anything.
rules.json is your personal file: make setup creates it from
rules.example.json, it is excluded from Git, and it is never included in a
shared ZIP. This page explains how rules behave; the field-by-field reference for
rules.json and classifier.json is Configuration.
Anatomy¶
{
"label_prefix": "AI",
"default_window": "newer_than:7d",
"sweep_guard": "-is:starred",
"tasks": {
"list": "My Tasks",
"default_due_business_days": 2,
"max_age_days": 14
},
"tags": [
{
"name": "Promo",
"description": "Retail promotions and discount advertising.",
"match": ["from:shop@deals.example"],
"on_tag": ["archive", "mark_read"],
"sweep": [],
"task": "never",
"on_task_done": []
}
]
}
Top level¶
label_prefix- Prefix of every Gmail label the assistant applies. With
AI, the tagMeeting/Invitebecomes the labelAI/Meeting/Invite. Labels are created on the first applied run that needs them, including parent labels so they nest. default_window- Gmail time filter used for rule matching and for pending mail when no
--windowis given. The defaultnewer_than:7dmeans older inbox mail is not touched by a normal run. sweep_guard- A Gmail query fragment added to every sweep. The default
-is:starredkeeps starred mail out of every sweep. It does not apply toon_tagoron_task_doneactions. tasks- Google Tasks settings: the list name, the default due date, and the maximum age of mail that gets an automatic task. See Tasks.
A tag¶
name- The tag name. The model may only answer with names that exist here.
description- What belongs in this tag. The model sees these descriptions; they are its only definition of your categories. Write them plainly.
match- A list of Gmail search queries. Anything the query matches in the inbox, within the window, gets this tag by rule, without a model.
on_tag- Actions applied at the moment a message gets this tag, whether by rule or by the classifier.
sweep- A list of
{"when": "<gmail query>", "do": [<actions>]}entries. On every run, inbox mail carrying this tag's label that also matcheswhen(and thesweep_guard) gets the actions. taskalways,optionalornever. See Tasks.on_task_done- Actions applied to the thread's inbox messages once its task is completed.
First match wins¶
Tags are evaluated in the order they appear in the file. For every tag, each
match query is searched as:
The exclusions cover every tag, so mail that already has an AI/* label is never
re-tagged, and a message matched by an earlier tag in this run is skipped by later
ones. Put specific rules before broad ones. If an early tag has a broad query (for
example a whole domain), it will capture mail you meant for a later tag.
Mail no rule matches becomes pending and goes to the classifier, unless you run
with --no-model, in which case it stays untagged in the inbox.
Actions¶
| Action | Effect in Gmail |
|---|---|
archive |
Removes the INBOX label. |
mark_read |
Removes the UNREAD label. |
star |
Adds the STARRED label. |
important |
Adds the IMPORTANT label. |
trash |
Moves the message to Gmail Trash. Opt-in only. |
An unknown action name stops the run with an error listing the allowed ones.
trash is opt-in
trash is never in the starter rules and is used only where you write it. Gmail
keeps trashed mail for 30 days, and trash operations are journaled, so
triage.py undo can restore them. Nothing is ever permanently deleted. A task
in a hand-written decisions file can narrow a tag's completion actions, but
never add trash; the classifier cannot set completion actions at all.
Worked example: the demo rules¶
The demo uses this ruleset (from
demo/demo_mailbox.py) on an invented mailbox. Descriptions are shortened here.
| Tag | match |
on_tag |
sweep |
task |
on_task_done |
|---|---|---|---|---|---|
| Action | always | archive |
|||
| Alerts | from:status@monitor.example |
subject:RESOLVED → archive, mark_read |
optional | ||
| GitHub-Admin | from:notifications@code.example |
older_than:1d → archive, mark_read |
optional | ||
| Meeting/Invite | from:calendar@harbor.example |
never | |||
| Meeting/Notes | from:notes-bot@meet.example |
optional | |||
| Shares | from:drive-shares@docs.example |
optional | |||
| Admin | from:receipts@travel.example OR from:billing@cloud.example OR from:payroll@harbor.example |
optional | |||
| Internal-Comms | from:all-hands@harbor.example |
optional | |||
| Newsletter | from:hello@weeklybyte.example OR from:digest@productnotes.example OR from:hello@designsystems.example |
older_than:1d → archive, mark_read |
never | ||
| Promo | from:shop@deals.example |
archive, mark_read | never | ||
| FYI | optional | ||||
| Backlog | never |
The same tags as JSON, for three of them:
[
{"name": "Alerts", "description": "Monitoring incidents, outages, security and expiry warnings.",
"match": ["from:status@monitor.example"], "on_tag": [],
"sweep": [{"when": "subject:RESOLVED", "do": ["archive", "mark_read"]}],
"task": "optional", "on_task_done": []},
{"name": "Promo", "description": "Retail promotions and discount advertising.",
"match": ["from:shop@deals.example"], "on_tag": ["archive", "mark_read"],
"sweep": [], "task": "never", "on_task_done": []},
{"name": "Action", "description": "A human requests a reply, decision, approval, signature or commitment.",
"match": [], "on_tag": [], "sweep": [], "task": "always", "on_task_done": ["archive"]}
]
What happened in the demo run on the invented mailbox:
- Rules tagged 27 of the 37 messages in the window without a model: alerts, code-review notifications, invitations, the notes bot, document shares, receipts and invoices, internal announcements, known newsletters and promotions.
- Promotions were archived and marked read the moment they were tagged
(
on_tag). - Alerts stayed in the inbox while firing; the
[RESOLVED]notification was swept out. - Code-review notifications and newsletters older than a day were swept; recent ones stayed visible.
- A starred newsletter older than a day stayed in the inbox, because the
sweep_guard-is:starredexcludes it from every sweep. - Human mail (
Action,FYI, and the meeting notes a colleague wrote by hand) has nomatchqueries, so it went to the classifier. The notes from the bot were matched by rule; the notes from a person were classified asMeeting/Notesby the model. - A newsletter sender no rule knew (
events@summit.example, three messages) was classified asNewsletterand listed instate/summary.jsonas a rule candidate. Its older messages were then swept by theNewslettersweep in the same run. - A 12-day-old message was outside
newer_than:7dand was not touched. - Completing the
Actiontask "Approve the Q4 hiring plan" archived its thread on the next run (on_task_done: ["archive"]). The message stays unread, because onlyarchiveis configured.
Writing good rules¶
- Prefer exact senders (
from:alerts@vendor.example) over domains. Themail-setupskill only ever proposes exact senders. - Keep
ActionandFYIfree ofmatchqueries unless you are sure. Whether a person is asking you for something is exactly what rules cannot see. - Add cleanup (
on_tag,sweep) only for mail you would never want to miss in the inbox, and preview with a dry run first. - Use
older_than:in sweeps so recent notifications stay visible for a while. - Watch
rule_candidatesinstate/summary.json: any bulk sender that appeared at least three times in a pending batch is listed there, with the tag the model gave it. The runner never editsrules.jsonitself.
Changing rules.json invalidates the current pending batch and the
classification cache, because both are tied to a hash of the rules. A model-free
preview (make preview) shows the effect of a change.
Sender rules from mail-setup¶
The starter rules have no sender queries, so at first every message goes to the
classifier. The mail-setup skill proposes exact-sender rules for the automated
mail you receive regularly. How to run it, in Copilot or Claude Code, is in
Getting started.
This section adds what the scripts check along the way.
Discovery (make discover, python3 mail_setup.py discover) searches received
mail from the last 30 days, excluding sent, drafts, spam and trash, up to 200
messages. For each one it fetches only the From, Subject, Date, List-Id,
List-Unsubscribe and Precedence headers and Gmail's snippet. It groups messages by
exact sender address, keeps senders with at least three messages (up to 20 groups
by default), and sets aside up to three development and two validation examples
per group. A group is eligible only if every sampled message from that sender
has bulk or mailing-list headers and both example sets are complete. The result
goes to the private state/setup-discovery.json.
Preview (make rules-preview) writes the proposed ruleset to
state/setup-rules.preview.json and prints the changes; rules.json is not
touched.
Approval (python3 mail_setup.py approve --apply) saves the rules only if:
- the discovery is less than 24 hours old and was made with the current rules;
- every selected group is eligible;
- every development and validation example was given the selected tag;
- the tag is not
Action,FYIorBacklog; - the exact query does not already exist under a different tag.
It then backs up the previous rules to a private
state/rules.before-setup-<id>.json and saves rules.json with the new
from:address queries appended to each tag's match list. It cannot add cleanup
actions or domain-wide queries.
New rules inherit existing cleanup
On the starter rules no cleanup is enabled, so a new rule only tags mail. If
you have already added on_tag or sweep actions to a tag, a new sender query
under that tag inherits them on the next applied run. Review those before
approving.
The validator rejects only exact duplicates. A broader, earlier query (for example a domain-wide one you wrote yourself) can still capture the sender first, because the first match wins.