Configuration
Everything is set through environment variables (prefix KATHA_). A copyable template lives in .env.example.
Required
| Variable | Description |
|---|---|
KATHA_BASE_URL | Public URL the magic links point at (e.g. https://katha.example.com). |
KATHA_HMAC_SECRET | Secret that signs the magic-link tokens. Generate with openssl rand -hex 32. |
KATHA_SMTP_HOST | SMTP server host. |
KATHA_SMTP_USER | SMTP username. |
KATHA_SMTP_PASS | SMTP password. |
KATHA_SMTP_SENDER | The From: address on outgoing mail. |
KATHA_SMTP_SENDER_NAME | The display name beside that address (default katha) — what an inbox shows in its sender column. Set it empty for the bare address. Non-ASCII is encoded per RFC 2047, so an emoji works. Optional, and listed here rather than above because it needs nothing from your relay: same envelope sender, same auth. |
KATHA_SMTP_PLAINTEXT | true speaks plain SMTP with no TLS, for a local mail catcher such as Mailpit (see scripts/dev.sh). katha refuses to start with it unless KATHA_SMTP_HOST is localhost, 127.0.0.1 or ::1. Default false: implicit TLS. |
KATHA_KAVITA_DB | Path to Kavita's SQLite database (opened read-only). |
KATHA_COVERS_DIR | Path to Kavita's covers directory. |
KATHA_DB | Path to katha's own SQLite database (created if absent). |
Optional
| Variable | Default | Description |
|---|---|---|
KATHA_BIND | 0.0.0.0:8792 | Address the HTTP server binds to. |
KATHA_SMTP_PORT | 465 | SMTP port (465 = implicit TLS). |
KATHA_LIBRARY_ID | 1 | Which Kavita library to recommend from. |
KATHA_LIBRARY_IDS | (= KATHA_LIBRARY_ID) | Every library katha offers readers picks from, comma-separated. Each reader's pool is this list intersected with the libraries Kavita grants them (its own AppUserLibrary ACL, read live and never copied), minus any they switch off on /me/collections. Leave unset for a single-library instance: behaviour and UI are unchanged. |
KATHA_LIBRARY_LANGS | (unset) | What language each collection is in — <library_id>:<fr|en>, comma-separated (e.g. 2:fr,1:en). Operator-declared rather than derived from Kavita's per-series Language, which is dirty in practice. Shown on /me/collections; and for a reader who has stated no language of their own it decides which language katha writes to them in, ahead of KATHA_DEFAULT_LANGUAGE. A reader whose collections disagree falls through to the default rather than being handed a guess. |
KATHA_KAVITA_URL | (unset) | Public URL of your Kavita. Adds a "browse the library" link to the reader page and the email footer, deep-linked to KATHA_LIBRARY_ID. Left unset, the link is omitted rather than broken. |
KATHA_TOKEN_TTL_DAYS | 30 | How long a magic link stays valid. |
KATHA_ON_DEMAND_COOLDOWN_MINS | 60 | Minimum minutes between a reader's on-demand "send me one now" picks (floored at 1). |
KATHA_LOGIN_COOLDOWN_MINS | 15 | Minutes before the same address may request another login link (floored at 1). |
KATHA_LOGIN_MAX_PER_IP_HOUR | 10 | Login requests allowed from one client IP per hour (floored at 1). Together with the cooldown above, the only abuse controls on the public login form. |
KATHA_SESSION_TTL_DAYS | 90 | How long a signed-in browser session lives. Absolute — never renewed by use, the same die-on-schedule rule the emailed links follow (floored at 1). |
KATHA_CATEGORIES_PATH | categories.toml | The operator-curated fold of raw Kavita genre strings into reader-facing taste categories. A missing file disables categories; a malformed one fails at startup. Grow it with katha categories unmapped. |
KATHA_METRICS_BIND | 0.0.0.0:9715 | Address the Prometheus exposition (/metrics) binds to — a separate listener from KATHA_BIND. |
KATHA_METRICS_TEXTFILE_DIR | (unset) | Directory for the node_exporter textfile collector. run-due is a one-shot command that exits before Prometheus could scrape it, so it writes its counters here instead (katha_rundue.prom), every series labelled source="rundue". Left unset, the export is disabled. |
KATHA_OPERATOR_EMAIL | (unset) | The operator's address. The Kavita user with this email is the admin and gets the announcement composer at /admin/announce; a reader's collection ask is mailed here. |
KATHA_DOCS_URL | (unset) | Adds a documentation link to the landing page. Left unset or blank, the link is omitted. |
KATHA_ADJACENCY_PATH | adjacency.toml | Author aliases and the optional [map] override over computed neighbours (see Author neighbours). |
KATHA_DEFAULT_FREQUENCY | weekly | Default cadence: weekly | fortnightly | monthly | quarterly. |
KATHA_DEFAULT_BOOK_COUNT | 1 | Suggestions per email. |
KATHA_DEFAULT_LANGUAGE | fr | Default email language: fr | en. |
KATHA_LANGUAGE_FROM_KAVITA | false | Seed an unset reader's language from their Kavita locale instead of the default. |
KATHA_DRY_RUN | false | Rehearse run-due: render each email, then discard it. No mail is sent and no suggestion is recorded, so no reader's cadence clock moves. |
Running katha behind a proxy.
KATHA_BINDdefaults to0.0.0.0:8792— katha listens on every interface and does nothing on its own to confine that. The per-IP login limit readsX-Real-IP, so it only means what it claims to mean when katha is reachable exclusively through a reverse proxy that overwrites that header with the real client address — a replace, not an append, since an appending proxy still leaves an attacker-supplied value in front for katha to read. Expose katha directly andX-Real-IPis whatever the client sends, which defeats the per-IP limit entirely. Requests that arrive with no such header at all share one bucket, so the cap degrades to a global one rather than disappearing.
Do not put
/metricsbehind your public vhost. katha serves it on a second listener precisely so it never shares a port with reader-facing routes. BindKATHA_METRICS_BINDto an interface Prometheus can reach and the internet cannot; the endpoint has no authentication.
Removed: author requests
Author requests and acquisition were removed: chat (livrarr-mcp and seerr-mcp) takes requests now. The watch and requests commands are gone, and the old variables (KATHA_REQUESTS_ENABLED, KATHA_ACQUISITION, KATHA_LIVRARR_*, KATHA_PROWLARR_*, KATHA_MAM_* and the rest) are ignored if still set. On first start, katha drops the request tables from its database.
Choosing the language behavior
A reader's language is resolved in this order:
- Their explicit choice on the settings page — always wins.
- Their Kavita locale — only if
KATHA_LANGUAGE_FROM_KAVITA=true. - The instance default
KATHA_DEFAULT_LANGUAGE.
If all your readers should get one language regardless of their Kavita UI locale, leave KATHA_LANGUAGE_FROM_KAVITA=false and set KATHA_DEFAULT_LANGUAGE. To mirror each reader's Kavita locale instead, set it to true.
Author neighbours
Once a reader has had every book by an author they like, katha widens to a neighbouring author ("you enjoyed X, so here's the related Y") before falling back to the rest of the library.
Computed neighbours
katha works out neighbours itself, from the genres your Kavita library already holds. It never makes a network call to do it. Each author's genres are pooled across their books. Spellings that fold or alias to one author count as one profile. Authors are then compared so that a rare genre two authors share counts for much more than a common one. Each author keeps up to five neighbours, best match first.
The genres come from each book's metadata as Kavita imported it, so the result is only as good as that tagging:
- An author whose books carry no genre gets no computed neighbour. Their readers widen straight to the rest of the library, as before.
- Terms that say nothing about taste are ignored: "fiction", "general", "novel", "literature", "ebook", "audiobook", "non lu" and the like. An author tagged only with those counts as having no genres.
- A genre that every tagged author carries matches nobody.
The neighbours are recomputed on every run from the current library, so an import or a retag takes effect on the next email with nothing to restart.
The [map] override
adjacency.toml's [map] table is an optional override for where the genres say too little or the wrong thing:
[map]
"Ursula K. Le Guin" = ["Adrian Tchaikovsky", "N. K. Jemisin"]
"Fredrik Backman" = ["Gabrielle Zevin", "Matt Haig"]An author's [map] entries come first, in the order written, and their computed neighbours follow. An author listed in both places appears once, in the [map] position. Authors named in the map who aren't in your library are skipped, so aspirational entries are harmless.
To see what katha will do, use katha neighbours show "<author>" and katha neighbours missing (see CLI).
Author spellings
Imports often leave one writer under several spellings. katha compares authors by a folded key — case, accents, punctuation and word order are ignored, so Dostoïevski, Fiodor and fiodor dostoievski are one author. For variants that fold can't merge (different transliterations, or a bare surname versus a full name), add an [aliases] table to the same file:
[aliases]
"Fiodor Dostoïevski" = ["Dostoïevski", "Fyodor Dostoevsky", "Fyodor Dostoyevsky"]Each key is the canonical author, each value the spellings that mean the same writer. This affects matching only — same-author picks, adjacency and reader author preferences — and readers still see each book's author exactly as Kavita stores it. A spelling claimed by two authors stops katha at startup with an error.
Taste categories
categories.toml folds Kavita's raw genre strings into the few categories readers like or exclude on their tastes page. Each key is a category, each value the raw strings it gathers, matched case-insensitively. katha categories unmapped lists the raw strings no category gathers yet, most used first.
A key is the category's identity: readers' choices are stored under it, so renaming a key loses every choice made on it. The key is also what French readers see. A [labels.<lang>] table sets what readers of another language see instead, and a category it leaves out shows its key:
[categories]
"Horreur" = ["Horror"]
"Polar & thriller" = ["policier", "Thriller", "Mystery"]
[labels.en]
"Horreur" = "Horror"
"Polar & thriller" = "Crime & thrillers"A label for a key that isn't a category, in a language katha doesn't speak, or left blank stops katha at startup with an error. katha categories unmapped also names any category with no English label.
CLI
| Command | What it does |
|---|---|
serve | Run the HTTP server (magic links + /health). |
run-due [--only-user <id>] | Send to every reader who is due now; --only-user narrows to one. |
preview [--reader-id <id>] [--lang fr|en] [--send <address>] | Render a sample email to preview.html, or --send it. With --reader-id, picks come from the real pipeline; without it, a built-in sample stands in. |
announce-test --reader-id <id> [--lang fr|en] | Mail one reader a draft announcement from stdin (first line the subject, the rest the body), marked [test], at the address Kavita holds for them. Writes nothing, so the real send still reaches them. Its footer link is real: clicking it pauses that reader. |
announce-draft | Store an announcement from stdin for the operator to review: the French copy, a line of =====, the English copy, each its subject line and then its body. Refuses a book: line no offered library holds, mails nobody, and prints the link that opens the rendered emails and the send button on /admin/announce. |
health | Probe the running server's /health; exit 0 if healthy. |
categories unmapped | List the raw Kavita genres no category in categories.toml maps yet. |
neighbours show <author> | Print an author's neighbours in the order katha tries them, each marked manual (from [map]) or computed with its score and the genres it shares. Any spelling of the author works. |
neighbours missing | List the authors katha cannot widen from, each marked no genres (fix by tagging their books) or no match (fix with a [map] entry), then a count. |
Observability
serve is scraped on KATHA_METRICS_BIND. run-due exports through KATHA_METRICS_TEXTFILE_DIR.