Personal project · System & data model

Gmail Sorting Agent

An agent that reads a job-search inbox, works out what each email actually is, and clears the noise out — under one rule: it is allowed to leave junk in your inbox, but it is never allowed to bury an interview.

Python Gmail API (OAuth2) Claude API (Haiku) JSON local memory 85 tests
01

In plain English — the whole journey

No jargon. Here is what happens from the moment you ask it to tidy your inbox to the moment an email leaves it — and why it gets cheaper every time it runs.

1 You tell it how far back to look

A week, usually — "the last 7 days". It searches your inbox the way you would, and it caps how much it will archive in one go.

2 It skips what it has already dealt with

It owns twelve labels. Anything already wearing one is excluded from the search itself, so a daily run only looks at genuinely new mail.

3 It decides whether the email is even about your job search

Most of your inbox isn't. Those emails are left completely alone — not labelled, not touched. It would rather ignore an email than guess at one.

4 It tries to recognise the email for free

First your own corrections, then its pattern rules, then templates it has been taught, then senders it knows. A job alert from SEEK looks like every other job alert from SEEK — that costs nothing to know.

5 It asks the AI only when it is genuinely stuck

And only if you switched the AI on for that run, and only until it hits the call budget you set. There is no way for a run to quietly spend money you didn't expect.

6 When it isn't sure, it leaves the email alone

Not sure is a real answer, and it's the default one. The email gets a Review Manually label and stays exactly where it is. An unsure email is never archived — there is no code path that allows it.

7 It shows you the whole plan before it touches anything

Every label it intends to apply, every email it intends to archive, listed out — then it waits. Say no and nothing happens at all. Nothing is ever deleted; archiving only means taking it out of the inbox.

8 It writes down everything, so it can be undone — and so it can learn

Every action is logged against a run ID, so a whole run can be reversed. And once a sender has been sorted the same way enough times, the agent can propose a rule for it — you approve it, and that sender never needs the AI again.
↺ loops back to step 4

02

What keeps it safe — in plain words

An agent clearing your inbox has an obvious temptation: archive more, because archiving is the visible win. These are the guardrails against that, each in one sentence. Every one is a rule in code with a test behind it — not a polite request in a prompt.

Uncertainty is never archived

Anything it can't place confidently becomes Review Manually and stays in the inbox. The fallback isn't a guess — it's leaving the email alone.

A known sender can't bury a real task

If an email shows interview, assessment, document or offer language, every archive-shaped rule is suppressed. A noisy sender doesn't get to hide the one message that mattered.

It doesn't trust its own AI

A model answer passes the same gate as everything else: the category must be archivable and confidence at least 0.80. A category it doesn't recognise is forced to Review Manually.

It reads the body, not just the subject

"Thank you for your application" that also says "confirm your residency" is a task, not a receipt. A specific request in the body overrides a reassuring subject line.

It cannot delete anything

Archiving removes one label. Everything stays in All Mail, fully searchable. The permission it holds can't send mail or delete it — that isn't policy, it's the scope it was granted.

Every run can be reversed

Each run has an ID and every label and archive is recorded with the email's prior state. One command replays a run backwards and makes the agent forget it ever touched those emails.

It can't spend without being asked

The AI is off unless you turn it on, and capped when you do. Once the budget is gone the remaining emails become Review Manually rather than more spend.

It shows its work before acting

A dry run writes nothing at all. A real run prints the full plan and waits for a yes — and if you abort, no state is written and nothing is remembered.

03

The same journey, in detail

Every email runs through a fixed funnel. Most of the inbox is eliminated at the relevance gate for free and never touched again; only job-search mail reaches the classifier, and only confident, archivable classifications reach the inbox itself.

Fetchinbox only, minus the 12 labels it already owns
1 · Relevance gatedenylist runs first
2 · Classifycheapest source first
3 · Archive gatecategory + confidence ≥ 0.80
Apply plan — printed in full, waits for confirmation
noise · confident
Label + archiveout of the inbox
Logged for undorun id, labels, prior state
action needed · or unsure
Label + keepstays in the inbox
Not job-related? Left completely untouched — never labelled, never archived.
TWO GATES The two gates fail in opposite directions, on purpose. The relevance gate is deliberately generous — it would rather admit a newsletter than miss a real application, so a wrong answer here only costs a label. The archive gate is deliberately strict — an email leaves the inbox only if its category is archivable and confidence clears 0.80, because a wrong answer here costs an opportunity.
ELEVEN LABELS Six categories always stay in the inbox — interview invite, assessment due, recruiter message, documents needed, offer, and review manually. Five can be archived: applied, rejection, application viewed, job alerts, platform noise. Which list a category sits on is data, not judgement — a rule cannot archive a keep-in-inbox category even when it explicitly claims it's safe to.
04

