GitHub - k08200/klorn: Klorn turns scattered work signals into approval-ready decisions.
> **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
[](https://github.com/k08200/klorn#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
[](https://github.com/k08200/klorn#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
[](https://github.com/k08200/klorn#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
[](https://github.com/k08200/klorn#read-the-thinking) Three writeups walk through the architecture, with the tradeoffs and the honest edges:
1. I let GPT-4o and a cheaper model fight over my inbox. GPT-4o lost. — the model bake-off 2. I don't trust the LLM to classify my email. So I don't let it. — feature-scorer vs. decider 3. Confidence is enough to decide. It's not enough to do. — the deterministic floor
What it's NOT
[](https://github.com/k08200/klorn#what-its-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)
[](https://github.com/k08200/klorn#trying-the-hosted-demo-klornai)
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.com` 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
[](https://github.com/k08200/klorn#what-were-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
[](https://github.com/k08200/klorn#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
[](https://github.com/k08200/klorn#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
[](https://github.com/k08200/klorn#local-development)
Requirements
[](https://github.com/k08200/klorn#requirements)
- Node.js 22+
- pnpm
- PostgreSQL 16 (recommended)
Install
[](https://github.com/k08200/klorn#install)
git clone https://github.com/k08200/klorn.git cd klorn pnpm install
Environment files
[](https://github.com/k08200/klorn#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'))"
**2. 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)
[](https://github.com/k08200/klorn#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.
1. Google Cloud Console → create (or pick) a project. 2. **APIs & Services → Library** → enable **Gmail API** and **Google Calendar API**.
3. **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.) 4. **Scopes**: add `gmail.modify` and `calendar` (Klorn reads mail and writes tier labels; see `scope-justification.md`). 5. **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`). 6. 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)
[](https://github.com/k08200/klorn#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
[](https://github.com/k08200/klorn#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
[](https://github.com/k08200/klorn#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 --filter @klorn/web dev
Default 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 8003
Open `http://localhost:8001` (or your override) — you should see the Klorn landing page.
Telegram notifications (optional)
[](https://github.com/k08200/klorn#telegram-notifications-optional) PUSH-tier interrupts can also be delivered to Telegram — useful when you self-host without web-push (VAPID) configured. Bring your own bot:
1. Open @BotFather, send `/newbot`, and follow the prompts. Note the **bot token** and **bot username**. 2. 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)"
1. 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"
1. Link your account: call `POST /api/telegram/link` with your Klorn bearer token — it returns a one-time code (10-minute expiry) and a `https://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.
Docker
[](https://github.com/k08200/klorn#docker) Run the full stack with the required secrets in the root `.env`:
docker compose up --build
Docker Compose ports: Web `3000`, API `3001`, PostgreSQL `5432`.
Common commands
[](https://github.com/k08200/klorn#common-commands)
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/
Phone escalation (optional, off by default)
[](https://github.com/k08200/klorn#phone-escalation-optional-off-by-default)
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/gather
Each 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.
Deployment notes
[](https://github.com/k08200/klorn#deployment-notes)
- **Vercel Web**: set `NEXT_PUBLIC_API_URL` to the deployed API URL.
- **API**: set `DATABASE_URL`, `JWT_SECRET`, `TOKEN_ENCRYPTION_KEY`, `WEB_URL`, and `CORS_ORIGINS` for 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`.
QA flows
[](https://github.com/k08200/klorn#qa-flows) 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.
Contributing
[](https://github.com/k08200/klorn#contributing) 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.
License
[](https://github.com/k08200/klorn#license)
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.