A small, fully offline chat UI for local Ollama models, built to get the most out of small models.
Flint is one Go binary that serves both the web UI and a JSON/streaming API, stores everything in SQLite, and talks to a local Ollama for the models. There’s no build step, no CDN and no other service to run.
browser (htmx + Alpine.js + Pico.css, all vendored)
│ same-origin HTTP: server-rendered pages + /api/*
▼
flint (Go, net/http)
├── SQLite (modernc.org/sqlite, pure Go, WAL, one connection)
├── attachment files on disk
├── Ollama HTTP API (chat, embeddings, model management;
│ a `:cloud` model is forwarded by Ollama to ollama.com)
└── Brave Search API, only on an explicit `@web` query
A well-guided 3-4B local model isn’t an inferior model, just an under-scaffolded one. Flint’s work goes into the scaffolding around it: careful context management, a structured tool loop with human approval, and durable history. It avoids anything that only adds weight, which is why there’s no bundled RAG or embedding stack.
backend/ Go module; run from here (asset paths are ../frontend/...)
main.go config from env, routes, graceful shutdown, /healthz
auth.go signup, login, logout, sessions, requireAuth
recovery.go security-question password recovery
ratelimit.go in-memory sliding-window limiter for auth endpoints
handlers.go conversations, messages, streaming turns, tool approval
context.go request budget and history fitting (see context-management.md)
summary.go background layered summaries
memory.go `@memory` save, draft, recall, folder memories, memory API
ablate.go FLINT_ABLATE: switch scaffolding off for the benchmark
ollama.go Ollama client: chat (streaming and not), models, embeddings
tools.go the run_shell tool definition and reasoning nudge
shield.go hard block list for catastrophic commands
preconditions.go cheap checks before a command is offered for approval
folder.go folder manifest and per-turn anchor header
images.go attachment upload, storage and image token estimate
websearch.go Brave Search, then re-ranking via Ollama embeddings
views.go server-rendered pages
db.go schema, migrations, all SQL, chat search
frontend/
templates/ html/template pages (base, chat, settings, login, signup, recover)
static/js/ chat.js (streaming chat UI, markdown/math setup), settings.js, app.js,
vendored htmx, Alpine.js, marked, DOMPurify, Temml
static/css/temml/ Temml's stylesheet and math font (local only)
static/css/ flint.css on top of Pico.css
bench/ scored benchmark: run.py, tasks.py, fixture/
docs/ this documentation
Dockerfile, docker-compose.yml (Linux), docker-compose.desktop.yml (Mac/Windows)
.github/workflows/ docker.yml: publishes the image on version tags
| Table | Holds |
|---|---|
users |
account, bcrypt password hash, own Ollama URL, preferred models, Brave API key, context window overrides (num_ctx local, cloud_num_ctx cloud) |
sessions |
session id (the cookie value), user, expiry |
security_questions |
recovery questions, answers bcrypt-hashed |
conversations |
owner, title, model, attached folder, last context use, token ratio |
messages |
role (user/assistant/system/tool), content, thinking, tool calls |
attachments |
metadata; the file itself lives under ATTACHMENTS_DIR |
commands |
every model-proposed shell command, its status, output and exit code |
summaries |
layered summaries covering message id ranges (see context-management.md) |
memories |
facts a user saved with @memory, optionally tied to a folder and to the chat it was saved from (conversation_id, cleared if that chat is deleted) |
memories_fts |
FTS5 index over memories, kept in sync by triggers |
messages_fts |
FTS5 index over message text, kept in sync by triggers |
Deleting a conversation cascades to its messages, attachments, commands and summaries; a memory saved from it stays and only loses its link. Deleting a user removes everything they own. Rows are never shared between users.
Schema changes that CREATE TABLE IF NOT EXISTS can’t make on an existing
database (new columns, the FTS index) are applied in migrate at startup.
Each step is idempotent.
POST /api/conversations/{id}/messages takes the conversation’s lock
(lockConversation), so overlapping requests to one conversation run
in arrival order.@web query runs its search first. An @memory recall adds the
matching memories first, and @memory save is handled on its own with
no chat turn, and so is @compact (see features.md).streamAssistantTurn builds the budget and the fitted history,
appends the tool nudge and folder anchor to the last message when a
folder is attached (the nudge is left off when the user’s message says
not to run commands, see experiments.md E17), and streams the model’s
reply. Without a folder no tool is offered; if the message asks to run
or check something, a note tells the model to give the command for the
user to run instead of inventing output (E19).deny?reply=1), and the user’s reply is sent as an ordinary message.Replies stream as text/plain: the model’s tokens, plus marker lines the
client consumes:
| Marker | Meaning |
|---|---|
<<<LOADING>>> |
the model isn’t loaded yet; a cold start can take a while |
<<<THINK>>>"..." |
one JSON-string chunk of thinking, shown apart from the answer |
<<<CONTEXT>>>{"used":N,"max":M,"condensed":C} |
context use for this response, sent after every model response; condensed is how many messages a summary replaced in the request |
<<<TOOL_RESULT>>>{"status":..,"output":..} |
what an approved or denied command produced, for display |
<<<TOOL_CALL>>>{"id":..,"command":..} |
a pending command awaiting approval; ends the stream |
<<<STATS>>>{"tokensPerSec":N} |
generation speed; ends a final text reply |
<<<MEMORY_SAVED>>>{"content":..} |
@memory save <text> stored that text; shown as the “Saved to memory” card |
<<<MEMORY_DRAFT>>>{"text":..} |
a drafted memory for the user to review; ends the stream |
<<<SEARCHING>>>{"query":..} |
an @web search has started |
<<<SOURCES>>>{"query":..,"sources":[{"title","url","date"?}]} |
the results the answer will be based on; date only when every result has one |
The reasons behind the settled choices (pure-Go SQLite, a single connection, cookie sessions instead of JWT, 404 instead of 403, no CORS, native tool calls, the nudge placement and so on) are described where they apply: security.md for accounts, sessions and the shell tool, context-management.md for prompts and the window, and experiments.md for the measurements behind them.
Each borrows from research, scaled down to what a small local tool can honestly claim:
[exit code: N] or [FAILED, exit code: N]; a silent failure used to
look like success), and after 3 tool calls in a row the model has to
answer in text. A small retry budget beats one greedy attempt without
a bigger model, while each retry still costs you an approval.@web re-ranking uses Ollama’s own /api/embed instead.find, ls -R
and git ls-files all count the same files).Neither should come back without solving the problem that stopped it.