The Local Model Passed the Chat Test and Failed the Pipeline
// 2026-05-20 · Frederic Haddad · 10 min read
Back in May I stopped paying a monthly subscription for an AI email client and decided to run the same job myself. Self-hosted, open source, local models, four mailboxes, no recurring bill. It took one day. By lunchtime the thing was answering questions about my inbox in fluent English, and by mid-afternoon I'd confirmed it was doing absolutely nothing to my incoming mail.
Same model, same machine, same running instance. Fluent in the chat window, inert in the pipeline. The gap between those two facts is the most useful thing I learned all year about putting cheaper models into automation.
The boring part first
I picked an actively maintained open-source project with a real user base rather than assembling something myself, because the bar for anything touching my mail is "reliable", not "clever". Postgres and Redis underneath, four mailboxes on one instance, every model call pointed at a local proxy running open-weights models on my own hardware — the setup I describe in more detail in Cloud vs. Local Models in 2026.
The first hours went on the unglamorous work. The default deployment exposed four ports to the host; I consolidated them down to one, bound to loopback, with exactly one path — the incoming webhook — reachable from the public internet through a tunnel. Everything else stayed inside the machine. Then I set every new automation rule to "label" rather than "archive" for a trial week, because I'm not letting a model I've known for four hours delete my mail.
That's the correct, dull version of self-hosting. The interesting failure came after.
It worked in chat and did nothing in production
The app has a chat panel. I asked it things about my inbox and it answered well. I triggered categorization manually and it categorized. Every light in the interface was green.
New mail arrived and nothing happened. No label, no archive, no error, no alert. Silence — which is the worst kind of failure, because silence looks exactly like "no work to do". I've written before about health checks that read data instead of process status; this is the same disease in a new host.
The cause was a distinction that never shows up in a demo. When the app generates prose — chat replies, drafts, summaries — it asks the model for free text. When the automated pipeline classifies an email, it asks for strict schema-validated JSON: named fields, specific types, no commentary, no stray prose, or the response is thrown away. Same model, same prompt budget, completely different reliability. The small local model handled the free-text path fine and failed the structured path the majority of the time. Watching it run, my estimate was a 30 to 50 percent failure rate on structured calls.
There's a trap hiding in that sentence for anyone self-hosting: the app was setting a response-format flag that declares the exact schema, and it reads like a guarantee. On a self-hosted inference server it is a suggestion, not a contract — the instruction gets forwarded and the model samples freely, with nothing enforcing the shape server-side. No constrained decoding, no grammar at the sampler, no validation pass. The flag's name says specification; its behaviour on local hardware is a strongly worded request.
It's the difference between asking someone to write you a paragraph and asking them to fill in a form in triplicate without going outside the boxes. Most people can do the first. Far fewer do the second reliably, every time, at speed, without being reminded of the rules.
The app ships guidance for exactly this problem — a block of instructions that teaches a model to hold JSON discipline. Reading the source, I found it was gated to fire only for one specific model type. Mine wasn't that type, so the guidance silently never fired. Nobody had written a bug. It just quietly excluded me.
Fix it at the layer that outlives the app
Obvious move: patch the app's source. I didn't, for one reason — I want to keep upgrading that app for years without maintaining a fork.
So the fix went into the proxy instead. A callback inspects every request heading to any model behind it and, when the request is a structured-output call, injects schema-aware instructions before it leaves. The app stays stock. Every model I add to that proxy in future inherits the fix on the day I add it.
Put the correction at the layer with the longest life. Application code churns. The routing layer in front of your models is where cross-cutting rules belong — retries, guidance, logging, model choice.
It still took three tries. The first attempt skipped the strong model, on the theory that it didn't need help. The second injected guidance but not the schema itself, which is like telling someone to fill in the form correctly without handing them the form. The third landed on the wrong model for the job. Each partial fix moved the failure somewhere new, which is how you know you're working on a real system and not a tidy example.
The version that stuck split traffic by task shape rather than by cost. The reliability-critical path — real-time classification of incoming mail, where a failure is invisible — goes to the larger, slower model. Interactive chat and bulk review, where I'm sitting right there and would notice something stupid, stay on the smaller, faster one.
The structured half of the fix changed the shape of the request rather than the tone of the instruction, and it made the difference. Two changes. First, take the caller's actual schema and put the literal schema text into the system prompt — not a description of the required shape, the shape itself, sitting in front of the model where it can be copied. Second, stop asking the server for schema-enforced mode and ask only for plain valid JSON, so the server stops applying handling of its own that fights how the model naturally writes. These systems match patterns far more reliably than they comply with rules; showing a model the target beats telling it about the target.
Measuring concurrency instead of guessing at it
My instinct said running two models at once on one machine would be a disaster: memory pressure, thrashing, both jobs slower than either alone. I measured instead, and the numbers changed the design.
- A single call to the small model: 4.5 seconds.
- The same call to the large model: 12.3 seconds.
- Both running at once: 6.7 seconds for the slower of the two, against 16.8 seconds run back to back.
Roughly 60 percent more total throughput, free, purely from letting them overlap. My intuition was wrong, and trusting it would have cost me the two-model design.
Two more numbers reframed the job entirely. A one-time re-categorization of a full inbox comes to about 25 minutes on the large model against about 8 minutes on the small one — but the small one carries that 30 to 50 percent structured failure rate, so the fast option is the one that produces garbage you then have to go and find. And the work batches per unique sender rather than per email, so at that ratio a 10,000-email inbox needs somewhere around 150 to 500 model calls, not 10,000. A job I'd mentally filed under "overnight" was a coffee break.
Nobody tells you which of your assumptions is the expensive one. You find out by measuring the specific thing.
Then the mail stopped arriving at all
Evening. New mail across all four mailboxes stopped triggering anything again, and this time nothing I'd touched was at fault.
I worked the path end to end: watch registrations, webhook logs, every component in between. Triggered by hand, all of it did its job perfectly. That's a useful shape of evidence — when a pipeline works on demand and fails on arrival, the fault is upstream of everything you control.
It was a third-party push-notification subscription. The vendor console said "active". It was delivering nothing. A status label is a claim about configuration, not a measurement of delivery. What finally proved it was the vendor's own delivery metrics: messages published, messages successfully pushed, two very different counts. The fix was deleting the subscription and recreating it. Thirty-seven email events had queued invisibly behind the blockage, and only surfaced when I triggered a sync by hand.
But the part worth your attention is what I did to myself while diagnosing it. To prove the pipeline could still process a notification, I sent a synthetic test trigger carrying a fabricated sequence number. The system accepted it, believed it, and wrote it into the sync checkpoint for all four mailboxes as though it were real. My diagnostic became the second incident. I'd poured dye into the pipe to find the blockage and the dye set like concrete.
Recovery was resetting the checkpoint to a deliberately low value and accepting some duplicate processing, rather than reconstructing the exactly correct number. When you're already in a hole, take the safe repair, not the elegant one.
The two rules I took out of the day
Benchmark the call shape your pipeline will actually make. Not the chat demo. If your automation asks for strict JSON, test strict JSON, on your data, at volume, and count the failures. A model that talks beautifully can be majority-unreliable at the one thing your pipeline needs, and nothing in the interface will tell you. This is the trap waiting under every "let's try a cheaper model" decision: the evaluation is a conversation and production is a form.
Prefer read-only checks when probing a live system. Anything you send into a running pipeline to diagnose it may be persisted as real state. Read the logs, read the metrics, read the vendor's counters. Write nothing you wouldn't be happy to find in the database tomorrow.
Both rules point at the same weakness: the evidence for "it works" is almost always collected by a human standing over the system at the moment it runs. Every green light in this story was earned under supervision — the chat panel answered because I was the one asking, the manual categorization ran because I pressed the button, the console said "active" because someone had configured it correctly once. The unsupervised path failed in all three cases and reported nothing. So the useful question is never "did it work when I tried it" but "what would this look like if it had quietly stopped, and could I tell that apart from a quiet week?"
The related trap is evaluation itself: before you swap any model into a pipeline, you also have to know whether the quality gap you think you're paying for is real — I covered how to measure that properly in The 6.3-Point Gap That Wasn't. Because the failure mode I now expect when someone tells me their cheaper or local model "tested fine" is exactly this one. It usually did test fine — in a chat window, by a person, watching. The same model wired into an inbound queue, a back-office step, or an approval stage in the middle of a longer process asks a much harder question at 3am: it fails quietly, reports nothing, and by the time anyone notices there are weeks of work that silently didn't happen.
I ran my own email automation on local models for exactly this reason, and learned the call-shape lesson the hard way. If you're putting a cheaper or self-hosted model into a business process and want the structured-output failure modes found before they're live — in Dubai, across the UAE, or wherever your business runs — that mapping is a consulting day's work. Book a consulting day or send me an inquiry first if you'd rather talk before booking.