Local-first AI inbox triage. A small classifier on your own machine handles 80%+ of email routing in milliseconds for free. An LLM sees only the cases the classifier isn't sure about — and only if you opt in with your own key. Your inbox never leaves your machine unless you say so.
**▶ Run the demo locally in 5 minutes — see the Quickstart below. Synthetic data, real classifier, no signup needed.
Existing AI inbox tools are one of two things:
- Hosted SaaS that ingests all your email onto someone else's servers, or
- Thin LLM wrappers that fire a paid API call at every single message.
Winnow is neither. It's a tiered system: a cheap, private, local model does the bulk of the work, and the expensive model is a scalpel, not a firehose. That means your mail stays local, the running cost is ~$0, and the system gets better at handling things locally the more you use it.
flowchart LR
A[Incoming email] --> B["Tier 1 — local classifier<br/>MiniLM embeddings + logistic regression<br/>~5 ms · CPU · $0"]
B -->|confident| L["Lane<br/>Needs You / Informational / Hidden"]
B -->|uncertain| C["Tier 2 — PydanticAI agent<br/>structured triage + draft reply<br/>opt-in · your API key"]
C --> L
L --> U["You act:<br/>move · archive · star · edit draft"]
U --> T[("training_examples")]
T -.nightly retrain.-> B
- Tier 1 (local, free, private). A scikit-learn logistic-regression
classifier over engineered features (sender domain, thread depth, urgency
words, time of day, …) plus
all-MiniLM-L6-v2sentence embeddings. Runs on CPU in milliseconds. Handles the large majority of triage decisions and returns a signed per-feature explanation for every one. - Tier 2 (LLM, opt-in). A PydanticAI agent with a structured output schema, invoked only when tier-1 confidence is below threshold, or when you explicitly ask for a drafted reply. Bring your own key (Anthropic default; OpenAI and Ollama behind a provider abstraction).
- Learning loop. Every action you take — moving an email between lanes, archiving, starring, editing a draft — becomes a labeled training example. A nightly job retrains tier-1 on your own history, gated by guardrails (won't retrain on too little data, won't deploy a model that regresses, keeps the previous model for one-command rollback).
- Drafts only, never sends. Tier-2 can draft a reply and surface the assumptions it made, but Winnow never sends mail on your behalf. Deliberate product call — you stay in the loop for anything irreversible.
Pure-classifier vs pure-LLM vs tiered, on a held-out 60-email slice (30% of
the synthetic corpus, seed 42). Full breakdown + threshold sweep in
docs/evals.md and on the
/evals page. Reproduce with winnow eval.
| Strategy | Accuracy | Mean latency | Cost / 1000 | Escalated |
|---|---|---|---|---|
| Pure classifier (tier-1) | 86.7% | 3.7 ms | $0.00 | 0% |
| Pure LLM (tier-2) | 100%* | 1.20 s | $5.30 | 100% |
| Tiered (Winnow) | 88.3% | 104 ms | $0.41 | 8.3% |
The result: tiered beats the standalone classifier (88.3% vs 86.7%) by escalating only the ~8% of emails tier-1 is unsure about, which buys most of the accuracy gap to the LLM at a fraction of its cost and latency. Raise the confidence threshold and accuracy climbs toward the LLM ceiling (~98%) as cost rises with it; that dial is the whole point of a tiered system.
* Pure LLM shows 100% because the demo's tier-2 fixtures are rule-based
stubs whose labels mirror ground truth, so treat it as a ceiling rather than a
measured LLM score. The classifier accuracy, escalation rate, latency, and
cost are all real, measured on held-out data. Run
packages/seed-data/generate.py with a real key to publish genuine LLM
accuracy.
The public demo never calls a paid LLM API. Not once.
- Tier 1 runs for real — it's CPU-only and free, so every visitor gets genuine live classifier decisions with real feature-importance explanations, retraining on their own drag-and-drop edits within their session.
- Tier 2 is pre-recorded. Every synthetic email's tier-2 response is
generated once offline (
packages/seed-data/generate.py), committed as a JSON fixture, and served with a realistic simulated latency. The API badges each responsetier_2_source: "prerecorded"so the UI is honest about it. - Novel emails with no fixture get a graceful "run Winnow locally with your own key to see the LLM handle this" placeholder.
Session state is isolated per visitor by cookie and garbage-collected after 24h. This is a strength, not a limitation — it's why anyone can try it without costing the maintainer money or risking abuse.
Prereqs: uv, Node 18+, Docker.
# 1. Postgres
docker compose up -d postgres
# 2. Python workspace (installs the API, the winnow CLI, uvicorn, alembic, ML deps)
uv sync --all-packages
# 3. Dashboard deps
(cd apps/web && npm install)
# 4. Point at the DB in demo mode (bash; PowerShell: $env:NAME='value')
export WINNOW_MODE=demo
export WINNOW_DATABASE_URL=postgresql+psycopg://winnow:winnow@localhost:5432/winnow
export WINNOW_IP_HASH_SALT=local-dev-not-a-secret
# 5. Migrate + generate the 200 synthetic emails + train the tier-1 model
(cd apps/api && uv run python -m alembic upgrade head)
uv run python packages/seed-data/generate_emails.py
uv run python -m winnow_api.classifier.train
# 6. Boot API + dashboard (two shells)
uv run python -m uvicorn winnow_api.main:app --app-dir apps/api --port 8000
(cd apps/web && npm run dev)
# 7. open http://localhost:3000Config is entirely environment variables — see .env.example.
The API refuses to boot if WINNOW_MODE contradicts the database:
demo mode with real users, or real mode with no owner row. Fail loud,
fail early.
Real mode is single-user (you), self-hosted on your machine, and gated behind
WINNOW_MODE=real — the demo backend can't even import the Gmail modules.
Three parts: connect Google, configure secrets, run.
Winnow talks to Gmail through your own OAuth client, so nothing is shared:
- console.cloud.google.com → create a project.
- APIs & Services → Library → enable Gmail API.
- OAuth consent screen → External → add your own address as a Test user.
- Credentials → Create Credentials → OAuth client ID → Desktop app →
download the JSON. Save it as
./credentials.json(gitignored).
cp .env.example .envThen edit .env:
WINNOW_MODE=real
WINNOW_ENCRYPTION_KEY= # generate: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
WINNOW_LLM_API_KEY=sk-... # optional — enables the tier-2 "ask LLM" button
WINNOW_LLM_PROVIDER=openai # or anthropic
WINNOW_LLM_MODEL=gpt-4o-mini
Connect Gmail once on the host (the OAuth loopback needs your browser; the encrypted refresh token lands in Postgres), then start the stack:
docker compose -f docker-compose.full.yml up -d postgres # DB first, for bootstrap
uv sync --all-packages # host tools for the one-time connect
set -a && source .env && set +a # load .env into the shell
winnow bootstrap --email you@gmail.com # create the owner row
winnow gmail authorize --credentials-file ./credentials.json # opens your browser
winnow gmail sync --full # backfill + classify last 30 days
docker compose -f docker-compose.full.yml up --build # Postgres + API + dashboard
# open http://localhost:3000 — your inbox, triagedIn the dashboard: drag to correct a lane (every move trains tier-1), click
archive/star (also training signal), or ask LLM to escalate an
email to the tier-2 agent with your key. winnow retrain (or the nightly job)
folds your corrections into a new model, with a regression guardrail and
one-command winnow rollback.
Cost: tier-1 runs locally for free; the paid LLM fires only when you click "ask LLM" (never per-sync), so a normal day costs cents or nothing.
The refresh token is encrypted at rest with cryptography.fernet. Sync is
incremental via historyId with a polling fallback and optional Pub/Sub push.
Details in docs/architecture.md.
apps/
api/ FastAPI backend — real app + demo share one codebase, split by WINNOW_MODE
winnow_api/
classifier/ tier-1: features, MiniLM embeddings, train, inference + explainability
agents/ tier-2: PydanticAI schema, prompt, provider factory, live + fixture providers
triage/ confidence-threshold orchestrator (tier-1 → maybe tier-2)
learning/ action→label mapping, nightly retrainer, guardrails, artifact rotation
gmail/ real-mode only: OAuth, API client, historyId sync, Pub/Sub webhook
realapp/ real-mode only: owner-scoped dashboard API (list, lane, archive, star, escalate)
eval/ pure-classifier vs pure-LLM vs tiered harness
demo/ session middleware, seeder, fixture loader, demo routes
db/ SQLAlchemy models + Alembic migrations
web/ Next.js 15 dashboard (App Router, Tailwind, dnd-kit)
site/ Marketing landing + embedded /demo + /evals
packages/
seed-data/ 200 synthetic emails, tier-2 fixtures, generators, freshness check
docs/ architecture.md, evals.md
Backend Python 3.12 · FastAPI · Pydantic v2 · PydanticAI · SQLAlchemy 2 + Alembic · Postgres 16 · scikit-learn · sentence-transformers · APScheduler · structlog Frontend Next.js 15 · TypeScript (strict) · Tailwind · shadcn/ui · dnd-kit Infra Docker Compose · Railway (demo API) · Vercel (site) · GitHub Actions Tooling uv · ruff · mypy · pytest (153 tests) · Vitest
Deliberate non-goals, so the project stays focused:
- Multi-account support, non-Gmail providers, team/shared inboxes, mobile app
- Auto-sending replies — drafts only; you send
- Calendar integration
- Live LLM calls in the public demo — pre-recorded fixtures instead
- Hosting Winnow-as-a-service for other people's real inboxes
MIT — see LICENSE.