Local Agent (Part 2)
Neil Haddley • November 6, 2026
How the blog assistant works now: a gatekeeper in front of a hosted Qwen3.5 9B, an agent loop that runs in the browser, and a checked wiki of the posts
In Part 1 I added a chat button to this blog. It had two backends: a model that ran inside the visitor's browser, and an Ollama server that I ran myself and put on the internet.
I have since rebuilt most of it. The browser model is gone. There is one fixed model, one gatekeeper program in front of it, and a small wiki that the assistant reads. This post walks through every part, with a diagram for each.
What changed since Part 1
| Part 1 | Now | |
|---|---|---|
| Model | The visitor chose WebLLM or Hosted | One fixed model, Qwen3.5 9B |
| Who reaches Ollama | Anyone, through a reverse proxy | Nobody. Ollama listens on localhost only |
| What the internet reaches | Ollama | A gatekeeper program that I wrote |
| What the assistant knows | Titles, tags and post text | Those, plus a wiki of checked summaries and topic pages |
| Which page it is on | Post pages | Posts, categories and topics |
The whole agent on one page
Here is everything on a single picture. The left half is the visitor's browser. The right half is a Mac mini at home.
Look at the arrow between the two halves. Searching the blog, reading a post and looking something up in the wiki all happen in the browser, on JSON files that the site already serves. My server never sees them. The only thing that crosses to the Mac mini is the conversation that the model needs to read.
That split is deliberate. The model is the expensive, risky part, so it is the only part that I run and defend myself. Everything else is static files on GitHub Pages.
The loop in the browser
The model never searches the blog itself. It asks, my code in the browser does the work, and the answer goes back. They repeat until the model has enough to write a reply.
Here is what one turn looks like:
- The page builds a request: the system prompt with the tool definitions, the last few exchanges, a hint about the current page, and the visitor's question.
- The page sends the request to the backend and gets a reply.
- If the reply is a JSON block such as
{"name": "search_posts", "arguments": {"query": "docker"}}, the page runs that tool on the local data. - The page wraps the result in a
<tool_response>block, adds it to the request and asks again. - If the reply is plain text, that is the answer, and the loop stops.
Two guard rails keep a small model out of trouble. The page refuses to run the same tool call twice in one turn, and it limits how many full posts one turn may fetch. In the last rounds it tells the model to answer now, so a model that keeps searching still ends up saying something.
The Show working toggle under an answer lists every step: what the model thought and which tools it called. That is the same list the loop kept while it ran.
The tool belt
The model has nine tools. Each one reads from one of five places.
| Tool | Reads | Used for |
|---|---|---|
search_posts | agent-data.json | Keyword search over titles, descriptions and tags |
get_posts_by_category | agent-data.json | "Any Java posts?" |
list_categories | agent-data.json | "What categories are there?" |
get_post_content | agent-posts/<slug>.json | "Summarise this post" |
search_wiki | agent-wiki.json | "What did you conclude about RAG?" |
list_topics | agent-wiki.json | "What topics do you have?" |
get_topic | agent-wiki.json | One topic page: summary, quoted claims, differences |
navigate_to_post | The Next.js router | Taking the visitor to a post |
web_search | The web | A last resort, for things the blog does not cover |
The site builds the three JSON sources before every deploy. agent-data.json is the list of posts, agent-posts/ holds one file per post, and agent-wiki.json is the wiki. The wiki file is the largest, so the page downloads it only the first time a wiki tool runs.
Where am I?
A visitor on a post page says "summarise this", and the assistant needs to know what "this" means. So every question goes out with a short hint that names the page.
What the model sees
Every call carries the whole conversation again, because the model has no memory of its own. So I keep that conversation small.
After each answer the page keeps only your question and the final reply. The tool results from that turn are thrown away, because the answer already says what mattered in them. The page keeps the last six messages, which is three exchanges, and always starts the window on a message from the visitor.
The gatekeeper
Part 1 put Ollama on the internet behind a reverse proxy. That worked, and it meant that anyone could pick any model and send any prompt. I wanted none of that.
Now Ollama listens on the Mac mini's own address only, so nothing outside the machine can connect to it. In front of it sits a small Node program with no dependencies, which I call the gatekeeper. Caddy handles HTTPS and forwards two paths to it. Every other path, including every Ollama path, gets a plain 404.
The last box matters most. The gatekeeper does not forward what the page sent. It keeps only the role and content of each message and builds a fresh request: the fixed model, thinking off, and a cap on the length of the answer. A page that sends a different model name, a tools field or any option has those parts dropped.
Thinking off
Qwen3.5 can reason before it answers, and that reasoning costs tokens. With it on, my first test question on the Mac mini took 139 seconds and came back with an empty answer, because the model spent its whole token allowance thinking. With thinking off, the same kind of question comes back in a fraction of a second.
The gatekeeper now asks Ollama for no reasoning by default. A setting turns it back on, for the day I want slower and more careful answers.
The wiki
The assistant used to search titles and tags. That finds a post called "Docker", but it cannot answer "what did you conclude about running models locally", because the conclusion is a sentence somewhere inside several posts.
So I wrote a wiki: a short page for each post that has enough text to summarise, and a page for each topic that several posts share.
Each post page holds a summary, a list of claims and, for every claim, a quote from the post. A topic page pulls the claims from the posts that share a tag, and says where those posts differ. I can read these pages without a model: they are plain Markdown in the repository.
Under the hood: Why the model never writes a quote
If I ask a model to quote a post, it tends to improve the quote. It tidies a word, drops a clause or merges two sentences, and the result looks like a quotation while no longer being one.
So the script splits the post into numbered sentences and shows the numbered list to the model. The model returns numbers, and the script copies the sentences with those numbers into the page. The quote is the post's own text by construction, not by the model's good behaviour.
An audit then checks every quote anyway. It confirms that the text appears word for word in the post, ignoring only whitespace, curly quotes and Markdown emphasis. At the time of writing it checks 1,720 quotes across 292 post pages and 15 topic pages, and every one passes.
Keeping it current
A new or edited post is picked up with one command, npm run wiki. It regenerates only the pages whose post changed, rebuilds the topic pages that depend on them, runs the audit and refreshes the data file. The audit also runs in the deploy workflow, so an edit that removes a sentence the wiki quotes stops the build and names the page.
The topic pages on this site use Head First boxes now too: a question before the explanation, the idea in a recap box, and a warning box where my posts disagree. Only the layout changed. Every sentence is still the audited text.
Try it
What is still rough
- A 9B model is small. It follows the tool format most of the time, not all of the time. The guard rails in the loop exist because of the times it did not.
- The hint is a request, not a guarantee. I tested the rule text on a cut-down prompt and the hosted model chose the right tool, but a model can still ignore a rule, and I have not tested every page type end to end.
- One machine. If the Mac mini is off, the assistant says that it is not reachable and offers a Retry button. There is no second server.
- The wiki lags my edits until I run
npm run wiki. The audit tells me when a page is older than its post.