A self-hosted web UI for your language models, written in Python.
Talks to anything that speaks the OpenAI API. Themed after Middle-earth.
---
*Lembas* is the Elvish waybread — one bite sustains a traveller for a day's
march. The capitals hide what it runs on: **LLeM**bas.
## Why this exists
Most self-hosted LLM front-ends are large JavaScript applications with a Python
API bolted underneath. LLeMbas is the other way round: **server-rendered
Python**, with htmx and a little Alpine for interactivity. There is no
`package.json`, no bundler, no build step, and nothing is fetched from a CDN at
runtime. Clone it, `pip install -e .`, run it.
## Features
**Working now**
- **Chats** — streaming replies, Markdown with server-side syntax highlighting,
copy and regenerate, automatic chat titles. Chats are created when you send
the first message, so an abandoned one never clutters the sidebar
- **System prompts** — instance-wide, per-model and per-chat, with the most
specific winning outright
- **Reasoning display** — thinking streams into its own collapsible block
(closed by default), labelled with how long it took, and is never replayed as
context
- **Live Markdown** — formatting appears as the model writes, not at the end
- **Stop and rewind** — cut a reply short and keep what arrived, or edit an
earlier message and run the conversation on from there
- **Replies keep running in the background** — navigate away, open another
chat, close the tab; a green dot and a notification tell you when it lands
- **Attachments** — drag, paste or pick images, PDFs and text files. Images are
downscaled and sent to vision models; PDF and text content is extracted and
put in the prompt
- **Folders** — arbitrarily nested, delete a folder without losing the chats
inside it
- **Web search** — offered to the model as a tool it calls when a question needs
it. DuckDuckGo out of the box (no account, no key), or point it at your own
SearXNG, or Firecrawl. The sources stay in the transcript
- **Your own tools** — describe an HTTP call in the admin area (a schema, a URL
template, a secret) and a model can make it. Or add an **MCP server** by URL
and its tools appear beside the built-in ones. Both restrictable to groups,
and neither can be pointed at your own network unless you say so
- **Speech in and out** — dictate a message and have replies read aloud, against
any OpenAI-compatible audio endpoint (whisper.cpp, Speaches, Kokoro…). Each
person picks their own voice
- **A library** — four places a model can reach for. **Knowledge**: documents,
images and web pages you collect, grouped into named bases so a chat can be
pointed at just the right one, searched before the web. **Notes**: longer
things it writes down and finds again later. **Memory**: short facts about you,
in front of it on every turn. **Skills**: saved procedures it can follow, and
write. All of it visible and editable by you, and shareable with a group or a
person, read-only
- **Installable** — add it to a phone home screen or a desktop launcher and it
runs in its own window
- **OpenAI connections** — point at OpenAI, LM Studio, vLLM, llama.cpp,
llama-swap, Ollama or OpenRouter; models are discovered and cached
- **Model settings** — searchable, filterable list with a page per model:
ordering, pinned models, an instance default and a per-user default, custom
names, descriptions and images. Scales to hundreds of models
- **Users, groups & permissions** — per-group grants that union rather than
override, and model access restricted to chosen groups
- **Accounts** — first account becomes the administrator, argon2 password
hashing, revocable server-side sessions, self-service password change,
admin-managed accounts
- **Admin settings** — open or close registration from the UI, stored in the
database and effective immediately
- **Two themes** — *Moria* (dark) and *Shire* (light), switchable per user
**Planned**
Agentic execution (local and over SSH) · image generation · OCR for scanned
PDFs · semantic search in the library.
See [PLAN.md](PLAN.md) for what is built, what is not, and why.
## Quick start
```bash
git clone https://git.houmeres.sk/Houmeres/LLeMbas.git
cd LLeMbas
python -m venv .venv && . .venv/bin/activate
pip install -e ".[dev,search]" # `search` adds DuckDuckGo; drop it if unwanted
cp .env.example .env
lembas secret-key # paste the result into LEMBAS_SECRET_KEY
lembas serve # http://127.0.0.1:8080
```
Open the address and create the first account — it becomes the administrator.
Then go to **Admin → Connections** and add an endpoint. For a local runner that
is usually `http://localhost:1234/v1` with no API key. Press **Test & refresh**
and its models appear in the chat model picker.
> The vendored browser libraries (htmx, Alpine) are committed, so no network
> access is needed to run. To re-fetch or bump them:
> `python scripts/fetch_vendor.py --update`.
### Web search
**Admin → Web search.** DuckDuckGo needs nothing beyond the `search` extra
above. SearXNG needs its JSON format enabled — add `- json` under
`search.formats` in its `settings.yml`, or every search fails. Firecrawl needs
an API key.
Search is offered to the model as a *tool*, so it decides when a question needs
looking up. It is only offered to models marked **tools** under
**Admin → Models**: an endpoint without tool support rejects the whole request
rather than ignoring the extra field, so the flag is a real switch and not a
hint.
### Audio
**Admin → Audio.** Two endpoints, because they are usually two servers:
| | Speaks | Example |
|---|---|---|
| Dictation | `POST /v1/audio/transcriptions` | whisper.cpp's `whisper-server`, Speaches, faster-whisper-server |
| Read aloud | `POST /v1/audio/speech` | Kokoro-FastAPI, OpenAI |
If the speech endpoint also answers `GET /v1/audio/voices` the voice list is
read from it, and each person can pick their own under **Settings → Audio**.
Recorded audio is passed straight through and never written to disk.
> The microphone needs HTTPS or localhost. Browsers do not grant it over plain
> HTTP, so a LAN install without TLS will not offer dictation.
### The library
**Sidebar → Library**, and **Settings → Memory**. Nothing is on by default for a
model: give it the tools it should have under **Admin → Models**, where
`tools` decides whether a tool list may be sent at all and the built-in tools are
chosen one by one.
Knowledge is organised into **bases** — one per subject, project or client. A
chat with no base attached searches everything you have; tick some in the chat's
settings panel and it searches only those. Sharing happens at the base: share it
and everything in it comes too, read-only.
Search is SQLite's FTS5 — keyword matching with BM25 ranking, no embedding
service to run and nothing that stops working offline. It will not match a
paraphrase, so a line of description on a document is worth writing.
> Saving a **link** makes your server fetch a URL. Addresses on your own machine
> and network are refused unless an administrator opts in under
> **Admin → Web search**, because the address can come from a model and the
> server can reach things your browser cannot.
### Installing as an app
Open it in a browser and use *Install* (Chromium) or *Share → Add to Home
Screen* (iOS). This also needs HTTPS or localhost — service workers are
unavailable over plain HTTP, and without one there is nothing to install.
There is no offline mode beyond a page saying so. Everything is rendered by your
server, so a cached conversation would be a snapshot that silently went stale.
## Configuration
All variables are prefixed `LEMBAS_` and can live in `.env`. See
[`.env.example`](.env.example) for the annotated list.
| Variable | Default | Purpose |
|---|---|---|
| `LEMBAS_SECRET_KEY` | *generated* | Signs sessions and encrypts stored API keys. **Set this.** A generated key changes every restart, signing everyone out and making stored API keys unreadable. |
| `LEMBAS_DATA_DIR` | `./data` | SQLite database and uploads. |
| `LEMBAS_HOST` / `LEMBAS_PORT` | `127.0.0.1` / `8080` | Bind address. |
| `LEMBAS_ALLOW_SIGNUP` | `true` | Whether new users may register themselves — the *initial* value only. Once set under **Admin → General** the stored setting wins. The first account is always an admin regardless. |
| `LEMBAS_DEFAULT_THEME` | `moria` | `moria` (dark) or `shire` (light). |
| `LEMBAS_SESSION_TTL` | `2592000` | Session lifetime in seconds. |
| `LEMBAS_REQUEST_TIMEOUT` | `300` | Seconds to wait on an upstream model. |
### Commands
```bash
lembas serve # run the server
lembas info # where data lives, what is configured
lembas secret-key # generate a value for LEMBAS_SECRET_KEY
lembas create-admin # create or promote an administrator
```
## How it fits together
```
Browser ──form POST──▶ FastAPI ──▶ SQLite
▲ │
│ └──httpx──▶ any OpenAI-compatible endpoint
└──── server-sent events ◀───────────────┘ (streamed reply)
```
Sending a message stores the turn and returns two HTML fragments: the user's
bubble and an empty assistant bubble carrying an `sse-connect`. That opens a
server-sent event stream which appends tokens as they arrive, then replaces the
whole bubble with the finished, Markdown-rendered version. Rendering and
highlighting happen in Python, so the streamed and final views cannot disagree.
```
src/lembas/
api/ routes: auth, chats, folders, admin, pages
db/models/ SQLAlchemy schema
security/ password hashing, sessions
services/ llm client, chat orchestration, markdown, crypto, sse
web/ Jinja templates and static assets
assets/ SVG artwork masters
scripts/ artwork generator, vendored-JS fetcher
deploy/ systemd unit and nginx vhost for a real install
```
## Development
```bash
pytest # test suite
ruff check . # lint
python scripts/build_artwork.py # regenerate the SVG artwork
python scripts/fetch_vendor.py # verify vendored JS against the lockfile
```
There is no Alembic. The schema is SQLite-only and synchronised at startup:
missing tables and missing columns are added automatically, so adding a field to
a model needs nothing but a restart. Renames, drops and retypes are still manual
— see `CLAUDE.md`.
## Artwork
The logo, favicon and banner are original vector work, generated by
[`scripts/build_artwork.py`](scripts/build_artwork.py) so the mallorn leaf stays
identical across every size it appears at. The wordmark is
[Source Serif 4](https://github.com/adobe-fonts/source-serif) (SIL OFL 1.1)
converted to outlines — a README banner cannot load a webfont, and ``
would render in whatever serif the reader happens to have.
## Licence
[GPL-3.0](LICENSE).
## A note on the theme
This is an independent hobby project, themed as an affectionate nod to
J.R.R. Tolkien's world. It is **not affiliated with, endorsed by, or connected
to** the Tolkien Estate, Middle-earth Enterprises, or any related rights
holder. All artwork here is original.