{"slug": "my-friend-can-t-stop-buying-things-at-2am-so-i-built-an-ai-that-can", "title": "My friend can't stop buying things at 2am. So I built an AI that can.", "summary": "A developer built The Veto, a local open-weight model (gemma3:4b) that blocks a friend's late-night purchases and messages by checking them against rules the user wrote himself while calm. The system returns one of three verdicts — PASS, HOLD, or ASK — and runs entirely on the user's own laptop so nothing leaves the machine, with overrides allowed but followed by a regret question that lengthens the next hold on that rule.", "body_md": "*This is a submission for the [Hacktoberfest Weekend Challenge: Build for a Friend](https://dev.to/challenges/hacktoberfest-weekend-2026-10-01)*\n\nEvery AI tool my friend Akshay has ever installed was trying to help him do the thing.\n\nThe problem is that the thing was usually buying something at two in the morning.\n\nAkshay does not have a shopping problem in any dramatic sense. He has a 2am problem. Something lands in a cart during the day, he sleeps on it, and then at some point after midnight he is awake and scrolling and the sleeping-on-it quietly stops counting. He is not confused in that moment and he does not need information. He knows exactly what he is doing, and he does it anyway, and the part that costs him is that it is always money he had a plan for.\n\nEvery piece of software in that moment is on the wrong side. The checkout page is designed to make the next step easy. The recommendation engine is designed to find him one more thing. There is no software anywhere in that flow whose job is to make it *harder*.\n\nSo I built him **The Veto**: a local open-weight model whose only job is to stop him. It cannot help. It cannot rewrite anything, soften anything, or suggest a better version. Every capability you would normally want from an assistant is stripped out of the system prompt deliberately. It has exactly three things it is permitted to say.\n\nHe writes rules about himself **when he is calm**. Not policies, just sentences in his own words:\n\n*I regret anything I buy after midnight.*\n\n*I regret buying things on a night I have had a bad day.*\n\n*I regret messages I send after 1am.*\n\nThe last one is there because once a gate exists, the same machinery covers the other thing you do at 2am that you regret at 9am. The purchase rules are the ones built for Akshay.\n\nThen, later, in the seconds between wanting the thing and clicking the button, a model running on his own laptop reads what he is about to commit, checks it against **his rules and nothing else**, and returns one of three verdicts:\n\n| Verdict | What happens | \n|---|---|\n| **PASS** | Trips none of his rules. It gets out of the way. He never sees it. | \n| **HOLD** | Trips a rule. The send is blocked, a countdown starts, and his own sentence is quoted back at him. | \n| **ASK** | Trips a rule only he can resolve. One question, in his framing, answered before the override unlocks. | \n\nIt is not a safety filter. It has no opinion on whether the thing he is buying is expensive, or whether the message he is sending is unwise. A ₹4,000 purchase at 2pm passes. The same purchase at 2am does not. The only thing that matters is what he wrote down about himself.\n\n**A real checkout, stopped.** The rule is his. The clock is real. The model is running on the same laptop.\n\n**He overrides it, and that is the point.** A gate you cannot open gets torn off the wall. So he opens it, the purchase goes through, and then the rulebook asks him the only question that matters. The next hold on that rule is 180 seconds.\n\n**And the half that is easy to forget.** The same gate, the same composer, seconds apart: one message held, the next one straight through, untouched. A Veto that stops everything is a Veto nobody keeps.\n\n**My friend can't stop buying things at 2am. So I built an AI that can.**\n\nA local open-weight model whose only job is to stop you.\n\nEvery AI tool in your life is trying to help you do the thing. This one is the\nonly one trying to stop you, and it runs entirely on your own laptop, because\na tool that reads what you are about to say, *before you say it*, has no\nbusiness being an API call.\n\nBuilt for the [Hacktoberfest Weekend Challenge: Build for a Friend](https://dev.to/challenges/hacktoberfest-weekend-2026-10-01).\n\n*A real checkout, stopped by a rule he wrote himself. Judged by gemma3:4b on\nthe same laptop. Nothing left the machine.*\n\nThen the part that makes it more than a nag: you can always override it, and afterwards it asks whether you regretted it. That answer is the only training signal in the system.\n\nIt works…\n\nRoughly 1,400 lines. The parts worth reading are `src/judge.js`, which is the adversarial prompt and the grounding checks, and `extension/content.js`, which is the interceptor that has no idea what site it is on.\n\nFive test suites, because most of the project is a small model being wrong in interesting ways: the grounding guard (11 checks, no model needed), the store counters that drive escalation (12), end-to-end against real Gemma (9), model behaviour (4) and verdict stability (12).\n\n**Gemma 3 4B via Ollama, running locally.** No API key. No account. The daemon binds to `127.0.0.1` and makes no outbound request, ever.\n\nThe architecture is three pieces:\n\n```\n  Chrome extension                Local daemon (127.0.0.1:4777)\n  ┌────────────────┐              ┌──────────────────────────┐\n  │ generic send   │  draft       │ judge.js  ── adversarial │\n  │ interceptor    │ ───────────► │             system prompt│\n  │                │              │     │                    │\n  │ overlay:       │ ◄─────────── │     ▼                    │\n  │ HOLD / ASK     │  verdict     │  Ollama · gemma3:4b      │\n  └────────────────┘              │     │                    │\n                                  │     ▼                    │\n                                  │ store.js ── rules,       │\n                                  │   stops, overrides,      │\n                                  │   regret history         │\n                                  └──────────────────────────┘\n                                         no outbound network\n```\n\nIt runs on a 2017 ultrabook: an i5-8350U, no GPU, 100% CPU inference. That machine shaped almost every decision below, and in every case for the better.\n\nI started on `gemma3:1b` because it was the fast download. It failed 3 of 4 behaviour checks, and it failed them in the most instructive way: it held *everything*, cited the wrong rule, and justified itself in the language of a content filter: *\"potentially problematic\"*, *\"disrespectful\"*, *\"damaging to your reputation.\"* That is a model inventing its own notion of harm, which is precisely what this tool must never do.\n\nI could not make it stop by asking. So I stopped asking, and made it prove itself instead. **To stop a draft, the model must quote the words in the draft that trip the rule, and code checks that those words are really there.**\n\nThat single constraint turned an unreliable model into a safe one. On the 1B run the guard caught 4 out of 4 bad outputs and turned each into a PASS. The model was wrong constantly and the product was still correct, just useless, which is why I moved to `gemma3:4b`. It passes 4 of 4.\n\nRule IDs were originally random: `r_61d61d78`. With that ID, `gemma3:4b` stopped the message *\"hey, are we still on for 7?\"* against a rule about dragging up the past. Same model, same rule text, same draft. But with the ID `r_past`, it passed.\n\n**The identifier changed the verdict.** An opaque ID gives a small model nothing to anchor to, so it reaches for a match. Rule IDs are now generated from the rule's own distinctive words (`r_drag_months`, `r_after_1am`), which costs nothing and measurably improves judgement.\n\nThere was a second tell in that failure. When the model has a real match it quotes *part* of the draft, such as \"remember when you bailed on me months ago\". When it is confabulating, it hands back the entire draft as its own evidence. Citing everything is citing nothing, so a whole-draft quote is now only accepted if the draft actually shares vocabulary with the rule, or the rule is about time (where the clock is the evidence, not the wording).\n\nAll of this fails **open**. Every guard, when it fires, releases the message. The cost of a confused model is a missed stop, never a false one.\n\nWith the behaviour right, the thing was unusable. A 4B model on that CPU runs at **21.7 tok/s reading and 7.7 tok/s writing**, which is about **23 seconds per verdict**. You cannot put 23 seconds in front of the Enter key.\n\nTrimming the prompt from 745 tokens to 346 barely moved it, because Ollama was already caching the system prefix. The time was real work, and no amount of tuning was going to give me two orders of magnitude.\n\nSo I stopped trying to make it fast and changed when it runs. **The Veto judges while you type.** It starts thinking 900ms after you pause, and caches the verdict against the draft text. Nobody writes a message they will regret in under a second. By the time your finger reaches Enter, the answer is already sitting there.\n\n```\nfirst call, while still typing (invisible)   23.0s\nthe Enter press                               0.31s\n```\n\nSeventy-four times faster at the only moment that matters. I would not have found that interaction if the model had been fast enough to let me get away with the obvious one.\n\n**The interceptor is not built on per-site selectors.** My first version knew about WhatsApp's send button. That version is already broken, because these DOMs get renamed constantly, and a gate that breaks *silently* is worse than no gate, because you keep trusting it after it has stopped working. So it hooks the two gestures that cannot change: Enter inside a composer, and a click on something shaped like a commit button. One code path, every site, including checkout pages.\n\n**The model classifies. It never sets the penalty.** This is the one I would defend hardest. A 4B model has no business deciding how long to lock someone out of their own phone. Ask it and you get a number that sounds confident and means nothing. So it only names which rule was tripped. The hold length is a pure function of his own history with that specific rule:\n\n``` js\nconst regretRate = overridden ? regretted / overridden : 0;\nconst seconds = Math.round(30 * (1 + overridden * 0.5) * (1 + regretRate * 3));\nphp\nfresh rule, never overridden         ->  30s\noverridden once, no regret           ->  45s\noverridden 4x, regretted 3 of them   -> 293s\n```\n\nThat number is the only thing in the loop he cannot argue with, because he wrote it himself, one override at a time.\n\n**The gate fails open.** If the daemon is down, the model crashes, or inference times out, the message goes through. I went back and forth on this and it isn't close: a gate that jams shut is one you uninstall by Tuesday, and then it protects you from nothing, forever. Same reasoning covers hallucinations: a verdict naming a rule that doesn't exist is treated as a malfunction and\n\ndowngraded to PASS. The Veto is only allowed to stop him for a reason he actually wrote down.\n\nLate on, I reviewed the code against the thing it claims. I had written that nothing leaves the machine, and the daemon does bind to loopback, but it also sent `Access-Control-Allow-Origin: *`.\n\nLoopback stops the internet reaching in. It does **not** stop a page you are already visiting. Any site open in that browser could have called `http://127.0.0.1:4777/api/events` and read back excerpts of the drafts he decided not to send. The single most private thing this tool holds, readable by any tab.\n\nThe fix was architectural rather than a patched header. A `fetch` from a content script carries the *page's* origin, so the daemon would have had to accept whatever site you happened to be on. So all daemon traffic now goes through the extension's service worker, which carries `chrome-extension://…`, and the daemon refuses page origins outright:\n\n``` php\nOrigin: https://anything.example   -> 403\nOrigin: chrome-extension://…       -> 200\n```\n\nWriting \"nothing leaves this machine\" in a README does not make it true. I only found this because I went looking for the gap between the claim and the code.\n\nCaching the verdict is what makes the Enter press instant. The key was the rules, the kind of action, and the text.\n\nFor messages that is correct. For Akshay it was broken in the worst possible way. His rule is *\"I regret anything I buy after midnight\"*, so the same words on the same button must give a different answer depending on the hour. With time missing from the cache key, the first verdict froze: a purchase judged once at 2am stayed held at 2pm, still quoting *\"It is 02:14:00\"* back at him twelve hours later.\n\nA tool that blocks your checkout at lunchtime gets uninstalled that afternoon. The key now includes the hour, which keeps the type-then-commit path on a cache hit (those are seconds apart) while never reusing a verdict across times of day:\n\n``` php\n02:14  ->  HOLD   \"It is 02:14:00.\"\n14:30  ->  PASS   no rule tripped\n```\n\nI only found it because the person I built this for has a rule about *when* rather than *what*. Every test I had written until then used rules about words.\n\nHe can always override it. That is the point: a gate you cannot open is a gate you tear off the wall.\n\nBut every override is recorded, and **later, the Veto asks whether he regretted it.** That answer is the only training signal in the system, and it is the one signal that cannot be faked, bought, or scraped: his own hindsight about his own behaviour. Rules get harsher where he was wrong and stay out of his way where he was right.\n\nThe stop is written to disk *before* he chooses, so the record cannot be rewritten by whatever he does next. That detail took about four lines and is the difference between a log and an alibi.\n\nThis is the rare project where \"why not just call an API?\" has a one-sentence answer:\n\n**Because then every half-written, furious, 2am draft he never sent would be sitting on somebody else's disk.**\n\nThe drafts this model reads are, by construction, the things he most wants unsaid. They are not his published thoughts. They are the ones he thought better of. That is the most private category of text a person generates, and it is the only text this tool ever sees.\n\nA closed API could not have been used here. Not \"would have been worse\", but *could not*. Three things follow from open weights that no amount of vendor privacy policy substitutes for:\n\nThere is a fourth thing, which I did not expect going in, and it is the one I would actually argue for.\n\nRunning a small open model on a slow machine forced the architecture to be *honest about what the model is bad at*. I could not paper over weak judgement with a bigger model, so the model had to earn every stop by quoting evidence that code could re-check. I could not paper over 23-second inference with a\n\nfaster endpoint, so judging had to move off the Enter key and onto the keystroke before it. Both of those are better designs than the obvious ones, and I only found them because I had no escape hatch.\n\nA frontier API would have been good enough to let me skip both. I would have shipped a slower, more trusting, worse-designed tool, and never known. The constraint was the feature.\n\nThe whole build, including the parts that went wrong. The 1B model failing and why that was useful, the moment a rule identifier changed a verdict, measuring the 23 seconds before deciding what to do about it, and the CORS hole I found by auditing this project's own privacy claim.\n\nGemma 3 4B is not a component of this project, it is the premise. The whole argument is that a model reading your unsent drafts has to run on your own machine, and an open-weight model is the only kind that can. Everything in the build log above is a consequence of using a small open model on a slow laptop: the grounding checks exist because the model is unreliable, and the judge-while-you-type design exists because it is slow.", "url": "https://wpnews.pro/news/my-friend-can-t-stop-buying-things-at-2am-so-i-built-an-ai-that-can", "canonical_source": "https://dev.to/imarpan/my-friend-cant-stop-buying-things-at-2am-so-i-built-an-ai-that-can-11dc", "published_at": "2026-10-05 03:00:50+00:00", "updated_at": "2026-10-05 03:12:42.544578+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "large-language-models", "ai-products"], "entities": ["The Veto", "gemma3:4b", "Hacktoberfest Weekend Challenge", "Akshay"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/my-friend-can-t-stop-buying-things-at-2am-so-i-built-an-ai-that-can", "markdown": "https://wpnews.pro/news/my-friend-can-t-stop-buying-things-at-2am-so-i-built-an-ai-that-can.md", "text": "https://wpnews.pro/news/my-friend-can-t-stop-buying-things-at-2am-so-i-built-an-ai-that-can.txt", "jsonld": "https://wpnews.pro/news/my-friend-can-t-stop-buying-things-at-2am-so-i-built-an-ai-that-can.jsonld"}}