Flint

Flint logo

A small, fully offline chat UI for local Ollama models, built to get the most out of small models.

View the source on GitHub

Architecture

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

The bet

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.

Repository layout

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

Data model

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.

A chat turn

  1. POST /api/conversations/{id}/messages takes the conversation’s lock (lockConversation), so overlapping requests to one conversation run in arrival order.
  2. The message and any attachments are validated as a whole, then saved. An @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).
  3. 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).
  4. A tool call is checked by the shield, then by the preconditions, then saved as a pending command. The stream ends, and the user approves or denies it with a separate request, which runs the command and continues the same turn. Reply instead denies it without continuing (deny?reply=1), and the user’s reply is sent as an ordinary message.
  5. After a final text reply come the stats line, then title generation on the first message (skipped if the reply failed), then a background summarization pass.

Streaming protocol

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

Fixed decisions

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.

Where the ideas come from

Each borrows from research, scaled down to what a small local tool can honestly claim:

Deliberately not built

Neither should come back without solving the problem that stopped it.