AI News HubLIVE
In-site rewrite6 min read

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.

SourceHacker News AIAuthor: k08200

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.

  1. 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'))"

  1. 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]