Files
LLeMbas/CLAUDE.md
T
Homer c4aff999ba Files that outlived the chats that held them, and a page that led with its footnotes
The second audit pass. Four things, and the first two were reported.

The Prompts page put a screen of variables and a screen of preview above
the editor, so the tabs began two screens down and switching one had to
drag the whole page to be any use -- and on a short tab it could not drag
far enough, leaving the panel stranded above a screenful of nothing.
Editor first, reference after, bar sticky. Custom themes were three fixed
slots: fifty-seven empty colour boxes on a fresh instance and no way to
make a fourth theme. One block per theme plus a blank one, colours behind
a disclosure. Both measured rather than argued about -- rendered through
TestClient and driven under headless Chromium, where the tab bar moved
385->642px before and does not move now, and the themes page went from
5495px to 2820px.

Asking where generated images go found the other two. Deleting a chat
cascades to the attachment rows and leaves every file on disk; the helper
written for exactly that was called from one place, and it was not the
delete button, a schedule's chat, a helper's chat or deleting an account.
Underneath it, `claim` bound message_id and never chat_id, so anything
picked before a chat existed kept an empty chat_id forever -- which six
readers filter on, so those files were also unnamed in the prompt,
unopenable in the canvas, and invisible to the one caller the cleanup had.

And folders nest now. The route has handled parent_id since folders
existed, with a cycle guard and a depth cap the move path never applied;
the sidebar has always drawn a tree. Nothing could ask for one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 13:20:59 +02:00

113 KiB
Raw Blame History

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

