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.
| 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). |
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-flashscores 88% with 100% recall on urgent mail — beatinggpt-4oandgemini-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).
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.
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
- 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.mdlists 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 Budgetfails 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.
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
k0820086@gmail.comwith 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.
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
- 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).
| 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
- Node.js 22+
- pnpm
- PostgreSQL 16 (recommended)
git clone https://github.com/k08200/klorn.git
cd klorn
pnpm installKlorn 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 .envGenerate 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'))"2. API .env — the actual runtime env for the Fastify server.
cp packages/api/.env.example packages/api/.envOpen 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=8000JWT_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.
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.modifyandcalendar(Klorn reads mail and writes tier labels; seescope-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 andGOOGLE_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"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.
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 generatemigrate deploy is the non-interactive path. migrate dev would prompt for a migration name on first run, which is friction in a smoke test.
Terminal 1 — API:
pnpm --filter @klorn/api devWait 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 --filter @klorn/web devDefault ports: API 8000, Web 8001. If either is taken — common collision is another Postgres on 5432, or a Docker gateway on 8000 — override:
# API on 8002
PORT=8002 pnpm --filter @klorn/api dev
# Web on 8003 pointing at the moved API
NEXT_PUBLIC_API_URL=http://localhost:8002 \
pnpm --filter @klorn/web exec next dev --port 8003Open http://localhost:8001 (or your override) — you should see the Klorn landing page.
PUSH-tier interrupts can also be delivered to Telegram — useful when you self-host without web-push (VAPID) configured. Bring your own bot:
- Open @BotFather, send
/newbot, and follow the prompts. Note the bot token and bot username. - Set the env vars on the API:
TELEGRAM_BOT_TOKEN="123456:your-botfather-token"
TELEGRAM_BOT_USERNAME="your_bot_username" # without the @
TELEGRAM_WEBHOOK_SECRET="$(openssl rand -hex 32)"- Register the webhook (the API must be reachable over HTTPS):
curl -s "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/setWebhook" \
-d "url=https://your-api-host/api/telegram/webhook" \
-d "secret_token=$TELEGRAM_WEBHOOK_SECRET"- Link your account: call
POST /api/telegram/linkwith your Klorn bearer token — it returns a one-time code (10-minute expiry) and ahttps://t.me/<bot>?start=<code>deep link. Open the link and hit Start.
PUSH-tier messages arrive with Move to Queue / Silence buttons (the same manual tier override as the firewall UI, so your taps feed the classifier's ground truth) plus an Open Klorn link. DELETE /api/telegram/link unlinks. The webhook rejects requests that don't carry the X-Telegram-Bot-Api-Secret-Token header matching your secret.
Run the full stack with the required secrets in the root .env:
docker compose up --buildDocker Compose ports: Web 3000, API 3001, PostgreSQL 5432.
pnpm --filter @klorn/web build
pnpm --filter @klorn/api build
pnpm --filter @klorn/api test
pnpm eval:judge # run the 4-tier classifier against the committed gate set
packages/api/node_modules/.bin/biome check packages/If a PUSH-tier notification sits unacknowledged for 5 minutes, Klorn can place one plain text-to-speech phone call (press 1 to repeat, press 2 to acknowledge). No AI on the line — it's the PagerDuty/GoAlert escalation pattern applied to your inbox. Bring your own Twilio account:
PHONE_ESCALATION_ENABLED=true
TWILIO_ACCOUNT_SID="ACxxxxxxxx"
TWILIO_AUTH_TOKEN="..."
TWILIO_FROM_NUMBER="+15555550000" # a voice-capable Twilio number you own
PUBLIC_URL="https://your-api.example.com" # Twilio must reach /api/phone/gatherEach user must additionally opt in (AutomationConfig.phoneEscalationEnabled) and have a phone number on file. Hard rails, none configurable away: at most one call per notification ever, a per-user daily cap (default 3, PHONE_ESCALATION_DAILY_CAP), a 10-minute cooldown, and quiet hours always win — there is no urgency bypass. Klorn will never ring you at 3 a.m.; that's the whole point of an attention firewall.
Cost reality: every escalation call costs real money — roughly $0.02–0.06 per call depending on destination (US ≈ $0.014/min, Korea and most of Asia/EU more). The daily cap bounds worst-case spend. Korean numbers: Twilio outbound to +82 may display an international caller ID and can be filtered by carrier spam apps — test with your own number first.
- Vercel Web: set
NEXT_PUBLIC_API_URLto the deployed API URL. - API: set
DATABASE_URL,JWT_SECRET,TOKEN_ENCRYPTION_KEY,WEB_URL, andCORS_ORIGINSfor the target environment. - The Google OAuth redirect URI must point to the API's
/api/auth/google/callback. - For Neon or other serverless Postgres, use the PgBouncer connection options from
.env.example.
When touching core UX, verify at least:
- Founder — see a pending approval card in the decision queue and accept/reject it through to completion.
- Sales — mail list, mail detail, reply draft, and attachment signals render correctly.
- Ops — calendar readiness and briefing surface the right context.
- Mobile — the decision queue, mail, and top/bottom nav work at 390px width.
- New user — pre-connection state, initial learning hint, and the first settings screen are clear.
Issues and pull requests are welcome. For anything non-trivial, open an issue first to discuss the approach. Run pnpm -r test and biome check packages/ before submitting.
AGPL-3.0. You are free to use, self-host, and modify Klorn. If you run a modified version as a network service, the AGPL requires you to offer your modified source to that service's users. Copyright (C) 2026 k08200.
