The starting point: an inbox nobody sorts any more
A Gmail inbox opened years ago ends up as sediment: app notifications, newsletters never read, promotions, and somewhere in the middle, the few mails that matter. Gmail filters only solve part of the problem, because they assume you know the sender in advance.
On 7 October 2026, I commit the first version of an agent that runs in the cluster. It walks through the inbox from newest to oldest (the backfill), then keeps watching for new mail. For each mail, it has to answer a single question: keep, archive or bin?
The obvious temptation is to hand the mail to an LLM and ask it what to do. That is exactly what I did not do.
The stance: the model classifies, the code decides
A model deciding on its own to bin a mail is an irreversible action taken by a probabilistic component, on personal data, thousands of times. Even at 99% accuracy, 1% of 26,000 is 260 lost mails, and you do not know which ones.
The split I chose:
- the LLM classifies: it receives a batch of mails and returns, for each one, a category (personal, important, transactional, notification, newsletter, promotion, junk) and a confidence between 0 and 1. Nothing else;
- a deterministic policy (
policy.json, versioned in the repo) turns the category into an action. Below 0.7 confidence, the mail is kept and flagged “to check”. The bin requires 0.85 or more, otherwise it is archived.
The policy is a file I review, change through a commit, and whose every version is dated. On 8 October, for instance, I decided that receipts and order confirmations would stay in the inbox instead of being archived: one line changed, transactionnel → keep, without touching the prompt.
policy.json, relu et versionné.
The model owns one gate out of four, and it leads to no action: it returns a label.
What happens to the mail is decided in policy.json, reviewed and versioned.
The category descriptions in the policy also serve as the model’s prompt. One source for both: what the model reads and what the code applies cannot drift apart.
What never reaches the model
The triage node runs before any LLM call. These are set aside, kept as they are and never sent to the provider:
- starred mails;
- mails carrying one of my labels;
- every thread I wrote in (threads from the “Sent” folder are memorised at startup, then tracked);
- senders on an allow-list.
For the rest, the model only sees bounded metadata: sender, subject truncated to 160 characters, the snippet provided by Gmail truncated to 240, age, Gmail tab and three flags (“list”, “automated”, “direct recipient”). Never the body, never an attachment. The Gmail client only requests the metadata format to classify, so the body is not even downloaded.
Second saving: sender memory. A sender classified three times in a row in the same category (for low-stakes categories: notification, newsletter, promotion, transactional) no longer goes through the model.
The graph: a loop that survives everything
The agent is a LangGraph graph, but a modest one:
START → init ─┬─▶ fetch_backfill ─┐ (backfill: 50 mails per round)
├─▶ fetch_new ──────┤ (watch: Gmail history)
│ ▼
│ triage ── protected / already sorted / known sender ──┐
│ ▼ │
│ classify (LLM, 25 mails per call) │
│ ▼ ▼
│ archive_docs ────────────────────────────────────▶ apply ──▶ route
├─▶ retention ─▶ route
└─▶ wait_budget ─▶ route (daily budget reached: pause until midnight)
LangGraph is not here for “agency”: it gives an explicit router (route) and nodes that can be tested one by one. All durable state lives in SQLite on a volume: cursors, a log of every decision, sender memory, costs.
Two rules make the loop robust. Gmail first, log second, and cursors are only written at the end of a batch: a crash midway simply re-reads the batch. And a mail that is already logged or carries a Tri/… label is never classified again, so never paid for twice.
The dry-run: four days of labels before the first bin
The agent starts in dry-run. In that mode, it only adds labels: Tri/<category>, plus Tri/a-jeter or Tri/a-archiver for what it would do. Nothing moves.
It ran that way from 9 to 13 October, the time of a full backfill. The review happens in Gmail, with a label:tri-a-jeter search. A mail that must not go? Remove the label, or star it. That gesture is a veto: when switching to apply, the command that applies the accumulated decisions re-reads each mail and respects the vetoes.
On 13 October, review done, I switch to apply. The decision log keeps, for each one, whether the mail was in the inbox and whether it was unread: the undo command can put everything back. And the OAuth scope gmail.modify does not allow any permanent deletion anyway. The worst case is Gmail’s bin, recoverable for 30 days.
The first real-world trap: 26,000 decisions and a per-minute quota
apply-pending has to apply the 26,000 decisions taken during the backfill in one go. At the second batch, the command stops: Gmail answers “Quota exceeded” on its units limit per minute and per user, the client has used up its 31 seconds of retries, and the exception bubbles up.
The fix comes down to two principles:
- on that particular quota, wait 65 seconds and resume the same batch. Nothing is marked as applied before the batch ends, and Gmail writes are idempotent (setting a label that is already set changes nothing). Resuming a half-done batch therefore costs nothing;
- a 3-second pause between two batches, so as not to run straight into the next wall.
In the normal loop, the same quota no longer counts as an error and no longer triggers an alert: the loop waits two minutes and carries on.
The second trap: 150 PDFs at midnight on a 6 W NAS
On 9 October, a feature is added: PDFs attached to personal, important or transactional mails are filed into Paperless-ngx, on the NAS, which handles OCR, titles and filing. Each attachment is filed only once (SHA-256 fingerprint, plus Paperless’s own duplicate detection). A mail whose attachment is not archived yet is never binned: it is archived instead.
The cap was 150 uploads per day. The counter reset at midnight, and all the deferred attachments set off at the same time. The result, seen in Grafana: a burst of 150 uploads at 00:06, Paperless between 100 and 380% CPU, and an N100 processor saturated until 01:30.
The fix: a second, sliding cap of 12 uploads per hour. Same daily throughput, but spread out. As of 14 October, the counter shows 371 documents archived, 28 duplicates and 1,956 attachments excluded: train tickets, parcel tracking, terms and conditions and other boilerplate with no value, filtered by domain or by file name.
Choosing the model: not through the “OpenAI-compatible” layer
The code first talked to any OpenAI-compatible API. On 8 October, I switch to Claude Haiku 5.5. Anthropic’s OpenAI compatibility layer does not suit this model: a temperature other than 1 is refused, thinking is counted in max_tokens, response_format is ignored. So the client gains a second provider, the native Messages API, still written with the Python standard library only. A refusal from the safety classifier (stop_reason: refusal) simply leaves the batch “to check”.
The cost measured at the end of the backfill: 604 calls, 1.41 million input tokens, 0.31 million output tokens, $0.295 in total. One call classifies at most 25 mails, so at most 15,100 mails went through the model. More than 10,000 decisions were therefore taken without it, by sender memory or the protection rules. Watching now costs a few tenths of a cent per day. A daily budget ($1) pauses the agent until midnight if it is reached, with a Telegram message.
A deployment without an image
One last, more infra-minded choice: there is no image to build. The pod starts from python:3.12-slim (multi-arch), the code and policy arrive through a ConfigMap generated by a script, and an initContainer installs the dependencies into the volume, once per version of the lock file:
pip install --no-cache-dir --require-hashes --only-binary=:all: \
--target "/state/deps/<lock-hash>" -r requirements.txt
Hashes are checked, only binary wheels are accepted. The ConfigMap checksum is written into the Deployment, so changing the code restarts the pod. A test fails as long as the ConfigMap has not been regenerated. No registry, no pull secret, no build CI: for an agent of about 2,200 lines of Python, that is that many fewer moving parts.
The pod itself does not run as root, its root filesystem is read-only, it has no capabilities and no service account token, and a NetworkPolicy refuses any inbound connection except the metrics scrape. The suite has 132 tests, with a fake Gmail and a fake LLM, up to the end-to-end graph.
What I take away
- Separate classifying from deciding. The model returns a label and a confidence. A readable, versioned policy decides the action. You fix a decision with a commit, not with a prompt.
- Filter before the model, not after. What is sensitive is never sent. That is safer than asking the model to be careful.
- A dry-run that writes into the user’s own tool. Labels in Gmail are reviewed where you already read your mail, and a veto takes one gesture.
- Log everything so you can undo everything. With
undoand no permanent deletion, a mistake is acceptable. - Per-minute quotas are not errors. Wait them out, with idempotent batches you can resume halfway.
- Smooth your caps. A daily cap that resets at midnight creates a burst at midnight.