Architecture
Last reviewed: 2026-10-05.
This page says what runs where, what each part does, where words are kept, what leaves
your machine, and where to look next. It describes the code on main. Plans for later
work live in slices/; where this page mentions one, it names the
slice.
1. Overview
Section titled “1. Overview”Kotiko has two parts:
- The browser extension (
extension/, Manifest V3). It swaps words on the pages you read, and it has its own pages: the toolbar popup, the dashboard (your word list and settings) and the welcome tab. - The server (
server/, Elixir and SQLite). It is optional. It keeps your words, looks new words up with a model, and runs the Telegram bot.
The extension works in one of two modes, chosen in its settings (wordsHome in
extension/lib/local-mode.js).
Server mode: your words live on a Kotiko server you run yourself.
web page ── content script ◀── storage.local (the words pages need) ▲ popup, dashboard, welcome ──▶ background ──HTTP + token──▶ Kotiko server ──▶ model API │ (Elixir, ──▶ en.wiktionary.org Telegram (your phone) ◀──── long polling ────────────────────┤ SQLite) ──▶ transcription ▼ (optional) <data dir>/kotiko.dbLocal mode: your words live in the browser, and the extension looks new words up itself, with your own key for a model service or a model on your machine. No server is needed.
web page ── content script ◀── storage.local (the words pages need) ▲ popup, dashboard, welcome ──▶ background ──▶ IndexedDB "kotiko" (words, keys) │ ├──HTTPS + your key──▶ model API (OpenRouter, OpenAI, │ Anthropic, Gemini, Groq, or a │ local Ollama or LM Studio) └──HTTPS──▶ en.wiktionary.org (the word only)A browser in local mode can move its words to a server and back
(extension/background.js, "moving words between this
browser and a server"). Syncing several devices through the server in local mode is
planned in slice 39.
2. Components
Section titled “2. Components”The extension
Section titled “The extension”The extension ships exactly the files in extension/. There is no bundler and no npm
code at runtime; every script is a classic script that attaches one namespace to
globalThis, so Node tests can load the same files.
- Content scripts run on every page the browser lets extensions into (
<all_urls>incontent_scriptsinextension/manifest.json). The pure modules inextension/lib/do the thinking: the matcher (matcher.js), which parts of a page are in a language you read (page-lang.js), what not to swap (rules.js,sensitive.js,controls.js), capitals (casing.js) and which language wins (precedence.js).content/engine.jschanges the page without taking text nodes away from the site's framework.content/popover.jsshows the word card in a closed shadow root.content.jsties them together. Content scripts read onlystorage.local; keys and the server token live in the background's own store, out of their reach (an older install's token is moved there on upgrade). - The background is
background.js: a service worker in Chrome, an event page in Firefox (the manifest lists both forms). It is the only part that opens the word store, reads secrets, calls the server or a model, and runs background jobs: add jobs that survive the popup closing (lib/add-queue.js), the pronunciation refresh (lib/refresh-job.js) and the Wiktionary pass (lib/wiktionary-pass.js). - Extension pages: the popup (
popup.js, withpopup-more.jsloaded on first use), the dashboard (dashboard.html, also the options page, with bulk add inextension/bulk/) and the welcome tab (welcome.js). They talk to the background by messages. Every interface string is inextension/_locales/. - Messaging.
lib/messages.jsroutes each message type to a handler that names who may call it:page(an extension page) orcontent(a content script in a web page), plusdocs(a content script on Kotiko's docs site, only for the "Connect OpenRouter" sign-in code). Content scripts may only ask for a sync and the sensitive-site list. Adding, editing and deleting words, and anything that touches keys, are for extension pages only; a content script asking getsforbidden. - Local mode (
lib/local-mode.js): settings, the one-time upgrade of older installs, and the word routes over the extension's own store (lib/store.js). The lookup client (lib/llm/client.js, withpolicy.jsandcatalog.js) calls any OpenAI-compatible API through the presets inspec/providers.json.lib/pkce.jsholds the "Connect OpenRouter" sign-in; its button stays hidden until the docs site (slice 44) serves the callback page, so pasting a key is the way in today. - Server mode:
lib/sync-controller.jskeeps one sync running at a time, andlib/validate-words.jschecks the server's answer before it is stored.lib/words-v1.jsserves the dashboard's reads and edits through/api/v1. - Design system:
extension/ui/(tokens, components, icons), from slice 06.
The server
Section titled “The server”server/lib/kotiko/application.ex starts in this
order: the log filter that hides secrets, settings, the data folder, the API token,
database migrations (with a backup first), a startup summary, then the supervision tree.
- HTTP: Bandit serves
Kotiko.Router, a Plug router. Every request passesKotiko.Plug.HostCheck(plug/host_check.ex, refuses unknown host names against DNS rebinding), then the token check (deny by default; onlyGETandHEAD /healthare open), then body parsing (64 KB, 1 MB for the batch route)./api/v1is forwarded toKotiko.RouterV1. The routes are listed inreference/http-api.md. - Settings:
Kotiko.Configparses the environment (.env) once at boot and stops the server with one readable message (exit status 78) if anything is wrong. Every setting is inreference/configuration.md. - Token:
Kotiko.TokentakesAPI_TOKEN, or the saved<data dir>/api-token, or makes a new 256-bit one and saves it with mode 0600.mix kotiko.tokenprints it or replaces it. - Lookups:
Kotiko.Lookupturns typed text into checked words.Kotiko.LLMandllm/ask the model: which models and in what order (catalog.ex), the daily quota (quota.ex), a 30-day cache (cache.ex), what a failure means (policy.ex), one HTTP attempt (client.ex) and at most two calls in flight (slots.ex).Kotiko.WordSpecchecks every answer before anything is saved. - Pronunciations:
Kotiko.Pronouncereads the IPA on a word's Wiktionary page for languages with word stress;Kotiko.WiktionaryPassdoes this once for words saved earlier;Kotiko.PronunciationRefreshis the one-time job that asks the model for missing pronunciations. - Words and storage:
Kotiko.Wordsholds every database function;Kotiko.Repois Ecto over SQLite (ecto_sqlite3);Kotiko.WriteLockqueues writers.Kotiko.Migrationsbacks the database up before a pending migration.Kotiko.DataDirfinds the data folder and copies words over once from the folder used before the rename.Kotiko.Janitorcleans up daily. - Telegram:
Kotiko.Botlong-polls Telegram (no public URL or webhook) throughKotiko.Telegram, and answers only the IDs inALLOWED_TELEGRAM_IDS. It starts only whenTELEGRAM_BOT_TOKENis set.Kotiko.Transcriberturns voice notes into text whenTRANSCRIBE_URLis set. - Logs:
Kotiko.Log.Redacttakes tokens and keys out of every log line at every level. Words reach the log only withLOG_LOOKUPS=true, and then only at debug level.
The shared spec
Section titled “The shared spec”spec/ holds what both sides must agree on: the word record's
schema, the prompt, the validation rules (rules.json), language data, model and
provider presets, and test fixtures that both runtimes must pass. The server embeds the
files at compile time (Kotiko.Spec). The extension
can't read files outside its folder and has no build step, so
spec/tools/sync-extension.mjs copies them into
extension/spec/ and writes extension/spec/spec.js; CI fails if the copy is stale.
3. Data
Section titled “3. Data”The word record is defined once in
spec/word.schema.json: a target word (lang, native) with
its meaning in one base language (base_lang, gloss, forms), pronunciation fields,
status, origin and timestamps. Someone who reads two languages has two records for
the same target word. Deleting leaves a tombstone that can be restored for 30 days.
Where it is kept:
| Mode | Words | Keys and tokens |
|---|---|---|
| Server | SQLite at <data dir>/kotiko.db (default $XDG_DATA_HOME/kotiko, else ~/.local/share/kotiko); the extension keeps a copy of the words pages need in storage.local |
The server's token in <data dir>/api-token or .env; model and Telegram keys in .env. Any of them can be in its own file instead (NAME_FILE, docs/reference/configuration.md). In the extension, the server token is in the IndexedDB store |
| Local | IndexedDB database kotiko, opened only by the background (lib/store.js); a projection of the active words in storage.local for content scripts (lib/projection.js) |
Model keys in the same IndexedDB store's secrets, which content scripts can't reach |
The server also keeps a lookup cache, kept add responses (24 hours) and background-job
state in the same SQLite file, backups in <data dir>/backups/, and the model list in
<data dir>/models-cache.json.
What leaves your machine (read from the code; slice 28 will publish the full inventory and privacy policy):
- The model API you chose gets the text you type to add a word, your base languages, the languages you added lately (as a hint) and Kotiko's prompt. In server mode the server sends it; in local mode the extension does. Page text is never sent.
- en.wiktionary.org gets only the word, to read its pronunciation, for languages with
word stress.
KOTIKO_WIKTIONARY=falseturns this off on the server. - Telegram, only if you turned the bot on: your messages to the bot, and the bot's replies (word cards).
- The transcription endpoint, only if
TRANSCRIBE_URLis set: the audio of voice notes sent to the bot. - OpenRouter's model list and key status (
/models,/key), when the model API is OpenRouter. These requests carry your key, never your words.
There is no telemetry or analytics.
4. Main flows
Section titled “4. Main flows”Adding a word from the popup. The popup sends add to the background, which makes an
add job and answers at once. The job looks the word up: with the server
(POST /api/v1/words) or with your own key (local mode). The answer is checked against
the spec, merged into an existing record if there is one, and saved; the result
("Added", "Already in your list", "Updated") comes back with Undo. A client_request_id
makes a retry save nothing twice. Text written as native = meaning is saved without
any model.
Adding a word from Telegram. The bot receives the message (or a voice note, which
the transcriber turns into text), runs the same lookup and checks on the server, and
answers with a card: add … saves at once with Undo; a question shows Add and Skip.
Sync (server mode). The background asks the server for the words every minute (a
browser alarm), on page loads and after a change, keeps one request running at a time,
checks the answer, and writes the words to storage.local. Content scripts pick up the
change. The page sync still reads the older GET /api/words route; the dashboard reads
and edits through /api/v1.
Swapping words on a page. The content script works out which parts of the page are
in a language you read, splits the text into words with the browser's own
Intl.Segmenter, looks them up in an index of your words, skips what shouldn't change
(code, inputs, controls, likely names, sensitive sites), picks one of your languages per
word, applies that language's capitals, and changes the text. It watches the page for new
content.
5. Trust boundaries
Section titled “5. Trust boundaries”Kotiko treats these as untrusted and checks what crosses them:
- the web page and its scripts (the page DOM is read as text and never written as HTML);
- content scripts, which may only send the messages
messages.jsallows them; - the network between the extension and the server (bearer token, Host check; plain
HTTP only if you set
BINDto a network address, with a warning at startup); - the model's answers, which are checked against the spec before anything is saved;
- Telegram updates (allowlisted user IDs only);
- the files in the data folder, protected by your operating system's file permissions.
The full threat model, every input and how it is checked, and the evidence are in the assurance case.
6. External dependencies
Section titled “6. External dependencies”Services at runtime. None is required by the extension in local mode except a model you choose; all are optional for the server except a model API.
| Service | Used for | Receives |
|---|---|---|
An OpenAI-compatible model API (OpenRouter by default; presets in spec/providers.json) |
Working out a word you typed | The typed text, base and recent languages, the prompt, your key |
en.wiktionary.org (MediaWiki REST API, spec/wiktionary.json) |
Pronunciations | One word per request |
| Telegram Bot API (optional) | The bot | Messages to and from the bot, the bot token |
| A transcription endpoint, such as a local whisper.cpp server (optional) | Voice notes | The audio |
Platform.
- Browsers: Chrome, Brave and Edge (Manifest V3, service worker), and Firefox (event page; add-on ID in the manifest). Firefox for Android and Safari are planned in slices 45 and 51.
- Server: Elixir 1.15 or newer (
server/mix.exs). CI tests the oldest and newest supported pairs, Elixir 1.15.8 with OTP 26.2 and Elixir 1.19.2 with OTP 28.1 (.github/workflows/ci.yml). SQLite comes withecto_sqlite3. A systemd user service is installed (and removed, with--uninstall) byserver/install-service.sh; Docker and other service managers are planned in slice 40. - Node 22 is for development only: tests, linting and tools
(
package.json). The extension ships no npm code.
Libraries. The computer-readable lists are server/mix.exs and
server/mix.lock for the server (Bandit, Plug, Jason, Req, Ecto
SQL and ecto_sqlite3 at runtime), and package.json and
package-lock.json for development tools. Both lockfiles pin
exact versions and are committed.
Keeping them current. Dependabot opens weekly updates for Mix, npm and GitHub Actions
(.github/dependabot.yml). CI runs mix hex.audit (retired
packages) and mix deps.audit (known vulnerabilities) on every pull request. GitHub
Actions are pinned by commit SHA. A software bill of materials attached to each release
is planned in slice 30.
7. Where decisions live
Section titled “7. Where decisions live”slices/: one spec per piece of work, with its status and, once built, implementation notes. This is the detailed plan.slices/DECISIONS.md: decisions already made, who made them and why.docs/research/: the six research reports the plan came from.spec/README.md: the shared word spec.
This page is reviewed at each minor release, and updated in the same pull request as a change that moves a component, adds a service or changes what leaves the machine.
