backend / selah
Selah data and agent layer
A local SQLite data layer for a terminal Bible: importers that build FTS5-indexed text, commentary and Hebrew/Greek concordance databases, a reading tracker, and one set of read-only tools served to Claude, Codex and any MCP client.
2026 · live · open source
- [*]LanguagesTypeScript
- [*]FrameworksBun · Model Context Protocol SDK · Claude Agent SDK · AG-UI event protocol · zod · OpenTUI + Solid (client)
- [*]StoresSQLite (bun:sqlite, WAL) · SQLite FTS5
- [*]AsyncAsync-generator agent event streams (AG-UI) · Batched imports yielding to the event loop
- [*]Infrabun build --compile (8 release targets + SHA256SUMS) · Cloudflare Pages (site) · AUR package · GitHub Releases
- [*]Third partiesGitHub raw/codeload (scrollmapper bible_databases, lyteword mhenry-complete, STEPBible-Data) · GitHub Releases API (site build) · Claude via Claude Agent SDK · OpenAI Codex CLI
Selah is a terminal Bible reader, and the backend is everything below the UI: about 4,000 lines in src/data, src/agent, src/mcp.ts and src/cli.ts. Data is split across three SQLite files. bible.db holds public-domain translations with an FTS5 index, study.db holds Matthew Henry's commentary (its own FTS5 index) and a STEPBible word-level concordance, and user.db holds sessions, reads, bookmarks, highlights, notes and Ask threads, so re-importing or deleting a translation can never touch a reader's history.
Every source is pinned to a git commit and downloaded with a retrying fetcher: 4 attempts, backoff of 2s/6s/15s, Retry-After honoured, a 60s no-bytes idle cut, and a verify hook that rejects a truncated or wrong file before it is imported. Caches are written to a temp file then renamed. A translation import replaces its rows in one transaction and rebuilds the FTS index; the concordance import builds staging tables under a generation letter in batches, yielding to the UI between them, then swaps them in with a transactional rename so live queries are never served half an import.
The reading tracker counts only active time (gaps over 3 minutes count as zero) and marks a chapter read only when its end was reached and active time passed a word-count threshold, with one row per chapter per day enforced by a primary key and INSERT OR IGNORE.
The Ask agent and outside agents share nine tool definitions with zod schemas and a 7,000-character output budget. Claude runs them through the Agent SDK's in-process MCP server with Bash, file and edit tools disallowed; Codex is spawned as codex exec --ephemeral -s read-only and reaches the same tools through selah mcp over stdio. 31 test files with 318 cases cover the data layer, tools, MCP server, downloads and UI.
How it holds up
- Auth
- No server-side auth: it is a local-first app, and the agents run on the reader's own Claude Code or Codex login; access control is tool scoping instead: Claude gets only mcp__selah__*, WebSearch and WebFetch with Bash/Read/Write/Edit/Glob/Grep disallowed, Codex runs ephemeral in its read-only sandbox from a scratch directory with its shell tools turned off, and every MCP tool is annotated readOnlyHint.
- Data
- 16 tables in three SQLite files: bible.db (books, translations, verses, chapter_stats + verses_fts), study.db (commentary_sections, commentary_paragraphs, words, lexicon + commentary_fts) and user.db (settings, sessions, chapter_reads, bookmarks, highlights, ask_threads, journal, notes). Two FTS5 indexes, both external-content with porter unicode61 remove_diacritics tokenisation; schemas are CREATE IF NOT EXISTS with no versioned migrations.
- Consistency
- Translation imports delete and reinsert a translation's rows inside one transaction, then rebuild the FTS index; the concordance builds generation-lettered staging tables and swaps them in with DROP + ALTER TABLE RENAME in one transaction; reads are idempotent per chapter per day via a composite primary key and INSERT OR IGNORE; personal writes use ON CONFLICT upserts.
- Failure
- Downloads retry on a fixed set of retryable statuses (408, 425, 429, 5xx including Cloudflare 52x) with backoff and Retry-After, abort an attempt after 60s without bytes, and treat a failed verify as a retryable attempt; corrupt cached sources are deleted and refetched; temp-then-rename writes prevent half-written caches; a failed concordance import leaves the live tables untouched; tool errors return isError rather than throwing into the agent.
- Testing
- 31 test files (test/*.test.ts[x]) with 318 test() cases, run by bun test against a shared imported KJV + BSB database; includes tracker (9), MCP (15), download (11), concordance (23), agent, Codex, commentary, install and UI frame-capture tests.
Techniques
- Generation-swapped concordance importThe STEPBible import builds words/lexicon tables and their indexes under the other generation letter in batches, yielding to the UI between them, then swaps them in with DROP + RENAME in one transaction, so readers never see a partial import and a failure leaves the live tables intact.
src/data/concordance.ts:221src/data/concordance.ts:227src/data/concordance.ts:269 - Transactional translation import with external-content FTS5Each translation's verses and chapter word counts are replaced in one transaction, then the external-content FTS5 index is rebuilt; search parses user input into a quoted, injection-safe MATCH expression with in:book/in:ot filters and ranks by bm25 with highlight markers.
src/data/importer.ts:76src/data/importer.ts:105src/data/db.ts:49src/data/bible.ts:36src/data/bible.ts:145 - Resilient, verified downloads of commit-pinned sourcesSources are pinned to exact upstream commits; the downloader retries only retryable statuses with backoff and Retry-After, cuts attempts that go silent for 60s, runs a verify hook (66 books, correct chapter counts) and writes via temp file plus rename.
src/data/translations.ts:15src/data/download.ts:35src/data/importer.ts:37src/data/importer.ts:45 - Honest reading trackerActive time counts only input gaps up to 3 minutes; a chapter counts as read when its end is reached and active time exceeds 30% of a 250-wpm read (clamped 1-30s); the read is credited to the session's start day and stored once per chapter per day via a primary key and INSERT OR IGNORE.
src/data/tracker.ts:5src/data/tracker.ts:15src/data/user.ts:30src/data/user.ts:212 - One read-only tool set for three agent pathsNine zod-typed tools with a 7,000-character output cap are registered once and served to Claude through the Agent SDK's in-process MCP server (shell and file tools disallowed), to Codex as a read-only ephemeral subprocess wired to
selah mcp, and to any MCP client over stdio; the agent can only suggest a note, never write one.src/agent/tools.ts:34src/agent/claude.ts:14src/agent/claude.ts:44src/agent/codex.ts:21src/mcp.ts:16 - Personal data isolated from content databasesReader data lives in user.db and content in bible.db/study.db, so removing or re-importing a translation or study pack cannot touch history, notes or streaks.
src/data/user.ts:6src/index.tsx:89src/data/importer.ts:110
Counted
- 76Commits
git rev-list --count HEAD - 3 (bible.db, study.db, user.db)SQLite databases
src/data/paths.ts:38,42,49 - 16Tables
grep -c 'CREATE TABLE' src/data/{db,user,commentary,concordance}.ts (4+8+2+2) - 2 (verses_fts, commentary_fts)FTS5 indexes
src/data/db.ts:49, src/data/commentary.ts:36 - 9MCP / agent tools
grep -c 'name: "' src/agent/tools.ts - 31Test files
git ls-files test | grep -cE '\.test\.tsx?$' - 318Test cases
grep -cE '^\s*(test|it)(\.each)?\(' over the 31 test files, summed - 4,017Backend LOC (data, agent, mcp, cli)
git ls-files 'src/data/*.ts' 'src/agent/*.ts' src/mcp.ts src/cli.ts | xargs wc -l | tail -1 - 10,696All src LOC excl. theme JSON and scenes.json
git ls-files src | grep -v -e theme/assets -e scenes.json | xargs wc -l | tail -1 - 8Release targets
scripts/build.ts:14 RELEASE_TARGETS - 4 / 2s, 6s, 15sDownload attempts / backoff
src/data/download.ts:48