# How I built a local AI assistant that admits when it didn't actually do the thing

> Source: <https://dev.to/kayato123_fca8d3c04a54849/how-i-built-a-local-ai-assistant-that-admits-when-it-didnt-actually-do-the-thing-25bh>
> Published: 2026-11-04 18:48:39+00:00

The sentence that started this project was five words long:

"I've sent the email."

It hadn't. Nothing had run. There was no email tool call in that turn, no SMTP connection, no record of anything. Just a small local model, a confident tone, and a sentence that was completely false.

If you've wired a 7B model up to function calling, you've probably met the same failure. It's not a crash. It's not an exception in your logs. It's worse: **it's a fluent, well-formed paragraph that silently disagrees with reality.** Your UI shows a checkmark, the user moves on with their day, and the thing never happened.

This is the story of the fix in my open-source project, [Quasnooze](https://github.com/Quasynvo/Quasnooze), and the one design decision I'd defend hardest if you asked me why it's built the way it is.

## The problem: hallucination isn't just wrong facts, it's wrong

*actions*
Most discussion of hallucination is about knowledge. The model says the library was released in 2015 when it was 2017. Annoying, but visible — the user can catch it.

Agentic hallucination is different in kind. When a model has tools, it doesn't just describe the world, it *narrates its own effects on the world*. And small models are especially prone to narrating effects that didn't happen, because:

- The training signal rewards helpful-sounding completions. "I've sent it" is a more satisfying answer than "I can't confirm that."
- A tool call can fail, get denied, time out, or never be emitted at all — but the model's next token prediction doesn't care about any of that.
- The failure is
**invisible to the developer** unless you deliberately check for it. The HTTP response was 200. The text streamed fine. The turn "succeeded."
My first attempt was the obvious one: prompt engineering. A system prompt with strong instructions — never claim an action you didn't take, only refer to tool output, don't invent contact details. I wrote it carefully. I iterated on it.
It mostly worked.
**That's what made it dangerous.** A 90%-effective prompt doesn't fail randomly, it fails *confidently* — and it fails exactly on the turns where the model feels most helpful, which are the turns where you're least likely to double-check. Worse, a stronger model doesn't fix it, it just produces a more convincing paragraph. The lie gets better grammar.
Prompting, in other words, was asking the same component that made the error to be the component that catches it.
## The fix: audit the reply, don't trust it
The insight I built around is small but it changes the architecture:
The model's reply is **not output**. It's a **claim**, and claims can be checked against evidence.
At the end of every turn, Quasnooze has two things the model doesn't fully control:
**The tool trace** — which tools actually ran, whether they returned `ok`, whether the user *denied* them.
**The memory facts** — what's genuinely in the store, not what the model wishes were there.
So instead of trusting the prose, the app compares the prose to the evidence. `src/guard.js` is a pure function that takes the reply, the trace, the memory facts, the user's text and the tool output, and returns a list of flags.
Here's the actual API — this runs standalone, no server, no model:

``` js
import { auditReply, renderGuardNote } from "./src/guard.js";
const result = auditReply({
  reply: "Done — I've sent the email to priya@example.com.",
  trace: [],                       // nothing actually ran this turn
  memoryFacts: [],
  userText: "email priya about the launch",
});
console.log(result.flags);
console.log(renderGuardNote(result.flags));
```

Real output from that snippet:

```
[
  {
    "code": "action-without-evidence",
    "message": "the reply says \"i've sent\" but no tool ran that could have done it"
  },
  {
    "code": "invented-email",
    "message": "an address appears that was never given to me: priya@example.com"
  }
]
```

And the note the user actually sees:

```
---
🛡️ **Code check:** the reply above needs a second look.
  • the reply says "i've sent" but no tool ran that could have done it
  • an address appears that was never given to me: priya@example.com
_Quasnooze checks claims against what actually ran — this one did not line up._
```

The tempting version of this feature silently corrects the text, or regenerates the response, or quietly drops the offending sentence. I deliberately didn't do any of that, for a reason I now feel strongly about:

**A silent correction is just a tidier way of lying about what happened.**

If the code edits the model's words, the user cannot tell whether the assistant genuinely succeeded or whether a regex papered over a failure. So the guard appends a visible note and never touches the model's text. The transparency *is* the product. A flag is a nudge, not an alarm — the styling is deliberately calm, because false positives are real and I don't want to train people to panic.

The same principle — *never let one layer quietly speak for another* — shaped the permission system.

When a tool is sensitive, the model can't approve it. Neither can a spoken "yes." Neither can an animation. Approval is a distinct path from the visual state machine, which means:

```
git clone https://github.com/Quasynvo/Quasnooze.git
cd Quasnooze
npm run setup   # configure model, workspace, access key, optional channels
npm run doctor  # check what's actually working, including what isn't
npm run brain   # verify your model is reachable
npm start       # panel on http://localhost:7777 by default
```

`npm run doctor` deserves a mention: it only probes loopback origins, and it will happily tell you that a feature is *not* ready rather than pretend. It's the most-used script in the repo for a reason — most "it doesn't work" issues are configuration, not code.

If you want the voice visuals in your own project, the renderer is a plain ES module with no build step:

``` js
import { createVisualizer } from "/visualizer.js";
const motion = createVisualizer(document.getElementById("voiceCanvas"));
// Drive this from real application state, not from a decorative timer.
motion.setState("thinking");
```

I'd rather undersell this than have you find the gaps yourself:

`node --test`, and not one of them can tell you whether a microphone works.`auditReply`. Those cases are worth more than stars, and I'll credit them.`npm run doctor` usually points at the culprit.
PRs are welcome; there's a `CONTRIBUTING.md` and the test suite is plain
