Show HN: Klorn – I built an email firewall because every AI inbox made mine louder
Klorn is an open-source email firewall that classifies incoming emails into four tiers (SILENT, QUEUE, PUSH, AUTO) without adding chat surfaces or suggestion cards. It uses a cheap LLM for feature scoring combined with deterministic rules to decide the tier, ensuring safety and auditability. The system supports self-hosting and includes a complete local development guide.
Notifications You must be signed in to change notification settings
Fork 0
Star 5
BranchesTags
Open more actions menu
Folders and files
NameName
Last commit message
Last commit date
Latest commit
History
1,018 Commits
1,018 Commits
.agents
.agents
.github
.github
apps
apps
docs
docs
examples
examples
packages
packages
website
website
.dockerignore
.dockerignore
.env.example
.env.example
.gitignore
.gitignore
BACKLOG.md
BACKLOG.md
CHANGELOG.md
CHANGELOG.md
CONTRIBUTING.md
CONTRIBUTING.md
Dockerfile.api
Dockerfile.api
Dockerfile.web
Dockerfile.web
LICENSE
LICENSE
POC.md
POC.md
README.md
README.md
biome.json
biome.json
docker-compose.yml
docker-compose.yml
package.json
package.json
pnpm-lock.yaml
pnpm-lock.yaml
pnpm-workspace.yaml
pnpm-workspace.yaml
render.yaml
render.yaml
vercel.json
vercel.json
Repository files navigation
An attention firewall for your inbox. Not a suggestion engine.
Every other AI inbox tool adds a surface — a suggestion card next to each email, a badge that says "AI thinks you should reply," a draft waiting for review. The inbox gets louder, not quieter.
Klorn does the opposite. Each inbound email gets exactly one classification — SILENT / QUEUE / PUSH / AUTO — bound to the exact bytes that produced it. No chat surface. No suggestion cards. No 60-tool agent. The output is a single decision, and most of the time that decision is "you don't need to see this."
Read the doctrine before the code — that's the actual product.
▶️ 60-second walkthrough · 📖 Editions / open-core boundary · 📋 CHANGELOG
The four tiers
Tier What it means What happens
SILENT Recorded, never rendered The row exists for ground-truth feedback; you never see it. (marketing, receipts, FYI)
QUEUE Review on your own schedule Visible in the queue. No push, no notification. This is the default.
PUSH Worth interrupting you A notification fires. Optionally Telegram or one phone call.
AUTO Reversible, hands-off Classified today; the action side sits behind a deterministic floor (below).
How it decides — and why a cheap model runs it
The LLM does not pick the tier. On every email it scores four features between 0 and 1 — confidence, senderTrust, reversibility, urgency — and a deterministic rule in tier-policy.ts maps those four numbers to a tier. The model perceives; a rule you can read and unit-test decides. The policy is auditable without the model in the loop.
Two consequences fall out of that split:
A cheap model wins. When the model only has to read four signals consistently, you don't need a frontier model's reasoning depth. On the committed 50-email gate set (eval/judge-eval-set.json), gemini-2.5-flash scores 88% with 100% recall on urgent mail — beating gpt-4o and gemini-2.5-pro (both 82%) at a fraction of the cost. Run it yourself: pnpm eval:judge.
It fails open, safely. If the LLM is down or rate-limited, a keyword fallback produces the same four features with zero model calls, so urgent mail still gets through. The model is the perception layer, never the load-bearing one.
Every classification is content-hash-bound: the exact bytes the scorer read (from, subject, snippet, labels) are sha256'd at decision time and stored with the row. The read path re-hashes and throws AttentionHashMismatchError on mismatch, so a later enrichment can't silently invalidate a tier (PR #468).
The deterministic floor
Three actions can't be undone with one user click — send_email, permanent_delete, forward_external. These don't ride on classifier confidence. They require an ActionReceipt minted at /approve time that pins the payload bytes (a sha256 over the canonical recipient/subject/body), verified at execute time — any drift throws and the action is refused (PR #480, #481, doctrine).
It's enforced, not aspirational: a central guard in executeToolCall fails closed on any floor action that arrives without a verified receipt, so even the autonomous path can't side-step approval to send, forward, or hard-delete. Today send_email is the wired callable case; the other two are guarded fail-closed until their cases land. The autonomous agent itself defaults to SUGGEST mode — read-only tools plus propose-only — and only gets mutating power when you explicitly opt into AUTO.
Read the thinking
Three writeups walk through the architecture, with the tradeoffs and the honest edges:
I let GPT-4o and a cheaper model fight over my inbox. GPT-4o lost. — the model bake-off
I don't trust the LLM to classify my email. So I don't let it. — feature-scorer vs. decider
Confidence is enough to decide. It's not enough to do. — the deterministic floor
What it's NOT
Not finished. This is an early PoC with one real user (me); ICP retention measurement is what's happening now. The CHANGELOG is honest about what's solid vs. what's stitched.
Not a "chat with your inbox" thing. There is no chat surface.
Not multi-tenant cloud. Self-host is the only path right now.
Not feature-gated against open source. docs/EDITIONS.md lists what Cloud will sell on top (managed hosting, verified Gmail scope, team workspaces) — the firewall doctrine and code stay in the repo on both editions.
Why is a CI check red? Scope Budget fails on purpose. It's a self-imposed ratchet that trips when a change grows the route / page / schema surface past a fixed budget, forcing a conscious "yes, this scope is worth it" instead of silent sprawl. A red Scope Budget is by design, not a broken build — every other check (lint, types, tests, build, security, eval) is green.
Trying the hosted demo (klorn.ai)
The hosted demo runs in Google OAuth testing mode while we hold off on CASA Tier 2 verification (Klorn uses Gmail's restricted gmail.modify scope). To try it without self-hosting, you have to be added as a test user first. Three paths, fastest first:
Open an issue with the Google email you want to use: new oauth-tester issue — we add you, comment "added", you log in.
Email [email protected] with the same info.
Or skip the gating entirely and self-host — full feature parity, you bring your own Google OAuth credentials, no verification needed.
Google caps test-user slots at 100 in this mode. Once CASA verification ships (gated on PoC retention measurement), the OAuth screen flips to production and the gating goes away. For most people landing here, self-host is the fastest way in.
What we're building
Klorn's first screen is not a chat or an inbox — it's a decision queue. Scattered signals are collected and presented as cards that answer three questions: what to look at, why it matters, and what action is ready.
Decision queue — pending approvals, the commitment ledger, today's risks
Mail — priority, reply-needed flags, attachment and candidate signals
Calendar — meeting readiness, conflicts, context for what's next
Briefing — a daily summary of top signals and recommended actions
Settings — Google connections, notifications, execution boundaries, model and data controls
Product principles
Approval before action — sending mail, changing the calendar, or pushing externally requires a clear confirmation step.
Evidence-based automation — every suggestion shows the signal, the reasoning, and the staged action.
Progressive trust — Klorn starts in observe-and-suggest mode and earns more autonomy through your feedback.
The empty state is the product — even before any connection, the next step should be obvious.
One clear signal — the name Klorn comes from the Germanic klar (clear) and the Old English horn (a signal worth answering).
Tech stack
Layer Stack
Web Next.js 15, React 19, TypeScript, Tailwind CSS
API Fastify, TypeScript, Prisma
DB PostgreSQL
Auth JWT, bcrypt, Google OAuth
AI OpenAI-compatible (local-first), OpenRouter / Gemini failover
Realtime WebSocket, Web Push
Billing Stripe
Monorepo pnpm workspaces
packages/ api/ Fastify API, Prisma schema, agent/tool orchestration web/ Next.js app: decision queue, mail, calendar, briefing, settings core/ shared utilities and CLI-facing primitives docs/ doctrine, screenshots, operational notes
Local development
Requirements
Node.js 22+
pnpm
PostgreSQL 16 (recommended)
Install
git clone https://github.com/k08200/klorn.git cd klorn pnpm install
Environment files
Klorn reads two env files in local dev. Both need to exist before the database container will even start.
- Root .env — used by docker-compose to interpolate required vars into the postgres + api services. Without it, docker compose up -d postgres fails with required variable JWT_SECRET is missing a value.
cp .env.example .env
Generate a 32-byte base64 key for TOKEN_ENCRYPTION_KEY and paste it into the root .env:
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
- API .env — the actual runtime env for the Fastify server.
cp packages/api/.env.example packages/api/.env
Open packages/api/.env and at minimum set:
DATABASE_URL="postgresql://klorn:klorn-local-dev@localhost:5432/klorn" OPENROUTER_API_KEY="" # https://openrouter.ai/keys — a free key works (or go fully local, below) WEB_URL="http://localhost:8001" PORT=8000
JWT_SECRET and TOKEN_ENCRYPTION_KEY are optional in dev — the server falls back to insecure defaults with a warning. Set them if you want the same dev cookies/tokens across restarts.
Google OAuth (Gmail + Calendar)
To sync mail you bring your own OAuth client — no Google verification or CASA needed for self-host, since you stay the app's owner and sole user.
Google Cloud Console → create (or pick) a project.
APIs & Services → Library → enable Gmail API and Google Calendar API.
OAuth consent screen → User type External → fill the basics → under Test users, add the Google account you'll log in with. (Unverified apps only work for accounts on the test-user list — that's the 100-slot cap, and it's why self-host has no verification step: it's your account on your client.)
Scopes: add gmail.modify and calendar (Klorn reads mail and writes tier labels; see scope-justification.md).
Credentials → Create credentials → OAuth client ID → Web application. Set the Authorized redirect URI to http://localhost:8000/api/auth/google/callback (match your API port and GOOGLE_REDIRECT_URI).
Copy the client ID and secret into packages/api/.env:
GOOGLE_CLIENT_ID="...apps.googleusercontent.com" GOOGLE_CLIENT_SECRET="..." GOOGLE_REDIRECT_URI="http://localhost:8000/api/auth/google/callback"
Local LLM (keep your email on your machine)
Klorn speaks to any OpenAI-compatible endpoint. Point it at a local server (Ollama, LM Studio, vLLM, llama.cpp) and email classification runs against it first — cloud keys, if configured at all, are failover only:
OPENAI_COMPAT_BASE_URL="http://localhost:11434/v1" # Ollama default OPENAI_COMPAT_MODEL="qwen3:8b"
With no cloud keys set, Klorn is fully local. See .env.example for OPENAI_COMPAT_PRIORITY and the other knobs.
Database
The bundled docker-compose ships a Postgres 16 with the credentials the default DATABASE_URL expects. If you have a Postgres already on 5432, either stop it or change the port mapping in docker-compose.yml and update DATABASE_URL.
docker compose up -d postgres pnpm --filter @klorn/api exec prisma migrate deploy pnpm --filter @klorn/api exec prisma generate
migrate deploy is the non-interactive path. migrate dev would prompt for a migration name on first run, which is friction in a smoke test.
Dev servers
Terminal 1 — API:
pnpm --filter @klorn/api dev
Wait for Server listening at http://127.0.0.1:8000 (can take 5–10s while background imports load — silence in between is normal). Verify in another terminal:
curl http://localhost:8000/api/health
→ {"status":"ok","db":"connected","version":"0.3.0",...}
Terminal 2 — Web:
NEXT_PUBLIC_API_URL=http://localhost:8000 pnpm
[truncated for AI cost control]