# CLAUDE.md Working notes for LLeMbas. Read this before changing anything. ## What it is A self-hosted web UI for OpenAI-compatible LLM endpoints, written in Python and themed after Middle-earth. Server-rendered FastAPI + Jinja + htmx; SQLite; no JavaScript build step. ## Commands ```bash . .venv/bin/activate pip install -e ".[dev,search]" # `search` adds ddgs for DuckDuckGo lembas serve # http://127.0.0.1:8080 lembas info # paths + counts, useful when confused lembas secret-key # generate LEMBAS_SECRET_KEY lembas create-admin # create or promote an admin pytest # 698 tests, ~40s # PLAN.md tracks what is and is not built ruff check . # lint (line length 100) python scripts/build_artwork.py # regenerate artwork (SVG + PWA icons; # needs fonttools and cairosvg) python scripts/fetch_vendor.py # verify vendored JS against the lockfile ``` ## Hard rules These are the constraints the project is built around. Breaking one is a redesign, not a tweak. 1. **No Node, no npm, no build step.** Browser libraries are downloaded once by `scripts/fetch_vendor.py`, hash-pinned in `scripts/vendor.lock.json`, and committed under `web/static/vendor/`. 2. **Nothing loads from a CDN at runtime.** A self-hosted tool must work offline and must not report page views to a third party. 3. **No hard-coded values outside `tokens.css`.** Every colour, space, radius and control height resolves through a CSS variable. `--control-h` is why buttons, inputs and selects line up: they all take their height from it, so a mixed row is flush by construction rather than by nudging. 4. **Additive-only schema changes.** SQLite only, no Alembic. `init_db()` runs `db/migrations.py:sync_schema()`, which creates missing tables *and* adds missing columns by diffing the models against the database. Renames, drops and retypes are still manual. See "Changing the schema" below. 5. **Secrets never reach the browser.** API keys are Fernet-encrypted at rest and only ever rendered masked. 6. **Model output is untrusted.** Everything from an endpoint goes through `services/markdown.py` (markdown-it → nh3) or `escape_text()`. Never `|safe` on anything that has not. ## The flavour rule Middle-earth lives in the **artwork, theme names, empty states, loading lines and error pages**. It does not live in the functional UI. Chats are called *Chats*, not *Tales*. Folders are *Folders*, not *Chapters*. Buttons say what they do. Someone who has never read the books must be able to use this without a glossary. The two themes are named `moria` and `shire`, and the 404 says "Not all those who wander are lost. This page, however, is." — that is the right amount. ## Layout ``` src/lembas/ main.py app factory, lifespan, error handlers config.py pydantic-settings, all LEMBAS_* variables cli.py typer entry points api/ deps.py Db / CurrentUser / RequiredUser / AdminUser auth.py register, login, logout pages.py full-page routes (chat shell, settings) chats.py messaging + the SSE stream folders.py folder CRUD admin.py connections + instance settings admin_models.py model ordering, defaults, images, access admin_users.py users, groups, permissions admin_audio.py speech-to-text and text-to-speech endpoints admin_search.py web search provider and credentials admin_prompts.py the prompt fragment editor and its preview admin_suggestions.py the cards offered on the new-chat screen admin_tools.py custom HTTP tools and MCP servers audio.py transcribe, speak, voice discovery library.py knowledge, notes, skills pages; memory CRUD files.py upload, serve, remove attachments preferences.py per-user theme, default model, password, audio db/ base.py Base, UUID/Timestamp mixins session.py engine, SQLite pragmas, init_db, session_scope migrations.py additive schema sync (tables + columns) models/ user, chat, connection, setting security/ passwords (argon2), sessions, permissions services/ llm/openai_client.py httpx streaming + model discovery search/ ddgs, SearXNG and Firecrawl behind one shape library/ documents, notes, memories, skills, FTS mcp/ remote MCP servers: framing, transport, rows to tools audio.py OpenAI-shaped /v1/audio/* client fetch.py URL retrieval, HTML to text, the SSRF guard sharing.py one visibility rule for every library store prompts.py every injected prompt fragment, and {{variables}} metrics.py tokens, context percentage and tokens/second tokens.py the chars/4 estimate, for endpoints that report none compaction.py summarising the earlier turns of a long chat suggestions.py new-chat starting points, seeded once harness.py the operational prompt built from what a model has tools.py tool registry, schemas, streamed-call reassembly custom_tools.py the admin-defined HTTP tool runner tool_access.py who may be offered which admin-defined tool chat.py request building, endpoint resolution, titles markdown.py markdown-it + pygments + nh3 crypto.py Fernet encrypt/decrypt/mask files.py attachment validation, images, PDF/text extraction reasoning.py splits thinking from the answer settings_store.py runtime instance settings uploads.py validated image storage sse.py event framing web/ templating.py render() -- always use this, not TemplateResponse templates/ Jinja static/ css, js, vendor, img, sw.js assets/ SVG masters and PWA icons (generated) deploy/ systemd unit, nginx vhost, install/update scripts ``` ## Things that will bite you **`render()`, not `TemplateResponse`.** `web/templating.py:render()` injects `user`, `theme`, `version` and `allow_signup`. Templates assume they exist. If you must call `templates.TemplateResponse` directly (the SSE path does, because there is no `Request`), pass `user` explicitly — `chat/_message.html` renders both roles and the user branch dereferences it. **The message template is the state machine.** `chat/_message.html` renders an incomplete assistant message as a streaming shell carrying `sse-connect`, and a complete one as finished output. That is the *only* thing that starts a generation. A consequence worth knowing: loading a page whose last reply is unfinished restarts it, which is how a dropped connection recovers. **SSE framing.** `services/sse.py:event()` splits payloads on newlines into several `data:` lines. A raw newline in a single `data:` line truncates the event — the failure shows up the first time a model emits a code block. **Streaming opens its own database session.** `api/chats.py:_generate()` uses `session_scope()`, not the request's session, because streaming outlives the request handler. **Escaping is chunk-safe on purpose.** `escape_text()` is `html.escape`, which works character by character, so escaping stream chunks separately equals escaping the whole string. `nh3.clean_text` would also be safe but escapes spaces and slashes, tripling the size of every streamed token. **The fence renderer is replaced, not configured.** markdown-it's `highlight` option re-wraps output in `
` unless the string starts with `` inside our wrapper. `markdown.py` overrides
`renderer.rules["fence"]` instead. There is a regression test for this.

**SVG `