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

AIDevOpsRunning models locally with Ollamaollamaqwenarchitecturecaddywikitool-calling

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 1Now
ModelThe visitor chose WebLLM or HostedOne fixed model, Qwen3.5 9B
Who reaches OllamaAnyone, through a reverse proxyNobody. Ollama listens on localhost only
What the internet reachesOllamaA gatekeeper program that I wrote
What the assistant knowsTitles, tags and post textThose, plus a wiki of checked summaries and topic pages
Which page it is onPost pagesPosts, 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.

The browser does the searching and reading. Only the call to the model travels to my Mac mini.Scroll sideways, or tap the diagram to open it full size

The browser does the searching and reading. Only the call to the model travels to my Mac mini.

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.

One question, up to eight rounds. A plain-text reply ends the loop.Scroll sideways, or tap the diagram to open it full size

One question, up to eight rounds. A plain-text reply ends the loop.

Here is what one turn looks like:

  1. 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.
  2. The page sends the request to the backend and gets a reply.
  3. If the reply is a JSON block such as {"name": "search_posts", "arguments": {"query": "docker"}}, the page runs that tool on the local data.
  4. The page wraps the result in a <tool_response> block, adds it to the request and asks again.
  5. 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.

Three tools search the post list, one reads a post, three read the wiki, one moves the page and one goes to the web.Scroll sideways, or tap the diagram to open it full size

Three tools search the post list, one reads a post, three read the wiki, one moves the page and one goes to the web.

ToolReadsUsed for
search_postsagent-data.jsonKeyword search over titles, descriptions and tags
get_posts_by_categoryagent-data.json"Any Java posts?"
list_categoriesagent-data.json"What categories are there?"
get_post_contentagent-posts/<slug>.json"Summarise this post"
search_wikiagent-wiki.json"What did you conclude about RAG?"
list_topicsagent-wiki.json"What topics do you have?"
get_topicagent-wiki.jsonOne topic page: summary, quoted claims, differences
navigate_to_postThe Next.js routerTaking the visitor to a post
web_searchThe webA 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.

The address decides which hint the model reads and which tool it should reach for.Scroll sideways, or tap the diagram to open it full size

The address decides which hint the model reads and which tool it should reach for.

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.

Everything in one request, sent again with every call.Scroll sideways, or tap the diagram to open it full size

Everything in one request, sent again with every call.

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.

Before, the internet reached Ollama. Now it reaches a program that decides what Ollama may be asked.Scroll sideways, or tap the diagram to open it full size

Before, the internet reached Ollama. Now it reaches a program that decides what Ollama may be asked.

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.

A request passes seven checks before the gatekeeper builds its own request for Ollama.Scroll sideways, or tap the diagram to open it full size

A request passes seven checks before the gatekeeper builds its own request for Ollama.

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.

The model picks sentence numbers. A program copies the words. An audit checks every quote.Scroll sideways, or tap the diagram to open it full size

The model picks sentence numbers. A program copies the words. An audit checks every quote.

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.