Classifying an email — cheapest source first

The distinctive part. Four local sources are exhausted before a single API call, and the model isn't reached at all unless it was explicitly switched on for that run. One gate sits across all six tiers — including the model's own answer.

01 Manual overrideyour explicit correction — beats everything below no call
↓ falls through
02 Tier 1 rulesdeterministic patterns — must clear 0.75 to be trusted no call
↓ falls through
03 Template rulessender + body marker — "answer a few questions" no call
↓ falls through
04 Sender rulessenders that always mean the same thing no call
↓ falls through
05 Claudegenuinely unfamiliar — only if enabled, only within budget
↓ falls through
06 Review manuallythe default answer, not the last resort no call
Archive gate every tier · no exceptions
Archivedremoved from the inbox
Labelled, keptneeds your action
Review manuallynever archived

Figure 1 — The six-tier classification cascade. Four free sources are exhausted before a single API call is made, and the gate spans every tier: the model's own answer is not trusted merely because a model produced it.

ORDER Tier 1 sits above the sender and template rules, which is the opposite of the obvious arrangement — rules are cheaper, so why not check them first? Because a broad rule that fires early is a rule that can archive something before anything has read the body. The cheap rules fill the gap below Tier 1's confidence threshold instead — which is precisely where the API calls were coming from.
05

Local memory — the data model

There is no database. Memory is four rule files, one state file and two logs on disk — the right shape for a single-user agent whose rules must be readable, hand-correctable and diffable in Git. The relationship that matters is the loop at the bottom: decisions_log is mined into sender_rules, so the agent's own past is what makes its future free.

%%{init: {'theme':'base','themeVariables':{'fontFamily':'-apple-system,Segoe UI,Roboto,sans-serif','fontSize':'13px','primaryColor':'#f4f1e8','primaryTextColor':'#20303f','primaryBorderColor':'#16324f','lineColor':'#16324f'}}}%%
erDiagram
  IRRELEVANT_SENDERS ||--o{ EMAIL_STATE : "gates before all"
  MANUAL_OVERRIDES   ||--o{ EMAIL_STATE : "wins first"
  TEMPLATE_RULES     ||--o{ EMAIL_STATE : "then"
  SENDER_RULES       ||--o{ EMAIL_STATE : "then"
  EMAIL_STATE   ||--o{ ACTIONS_LOG   : "produces"
  ACTIONS_LOG   ||--o{ EMAIL_STATE   : "undo forgets"
  EMAIL_STATE   ||--o{ DECISIONS_LOG : "writes"
  DECISIONS_LOG ||--o{ SENDER_RULES  : "suggests · human approves"

  IRRELEVANT_SENDERS {
    list sender_contains "never job-related"
    list domains
  }
  MANUAL_OVERRIDES {
    string name PK
    list   sender_contains
    list   subject_contains
    list   body_contains_any
    string classification
    bool   safe_to_archive "explicit"
    float  confidence
  }
  TEMPLATE_RULES {
    string name PK
    list   sender_contains
    list   body_contains_any "action markers"
    string classification
    float  confidence
  }
  SENDER_RULES {
    string name PK
    list   sender_contains
    list   domain_equals
    string classification
    float  confidence
  }
  EMAIL_STATE {
    string message_id PK
    string classification
    list   labels
    bool   safe_to_archive
    string subject
    string classified_at
    string labelled_at "null until applied"
    string archived_at "null until archived"
  }
  DECISIONS_LOG {
    string timestamp
    string message_id
    string sender
    string subject
    string phase "fetch · classify · apply"
    string classification
    float  confidence
    string source "override · tier1 · template · sender · claude"
    list   evidence
  }
  ACTIONS_LOG {
    string run_id PK
    string message_id
    string action "labels_applied · archived"
    list   label_ids_added
    list   label_ids_before "for undo"
  }
PK key field one ──< many (one log suggests many rules) JSON document stores, not tables

Figure 2 — Four rule files, one state file, two logs. The bottom edge is the learning loop; the edge above it is undo, running the other way.

WHY JSON A relational store would be the reflex choice and the wrong one here. The rules are edited by hand constantly — a wrongly archived email is fixed by opening a file and adding four lines. Keeping memory as readable documents means every rule change is reviewable in a Git diff, and the safety-critical ones stay legible to the person accountable for them.
THREE STATES An email is classified, then labelled, then archived — three separate timestamps, not one "processed" flag. That distinction is load-bearing: a flat processed list once marked labelled emails as finished, so the archive pass skipped them and they sat in the inbox wearing a label forever. Failed actions never write a timestamp, and a dry run writes nothing at all.