. .venv/bin/activate
pip install -e ".[dev,search,ssh]"  # `search` adds ddgs for DuckDuckGo,
                                    # `ssh` adds asyncssh for agent chats

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                              # 2088 tests, ~2min
                                    # 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/. Adding one is a --update, same as bumping one: a name in PACKAGES with no lock entry has nothing to verify against, and the script refuses rather than writing it unpinned. xterm is the one heavy dependency — 280KB, more than everything else together — and is loaded only on a chat that can open a terminal.
  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, quotas -- list plus detail,
                     with "what can this person actually do?" answered
    sharing.py       the share panel: search, and one grant per request
    admin_audio.py   speech-to-text and text-to-speech endpoints
    admin_search.py  web search provider and credentials
    admin_images.py  the ComfyUI, and the workflow templates on it
    admin_prompts.py the prompt fragment editor and its preview
    admin_branding.py  the name, the logo, the wording and the themes
    admin_extraction.py  what a file may cost, and what finds it afterwards
    admin_updates.py   what is running, what is available, and a button that
                     writes a file rather than doing the work
    branding.py      /branding.css and the assets behind it, both unauthenticated
    admin_suggestions.py  the cards offered on the new-chat screen
    admin_tools.py   custom HTTP tools and MCP servers
    admin_agents.py  whether agent chats exist, and what they may spend
    agents.py        SSH connections, kept by the people who own them
    terminal.py      the terminal panel's WebSocket, and its two locks
    audio.py         transcribe, speak, voice discovery
    library.py       knowledge, notes, skills pages; memory CRUD
    messages.py      the one long conversation, and paging back through it
    reports.py       the Reports feed, and one report on its own page
    schedules.py     the Scheduled list, the rule form, and a task's controls
    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
    images/          drawing on a ComfyUI: comfy.py speaks HTTP,
                     workflow.py fills a template, tool.py ties them
                     to a chat and decides whether to keep the result
    library/         documents, notes, memories, skills, FTS
    mcp/             remote MCP servers: framing, transport, rows to tools
    agent/           agent chats: the mode table, SSH, the six tools,
                     terminal.py (shells held open behind the panel),
                     shell_marks.py + capture.py (where one command ends),
                     index.py (what is in the project directory),
                     instructions.py (the project's own AGENTS.md),
                     patch.py (applying a unified diff, and rendering one)
    audio.py         OpenAI-shaped /v1/audio/* client
    push.py          web push: the only thing here that talks to an outside
                     service, and only because there is no other way to reach
                     a browser that is closed
    fetch.py         URL retrieval, HTML to text, the SSRF guard
    messages.py      the one conversation per person: bounded in the request,
                     unbounded on disk
    reports.py       filing a finished piece of work, and finding it again
    schedules.py     making, changing and stopping a schedule
    schedule/        work that happens because time passed: clock.py (whose
                     "now"), rule.py (the recurrence, pure and total),
                     ticker.py (the loop and the claim), runner.py (firing),
                     compile.py (plain words into a rule), tool.py (the four
                     a model calls)
    wake.py          starting a reply from outside a request -- one lock
                     discipline, shared by finished jobs and by schedules
    sharing.py       one visibility rule for every library store
    usage.py         what an account spent this month, and whether it may spend
                     more -- written even when the reply failed
    updates.py       two channels (release tags, or the branch tip), what is
                     running, and the request file the helper watches for
    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
    tool_labels.py   what each tool is called and looks like, in one table
    plans.py         a plan's shape, and keeping one current
    custom_tools.py  the admin-defined HTTP tool runner
    tool_access.py   who may be offered which admin-defined tool
    interaction.py   pausing a reply to ask the reader something
    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
    subagent.py      a helper another model sent: a hidden chat, one turn,
                     and everything it may not do
    branding.py      whose instance this is: the name, the artwork, the
                     wording and the themes, cached once per process
    llm/embeddings.py  /v1/embeddings, batched, normalised on the way out
    library/chunks.py    splitting a record, and packing a vector
    library/indexing.py  keeping the semantic index current, and rebuilding it
    library/retrieval.py keywords and meaning, fused
    settings_store.py  runtime instance settings
    canvas.py        what is open in the canvas panel, and where it comes from
    scratch.py       a chat's own working document
    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
      js/commands.js   the / table, and the keyboard that does the same jobs
      js/canvas.js     the canvas panel's three behaviours
      js/composer.js   the menu / and @ open, and the mirror that marks them
assets/              SVG masters and PWA icons (generated)
deploy/              systemd unit, nginx vhost, install/update scripts, the
                     opt-in update helper, and an LXC bootstrap
Dockerfile           one stage; no secret and no data baked in
docker-compose.yml   loopback only, one replica, TLS proxy expected in front
docs/notes/          the rest of "Things that will bite you", by topic

Things that will bite you

Several topics live in docs/notes/, and are not loaded with this file. They were split out because this one is read in full on every session and each of them is only wanted while you are in that corner of the code. Read the file before touching the code it names -- these are the same notes, not a summary.

  • docs/notes/agent-chats.md -- the four modes and where they are enforced, how a round is authorised and how "always allow this" derives a pattern, the agent harness fragments, SSH, background jobs, the file tools and the patch matcher, the project listing and a project's own AGENTS.md, and the terminal panel.
  • docs/notes/schedules-and-reports.md -- claiming a schedule before firing it, the pure recurrence rule, the wake lock, task chats, Reports, why an empty kind means both sides of the sidebar switch and never "no filter", and the four scheduling tools a model calls (which did not exist, so a model asked to schedule something wrote a note and said it had).
  • docs/notes/image-generation.md -- the ComfyUI workflow with holes in it, what substitution walks, the review-and-retry loop, and how a failure reports itself.
  • docs/notes/permissions-and-sharing.md -- the union rule shown rather than thrown away, where read and write are split and why not everywhere, quotas as the union rule applied to numbers (and why zero wins), where each is enforced, and the three deletes that have to forget a share because nothing cascades.
  • docs/notes/search-and-extraction.md -- extraction limits as a snapshot, why reciprocal rank fusion and not a weight, how a record scores as its best chunk, why vectors from two models never meet, and the session event that notices a library record changing.
  • docs/notes/branding.md -- the branding snapshot and why it is a Jinja global, where the instance name went and how an upgrade keeps it, how a custom theme inherits through data-base, and why /branding.css is a route.
  • docs/notes/subagents.md -- the hidden chat a helper runs in, why unattended is a column and not a kind, the two halves that stop a helper stalling on a card nobody can see, what it may run and why Auto is never inherited, and where the three bounds are counted.

render(), not TemplateResponse. web/templating.py:render() injects user, theme, layout, 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 <pre><code> unless the string starts with <pre, which would nest a second <pre> inside our wrapper. markdown.py overrides renderer.rules["fence"] instead. There is a regression test for this.

SVG <style> is document-scoped. Two text runs in one SVG sharing a class name means the later rule recolours both. build_artwork.py takes class names as parameters for exactly this reason.

Gradient ids are document-global. The mark() macro takes a uid because two marks on one page with identical ids make the second silently reuse the first one's gradients.

Two kinds of settings. lembas.config is deployment configuration read from the environment at startup. services/settings_store.py is instance settings an admin edits at runtime, stored in the settings table. Environment variables seed the latter as an initial value only — once stored, the database wins, or a toggle in the UI would silently revert on the next restart.

Permissions are a union, and admins bypass them. security/permissions.py resolves a baseline (instance setting) widened by each group. A group grants; it never denies — otherwise "why can this user not do X" needs a simulation of every group to answer. Model access is separate: models_visible_to().

FastAPI cannot tell an empty form field from an absent one. With x: str | None = Form(None), a submitted x= arrives as None, so "clear this field" is indistinguishable from "leave it alone". api/chats.py:update_chat reads await request.form() and checks key presence instead. Anything with a clearable field must do the same.

Mapped[list] without an element type is not a collection. SQLAlchemy treats a bare Mapped[list] as a scalar and hands back None instead of []. Always write Mapped[list[Group]], with a TYPE_CHECKING import if the class lives in another module.

Auto-titling is a request like any other, and it was reading the wrong field of one. complete() hands back message.content verbatim, and a model that emits <think> inline puts its thinking in exactly that field -- so a title came back as "Okay, the user wants a short title for", or, once the too-long guard caught that, as the first prompt trimmed, which looks precisely like titling never having run. generate_title puts the reply through reasoning.strip_reasoning now. The budget was 24 tokens, which is ample for six words and nowhere near enough for a model that thinks first: too small is not a shorter title, it is no title, because the thinking consumes the budget and content comes back empty. It is TITLE_MAX_TOKENS and generous.

Deliberately not apply_effort(body, "low"), tempting as that is. Those two fields appear only where somebody has opted in, precisely so a provider strict about unknown parameters sees the request it always did -- and an LLMError here is caught and turned into a fallback title, so a 400 would be titling silently switching itself off. The token budget is what makes room for the thinking.

Reasoning arrives two ways. A reasoning_content delta field (llama.cpp, llama-swap, vLLM) or <think> tags inline in content (Ollama and friends). services/reasoning.py handles the second with a streaming splitter, because the tags arrive split across chunks. Reasoning is stored in Message.reasoning and is deliberately not replayed as context on the next turn.

Attachments are typed by their bytes, not their name. services/files.py sniffs magic numbers; a .png full of text is stored as text. Images are downscaled and re-encoded (a phone photo is megabytes of base64), PDFs have their text extracted once at upload — re-extracting per request would let a reply change because a parser was upgraded.

Images only go to models marked vision. Sending content parts to an endpoint without multimodal support is not graceful degradation; most reject the whole request. build_request() checks the capability and falls back to a plain string. A plain text turn must stay a plain string for the same reason.

Attachments are served, never linked. Images reach the model as base64 data URIs: a local endpoint has no route back to LLeMbas and a hosted one has no credentials. Non-images are served Content-Disposition: attachment with nosniff, so an uploaded .html cannot execute in this origin.

Uploads are unbound until the message is sent. Attachment.message_id is null in the composer; files.claim() binds them, and only unclaimed rows owned by that user, so a forged id cannot pull in someone else's file. Abandoned ones are swept at startup.

Generation is a background task; the SSE endpoint only follows it. services/generation.py owns the work and the registry; api/chats.py:_follow watches a Generation and streams what it sees. Closing the connection does NOT stop the reply -- that was the old behaviour and it cut answers off when the reader navigated away. Any route that creates an assistant placeholder must also call generation.ensure().

ensure attaches, restart replaces. The registry is keyed on message id and finished generations linger KEEP_FINISHED so a follower arriving at the last moment still gets the final frames. ensure is idempotent because a page load finding an unfinished reply must attach rather than start a second one. Regeneration is the only caller that reuses a Message row, and therefore the only one for which idempotence is wrong -- it got the finished generation back, made no request, and left the browser reconnecting to a stream with nothing to say. It calls restart. _persist refuses to write when another generation owns the message, because a cancelled predecessor's finally: still runs.

The row is written before done is set. _follow breaks out the instant it sees that flag and re-renders the bubble from the database, so the row has to be authoritative first. The other order silently showed the previous turn's stored metrics.

A thinking block reports its own round. Message.reasoning_ms was the reply's first burst, written once, so on a forty-round reply only the first block could honestly claim a duration and the rest said "Thought" and nothing. Generation.thinking_ms accumulates per round — the interval between a round's first and last reasoning delta, not a sum of per-delta gaps, which would count the network's latency as the model's thinking — and close_step stamps it cumulatively, so steps.py diffs it exactly as it diffs the three lengths. The last round closes no mark (it is the round that stopped calling tools), so its duration is what the reply spent beyond the last mark, which is why _persist stores thinking_ms into reasoning_ms in preference: it is the same measurement done properly.

The live block's numbers come from a think frame, and round_thinking_ms is written by the producer. Computing it in the follower from a start time would keep the clock running after the model had stopped thinking and moved on to a tool — a timer rather than a measurement. The animated ellipsis is a content keyframe in CSS: no timer to start, stop or clean up when the block is swapped away, and it stops existing when the element does.

include_open and live are two questions, and conflating them put a caret on every finished reply. One says whether to emit the trailing step, the other whether it is still being written. for_message wants the first without the second. The test that existed asserted the caret was on the right step and passed; it never asked whether a finished reply should have one.

A reply is a sequence of steps, and the marks are what make it one. The three stores a reply writes into -- content, reasoning, tool_events -- are each append-only and each correct, and none of them records interleaving. So a bubble was rendered as three zones (all the thinking, then every tool block, then all the prose), which reads fine on a two-round answer and is unusable on a forty-round one. Message.steps_json is a table of contents over the three, not a fourth copy of anything: one entry per closed step holding the cumulative length of each at that moment, written by Generation.close_step(). Because they are marks, build_messages, compaction, titling and the copy button all still see message.content as the one string it always was. services/steps.py does the walk; no marks means the old layout, which is what every row written before this reads back, with no version flag and no branch in the template. close_step is called where a round ends and in _gave_up/_wrap_up, which append outside the round loop -- without their own mark the one line saying why the reply stopped lands in the step still being written, where the live view has no tools slot to show it in.

Splitting Markdown at those boundaries can leave a code fence open, which markdown-it then runs to the end of the segment and mispairs every later fence in the reply. markdown.open_fence plus a carry in steps.py closes it at the end of one piece and reopens it at the start of the next. Rendering only -- message.content is never touched.

Stream frames carry whole blocks, not deltas, and the split is closed versus open. steps carries every finished step and moves only when a round ends; reasoning and render carry the step still being written and move at streaming speed; metrics and status send the complete value each time. All are swapped with innerHTML. That is what makes reattaching mid-reply work: a follower arriving late has no earlier fragments to append to, and _follow's rendered list is a render cache rather than a wire protocol -- it starts empty per follower, so the first frame carries the whole prefix.

The split is what makes it affordable. The old tools frame re-rendered every tool call in the reply twelve times a second, against an output_bytes budget of a megabyte, so a long agent reply spent most of its wall clock re-rendering its own transcript. Do not "simplify" this back into one frame.

Two frames must never blank themselves, and the rest must be able to. steps and canvas are sent only when they have something in them, so neither can wipe what is on screen: steps carries the whole reply so far, and an empty canvas would close every tab somebody had open. metrics, status, ask, reasoning, think and render are sent on every version bump including empty, because each has to be able to clear — an approval card that survived being answered would be a button you could press twice, and the live tail has to empty when a round closes and its contents become a step above.

Which side a frame is on follows from what it carries, and that is what changed. reasoning and render once carried the whole reply, so blanking them would have wiped the answer and they needed the never-blank guard; clearing the tail then took a separate fragment re-emitted inside the steps payload, and an ordering constraint between the frames to go with it. Now they carry the open tail onlysteps_service.tail — so an empty one honestly means the tail is empty, the fragment is gone and the ordering constraint with it.

One sse-swap element must never contain another. The live containers are siblings of the steps container, never inside it. That one is swapped whole at every round boundary, so anything nested in it is torn out and rebuilt exactly when the frames aimed at it arrive — which made an agent reply render nothing at all from its first tool call onwards, while an ordinary chat was fine, because an ordinary chat closes no steps and the swap never happened. There is a test.

An opened block has to survive the swap, and the ids are how. The steps container is replaced with innerHTML up to twelve times a second and the done frame replaces the whole article, so a <details> somebody expanded shut itself again — which at that rate is not an annoyance, it is a block that cannot be opened at all. web/static/js/steps.js records which are open before a swap and puts them back after. It works only because the ids are stable: tool-{message}-{step}-{n} and think-{message}-{step} come from the mark index, the marks are append-only, and the finished bubble emits the same container id and the same inner ids as the live one. hx-preserve cannot do this — it keeps a node and its contents, and these have to update.

The other half was the scroll. scrollThread refused to scroll only when the reader was far from the bottom, but somebody near the bottom who opens a block is reading, and the next frame dragged them back down. There is an explicit stick flag now: opening a <details> in the thread clears it, and returning to the bottom sets it. The toggle listener must be registered in the capture phase — toggle does not bubble, and without the third argument the whole feature is silently dead in every browser. tests/test_ui_js.py pins that.

Stopping sets a flag the producer checks -- except while it is paused. generation.request_stop(); whatever arrived is kept and the message is marked stopped, distinct from error. In-process, so single-worker only. cancel is read in exactly one place, between streamed chunks, and a reply waiting on an approval produces no chunks -- so request_stop also resolves generation.pending, and that is the wakeup. Without it the Stop button does nothing at all while a card is on screen, silently.

Asking a person is one primitive with three uses. services/interaction.py: a command waiting to be allowed, a question the model asked, and "this reply is waiting for you" are all pause, render a block in the bubble, wait for a POST, resume. It pauses a round, not a call -- a round's calls run together under a semaphore, and parking four coroutines on four separate answers inside that gather would queue them behind each other invisibly, and hand the reader four cards for commands whose order matters. So one card covers everything in the round, and _authorise returns pre-decided outcomes keyed by call index, which is what keeps zip(calls, outcomes, strict=True) aligned.

One ask_user call may carry several questions, and they come back at once. Each becomes an Item with its own key; several items can share an index because they belong to one call, and one tool turn answers them all with each answer quoted beside its question. Asking one at a time would cost a round trip and an interruption each, and answering the third would mean having forgotten the first. _questions_in reads the singular form and bare strings too, and _options_in reads an option written as a bare string as well as one written as {label, description}: a small model sends something close to the schema rather than the schema, and getting it wrong costs a whole round trip to show a card that says nothing.

"Something else" is added by the template, never by the model, and that is why the model is told not to offer one. Every question gets it, with a text box behind it revealed by :has() — no JavaScript, and nothing that can fall out of step with the control. An "Other" the model wrote would be an option with no box behind it: a choice that submits a word and means nothing. It carries interaction.OTHER as its value rather than an answer, and the endpoint replaces it with whatever was typed beside it — or drops it entirely when the box is empty, because telling the model the answer is __other__ is exactly the shape of thing it would try to act on.

Options are required now, and stacked one per line rather than in a row: an option carries an optional description, and a row of chips has nowhere to put the second and no room to read the first. A question with no options is a blank box, which asks the reader to do the thinking the model was meant to do.

The model says whether its options are exclusive, because only it knows whether they are alternatives or a set — multiple picks radios or checkboxes, and the default is exclusive because that is the cheaper mistake: a radio group where checkboxes were meant costs one clarifying round, while checkboxes for alternatives invite an answer that contradicts itself. A multiple question posts the same field name once per ticked box, so the endpoint gathers choice. fields into a list and joins them; the setdefault it used to do kept the first and lost the rest, which is an answer that says something the reader did not. Typing no longer beats picking — that rule belonged to a box that was always visible, and this one only exists when its own option is chosen.

A paused reply is deliberately not done. That is what lets a page reload reattach to it. Its timeout is what stops it lingering, not _prune, which only drops finished ones -- so approval_timeout is clamped to at least a minute on read, and _prune resolves anything whose deadline is long past as a backstop.

Nothing is persisted while paused. A restart abandons the pending question along with the reply, and a reload starts the turn afresh -- the model asks again. That is consistent with "a restart abandons replies in flight", but it means an approval is not a durable record of consent.

There is no test runner for the JavaScript, so drive it under a DOM stub. Hard rule 1 keeps Node out of the project; it does not stop using the node on this machine as a development instrument, the way curl is used. This is not a nicety. composer.js built its menu lazily inside show() while refresh() wrote to list before calling it — so the first / or @ ever typed threw on a null and took the handler with it, and the menu never appeared in any browser for the whole life of the feature. node --check parses that file happily. A forty-line stub of document, window and fetch that fires one input event catches it in a second, and caught two more on the same run: choosing a command from the menu left /help sitting in the box, and Tab did not complete. Anything touching these files gets driven before it is committed.

htmx fires afterSwap and afterSettle before afterRequest. The composer empties itself from hx-on::after-request, and the mirror repainted on the first two -- so every repaint ran while the box still held the message, and the highlighting sat over an empty field until the next keystroke. It repaints on htmx:afterRequest and on reset as well now, both deferred a frame: a form's reset event fires before its fields are actually cleared, so reading the value in the same turn paints the text that is about to vanish.

Two things must be sized the same or the composer's highlighting slides off. A <textarea> cannot style its own contents, so .composer__mirror sits behind it holding the same text with every character transparent, contributing nothing but a rounded rectangle behind each token. Every property that decides where a character lands — font, size, line height, letter spacing, padding, wrapping — is declared once for both. The usual version of this trick hides the textarea's text and shows the mirror's; drawing only backgrounds instead means a pixel of drift is a rectangle slightly out of place rather than a doubled glyph. The scroll positions are synced, because the textarea scrolls past data-max-height.

A slash command must never swallow a message. static/js/commands.js intercepts only an exact match against its table; // escapes, and anything unrecognised is sent as written. Eating somebody's message because it began with a slash is a far worse failure than an unknown command, and it is the one the implementation has to be arranged around rather than patched for afterwards.

A shortcut clicks the button that already does the job. Alt+M dictates, Alt+R reads the last reply aloud, Ctrl/⌘+Enter sends from anywhere — and all three dispatch by finding the existing control and calling .click(), so audio.js keeps its one delegated listener and there is no second copy of the recording state machine. Alt+M and not Alt+D: Alt+D is the address bar in Chrome and Firefox, and a shortcut the browser wins looks broken. Ctrl+Enter never means Stop, because Send and Stop are the same element and Esc already stops. Every key is matched on event.code, and tests/test_commands_js.py pins that each one has a row in SHORTCUTS/help reads that list, so a key missing from it is a key nobody can discover, and that is the direction this actually rots.

The directory chip shows a name, not a path. A real project path is long enough that showing it whole filled the chip's 16rem basis and pushed the mode select off the end of the row -- and the leading directories are the part nobody reads: what you check before sending is that you are in myproject rather than myproject-old. setDir shows baseName(path) and puts the whole thing in the tooltip; the hidden field still submits the full path, because shortening a label must never shorten a value, and there is a test on the row for that. Three CSS rules hold the row together and none of them is visible from the markup: .composer__agent needs min-width: 0 or the group will not shrink below its content and the last child is what falls off, .composer__dir is capped because it is the only child here whose content is unbounded, and the mode select is flex: none because it is the control being read and changed constantly. The topbar's copy of the same path was already bounded and truncating, and is left alone.

The composer's toolbar is one row, always. It used to wrap, and .composer__actions is last in the DOM with margin-left: auto — so the moment an agent chat added a connection, a directory and a mode, Send and the microphone were what dropped to a second line. chat.css has no media queries by design and the fix is not to add one: .composer__context is the single child allowed to shrink past its content and scroll sideways, everything else is flex: none. There is a test asserting the file contains no @media, so nobody "fixes" a future version of this with a breakpoint.

The scope menu is on the new-chat screen too, and there it writes nothing. It used to appear only once a chat existed, on the reasoning that there was no row to post to -- which was true and was the wrong conclusion. The harness puts a tool's guidance in front of the model the moment the tool is offered, so the menu could not be reached until after the model had been told how to keep notes and handed the tools to do it; switching it off then does not un-send that turn.

_scope_context builds a stand-in Chat for the prospective one, which is agent/draft.py:as_chat's trick again -- resolve_tools reads the kind, the model and the scope and never queries or writes the row, so one constructed and never added satisfies it unchanged. Deliberately an ordinary chat even though the kind is still switchable on that screen: an agent chat's tools depend on a connection that is not settled until the chat is created.

Checked means on, and a browser submits only the ticked boxes -- so every gate also renders a hidden input naming it, always submitted, and start_chat subtracts one list from the other. Inverting the control so ticking means "off" would read backwards under a menu that says everything is on unless you say otherwise. _new_chat writes only the off ones, because absent means on and one representation of "on" is what keeps "why is this off?" to a single answer. Nothing needs validating against the offered set: scope_json narrows inside resolve_tools after every gate, so naming a gate that was never offered switches off something that was not on.

The @ button became the scope menu, and is called Toggle. It only ever inserted the character, which the @ key already does without a button. It then kept that as a row inside the menu, which was the same redundancy one level down — a menu you open in order to press a button that types one character — and that is gone too. Typing @ is untouched: composer.js recognises the token on its own and knows nothing about this menu.

With the row gone the guard is has_scope alone rather than has_scope or can upload, because there is now nothing to show a chat with no scope and an empty menu is worse than no button. The switches inside are <label>s that deliberately carry no role="menuitem", because ui.js closes a picker when a menuitem is clicked, which is right for an action menu and wrong for a list of switches you want to set several of. That is the whole reason the menu needs no JavaScript at all. The verb is on the checkbox, per the usual rule.

hx-target is inherited, and the composer's form targets the transcript. The chip below sits inside <form class="composer__form" hx-target="#thread" hx-swap="beforeend"> — that target is what makes a sent message append a bubble. htmx resolves hx-target by walking up the DOM, so an element in there that fetches and does not name its own target aims at #thread too. The jobs chip declared hx-swap="outerHTML" and nothing else, which reads as "replace yourself" and meant "replace the whole transcript with yourself" — on load, and then again every five seconds. Every agent chat rendered its reply and then went blank, the reader's own prompt with it, while the server logged nothing at all because nothing had gone wrong there.

Anything inside that form that fetches must carry hx-target (or hx-swap="none"). tests/test_chat.py walks the form and refuses the rest. Note what the failing version looked like: correct, idiomatic markup whose meaning came from an ancestor — the same family as the trigger bound where the event does not go, and the reason that test asserts the resolved property rather than the attributes.

And htmx events bubble, which is the same lesson a second time. The form also declares hx-on::after-request so it can clear itself after sending — and htmx:afterRequest bubbles, so every request made by anything inside the form ran that handler. Six things do: the two scope switches, "ask me about these again", the agent mode select, the effort select and the jobs chip. Changing the mode or the effort while typing therefore called this.reset() on the composer and dragged the view to the bottom, and had done for as long as those controls existed; the chip only made it periodic, and therefore visible. The guard is event.target === this. A form's own handler answers its own request.

Both bugs had correct markup whose meaning came from an ancestor. That is why both tests assert the resolved behaviour — one walks the form and refuses a descendant that fetches without a target, the other drives the handler under a DOM stub and fires the event from a descendant.

The open chat page polls for turns it has not got. jobs.wake starts a reply without any request from the browser, and there is no channel to say so: the only stream is per-message and it is opened by the sse-connect on an incomplete assistant bubble — a bubble the page does not have, because the reply that made it began elsewhere. _queue_frames proves the swap works but can only ride a stream already open, so a job finishing on an idle chat lit the sidebar dot for the chat the reader was looking at and did nothing else until a reload. GET /api/chats/{id}/tail?after= is the answer, polled for the reason /unread is. Four things about it:

  • A cursor it cannot place is answered with 204, never with the transcript. An absent after, one from another chat, one a rewind deleted: returning the thread would append a second copy of every bubble the page still holds. A page whose history was rewritten underneath it is one only a reload can reconcile, and that is not this route's call to make with a half-typed message in the box.
  • The cut is read from the row, so _inject's restamp of the placeholder moves it too, and the comparison is done in SQL — a row read back from SQLite is naive and one still in the session is aware. The id > tie-breaker is not decoration: under a bare > a row sharing the cut's microsecond is skipped forever.
  • The cursor comes from the DOM (app.js, on htmx:configRequest), because the DOM is the honest answer to what the page holds — the composer's POST, the done frame and the last poll all move it, and a variable would have to be updated by each of them forever. Not hx-vals="js:…": two of the three things that handler does are cancellations, which hx-vals cannot express. Not article.msg:last-of-type either — that is per-parent, so on a compacted chat it answers with the last article inside the <details> rather than the newest message, and the poll then re-appends half the conversation.
  • It is silent while a reply is streaming, and a htmx:beforeSwap listener drops any answer containing a bubble the page already has. hx-sync cannot reach that race — the two requests come from different elements — and a duplicate here is not cosmetic, it is a second sse-connect for one message.

The route also clears unread/unread_notified on every tick including the 204: _persist marks a reply unread whenever followers == 0, which is true of a job-woken reply with the reader watching it. The element lives outside #thread (a rewind swaps that container's contents and would take the poller with it) and outside the composer form (which would lend it hx-target="#thread", the jobs chip's old bug); tests/test_chat_tail.py walks the page and refuses both.

A background job's completion is a user turn on the wire and a machine event on screen. The role is load-bearing — _inject sends a queued turn verbatim and build_messages must keep seeing a user turn — so Message.machine marks the bubble instead, and nothing about the request changes. Without it the transcript rendered a machine's report under the reader's name with their initial beside it and a pencil offering to rewrite it, which is the application putting words in their mouth; the route refuses the edit too, because a hidden button is a courtesy. _completion_text is deliberately untouched: the tool.background fragment quotes its opening sentence to the model, so rewording it would break that instruction with nothing anywhere to notice. The body skips the tokens filter — an @ in a command line is not a mention of anybody's files.

Reasoning effort goes out twice, and only when it is set. There is no field that works everywhere. OpenAI and vLLM read reasoning_effort; llama.cpp's own documentation says other values "have no effect", its maintainer says "llama-server cannot support reasoning_effort at all" and that the field "simply gets dropped without error or logging", and what actually reaches a gpt-oss behind it is chat_template_kwargs. So chat.apply_effort writes both. The second half is what makes that safe: neither field appears unless a chat has an effort set, so a provider strict about unknown parameters sees exactly the request it always did until somebody opts in. EFFORTS lives in services/chat.py and the command, the control and the admin default all read it, so they cannot disagree about what a valid effort is.

The picker shows the level in force, never the word "default". "Effort: default" named no level and was true of nothing in particular. chat.resolved_effort is the chat's own value and nothing else, and build_request reads the same field, so what is shown is what is sent by construction. The model's default is a seed — copied onto the row by _new_chat and by a model change, and deliberately never consulted at request time. A fallback would resurrect it underneath a cleared effort and make "off" silently do nothing, which is precisely the failure this codebase keeps cataloguing. The seed on a model change only applies when the key is absent; None means somebody cleared it deliberately.

"Effort: off" has to be a sentinel, not an empty value. start_chat declares reasoning_effort: str = Form(""), so an absent field and an empty one are indistinguishable there — the FastAPI trap already documented for update_chat. With value="" the reader picks off, the value falls out of EFFORTS, the model's seeded default stays, and they silently get "high". The option sends "off", and _new_chat, update_chat and /effort all know it.

A control that writes needs a form it is allowed to be outside of. Two selects in the composer — the agent mode and the effort — belong to empty <form> elements that are siblings of the composer's own form, referenced by form="…". A form cannot nest inside another; the browser silently drops the inner one, and the control then posts nothing at all.

form="…" scopes the values; it does not route the event. That is half of the paragraph above, and taking it for the whole cost both those selects an entire release in which they wrote nothing. htmx binds a trigger listener to the annotated element itself unless from: says otherwise — if(c.from){t=m(l,c.from)}else{t=[l]} — and change fires on the select and bubbles through its DOM ancestors, which a sibling form is not. So the verb goes on the control; the empty form stays, earning its keep as the answer to htmx's "whose values are these", which is function Nt(e){return e.form||g(e,"form")}e.form first, so a form-associated control resolves to the empty form and the PATCH carries that one field. Without it, closest("form") finds the composer and the request carries project_dir, which update_chat answers with a 409. tests/conftest.py:control_named exists to pin this: the element carrying the name must be the element carrying the verb. The three failures in this feature — hx-post at a PATCH-only route, a menu built after it was written to, a trigger bound where the event does not go — were all silent, and all three had passing tests that asserted the markup rather than the property.

A rule written for one context matches every context. .tok-mention styles a mention in the transcript: accent colour, monospace, 0.95em. Nothing scoped it there, so it also hit the composer mirror's spans — and .composer__mirror's color: transparent is inherited, which loses to a colour the span declares itself. The mirror painted its token visibly, in a different font, over the textarea's own text: doubled, and shifted from that point on because the metrics differ. Transcript token styles are .msg .tok-*; the mirror's restate color: transparent and font: inherit rather than relying on inheritance, and bleed with box-shadow rather than padding and a negative margin, because a shadow cannot move a glyph.

Unused columns are worse than missing ones. Model.params_json documented itself as "default sampling params applied to new chats using this model" and was applied nowhere for its entire existence, which is how a per-model default effort looked like it needed a new column. _new_chat seeds from it now. It is empty on every existing row, so honouring it changed nothing for anyone.

@ inserts a reference and attaches the contents. The token stays in the sentence so "change the thing in @main.py" reads as one, and the file arrives as an attachment chip — the same component every other attach path returns, so the composer learns nothing new. Attachment.source_path and source_label carry where it came from into chat.document_context's tag, because a model handed main.py cannot tell which of four it is looking at and cannot name it back when asked to change something. Those two are attribute values in a tag we write, so _attr strips quotes and angle brackets rather than escaping them.

@ offers everything a chat can reach, and a knowledge base is the exception that proves the rule. Project files, documents, notes, skills, this chat's earlier attachments and a URL all resolve to an attachment, copied — a transcript must not change because somebody edited a note afterwards, the same rule as PDF extraction. A base is a reference instead: POST /api/chats/{id}/bases puts it on Chat.knowledge_bases, which already narrows knowledge_search, and the harness already names the attached bases. Copying a folder of contracts into the window would cost the context on every request forever to answer one question. It therefore needs an existing chat, so it is absent on the new-chat screen — the same reason project files are.

Tabs remember their scroll position, and that made short panels look empty. A tab is a radio and a panel is shown by CSS, so switching one changes nothing about .tabs__body -- the element that actually scrolls. Read half way down the long Tools panel on /admin/prompts, switch to a short one, and the browser clamps the kept scrollTop to that panel's bottom: what lands on screen is the end of it above a screen of nothing, which reads as a page that failed to load. Nothing in CSS can reset a scroll position, so ui.js does it on change, delegated and keyed on the class -- the next tabbed screen would otherwise have the same bug and no sign of it.

The shell has one line along its bottom edge, and it took a token. The sidebar's footer and the composer sit either side of the same vertical edge and were both content-sized, so their top borders met it at different heights and read as one line that had been broken. Neither could match the other by accident: the footer's height depends on which entries a reader's permissions allow, and the composer's on how much has been typed. --footer-height is a calc of the pieces the footer is built from -- four rows at --control-h, the gaps, the padding -- applied as a min-height to both, which holds the footer at full height for somebody who sees fewer entries and lifts the composer to meet it. Exactly what --header-height already does at the top of the shell. A composer that grows past it as somebody types is expected; nothing is pretending the sidebar should follow.

An SSH connection may not point at this machine unless an administrator says so. "Nothing runs on the LLeMbas host" is the sentence the absent sandbox, the absent local MCP and the whole security story rest on — and a profile pointed at 127.0.0.1 walked straight past it, looking from the SSH layer down exactly like a container on the network. services/agent/hosts.py is one switch in three positions: off (the default, and the default on upgrade), port (one named port, for a container that published SSH on the loopback interface — 22 is refused there regardless, being this host's own sshd), and on.

Enforced in five places because a row can predate a setting: saving a profile, session.resolve (the control — every agent tool, the terminal and the canvas go through it), the composer's picker, browse, and the draft endpoint that the panels open against before a chat exists. Check refuses before opening its socket.

And the recognition never resolves a name on the request path. is_loopback reads the string only. A name pointing at loopback needs getaddrinfo, which blocks, and refusal is called several times per page render — the first version resolved inline and the suite went from two minutes to not finishing. So resolution happens where a network call is already expected (saving, and Check) and is written to SshProfile.resolves_here, which the request path reads for free. The gap this leaves is stated rather than discovered: a name whose DNS moves after it was saved is not noticed until it is saved or checked again. tests/conftest.py runs the suite with the switch open, because the tests that stand up a real asyncssh server can only listen on loopback; tests/test_agent_hosts.py closes it explicitly.

An empty htmx verb is a request, not a no-op. htmx looks for the attributeif(s(t,"hx-"+r)) is hasAttribute — so hx-get="" is a real request for the empty path, which the browser resolves against the current document. chat/_canvas.html rendered exactly that before a chat existed, so opening the canvas on the new-chat screen fetched the new-chat screen and swapped the entire site into the panel. The attribute is now omitted rather than emptied, and tests/test_canvas.py refuses an empty one anywhere on the page — the same shape would do the same thing at any other site, and it looks like a rendering bug rather than a request.

Which panel buttons exist is the server's answer; which are offered is the browser's. The canvas and the terminal both need an agent chat on a chosen connection, and before a chat exists both of those are controls in the composer. _agent_context answered with profiles[0] and stopped there, so both buttons appeared on an ordinary new chat with nothing selected. They render hidden carrying data-agent-only now and follow lembas:agent-target, the event ui.js:wire() already dispatched for the panels themselves. On a chat that exists neither attribute appears and the server's answer stands. An open panel whose target goes away is closed, or it shows one machine's files under a heading naming another.

One scroll container per screen, and .tabs__body is only sometimes it. .tabs assumes it is a bounded flex child: true for .main > .tabs on the settings page, false under the admin layout, where it sits inside .admin-scroll > .admin-page — a plain block — so flex: 1 and min-height: 0 mean nothing, .tabs__body has height: auto, and the page is what scrolls. The scroller rule names the position now (.main > .tabs > .tabs__body) rather than the class alone. The consequence worth knowing is why this went unnoticed: ui.js reset .tabs__body.scrollTop on every tab change, and setting scrollTop on an element that does not scroll is silent — so on /admin/prompts the fix had never once run, while the reader was dragged to the bottom of a document that had just got shorter. It walks up for the first ancestor that can actually scroll now, and brings the tab bar back into view rather than the page to zero, because there is content above the tabs there.

The bottom edge of the shell is not drawn. .sidebar__footer and .composer both carried a top border and met the sidebar's edge at different heights, which is what --footer-height was added to fix. The borders are gone — content scrolls under both, and an undrawn edge reads better than one that has to be aligned — and the token stays, because two ends at different heights is visible without a border to prove it. The top of the shell keeps its line: .topbar and .panel-head are both --header-height and align by construction, so removing one of those would recreate the broken line in the other direction.

A page that uses .page needs .admin-scroll around it. .main is a flex column with min-height: 0, so content dropped straight into it overflows the viewport with nothing to scroll — Save ends up below the bottom of the window, reachable only by zooming out. settings.html gets this from .tabs__body and the admin pages from .admin-scroll; the folder settings page shipped without either. The two class names are one rule in admin.css for that reason.

A path is chosen, not typed. [data-dir-field] in ui.js is the directory picker on a form that is not the composer, scoped to that attribute so it and the composer's own handler cannot both answer one click and open two dialogs. The composer keeps its own because it does more: it follows the selected profile's default directory until somebody picks their own, which only means something while a chat is being created.

Canvas asks the same way, having been the last control in the application expecting somebody to remember an absolute path on another machine. GET /api/agents/{id}/browse takes pick=file, and agents/_browse.html then makes files buttons carrying data-file-open while directories stay a step. One fragment for both modes, because a second copy of that listing is a second place for the path arithmetic to be got subtly differently -- and differently means a file that opens to the wrong path, or to nothing. app.js:chooseFile is its own function rather than a flag on chooseDirectory: what a click does, what finishes it, whether there is a "use this" button at all and what the dialog is called all differ. What they share is the listing, and that is shared where it matters -- on the server.

The button carries data-canvas-open rather than an hx-post, because the path is not known until the dialog closes; ui.js posts it afterwards through htmx.ajax so the response lands in the panel exactly as every other canvas action's does. fetch would mean swapping the fragment by hand, and then there would be two ways the canvas gets replaced. The key is agent:<path> -- the same key a tool call's read produces, so a file opened by hand and one opened by the model are one tab rather than two spellings of it, which is path_key's whole job and why the prefix is added in code rather than asked of the reader.

Messages is bounded in the request and unbounded on disk. One conversation per person, meant to run for years, so it cannot all be sent -- build_messages takes the last LIVE_CHUNK turns and nothing before them. Nothing is folded into text and nothing is deleted. The visible conversation is identical either way, so destroying the older rows would buy only disk, against being irreversible and losing every attachment and tool call in the range -- and it would contradict the rule compaction already holds, that hiding turns is not deleting them. compaction.should_compact refuses this kind for the matching reason: two mechanisms narrowing one transcript is how a summary ends up summarising a summary.

The consequence is worth stating rather than discovering: past the live chunk the model genuinely does not see what was said.

GET /api/messages/history?before= is the mirror of thread_tail and keeps its four properties -- 204 on a cursor it cannot place, the comparison in SQL with an id tie-breaker (without which a row sharing the cursor's microsecond can never be reached, and a message that cannot be scrolled back to is gone), an explicit hx-target, and a sentinel outside the composer form. The fifth is its own: prepending moves the scroll position, so app.js records scrollHeight before the swap and adds the difference back after. Without it the reader is dragged up the page the instant the sentinel fires, which reads as a browser bug.

TemplateResponse injects nothing. The history route renders chat/_message.html outside render(), so user and chat are passed by hand -- the same reason the SSE path does. Missing either is a 500 on scroll from a page that rendered perfectly.

A command can be corrected before it is allowed, and the edit lands in exactly one place. arguments is the list _run_calls hands to run_tool as parsed=, and run_tool never re-parses — so writing into it inside _authorise is the only mutation the runner sees. Editing the Item does nothing: it is frozen and display-only. Two things move with it. The raw call["arguments"] string is rewritten, and the assistant turn is built after _authorise rather than before it, or the model is told it ran what it proposed while something else ran and every later round reasons from a transcript that is quietly false. And _remember_always reads the edit, or "always allow this" stores a standing permission for a command nobody approved — it still derives the pattern itself through policy.subject, which yields nothing for a composed command line. The box is offered only where the detail is an argument (tool_labels.DETAIL_KEYS); anything whose detail is a k=repr(v) summary cannot be put back, and a box there would silently change nothing.

htmx's hx-prompt cannot be intercepted, and hx-confirm can. htmx calls the browser's prompt() synchronously and then fires htmx:prompt with the answer already in hand, so cancelling the event only aborts the request and the grey box appears regardless — htmx:confirm fires before, which is why that one works. data-prompt in ui.js follows the data-confirm-button shape instead: swallow the click, ask in the themed dialog, write the answer into hx-vals, click again behind a guard flag. JSON.stringify, never concatenation, or a folder called " produces hx-vals that does not parse and the request goes out with the field missing rather than with the name. There is a test that no template brings hx-prompt back.

An agent chat is named from its first prompt and costs no model call. Somebody starting one states an objective, not a topic. An ordinary chat opens with a question whose answer is what makes a title worth asking a model for, and is unchanged. update_chat answers a rename with the same out-of-band pair the done frame sends, so one response moves the heading and the sidebar row — only on a rename, because sending it for every PATCH would overwrite the heading from an unrelated save.

Three numbers per panel decide a width, in three files, and two of them fail silently. LAYOUT_BOUNDS drops an unknown CSS variable on purpose, so a panel missing from it has a drag handle that appears to work and forgets by the next page load. The allowlist's lower bound, the handle's data-resize-min and the --*-width-min token are pinned equal by tests/test_layout_bounds.py.

Unread is polled, not pushed. A browser on another chat has no connection to the one that finished. /api/chats/unread returns out-of-band dot spans and an HX-Trigger carrying items — each with a kind, a title and a URL, because a browser notification is a thing you click and a title alone cannot say where. unread_notified (on Chat and on Report) stops the same arrival being announced every tick. Re-rendering the whole sidebar instead would reset the folder open/closed state every 10 seconds.

Everything that can arrive is announced, not only chats. The dots covered Reports and Messages from the day those sections existed; the announcement did not, so a scheduled run that filed a report lit a dot in a corner and said nothing. That is exactly the arrival nobody is watching for — a chat reply is one you asked for a moment ago.

One arrival, three channels, and they must not all fire. A toast for somebody looking at the page; a count in the tab title ((3) LLeMbas) while it is hidden, cleared on focus; and a system notification for somebody elsewhere entirely. The service worker is the only place that can tell them apart — it skips showNotification when one of its own windows is focused, because the server cannot see focus and the page cannot see a push it did not receive.

Web push is the one thing here that contacts an outside service. services/push.py, hand-rolled against RFC 8291 and RFC 8292 with cryptography, which is already a dependency. It exists because everything else is polled by an open page, and the arrival worth notifying about is a schedule firing at seven in the morning with the browser shut. The trade is real and written down in the module: the POST goes to Google's or Mozilla's push service, the payload is encrypted end to end so they cannot read it, and what they do learn is that this server sent something and when. Opt-in per device, because the permission and the subscription both belong to a browser.

Three things about it that are easy to get wrong:

  • announce_later is called where something arrives, never from the poll — the poll needs a page, and this is the case where there is not one. Each arrival site runs exactly once, which is what makes it fire once with no flag of its own; borrowing unread_notified would let whichever channel got there first silence the other. It checks for a running loop before building the coroutine, or every synchronous caller raises "never awaited" at its own line.
  • The VAPID keypair is generated once and never regenerated. Its public half is inside every subscription a browser holds, so a new one silently invalidates all of them — notifications simply stop with nothing saying why.
  • 404 and 410 delete the subscription; anything else keeps it. Those two are the normal end of a subscription's life, not a failure.

Editing rewinds, it does not branch. POST .../messages/{id}/edit rewrites a user turn and deletes everything after it. Branching would need a UI for choosing between versions; "go back and try again from here" is what was asked for and what other clients do. Message.parent_id still exists unused.

Dialogs and toasts are ours, not the browser's. static/js/ui.js provides lembas.notify/confirm/prompt, and intercepts htmx's htmx:confirm so every existing hx-confirm gets the themed dialog with no change at the call site. Plain forms opt in with data-confirm, lone submit buttons with data-confirm-button. Never add a window.confirm back.

The model picker is hand-built. A <select> renders only text in an <option> -- no avatar, no description, no badges. chat/_model_picker.html plus the picker block in ui.js; the value lives in a hidden input so it still behaves as a form field.

The panels work before the chat does, and a draft is how. The terminal and the canvas both needed a Chat, which meant they were unavailable on the one screen where you are deciding which machine to work on. services/agent/draft.py holds an id and the three facts behind it -- owner, connection, directory -- and as_chat builds a transient Chat, constructed and never added to a session. That is the whole trick: canvas.agent_ready, _executor, _load_agent/_save_agent and agent_session.resolve read only user_id, kind, ssh_profile_id and project_dir, and none of them queries or writes the row, so every one of them works unchanged and none had to learn what a draft is. id and canvas_json are set explicitly: both are column defaults, which SQLAlchemy applies at flush, and this row is never flushed.

The id is derived from (owner, connection, directory) rather than invented, so returning to the same new-chat screen finds the shell already running there instead of quietly opening a second. The owner is in the hash because two people pointed at the same directory would otherwise share a shell.

Two canvas sources are refused on a draft, by name. scratch needs a row -- scratch_service.for_chat would write a ScratchDoc keyed on a chat that does not exist. file is the one that matters: _load_file authorises with attachment.chat_id != chat.id, and an upload made on the new-chat screen is stored with chat_id=None, so a draft whose chat carried no id would make that None != None -- False -- and open every unclaimed attachment its owner has. as_chat does set an id, so the comparison already fails; the refusal is stated anyway, because a guarantee that lives in an id-shaped coincidence is one the next change breaks silently.

Adoption is a re-key and a copy, and the redirect does most of it. start_chat already answers 204 + HX-Redirect, so the browser reloads and both panels re-render with the real id -- the canvas adopts by construction, since its URLs are server-rendered, and the terminal reconnects to the re-keyed session and replays its scrollback. terminal.rekey moves both the registry key and session.chat_id: close_for_profile, close_for_owner and the reaper all pop by the field, so a stale one would leave a dead session that get keeps handing out. The shell is adopted only when its profile and directory match the chat as finally resolved -- _new_chat falls back to the connection's login directory when the field is empty -- and otherwise left where it is rather than transplanted onto a chat that says it runs somewhere else.

lembas:agent-target is what re-points them when the selection changes; ui.js dispatches it from setDir and the connection select, because assigning to a hidden field's .value fires nothing on its own.

Chats are created lazily. There is no endpoint that makes an empty chat. "New chat" is a link to /chat, which renders a composer with no row behind it; POST /api/chats/start writes the chat together with its first message. That is why an opened-and-abandoned chat never appears in the sidebar. Tests that just need a chat use the make_chat fixture rather than the HTTP flow.

Admin lists are list-plus-detail, never a form per row. /admin/models renders compact rows with search, filter tabs and pagination; the full form lives at /admin/models/{id}/edit. A connection can advertise a hundred models, and a page that renders a form for each is unusable. Any future admin list (tools, agents) should follow the same shape.

Route order matters for static path segments. FastAPI matches in registration order, so /admin/models/bulk must be registered before /admin/models/{model_id} or "bulk" is parsed as a model id and 404s. This has already been a bug once.

Pinning is not ordering. The model picker is always in the administrator's position order. Pinned models get shortcuts in the chat sidebar and nothing else -- a picker whose order differs from the admin screen is just confusing.

The shortcuts come from sidebar_context, not from _chat_context where they began: they are sidebar content, the fragment route that re-renders the sidebar has only the former, and the library and connections pages carry the sidebar without ever calling the latter -- so the shortcuts were simply absent on all of them. They carry &kind=agent with the switch, and they sit above the tree it swaps, so they arrive out of band exactly as the New chat button does. The group is rendered even when empty, because a block that vanished when the last model was unpinned would leave that out-of-band fragment with nowhere to land, and htmx says nothing at all when a target is missing; .nav-group--pinned:empty is what stops the empty one taking room.

System prompts are precedence, not concatenation. chat > folder > model > instance, most specific wins outright (services/chat.py:effective_system_prompt). Stacking them reads well in a settings screen and badly in practice: two layers that disagree give the model contradictory instructions and nobody can tell which is losing.

The folder rung goes above the model deliberately: a model's prompt describes the model wherever it is used, a folder's describes this piece of work whichever model is pointed at it. It is read when a reply is built and never copied onto the chat, so editing a folder reaches the chats already in it, and the walk up the parents is bounded and cycle-safe because it runs on the request path. api/pages.py mirrors the ladder for the settings panel's "inherited from" hint, and has to keep mirroring it layer for layer — a panel naming the wrong source is worse than one naming none, because it is believed.

A folder's other settings are seeds, copied by _new_chat into whatever the request left empty and nothing it filled in: the folder says what this work usually needs, the screen in front of somebody says what they want this time. Folder.ssh_profile_id is a plain string rather than a ForeignKey, for the reason compacted_through_id is, and is validated on read.

JSON columns need reassignment. user.settings_json["theme"] = x on a plain dict is not detected. The columns use MutableDict (db/types.py), but the safe habit is obj.field = {**obj.field, "k": v}.

[hidden] needs !important. The browser's rule is [hidden] { display: none }, which any class setting display outranks — and .btn is display: inline-flex. That is not theoretical: it is why the old Stop button, created and then hidden = true, sat permanently beside Send. app.css forces the attribute to win. Anything toggled with hidden depends on that line.

Send and Stop are one button. chat/_composer.html renders both icons and ui.js flips data-composer-action plus type (submitbutton) when a message in the thread is still streaming. Do not add a second button back.

A second message while a reply streams is queued, not sent. It used to be accepted outright: a second assistant placeholder, a second concurrent Generation answering the same chat from a different prefix of it, and ui.js's first-match querySelector(".msg[sse-connect]") pointing Stop at whichever bubble came first in the document. A queued turn is a real Message with queued set — so it survives a restart, is in the transcript the moment it is typed, and can be withdrawn before it is ever sent. build_messages skips it. It must never carry sse-connect; the streaming shell is still the only thing that starts a generation, so one on a queued bubble is that second generation again.

Delivery is two places, and neither is a special case. _drain runs in _run's finally: between _persist and done — after the row is authoritative, before the flag _follow breaks on, because the done frame is the last thing that reaches a browser and has to carry the next turn's bubbles out of band. It takes one waiting prompt, not all of them: draining the lot puts two consecutive user turns in the next request. _inject takes one into a reply at a tool-round boundary, which is the point of queueing in an agent chat — steering work already under way — and restamps the assistant placeholder's created_at so the reply still sorts before the prompt it answered. It refuses on the last round: a prompt delivered into a reply that then runs out of budget is marked delivered and never sent again.

Stop leaves the queue alone, deliberately, and _drain refuses on stopped, errored and superseded. Stopped is the reader's decision; errored would feed the next prompt into an endpoint that has just failed; superseded is the same test _persist makes, without which regenerating drains the queue as a side effect. Cancellation sets stopped, so a restart never fires off a reply with nobody watching.

An interjection is sent verbatim, in the user role. Everything else this codebase injects is quoted and attributed because it came out of a file, a page or a machine; this one genuinely is the person at the keyboard. Wrapping it would teach a model that a user turn can be a quotation, which is the exact distinction execute_plan and Capture.as_text rely on. What the model needs — that this can happen at all — is the core.interjection harness fragment.

The tool loop is inside one generation. services/generation.py:_run() runs request rounds for a single reply: stream, accumulate tool calls, run them, append the results, ask again. Generation accumulates content across all of them, so text emitted before a tool call survives. Tools are only offered when search is enabled, the user has tools.web_search, and the model is flagged tools — sending a tools array to an endpoint without support fails the whole request, exactly as images do without vision.

A round ceiling is a ceiling, not a schedule. The loop ends the moment a round comes back with no tool calls — that is the model saying it has what it needs, and it is the same termination condition every agentic harness uses. The number only catches the case where it never says so: a small model that has decided searching is the answer, searching until the context runs out at a full request each.

It was 1, then 5, and it is 0 by default now — no ceiling, the loop falling back to MAX_TOOL_ROUNDS as a runaway backstop, which is the shape Limits.steps already had for an agent chat. Both numbers were the same mistake at different scales: low enough to be reached by ordinary work is low enough to be a schedule rather than a ceiling, overriding the model's judgement on every turn instead of catching a runaway. One left the library searchable and not readable, several built-ins being two-step pairsknowledge_get and notes_get read a document "by the id a search returned". Five ended a small local model's genuinely good piece of research at its sixth search. What bounds an ordinary chat is the context window, which is a real limit rather than a guess at how much looking-up a question deserves. settings_store.chat_rounds() is the number; tools.MAX_ROUNDS is only the fallback for callers with no session, and a test pins the two equal.

A budget must never end a reply in silence. Every one of them used to break where it was noticed, leaving whatever prose the model had emitted — which for a model that goes straight to tool calls is nothing, so the reader got an empty bubble with a red line under it and the whole reply thrown away. _wrap_up withdraws the tools and asks once more instead: what was gathered is in the transcript either way, and one request turns it into an answer. That is the move plan_submit already makes — a turn should not end mid-sentence — and it is why the loop runs to budget + 2: the round at budget notices the overrun, the one after it answers. The event still goes in the transcript, because an answer the model chose to give and one it gave because it ran out of room read identically otherwise. _too_big is the single exception and stays a hard stop: it is the finding that there is no room for another request, so a wrap-up round would be the same overflow with an upstream error in place of an explanation.

core.rounds and core.keep_working are gated on complements, so exactly one appears. round_budget is set only when an administrator has put a ceiling on an ordinary chat; unbounded is set precisely when it is not, and an agent chat always has the second. Neither renders anywhere — they exist to be requires. A model told it has a budget rations it and stops early to report progress, and one told to keep going does the work; those are different sentences rather than the same sentence with a different number in it.

An agent chat is sized by agent/policy.py:Limits instead, where steps is a runaway backstop and not a working budget. It was 40 and it was reached; a step count low enough to be the thing that ends a reply is a count that ends it halfway. What actually bounds one is the wall clock and completion_tokens. These are different sentences rather than the same sentence with a different number in it, which is why core.rounds and core.keep_working are two fragments gated on round_budget — blank in an agent chat, and blank again when an administrator has set no ceiling, so the fragment vanishes rather than promising zero rounds.

The token ceiling would have worked on OpenAI and silently done nothing elsewhere. generation.completion_tokens is only populated when the endpoint sends a usage block, and llama.cpp, Ollama and friends never do; the fallback estimate is computed once, in _run's finally:, long after the loop that needs it. _written() takes max(reported, estimated) so the limit fires everywhere. The worst kind of limit is one that looks configured.

The metrics interpolate between usage blocks, and never overwrite one. Usage arrives once per round, so reported or estimated stopped consulting the estimate the moment round one's chunk landed — and a forty-round agent reply then sat at round one's numbers for minutes at a time while text streamed underneath. The other direction is just as wrong: a reported count must never be replaced by four-characters-to-a-token, which is a downgrade dressed as a fix. metrics._since_counted is the seam. Generation.counted_chars is stamped at the end of each round — not where the usage chunk is read, because ReasoningSplitter is still holding that round's last characters back then, and stamping there made the stored figure a token or two above what the endpoint actually said — so the interpolation is zero exactly when a count lands and the displayed figures converge on the reported total rather than drifting past it.

Two consequences worth knowing. estimated is now a recorded fact (Generation.reported_usage) rather than an inference from "are both counts non-zero?", which the end-of-reply fallback made true of a reply nobody had counted — so the ~ showed all the way through and vanished at the moment the numbers were written down. And that fallback is gone: from_generation is the single place the three figures are worked out, live and at rest, because two copies of one rule is how the prompt figure came to jump at the done frame (one used prompt_estimate, the other prompt_estimate_total).

_follow also sends metrics on a clock (METRICS_INTERVAL) as well as on a version bump. There is no touch() inside _run_calls, so during a five-minute build on the far side nothing moved on screen while the elapsed clock advanced — and frozen chips beside a spinner read as a hang.

Neither chip was ever wrong, which is why this looked like arithmetic and was not: the first is what the reply cost (prompt + completion summed over rounds, the prompt paid for once per round) and the second is what the conversation now occupies (the last round's prompt plus its completion). On a multi-round reply those differ by a lot, and chat/_metrics.html says which is which in the titles now, because two token counts a few centimetres apart with no labels just look like one of them is broken.

A model that stops is believed, unless something objective says otherwise — and there are two such things. core.keep_working is the cheap half of stopping-halfway; generation._nudge is the other half, and it never fires on a hunch. The signals, in order:

  1. An open task on the chat's own plan. The strongest there is: the model wrote the list and has not crossed the item off.
  2. A long reply that touched nothing. No plan, no tool call anywhere in the reply, and more than NUDGE_MIN_CHARS of prose. That is a model deliberating itself to a standstill — announcing the call, reconsidering, announcing it again — and the reply simply ended, because a round with no tool calls is a model saying it has finished and it is taken at its word. core.commit is the prompt half.

The second is narrow on purpose. tool_events being empty is what keeps it away from the common case: a reply that did some work and then said it was done has made a claim anybody can check by reading the transcript, and arguing with that is how a model gets nagged for finishing. NUDGE_MIN_CHARS keeps it away from the other one — a question asked in an agent chat and answered in two lines is not a stalled agent.

It is asked at most MAX_NUDGES times in a row for a plan (the count resets the moment a tool is called again) and once for the second signal, because the text is cumulative across rounds — the signal stays true however the model answers — and the nudge explicitly invites a one-line "I have finished". Asking again would be refusing the answer we asked for. Never in Plan mode, and never past plan_submit — that ends the turn deliberately and nudging it would argue with the point of the mode. Giving up is recorded as an event rather than left silent. The model's own words go back with the nudge, or it is asked to carry on from a transcript in which it never spoke.

A round's text is flushed before it is echoed back. ReasoningSplitter holds back a few characters against a <think> tag split across chunks, so round_text at the end of a round was missing its last words — and that text is echoed as an assistant turn, both for a tool round and for a nudge. A turn missing its tail is one the model is asked to continue from having apparently trailed off mid-sentence.

A chat can narrow what it may use, and can never widen it. Chat.scope_json is filtered inside resolve_tools after the capability, permission and instance gates — exactly as chat.knowledge_bases narrows knowledge_search — so a crafted POST turning something on reaches a tool the gates already removed. Absent means on, for every key, so "why is this off?" has one answer. It is keyed on the gate, not the tool name, so notes is one switch rather than five, and a row-backed tool's custom:weather gets per-row control for free. Skills are narrowed both in the listing and in _run_skill_get: without the second the narrowing is advisory, since a model can name a skill it was never shown.

With no skills, nothing should mention them. tool.skills was gated on the family alone, so somebody with an empty library was told "the list below gives each one's name" above no list, handed skill_get, and watched the model spend a round finding out. It now requires=("skills",); the writing half moved to tool.skills_write, which is deliberately not gated, because saving the first one is what somebody with none most needs. resolve_tools drops skill_get and skill_edit at zero. And core.tool_list finally reads tool_names, which had been resolved and documented with no fragment using it — a model that has to discover its own tool list by calling something and being told it does not exist spends a round, and with one round that is the whole reply.

The registry is resolved per request, not imported. REGISTRY holds the built-ins; a custom tool or an MCP tool is a row. tools.resolve_tools() returns a ToolSet carrying the schemas and the runners, and the runners travel to the loop on ToolContext.tools — because a generation outlives the session that could look them up. run_tool consults that map, so what may be run is what was offered. None means nobody resolved a set and falls back to the built-ins; an empty dict is authoritative. Reaching for the global registry instead is how a model naming a tool its chat was gated out of used to get it run anyway.

A row-backed tool's family is gate:slug. custom:weather, mcp:github. The capability flag and the permission are named after the gate (tool_custom / tools.custom), so a server advertising forty tools does not mean forty checkboxes on every model; the full family exists so each row can carry its own prompt fragment, gated to appear exactly when its tool is offered. harness._families and admin_prompts therefore take a db. Custom and MCP deliberately do not require library.use: an endpoint an administrator wrote has nothing to do with anyone's own notes.

An argument may fill a hole; it may never move the target. A custom tool's URL is a template. The scheme and host must be literal — checked at save and again at call time, since a row can predate a check — values are escaped for where they land (quote(safe="") in a URL, JSON-escaped in a body, control characters stripped in a header), and the filled URL's origin is compared with the template's afterwards. An undeclared {{name}} becomes nothing rather than passing through, which is the opposite of prompts.substitute and deliberately so: a literal {{x}} in a URL is not a feature.

Three places now follow redirects by hand. fetch.fetch, custom_tools._send and mcp.client.Session._post, each re-running check_url on every hop. fetch() itself is not reusable — GET-only and bodyless. The duplication is deliberate; bending a page fetcher into a general HTTP client is not, and a fourth hand-rolled loop is how one of them loses its SSRF check. A secret is dropped when a hop leaves the origin it was issued for.

The content-type sniff was widened by exactly one list. It used to raise on anything that was not HTML or text/*, which is every JSON API there is — already wrong for the @-link attach path, and unusable once a model can ask for a URL itself. _TEXTUAL plus the +json / +xml suffixes now come back as text; images, PDFs and octet-stream still raise, because handing a model five megabytes of binary is what the refusal was for. That is a sniff being fixed, not a page fetcher becoming an HTTP client.

fetch is a tool, with its own family and its own switch. Separate from web search, because an administrator may reasonably want a model that can look things up but not follow an arbitrary URL it read somewhere — and the whole SSRF surface is on this side. The instance switch is separate again from allow_private_fetch and earns its keep: turning it off stops a model fetching while the composer's Link option keeps working, because that one is a person's instruction rather than a model's choice. MAX_FETCH_CHARS caps what reaches the model at 20k, since fetch() returns up to 120k — one call would otherwise fill an ordinary window and spend an agent chat's whole output budget on a single page.

MCP sessions are per call. Initialize, notifications/initialized, the call, then a best-effort DELETE. Caching one would need an owner, a TTL, eviction, a lock (a round runs its tools concurrently) and a shutdown hook, and the server can expire it underneath all of that anyway — ToolContext is a session-free snapshot precisely so nothing in a tool holds live state. The cost is one POST in front of a call that is already a network round trip. 307/308 are followed; 301/302/303 turn a POST into a GET and are refused rather than guessed at.

A discovered MCP tool is JSON, not a row. McpServer.tools_json caches tools/list. Model is a table because each row carries eight independent admin decisions; a discovered tool carries one (offered or not, in tool_overrides_json, where absent means on), credentials and guidance are per server, and the list is replaced wholesale on every refresh — a table would mean reconciling rows against a cache of somebody else's document.

An MCP tool has two names. The server's own, which tools/call needs, and the offered one in the schema — slug_tool, lowercased into [a-z0-9_-]{1,64} because endpoints accept less than MCP does. Built-ins claim their names first and can never be shadowed; a custom tool whose slug collides is refused at save, an MCP tool is renamed silently, since the one that can adapt should be the one that has to. The rename never leaves mcp/registry.py.

A server's tool metadata is untrusted input that becomes instructions. Names, descriptions and schemas from tools/list are bounded and sanitised in mcp/protocol.clean_tool before anything reaches a model. What a tool returns is untrusted too, and is rendered as escaped preformatted text — never through services/markdown.py, which is the one path allowed to emit HTML.

A round's tool calls run together. generation._run_calls gathers them under a semaphore of four and keeps the results indexed, not appended as they finish: each tool turn must line up with the assistant turn's tool_calls or an endpoint matching on tool_call_id pairs the right id with the wrong content. Safe because run_tool never raises and every runner opens its own session. generation.status names what is running, because a remote tool taking seconds with nothing streaming is exactly what a hang looks like.

Tool-call arguments arrive in fragments. delta.tool_calls carries an index, a name that appears once, and an arguments string split across chunks. tools.ToolCallAccumulator rejoins them keyed on index — not on name, which breaks the moment a model calls one tool twice in a turn.

Library search is keyword and meaning, and neither is a mode. fts.search_ids was already the one seam; library/retrieval.search sits in front of it and fuses its ranking with a vector one by reciprocal rank fusion -- ranks, not scores, because bm25 and cosine are not comparable and normalising them means picking a constant nobody can tune. With no embedding model configured it returns exactly what FTS returned, in that order, and no chunk row is ever written. See docs/notes/search-and-extraction.md.

Four stores, four different reasons. services/library/documents (uploaded by a person, searched by the model), notes (written by the model, searched), memories (short, and injected whole every turn), skills (index injected, body fetched by tool). The shape of each follows from how it reaches the model: a memory is capped short because it costs tokens on every request forever, a note is not injected because a dozen would fill the window.

Documents live in knowledge bases, and the base is what is shared. A Document always belongs to a KnowledgeBase; visibility comes from the base, never the document, which is why Document is absent from sharing.RESOURCE_TYPES and documents.visible() filters on base_id IN (visible bases). Per-document grants would mean answering "who can see this?" by checking every file. Document.base_id is nullable only because the column had to be added to a table that already had rows; documents.sweep_unfiled() runs at startup and files anything predating bases into its owner's default.

A chat attached to bases is scoped to them. Chat.knowledge_bases is many-to-many; empty means "everything the owner can see", not "nothing". tools.context_for(db, user, chat) carries the ids and knowledge_search filters on them — and the harness names the bases, because otherwise the model cannot tell "there is nothing about this" from "I am only allowed to see the contracts folder".

Sharing goes through one helper, and admins do not bypass it. services/sharing.py:visible_to() is the only definition of who can see a library item, and every listing and tool uses it. permissions.resolve gives an admin everything, deliberately — but that is about configuration, which an admin can grant themselves anyway. Reading someone's private notes is not the same act, so sharing has no admin branch. Sharing grants reading only.

FTS5 tables are outside the model-driven schema sync. They are not SQLAlchemy models, so sync_schema() cannot diff them; db/migrations.py: ensure_fts() writes them out with IF NOT EXISTS and creates the triggers that keep an external-content index correct. It runs at every startup and converges, like the column sync beside it. tests/conftest.py calls sync_schema rather than create_all so tests run against the same schema.

A failed search rolls back. One broken FTS statement otherwise leaves the session unusable and every later query in the request fails too, which looks nothing like a search problem.

Knowledge attachments are copies. Attaching a library document to a message duplicates its text and its file (files.copy_document). Referencing it would mean a conversation changing when a document is edited or deleted later — the same reason PDF text is extracted once at upload.

The link fetcher is an SSRF hole unless guarded. services/fetch.py refuses loopback, private and link-local addresses after resolution — a hostname pointing at 127.0.0.1 walks past any check that only reads the URL — and follows redirects by hand so every hop is checked. An admin can open it deliberately. The URL can come from a model, which can be talked into things by a page it just read.

The harness is an exception to the prompt-precedence rule, on purpose. "System prompts are precedence, not concatenation" governs the three authored layers, and it stands: exactly one still wins, and effective_system_prompt still decides which. services/harness.py is a different axis — it describes the machinery rather than the behaviour, nobody authored it, and there is nothing for it to disagree with. It is prepended to whichever authored prompt won, in one system message (several endpoints reject a second one), and build_request is where the two meet.

The harness holds no text. Every piece of it is a Fragment in services/prompts.py, edited on /admin/prompts. harness.py decides which fragments apply and what their variables resolve to; prompts.py owns the wording, the storage and the substitution, and knows nothing about chats or tools. Four rules hold the whole thing up:

  • Defaults live in code, overrides live in the database, and text equal to its default is never stored. That is what lets a later release improve a default and have it reach an instance whose administrator once pressed Save.
  • An empty override means off, which is why there is no separate enable flag: clearing the box in the admin page is the switch. A fragment that was not submitted at all keeps whatever it had — it may be missing from the page because the thing contributing it is switched off.
  • A fragment carries its gate as data (families, requires, when_tools), never as a callable, because a database row can carry the same three fields. requires is why there is no longer a hand-written pair of memory-guidance variants: the sentence that refers to a section lives inside that section, so it cannot outlive it.
  • {{name}}, and anything unrecognised passes through verbatim. Names are lowercase letters, digits and underscores, so {"total": 1} and ${PATH} are never candidates. Substitution is one pass and never recursive — {{memories}} carries text a model wrote, and a memory reading {{skills}} must not expand.

A model with no tools now gets the core fragments too, the date above all. "An empty harness is worse than none" was about tokens that say nothing, and a model with no clock being asked about the present is not that. Clearing those fragments restores the old silence exactly.

Tool descriptions are not fragments. They are schema, sent verbatim in the tools array, and they state facts about what a runner does — an administrator editing notes_edit's "omit a field to leave it alone" would make the text a lie with nothing to catch it. The page lists them read-only so nothing injected is hidden. A custom tool's description will be editable, because it is a row.

A model's tool flags default to on when tools is on. Rows configured before the per-tool split have no tool_* keys. Reading absent as off would silently take web search away from every model already set up for it, so tools.enabled_tools treats absent as inherited.

Tool results are not replayed. Like reasoning, Message.tool_calls_json is stored and rendered but never fed back as context. The answer already contains what the model made of the results; replaying stale results and the schema into every later request wastes the window and reliably sends a small model into a search loop. The sources stay visible in the transcript.

Search results are untrusted. Hard rule 6 covers them as much as model output. chat/_tool_activity.html escapes everything and only renders http and https URLs as links — a result carrying a javascript: URL must never become an anchor.

A message bubble is rendered from four places. pages.py, chats.post_message, chats.regenerate and chats._follow. Each needs audio_service.template_flags(db, user) or the speaker button's conditions are undefined; the template uses | default(false) so a missed one degrades to no button rather than an exception. _follow also passes just_finished, which is what read-aloud-automatically keys off — without it, reopening a chat would start reading its last reply out loud. tool_label and tool_icon are Jinja globals for exactly this reason — a fifth thing every one of the four would have to remember is a fifth thing one of them will forget.

What a tool is called lives in one table, and the static one wins. services/tool_labels.py is read by the transcript, the status line while a round runs, and the approval card; those three disagreed for the whole life of the feature — one said "homeserver", one said "shell_run", one said "Run a command" — and nothing checked. The precedence is inverted on purpose: tool events are persisted in Message.tool_calls_json, so every agent row already on disk carries label set to the SSH profile's name, and a resolver preferring the stored value would fix nothing for any transcript that already exists. So a name the table knows resolves from the table; a name it does not — a custom HTTP tool, an MCP tool, whose labels are per row and cannot be tabulated — keeps its own. One rule, both cases correct. The machine now travels in detail, where "where this ran" belongs.

Dictation audio never touches disk. api/audio.py reads it into memory, capped, and streams it upstream. It is not an attachment: it has no owner, no row, and nothing would ever sweep it.

The service worker must skip /api/. A reply is an endless event stream and passing one through a worker turns it into one delivery at the end, or nothing. static/js/sw.js bails out on /api/, /auth/, /admin/ and any request accepting text/event-stream. It is served from GET /sw.js rather than the static mount because a worker's scope is the path it came from.

ToolContext gained chat_id, and that fixed a tool nobody had ever run. _run_scratch_write read context.chat_id on a dataclass that had no such field, so every scratch_write call raised AttributeError — swallowed by run_tool's blanket except into "the scratch_write tool failed", which reads exactly like a model calling it wrongly. The test that existed asserted the family and the risk, which are properties of the declaration rather than of the code. There is one that runs it now.

A control wired to a method its route does not serve fails silently. The agent-mode select posted with hx-post against a route that only answers PATCH, so every change returned 405 and the mode never moved — for the whole life of the feature. htmx surfaces nothing on a failed request, so the select stayed where it was put and the server ignored it, which looks exactly like working. tests/test_agent_mode.py asserts the method is refused as well as that the right one works, because only the second half would have passed throughout. When adding a control that writes, check the verb against the route, and assert on the row rather than on the response.

Releasing, and the changelog

Every version bump gets a CHANGELOG.md entry, in the same commit. Not afterwards and not at release time: the reason a change was made is known while it is being made and is gone a week later, and a changelog assembled from commit subjects at the end is a list of things nobody can act on.

Four rules, and the last is the one that earns the file its place:

  • Newest first, one section per version, headed by the version alone. ## Unreleased sits at the top between bumps.
  • Written for somebody using or running this, not for somebody reading the diff. "Deleting a group left every share naming it behind" is an entry; "call forget_principal in delete_group" is not.
  • The version is __version__ and nothing else. pyproject.toml reads it from there, the service worker cache is keyed on it, and the footer shows it.
  • A fix to something that looked like it worked gets a line, always. Those are the entries somebody stops working around a bug because of, and they are invisible from the outside: nobody reports a control that silently does nothing, they just stop using it.

A release is a signed annotated tag whose message is that version's entry -- git tag -s v1.0.0 -m "$(…)". That is not decoration: /admin/updates reads release notes out of the tag object with git for-each-ref, so the tag message is what an administrator sees on the update page. services/updates.py strips the signature block, PGP and SSH both.

The first tagged release is 1.0.0. Everything before it shipped as a running deployment, which is why CHANGELOG.md covers versions that were never released -- 1.0.0's notes are assembled from them.

Changing the schema

There is no Alembic, but there is db/migrations.py. It compares the declared models against the live database and issues ALTER TABLE ... ADD COLUMN for anything missing, so adding a column to a model is free: restart and it appears, with existing rows backfilled from a type-derived default.

A nullable column is added with no default, so existing rows get NULL -- the value the model treats as absent. Only a NOT NULL column gets one, because SQLite refuses to add one without. An earlier version defaulted every column by type, which meant an added foreign key arrived as "" on old rows and every "is this set?" check downstream was wrong about them.

It cannot rename, drop or retype a column, or add a UNIQUE/PRIMARY KEY to an existing table — SQLite mostly cannot do those with ALTER TABLE either. Those need the create-copy-swap dance by hand; record them in MANUAL_STEPS so a failure has somewhere to point.

Because the runner exists, forward-looking columns are cheap now. Message.parent_id and content_parts_json (branching, multimodal) predate it and are still unread.

Artwork

Do not hand-edit files in assets/ — they are generated. Change scripts/build_artwork.py and re-run it. It also copies the few files the app serves into web/static/img/.

The leaf geometry is defined once (LEAF_BLADE, LEAF_MIDRIB, …) and reused by the icon, favicon, lockup and banner. The 64×64 mark must stay legible at 16px: the favicon variant drops the score lines, rim and veins because they turn to mud at that size. The icon sprite is a template partial (templates/partials/icons.html), not an asset, because same-document <use href="#id"> is universally supported and the cross-document form is not.

The mark() macro in _macros.html duplicates the mark geometry so it can be inlined and themed. If the mark changes, update both.

Deployment

deploy/ holds a systemd unit template, an nginx vhost template, and install/update scripts. Every template is parameterised (__PREFIX__, __SITE_HOST__, …) and substituted at install time, so nothing host-specific is committed here. See deploy/README.md.

Updates follow a channel, not a commit. stable is the newest vX.Y.Z tag, edge is the branch tip; a branch tip is not a release, and following one means deploying whatever was pushed five minutes ago. Read with git plumbing and never a forge API: a token on the deployment host to answer a read-only question is a bad trade, it ties this to one forge, and the Gitea API this was checked against 500s on exactly that endpoint. Release notes travel inside annotated tags, which git for-each-ref reads with no API anywhere. A tag with a suffix is not a release: git's version sort puts v1.1.0-rc1 above v1.1.0, so accepting one would step a stable host onto a candidate on the strength of a hyphen.

The update button cannot do the work, and that is the design. The service runs unprivileged and cannot restart itself; a web application that can is one whose worst day is much worse. So /admin/updates writes a file and an opt-in systemd .path unit runs deploy/update.sh as root. Three properties hold it together: the request file carries nothing that reaches a command line (the channel is baked into the unit at install time, so the button is always "deploy the channel this host was configured with"), it is off unless INSTALL_UPDATE_HELPER=1, and without it the page says so and prints the manual command. services/updates.py runs git with a fixed argv and shell=False -- which is not the "nothing executes on this machine" rule being bent, since that rule is about a model's commands and this is an administrator pressing a button.

Docker is one stage and bakes nothing. No secret key (one in an image is one every copy shares, and rotating it makes stored API keys unreadable), no data, and no .git -- so /admin/updates inside a container correctly says it was not installed from a checkout. docker-compose.yml publishes on loopback and expects a TLS proxy: the service worker and the microphone both require HTTPS or localhost, so plain http on a LAN address is a constraint rather than a preference.

This repository is public. Keep deployment-specific hostnames, ports and internal infrastructure detail out of it — those belong in whatever private notes describe the machine.

Not built yet

Nothing large. The tool loop in services/generation.py is what a new capability plugs into — a tool is a ToolDef reaching tools.resolve_tools() plus a permission and a capability flag, not a new code path. Its guidance is the same shape: a prompts.register_source yielding one Fragment per row puts it in the harness, on the admin page and in the preview without touching the assembler, the save handler or a template. Custom HTTP tools, MCP servers, agent chats and image generation are the four worked examples.

Nothing executes on this machine, and that is the design. Agent chats run their commands on a host reached over SSH. A local sandbox was designed in detail — bubblewrap, a masked data directory, a curated bind list — and dropped, because every hard problem in it came from running on the machine that holds the database and the encryption key: the service user cannot traverse /home, granting it needs ACLs, RLIMIT_NPROC is counted per uid so a fork bomb starves the server too, --size applies only to tmpfs so there is no disk quota, and a bind list is a standing invitation to widen until the sandbox is decoration. Over SSH, isolation is somebody's considered choice of host, using tools far better at it than anything that could be built here — and it is the only version that is honestly multi-user.

Two consequences worth stating plainly. The security of an agent chat is the security of the host behind its profile, and nothing here can tell a throwaway container from a production server. And there is no equivalent of the network: False switch the local sandbox would have had, because the network belongs to the far side — so an instruction injected through something the model read can, in principle, be carried out from there.

Local MCP is absent for the same reason. Only remote servers over streamable HTTP. Spawning npx would be a subprocess on this machine, which is the thing that is deliberately not done.

Unknown is not zero. Model.context_length of 0 means nobody has said how big the window is, which is different from "small". The context percentage is omitted rather than computed, and automatic compaction never fires. Token counts fall back to services/tokens.py -- four characters to a token -- and anything derived from an estimate is shown with a ~. Compaction does act on an estimate, because a premature compaction costs one turn of answer quality rather than data: the messages are kept.

Compaction hides turns, it does not delete them. Chat.compact_summary plus compacted_through_id say how far it reached; the messages stay in the transcript behind a <details> divider and simply stop being part of the request. The summary is carried by a user turn and an assistant turn, not one: a leading assistant breaks templates that require the first non-system message to be user, and a lone leading user produces user, user whenever the kept history starts on a user turn -- which it always does, because the cutoff lands on a finished reply. compacted_through_id is a plain id, not a foreign key, because migrations.py compiles only the column type and a REFERENCES clause would exist on a fresh database and not on an upgraded one; compaction.cutoff_message validates it on every read instead.

Compare message timestamps through compaction.moment(). SQLite does not store the offset, so a row loaded from disk is naive while one still in the session's identity map keeps its tzinfo. Comparing the two raises.

No OCR: a scanned PDF is stored with an explanatory extraction_error rather than silently contributing nothing.