Skip to content

Project · 2026

Zirkbot

A private Discord bot that turns conversation into actions across a household's real services: media, groceries, finances, and project tracking.

Role: Creator
  • TypeScript
  • Node.js
  • Discord.js
  • Anthropic SDK
  • SQLite
  • Ollama
  • Vitest

Project snapshot

Status
Actively used daily bot, four months of ongoing development, ten integrated services.
Platform
A Discord adapter normalizes messages into a common action-routing pipeline.
AI routing
Deterministic matching handles confirmations and follow-ups. Claude Haiku classifies everything else, with optional Ollama classification to reduce API usage.
Services
Ten integrated action groups covering media, groceries, finances, email, and project tracking.

Technical proof

  • The 850-line router tries deterministic confirm and continuation matching before ever calling Claude.
  • SQLite conversation history and pending-ref tracking support multi-turn, follow-up-aware replies.
  • 115 test files cover routing, actions, digests, and around 15 typed service clients.

What it is

Zirkbot is a private bot I run for my household, wired into Discord. You talk to it like a person: ask it to find a recipe, check a credit card’s rotating category, request a book, or check on a media download, and it figures out which service to call. Messages come in through a Discord integration adapter, get normalized into a common BotMessage shape, and land in an action router that uses Claude Haiku to classify intent before dispatching to one of the registered actions.

It is not a toy anymore. Ten action groups (audiobookshelf, bewks, cards, email, fidash, jellyfin, mizen, pantry, smarthome, plus a project-tracking group) sit behind the router, and I add to it whenever a household chore gets annoying enough to automate.

Capabilities

The bulk of it is media and home operations: media library search, playback, and library control, an automated media-download pipeline that takes a request from search through acquisition to the library with no manual steps in between, and smart home status and routine management. Alongside that: grocery and recipe management through a pantry service, book search and requests through a personal library API, personal finance perk and rewards tracking, email triage with search, drafting, and send, and read/search access into a personal ticket tracker. A CLI REPL (npm run chat) runs any of this locally without touching Discord, which is how I test new actions before wiring them to a real channel.

Architecture tour

Layering. The design keeps three things separate on purpose: the integration adapter owns platform mechanics (including missed-message recovery via a cursor file read on startup), the router holds no service knowledge at all, and each action is a self-contained unit wired to one of around 15 typed service clients. That separation is why adding a new service is mostly “write a client, write an action, register it” rather than touching the router.

Deterministic before probabilistic. The router, 850 lines on its own, tries two cheap deterministic paths before it ever calls an AI model: a confirm/cancel check for pending actions, and a continuation-pattern check (compiled, cached regexes per action) for messages that clearly continue the last exchange. Only if neither matches does it fall through to Claude Haiku classification. That ordering matters because confirmations (“yes”, “cancel”) are exactly the messages where a misclassification is most annoying, and a regex match is both faster and more reliable than asking a model to notice the word “yes.”

Two AI tiers, one router. Classification and response generation use different tiers of the same provider interface: a fast tier for intent classification, a standard tier for actually answering. An optional hybrid mode (HybridProvider) routes the fast tier through a local Ollama instance instead of the API, which cuts API spend on the classification calls that fire on every message, while keeping Claude for anything that needs real reasoning. That decision came from watching the Claude bill: most messages are short routing decisions, and a local model is plenty for “is this a pantry question or a smart-home question.”

Conversation state. SQLite stores conversation history and “pending refs” (things the bot is waiting on you to confirm or disambiguate, like “did you mean the first book or the second”). The router renders the last few exchanges plus any pending refs into the classification prompt, capped at a fixed character budget, so follow-ups like “the first one” or “same as last time” resolve without re-stating context. I hard-capped that context block after an early version let it grow unbounded and started silently truncating mid-JSON.

Testing

115 test files, run with Vitest. Every service client and action has its own test file, and the router has dedicated coverage for classification, deterministic matching, tool definitions, and a couple of full “journey” tests that walk a multi-step conversation end to end (finding a recipe, chaining a smart-home command into a media one) rather than testing each action in isolation. Four scheduled daily digests (pantry, finance perks, market news, email) each have their own runner and scheduler tests too.

Learnings

The media-download pipeline was the hardest part to get right, mostly because it’s the one piece touching several external services that all have their own opinions about what “done” means. I spent more time on retry and match-scoring logic there than on the bot itself, which in hindsight I should have expected: gluing chat to one API is easy, gluing chat to a pipeline of several APIs that all fail differently is the actual project.