Part 1 described a private AI workspace. This is what happened next: seventeen days, 240 catalogued changes, and one idea that kept surfacing until it reorganised everything — the model is a component, and the thing you actually build is the shell around it.
On 10 August, OnDevice.ai was a chat box with three document generators bolted to the side and a model name in an environment variable. By 26 August it was a shell: every message enters through one router, leaves through one of eleven named pipelines, and the model underneath is something you pick from a menu — local GGUF, local MLX, a cloud key you paste in, or your own vLLM endpoint. Nothing about the change was a rewrite. It was six decisions, each of which made the next one obvious.
The shape of it
Every material change went into a changelog entry and a catalogue row with a stable ID. That discipline was a note-to-self at the time; in hindsight it's the only reason this article can be written from evidence rather than memory.
Of the 240 entries, 238 are shipped, one is partial and one is pending. That ratio is less impressive than it sounds — it mostly means I didn't log things until they worked. The genuinely unfinished work is in the last section, and there is more of it than a status column admits.
A 27-billion-parameter model on a laptop is not slow in any absolute sense. It is slow compared to a web app, which is the only comparison a user actually makes. Ask for a deck and nothing visible happens for forty seconds. Every tester I put in front of the early build did the same thing at around the fifteen-second mark: they clicked again. Then they said it had crashed.
It had not crashed. It was working perfectly, in silence, which turns out to be indistinguishable from failure.
Local inference takes tens of seconds. With no feedback, users read silence as a crash — and clicking again queued a second job on an already-busy machine.
Anthropic-style SSE lifecycle, a phased progress panel with step N/M, an elapsed timer, keepalive frames every ~2s, Send-becomes-Stop with a real server-side cancel, and a browser notification when the tab is in the background.
It stopped being a slow app and became a working app. Nothing about the model changed. The perceived product changed completely.
preparing → drafting_spec → generating_bytes → validating → finalizing; this is the research-assisted deck path, which prepends its own refining_ask stage — hence "sharpening the request, then researching".The detail that mattered most was the smallest: naming the phase. "Loading" tells you nothing. "Sharpening the request, then researching… (2/5)" tells you the machine has a plan, knows where it is in that plan, and expects to finish. The elapsed timer does the rest of the work — it converts an unbounded wait into a measurable one.
SSE is a one-way stream from server to browser over ordinary HTTP. The server holds the connection open and writes text frames as work completes; the browser reads them as they arrive. Unlike WebSockets it needs no protocol upgrade, no special proxy configuration, and it reconnects on its own.
Two things make it work in practice. First, keepalive frames: if nothing is sent for a while, intermediate proxies and the browser both start to assume the connection is dead — so the server emits a comment frame every couple of seconds purely to say "still here." Second, buffering headers: Cache-Control: no-cache, no-transform and X-Accel-Buffering: no, without which a reverse proxy will helpfully collect your tokens and deliver them in one lump at the end, defeating the entire exercise.
Cancellation is the other half. The browser's AbortController tears down the fetch, but that alone only stops the client listening — the server happily continues burning GPU on an answer nobody will see. A server-side cancel_check polled between pipeline steps is what actually stops the work.
Catalogue: C-003, C-007 to C-012, C-015 to C-020 · Tests: Q-004, Q-006
This is the turning point that gave the article its title, and it arrived by accident.
The first local build had OLLAMA_MODEL in a .env file. Changing models meant editing a file and restarting the server. That was tolerable while there was one model. Then I wanted to compare Qwen against Glimmer. Then I needed a vision model for images, and a coder model for code, and the 27B was too slow for a one-line question that an 8B would answer instantly.
The obvious fix was a dropdown. The interesting consequence was what the dropdown implied: if the model can change between turns, then nothing in the rest of the system is allowed to assume which model it is talking to. Every capability has to be probed, not assumed. That constraint is what turned a chat app into a shell.
Seven backends now feed the same socket. Two are local runtimes on this machine, four are cloud providers unlocked by pasting a key, and one is whatever OpenAI-compatible server you point it at. The list of models is discovered live from whichever backend is active, merged with a preset catalogue, and tagged by format.
One decision here looks like a limitation and is actually the thing that makes it work: Ollama serves GGUF, LM Studio serves MLX, and they do not share a model directory. It is tempting to point OLLAMA_MODELS at the LM Studio library and have one pool of weights. It doesn't work — the formats and runtimes are genuinely different — and pretending otherwise produces failures that look like model quality problems rather than plumbing problems.
| Layer | Local | Cloud | What the shell does about it |
|---|---|---|---|
| Runtime | Ollama (GGUF) · LM Studio (MLX) | OpenAI · xAI · Anthropic · OpenRouter | Same OpenAI-compatible client for all seven |
| Vision | Probed via /api/show capabilities | Assumed from provider docs | Never sends pixels to a model that reports text-only |
| Context | num_ctx, 4k → 128k | Provider default | 1M YaRN needs vLLM — routed to Custom URL |
| Thinking | Ollama native think: true|false | Provider-specific | Separate reasoning_delta stream, collapsible panel |
| Caching | keep_alive=30m + prefix cache | cache_control: ephemeral | Cache hit/written shown under the reply |
| Keys | None needed | llm_secrets.json, mode 0600 | Server-side only — never NEXT_PUBLIC_* |
GGUF is the file format used by llama.cpp and everything built on it, including Ollama. MLX is Apple's array framework for Apple Silicon, with its own weight format that uses unified memory more directly. Same model, different container, different runtime — and no, you cannot point one at the other's folder.
Quantisation is what makes a 27B model fit on a laptop at all. Full weights ship at 16 bits per parameter — about 54 GB for 27B. Q4_K_M stores most weights at roughly 4 bits with the more sensitive layers kept at higher precision, landing somewhere near 16 GB. You lose a little accuracy. You gain the ability to run it.
The reason everything speaks OpenAI-compatible HTTP is not deference to OpenAI — it's that /v1/chat/completions became the de facto lingua franca. Ollama, LM Studio, vLLM and SGLang all expose it. One client, seven backends, and a new runtime is a URL rather than an integration.
Catalogue: L-002 to L-036 · Decisions: hybrid GGUF/MLX split (11 Aug), cloud keys on disk (23 Aug)
By mid-August the chat handler had grown a thicket of if-statements. Is this a document request? Does it mention a PDF? Are there images attached? Does it look like a summarise? Each check had been reasonable when added, and collectively they were unmaintainable and untestable.
The fashionable answer in 2026 is an agent: give the model tools and let it plan. I deliberately did not do that, and the reason is the same reason the documents pipeline works — an intermediate you can inspect beats a decision you have to trust.
Intent detection had become nested conditionals with overlapping conditions. Adding a capability risked silently breaking an unrelated one, and no test could assert "this message goes here".
Every message hits one router that classifies it into exactly one of eleven closed categories, then hands off to exactly one named pipeline. Rules run first; a short local-model JSON classify runs only when the rules return ambiguous.
The route is recorded on the message as extra.route — category, source and pipeline. When something goes to the wrong place you can see exactly where the decision was made, and write a test that pins it.
The specialists deserve a note, because "specialist" in this codebase means something narrower than the industry usually means by it. A specialist is not an agent. It has no planner, no tool discovery, and no ability to decide it needs a different specialist. The browser specialist visits only http(s) hosts the user named in the message — plus an optional environment allow-list — in an isolated Firefox profile, with downloads off and a Stop button that kills the job. It returns a screenshot and the page text. That is the entire contract.
The same restraint governs model selection. After routing, the shell may swap in a specialist model for that category — LLaVA for vision, a coder model for code — but only from a fixed allow-list, only if it's already installed, and only for that turn. Then it restores the home model. The status line says so out loud: "Using llava for vision; then back to qwen3.8-27b."
An agentic planner decides at runtime what steps to take. That's powerful when the task space is genuinely open, and it's a liability when it isn't. The cost is that every failure is a reasoning failure — the model chose badly, and reproducing the choice requires reproducing the exact context.
A router with a closed category set converts that into a classification failure, which is a completely different animal. It's inspectable (the category is written on the message), testable (you can assert message → category), and cheap to fix (add a rule). You give up the ability to handle requests nobody anticipated. For a workspace with eleven kinds of work, that trade is almost free.
The hedge is the ambiguous escape hatch: when rules can't decide, one short JSON classification runs on the local model. Even the fallback stays inside the boundary.
Catalogue: C-046 to C-048, C-057, A-011, A-012, L-035 · Tests: Q-019, Q-020, Q-024
Part 1 argued that document reliability comes from schema-validated specs and deterministic generators, never from asking a model to emit binary Office files. That bet held. What it did not do was make the documents any good.
On 14 August, "Create a 5-slide presentation on Q1 infrastructure goals" produced two empty slides that cloned the command string back at me. The file was structurally perfect. It opened in PowerPoint. It was worthless.
The single highest-leverage change was ensure_narrative_arc: whatever the model produces, the deck must contain an Agenda, the user's own topics as sections, a Plan or Timeline, Success Measures, and Next Steps. Not an infrastructure template — a shape, domain-agnostic, with the domain inferred from the request.
You can watch it happen. I asked for five slides on a datacentre migration and got nine, because the arc requires more than five to be complete:
Canvas is the part I underestimated. Because the intermediate spec is a real object rather than a hidden prompt, it can be shown — and if it can be shown it can be edited. "Change slide 3 title to Risks" patches the spec and regenerates the file. No re-prompting, no regenerating from scratch, no drift in the other eight slides.
Two words on that diagram carry all the weight. Hard: structural validation is the only thing that can block a download — if the OOXML isn't right, no file. Advisory: LibreOffice rendering and the quality score are opinions. They get reported (0.93 in the screenshot above) and they never stop you getting your file. Conflating those two would mean a slow or missing LibreOffice install could silently break document generation, which is exactly the class of failure this architecture exists to prevent.
Once a PPTX exists you can render its slides to images and look at them. That's what reflection does: render thumbnails, check for the failure modes that survive structural validation — an empty title, twelve bullets crammed on one slide, a body that repeats the title — and emit typed patches against the spec rather than free-text corrections.
Typed patches are the important bit. The model isn't asked to rewrite the deck; it's asked to fill fields in a known repair schema. The patch is applied to the spec, the deterministic generator runs again, and the whole thing is bounded by max_repairs so it cannot loop forever.
It's the same principle as the router: give the model a small, well-typed decision instead of a large, open one, and check the intermediate.
One branch of this deserves a mention because it complicates the story. If a model called deeppresenter-9b is installed, decks are generated by that model's JSON spec rather than the general path — a purpose-built 9B specialist for one task, with an optional Docker host that bakes in LibreOffice so PPTX pages can be rendered back for reflection. It's the one place the project runs an MCP tool list at all, and even there the tools belong to the host, not to the chat model. If the host is down, the normal pipeline still produces a deck.
The honest note: this is a good draft generator, not a good designer. Against Grok on depth of content, layout polish and language, local output is still thinner. That gap is tracked, not hidden — see below.
Catalogue: D-016 to D-028, D-030 to D-056, F-006 · Tests: Q-010 to Q-012, Q-025
A local-first product has an awkward relationship with web search. The whole promise is that your data stays on your machine; search is the one feature that deliberately sends part of your question to somebody else's server. It can't be on by default, and it can't be missing.
The resolution was WEB_SEARCH_MODE=auto: search runs only when the question looks live — news, weather, "latest", a URL, a fact-check phrasing. Ask it to explain a concept and nothing leaves the machine. Ask it who won yesterday and the query goes out, with the privacy cost documented rather than buried.
Searching on every message leaks the user's questions and pollutes answers with irrelevant snippets. Never searching makes the model confidently wrong about anything recent.
Auto mode gated on query shape; five providers behind one interface; authority ranking; page fetch to expand thin snippets; two-hop research; an explicit fact-check pipeline; and citations verified against the source file.
Search became a capability with a stated cost rather than an always-on default — and the offline status strip tells you why a search was skipped, so silence is explained rather than mysterious.
Citations got the same treatment as documents: don't trust, verify. When a model cites an attached file, the pipeline records the quoted text, the document index, the title, and the start and end page numbers — then checks the quote against the actual file. Verified citations are marked in the UI. A quote the model invented doesn't get the badge.
Retrieve-then-generate inverts the usual order. Instead of letting the model answer from memory and hoping it's right, you fetch sources first, inject them into the prompt as numbered documents, and instruct the model to answer only from those sources or explicitly abstain. Hallucination doesn't disappear, but it becomes visible: an unsupported claim has no [n] next to it.
That makes prompts long, which is where prefix caching earns its place. Turn two in a conversation usually repeats most of turn one — same system prompt, same PDF analysis, same citation pack — and only the last few hundred tokens differ. Two caches cooperate: an application-level prefix cache that avoids recomputing the analysis at all, and the runtime's own KV cache, kept warm by sending keep_alive=30m so Ollama doesn't unload the model between turns. Cloud providers get cache_control: ephemeral on the system message for the same reason. The UI reports hit or written under the reply, because a cache you can't observe is a cache you can't debug.
Catalogue: W-001 to W-020, C-045, L-032, F-001 to F-005 · Tests: Q-014 to Q-018
Sixty-odd features had accumulated in two weeks, and every one of them had asked for somewhere to live. The header had become a shelf: backend, model, online dot, temperature, reasoning, theme, files, canvas, prefs, memory, search status, account. All of it visible, all of the time, competing with the thing people actually came to do.
The last two changes in the log are the ones that made it feel finished, and neither adds a feature. C-065 moved every file — pending attachments, files already sent in this chat, and generated artifacts — into a Files drawer, and replaced the composer's row of attachment chips with a count badge. C-066 moved theme, panels, preferences, memory and status into a Tools drawer. The header kept four things: chat, backend and model, Tools, account.
There's a smaller entry from 16 August that I find more instructive than either drawer. I'd been trying to fix the type scale by nudging it a point at a time, and the log records why that failed: "a 1pt tweak was ~1px and did not show." The eventual fix was to double the scale outright, look at it, then come down one visible step. It's a reminder that when you can't see a change, you're not tuning — you're guessing, expensively.
New chat, canvas, incognito, annotate — without hunting for a control.
Graphite, Midnight, Forest, Amber. Also five deck themes for generated PPTX.
Chats stay out of the sidebar list and are deleted on reset.
Active model, why a search was skipped, whether the prompt cache hit.
Local memory is worth calling out because of what it doesn't do. It's opt-in, default off, written to plain files on disk, and editable by hand. You add to it by saying "Remember: …" in chat. No embeddings, no cloud sync, no silent accumulation of a profile. For a product whose entire argument is about where data goes, memory that quietly builds itself would have contradicted the premise.
Catalogue: P-007, P-008, C-039 to C-041, C-051, C-060 to C-066
The catalogue says 238 shipped, 1 partial, 1 pending. That's a flattering way to count, for two reasons. Things I decided not to do yet never get a row at all. And "shipped" only ever meant this landed — never this is finished. Two of the rows below are marked shipped in the catalogue and are still, honestly, gaps. Here's the ledger.
Muse_Contributer, the database is muse_workspace, the demo user is demo@muse.local, and a model preset is still called muse-glimmer-30b. Cosmetic, and it will stay that way until the name has proven it's sticking.num_ctx up to 128k — a lot, and not the number on the model card. The custom-URL backend is the honest route to the real thing, and nobody has walked it yet.AUTH_ALLOW_DEMO is still true by default, refresh tokens don't exist, and Redis is scaffolded rather than used. Correct for a single-user local tool; not correct for anything shared, and I'd rather say so than let the health endpoint imply otherwise.There's a category the list doesn't capture: things that got ruled out. No general agent. No sub-agents. No dynamic tool discovery. No MCP handed to the chat model — the DeepPresenter host has its own tool list, but the model you are talking to never sees it. No desktop control — the browser specialist visits named hosts and nothing else. Each of those was considered and declined, and the declining is what kept the eleven pipelines checkable.
Where it lands
The lesson I'd take from these seventeen days is not about local inference. It's about which part of an AI product is worth building carefully.
The model in the socket today will be embarrassing in six months. That's not pessimism, it's the whole trajectory — I swapped the default twice in this window alone, and the second swap took a dropdown selection rather than a deployment. Everything I'd point at as real engineering sits around the socket: a router whose decisions you can read, an intermediate spec you can edit, a validation gate that can't be talked out of blocking a broken file, a boundary that holds regardless of which core is plugged in.
That's the argument for the shell. Not that local models are as good as frontier ones — Part 1 was clear that they aren't. It's that the ceiling belongs to the model and rises on somebody else's schedule, while the floor belongs to the shell and is entirely yours to build.