Building Hamster AI — a local-first AI companion for Windows

I wanted an AI assistant that lived on my PC, not in the cloud. I didn't want subscriptions, accounts, or my conversations leaving my machine. So I built one. With a hamster.

The idea

I use AI tools a lot. But I kept running into the same frustrations — everything requires an account, everything sends your data somewhere, and none of it integrates with what's actually happening on your PC. I wanted something that sat quietly in my system tray, knew what I was doing, could remember things I told it, and would shut up when I was gaming or in a work call.

I also wanted it to be honest. Most AI assistants are weirdly sycophantic — they'll agree with everything you say and pretend they can do things they can't. I wanted one that would push back when I was wrong and say "I don't know" when it didn't know.

So I spec'd out Hamster AI. Local LLM via Ollama. Python. PySide6 for the UI. SQLite for memory. A plugin system so I could add features without breaking everything. And a hamster, because why not.

Why local-first?

The short answer: privacy and control. The longer answer:

Ollama solved the LLM problem nicely. You pull a model, it runs locally, and the API is simple. I went with llama3.2:3b as the default because it's fast on modest hardware and surprisingly good for day-to-day use. But any model works — you can switch in settings.

The architecture

I planned this properly before writing a line of code, which is unusual for me. The spec was about 40 pages. I broke it into 21 milestones and built one at a time.

The core is an AppContext — a central object that owns everything: the event bus, settings, plugin manager, memory store, LLM client, and all the observer modules. Everything talks to each other through an event bus with typed events like user_message, active_window_changed, work_mode_enabled, etc. Nothing is tightly coupled. Plugins subscribe to events; core systems emit them.

# The event bus is simple but it made everything else possible
event_bus.emit("active_window_changed", {
    "title": "VS Code",
    "process": "Code.exe"
})

The observer layer reads system state — active window, idle time, CPU/RAM, fullscreen detection — and emits events. No screenshots, no OCR, no keylogging. Just the stuff the OS makes available.

The plugin system

This was the part I was most pleased with. Every plugin is a class in its own folder:

plugins/
  session_awareness/
    plugin.py
    config.json
  voice_output/
    plugin.py
    config.json

Each plugin implements a simple interface:

class Plugin(PluginBase):
    name = "session_awareness"
    enabled_by_default = True

    def on_start(self, app): ...
    def on_stop(self, app): ...
    def on_event(self, event, data): ...
    def get_commands(self): return []

The plugin manager discovers them automatically, loads them in isolation so a crash in one doesn't kill the app, and respects all the mode rules — plugins don't run during Work Mode, Private Mode, Game Safe Mode, or Focus Mode unless the user explicitly allows it.

I shipped 8 plugins:

The mode system

This was non-negotiable from the start. Four modes, all core (not plugins), all overriding everything:

Work Mode — triggers automatically when Omnissa Horizon Client (or other configured apps) is detected. Shuts everything down. No greetings, no notifications, no logging of window titles, no LLM calls unless you manually open chat. I added this because I work from home through a VDI client and did not want to risk anything leaking.

Private Mode — same idea, triggered manually or by keyword. Hamster AI goes completely silent and stores nothing.

Focus Mode/focus 30 gives you 30 minutes of quiet. No interruptions. You can still open chat manually.

Game Safe Mode — activates when it detects anti-cheat processes (Vanguard, EasyAntiCheat, BattlEye, etc.). The app becomes just a background tray process. No overlays, no plugins, nothing that could look suspicious to an anti-cheat scanner.

Anti-cheat safety was actually the most important requirement. The app never injects into processes, hooks graphics APIs, reads game memory, or automates input. It's designed to be completely invisible.

The memory system

Everything goes into a local SQLite database at data/hamster_ai.db. Memories, notes, todos, activity logs, settings, plugin data — all of it. The schema has proper migrations so it can evolve without breaking existing installs.

The LLM gets relevant memories injected into its context window automatically — so if you told Hamster AI your name three weeks ago, it still knows. You can search memories, delete them, or wipe the whole database if you want a clean slate.

One thing I was particular about: it never silently saves inferred preferences. If it notices a pattern ("you always seem to want shorter answers"), it asks before saving anything. You're in control of what it remembers.

The UI

PySide6 (Qt for Python) for everything. A system tray app with:

The theme is warm beige — #FAF7F2 background, #A67C52 accent, rounded corners. I wanted it to feel friendly, not corporate. It has three themes: Soft Hamster Minimal (default), Dark Hamster, and High Contrast.

The hamster

The tray icon is a little procedurally drawn hamster face — ears, cheeks, nose, all drawn with QPainter at runtime. No image files. It goes grey when the app is paused. It was completely unnecessary and I have no regrets.

Testing

21 milestone test files, 283 checks, all passing. Each milestone has its own smoke test file that imports the relevant modules and verifies the interface contracts. It's not unit testing in the traditional sense — more like milestone regression testing. Good enough that I can refactor with confidence.

--- Hamster AI Milestone 1 Smoke Test ---
  [PASS] AppContext can be created
  [PASS] event_bus exists on context
  [PASS] settings exists on context
  [PASS] plugin_manager exists on context
  ...
  14 passed  |  0 failed

What I'd do differently

Honestly not much. The spec-first approach worked really well. Having 21 clear milestones meant I never got lost. The event bus architecture meant every new feature slotted in cleanly without touching existing code.

If I were doing it again I'd probably think harder about the plugin data isolation earlier — there were a couple of places where plugins were reaching into core state they shouldn't have. Fixed now, but it would have been cleaner to design the boundary more carefully from the start.

What's next

A proper installer — a PySide6 QWizard that checks prerequisites, lets you pick plugins, and sets up startup with Windows. Then probably more plugins. Maybe a web search plugin if I can figure out how to do it in a way that feels honest about what's happening.

The project is open source under GPL v3. The full documentation is at hamster.adameaston.co.uk. The code is on GitHub.


Documentation ↗ GitHub ↗ ← Back to devlog