qwen3:4b through Ollama on an Intel N100 mini PC. It writes only from facts that code selects, and code refuses to start without them. It writes a plan and then one section at a time. Code chooses the topic and inserts the links. Eleven checks that use no model gate every draft before a person sees it. A person decides whether it is true and presses Publish. The model is the least important part of the pipeline — which is the reason a small one works. It belongs to the two blogs in our local-business case study.
A 4B model on a 6-watt machine
The writer runs on a mini PC on our own network: an Intel N100, four cores, about six watts, no GPU. Ollama serves the model in its own container, reachable only from the writer’s internal network, because Ollama has no authentication of its own. The machine has no route to the code host on purpose; new versions arrive as a bundle over SSH and roll back to the previous image if they do not come up healthy.
The default model is qwen3:4b. A second compose file reserves an NVIDIA GPU and switches to qwen3:14b — about 9 GB of weights, sized for a 12 GB card — but the GPU is off by default, because not passing a device to the container is the only reliable way to force CPU. Measured on the N100, the model reads a prompt at about 14 tokens a second and generates at about 2.3. Each prompt is 2,300 to 2,700 tokens of facts and examples before the first word comes out, so the plan takes about six minutes, the first section about three, the rest about a minute and a half each, and a whole article about sixteen minutes. For a draft a day, that is plenty, and nothing the writer reads ever leaves the building.
Two settings that cost us days. The qwen3:4b tag points at a thinking build: on a tiny schema request it spent 82 seconds and 4,351 characters reasoning with thinking on, and answered in 1.7 seconds with it off. And Ollama’s default context window silently cuts the start of a long prompt — exactly where the instructions about voice sat — so the window is set explicitly to 16,384.
Facts before words, and a refusal without them
The writer reads the sites’ own repositories, mounted read-only: the published articles, the facts about each device model, the service or catalogue sections, and the list of pages. It reads them from disk rather than from the live sites, so a slow site or a stale cache cannot change what it knows.
The unit of a fact is a record, not a line — one record per service or section, one per device model — and only labelled fields are sent: what customers bring this model in for, what is checked first, typical problems, timing, price factors, FAQ. Code builds one ready sentence of specifications per model and labels it as safe to copy as is. Records are ranked by how many topic words they share, with a match on a record’s name worth ten times a match in its body, and sent until a budget of 6,000 characters per article and 2,500 per section is used.
The rule that matters most is the shortest one. If code finds no fact records for a topic, it stops before any prompt is written, with the message that writing without them means inventing. It replaced an instruction in the prompt to “write only what you are sure of”, which in practice the model read as permission to invent.
Code picks the topic, the model words it
Topic choice is mostly not a model’s job. Code picks the section of the blog with the fewest articles, breaking ties by the oldest last article. The model then proposes six topics for that section — each a concrete problem an owner has, not a review or a top-five list, using only the facts given and never repeating an existing title. Code drops any proposal whose title is too long, whose address is taken, or that shares 60% or more of its words with an existing title, and ranks the survivors by how many of their words appear in the facts. The schedule takes the first.
The honest limitation: the only signal is how many articles a section has. There is no demand data in the loop yet, which is the next thing an analytics agent could feed it.
A plan, then one section at a time
A model small enough to fit in 12 GB drifts on long answers, and a generation cut off halfway loses everything it wrote. So the writer asks for a plan first — title, description, lead and four to six sections, each with an intent and a shape (prose, list or steps) — and then writes each section in its own request, with its own facts between explicit “facts start” and “facts end” markers. A cut-off generation now costs one section, not the article.
Two prompt details earned their place. The no-invention rule is repeated at the very end of each section prompt, because the model reliably ignores instructions buried in the middle of a long one. And code cleans up after the model: it drops one-item lists, strips the numbering the model adds on top of list markup, removes duplicated blocks and keeps one note per section.
The model would not write links, so code does
Internal links were the most instructive failure. The 4B model never inserted one. We tried four times: a soft rule, a hard rule, regenerating the section, and a dedicated request to insert one given link — which came back byte-for-byte unchanged. The cause turned out to be our own voice rule against advertising in articles: the model read a link to a service page as an ad.
Now code chooses two or three real pages whose titles share stems with the topic, and on one of the blogs it wraps words the model already wrote: it finds a paragraph of at least 120 characters without a link, looks for a service word or a word from the page’s title, and adds the markup without changing a character of the sentence. It refuses anchors that start with a preposition or end in a verb form, because a link on “to repair” reads worse than none. If no good anchor exists, it leaves the text alone and lets the check decide.
Eleven checks, and none of them is a model
Every check is arithmetic or a regular expression. A second model judging the first would share its blind spots and answer differently each run; counting does neither.
| Check | What it measures | Rejects at |
|---|---|---|
| Length | Characters of the draft text | under 2,000 (warns over 12,000) |
| Duplicates | Jaccard similarity of five-word shingles against every published article | 0.30 (warns from 0.18) |
| Stock phrases | How many of 28 cliché patterns appear | 3 or more |
| Form of address | Informal “you” where the sites use the formal one | 2 or more hits |
| Prices | A sum next to a currency or a cost word | any, on the site that publishes no prices |
| Known links | Any link that is not one of the site’s real pages | any |
| Inline links | Links inside the text, per each site’s rules | none where they are required |
| Address taken | The article’s address already published | warns, and marks it as a replacement |
| Structure | Headings | fewer than 3, or duplicates |
| Grounding | Specifications and model designations not found in the allowed sources | any |
| Specifics | Amounts, periods, menu paths and terms not found | warns only |
Grounding is the check that matters. It pulls two kinds of hard claim out of the draft — a number with a unit such as Hz, mAh, W, mm, inch or MP, and a Latin-letter designation that contains a digit, which is what a device model looks like — and searches for each one in the allowed sources: every fact record in the topic’s scope (not only those sent to the model), all model names, all published articles, the topic and the chosen links. A number is never split off its word, so an invented “Model 26 Ultra” cannot pass on the strength of “Model” existing somewhere. Each section is checked as soon as it is written; if it carries hard claims nobody can find, it is rewritten once with those claims quoted back, and the second version is kept only if it is better.
Calibrated on people, not on the model. Every check is run against the human-written articles on both blogs. The first grounding run rejected about a quarter of them — the check, not the people, was wrong — so amounts, periods and menu paths were demoted from errors to warnings. Its honest limit is stated in the code: it checks where a value came from, not whether it is attributed to the right device. A draft once passed every check while claiming a laptop loses Wi-Fi from overheating “in 70% of cases”. Truth is left to the person on purpose.
The contract: what the site will accept
Early drafts passed every check and were then refused by the site: no category, a misspelled one, a 134-character title, an invalid address. The generator also counted length differently from the site. So before a person sees a draft, a contract check validates it against the site’s own publishing rules — the address pattern, title and description lengths, category, volume counted exactly as the site counts it — and a script reads those limits out of the sites’ validator code and fails if they ever disagree. A draft that fails the checks or the contract is saved aside with its findings and never shown to anyone.
A person presses Publish
A draft that passes arrives in a chat as a card — title, description, address, length, sections, links, any warnings under “look closer”, then the full text — with two buttons: Publish and Reject. A private review page shows the same draft with a fresh contract check; it is locked if no password is set, and it locks out repeated wrong guesses. The daily schedule only ever produces drafts. This is the human-in-the-loop part of the system, and it has no automatic fallback on purpose: if nobody presses a button, nothing is published.
Publish calls the site’s own endpoint with a token. The date is set at that moment, in the site’s time zone, because drafts that kept their creation date appeared backdated. An article whose address is already taken is sent only with an explicit replace flag, because one early publish silently overwrote a page that was already indexed. The site validates the article again, stores it, refreshes its cache and notifies search engines through IndexNow, and the result — published or replaced, and whether search engines were told — comes back to the same chat.
What went wrong on the way
- The five-minute wall was not the model. Long generations failed at exactly five minutes. The cause was Node’s
fetch— its HTTP client gives up waiting for response headers after 300 seconds — not Ollama. The same request returned in ten minutes overnode:httpwith streaming and a timeout on silence instead of on total time. - One prompt for a whole article drifted. Hence the plan and per-section writing.
- Line-based fact selection starved topics. A topic about earbuds once received none of the earbuds facts, because lines were selected out of context. Facts became records.
- A word-boundary regex let an invented price list through.
\bdoes not match next to letters outside the Latin alphabet in JavaScript, so the price check saw nothing. It now uses lookaheads. - A button bypassed the queue. A manual “write” started a second generation beside the scheduled one, and two drafts could overwrite each other. Everything now goes through one queue.
When this makes sense, and what it costs
A pipeline like this makes sense when the content is factual and the facts already exist in your own systems — product data, service records, a catalogue — and when you want drafts without a per-token bill or your data leaving your network. It does not replace an editor; it gives one a draft that is already grounded, checked and shaped. One workflow like it, built into a system you already run, sits in our AI automation band, $15,000–$30,000 over 2–4 weeks. Related: how we evaluate grounded AI output before production.