Architecture Overview
The runtime that turns a message into action — the core components, the provider layer, and how the pieces fit together.
Chalie is a persistent personal AI: a single Python process that keeps thinking between conversations. Memory accumulates and decays over time, background workers reflect while you’re idle, and every conversation builds on everything that came before. This page is a high-level tour of the runtime — what the major components are, how a turn actually runs, and where you’d plug in.
One process, many workers
Everything runs in one process. At startup the runtime boots the database, warms the on-device models, and registers a set of daemon-thread workers supervised by a worker manager that health-checks and restarts them on a fixed interval. There are no queues and no inter-process messaging — threads coordinate through SQLite, an in-process key/value store, and the WebSocket broker.
| Worker | What it does |
|---|---|
| REST/API worker | The HTTP API and a push-only WebSocket — the front door |
| Cron runner | Minute-aligned dispatch of scheduled jobs — fires due reminders and scheduled prompts, plus the idle-gated background-cognition tick (see Memory & Cognition) |
| Folder-watcher | Watches configured folders and ingests new documents |
| Document-purge | Periodic cleanup of deleted documents |
| Search-expander | Generates and embeds paraphrase variants for new memory |
| Temp cleanup | Scratch-file cleanup |
| MCP server / heartbeat | Inbound tool server plus outbound MCP connection keepalive |
The stack underneath is deliberately small: Flask for the HTTP API plus a push-only WebSocket; SQLite in WAL mode as the only database, with vector search and full-text search both running inside it; an in-process, thread-safe key/value store with TTL (no external cache); pluggable LLM providers; and two Vue 3 single-page apps — the chat interface and an admin “Brain” dashboard — that Flask serves as static builds. Both apps are written in TypeScript.
The one way to run a turn
Every LLM turn in the system runs through one component: the turn processor. A user chat message, a background reflection, a context compaction, a delegated web search — they all enter the same way, carrying an input text, a channel config, optional metadata (attachments, source, hidden input), and an optional cancellation signal. The call blocks until the turn completes and returns the reply text.
There are no turn-processor subclasses. What differs between a chat message and a background reflection is data, not type: each channel supplies a frozen config that decides which tools are pre-loaded, whether conversation history is included, whether the turn writes any transcript rows at all, and what runs after the turn finishes.
A turn has three phases:
- Setup — write the input row, and on the user channel fire turn-zero seed tool calls: an automatic memory recall, one document upload per attachment, and an internal thinking pass when the message looks like it needs deliberation.
- Act loop — assemble one request and call the model; if it asks for tools, run them, feed the results back, and call again — repeating until the model replies in plain text. See Message Flow for the full lifecycle.
- Record — write the assistant reply, then run any post-turn hooks in a failure-isolated loop.
Adding a new kind of turn means writing a new channel config and calling the processor — no new engine, no new loop.
The provider layer
Every LLM call — from any channel, for any purpose — flows through a single provider gateway. Callers build a provider-neutral request (system prompt, messages, tools, thinking level) and get back a normalized response (text, tool calls, token counts, latency). One thin client per platform is maintained (Anthropic, OpenAI and OpenAI-compatible, Google Gemini, and Ollama for local models); a factory picks the right one from the provider’s platform string.
Two things happen at this chokepoint before the model ever sees a request:
- A pre-flight size check. If the measured request would leave too little headroom in the model’s context window, the gateway rejects it before the network call. The act loop catches that, compacts the conversation, and retries — so an oversized request never reaches the provider.
- A single hard timeout. Each individual call is bounded by a 300-second wall-clock ceiling. A provider that stalls past it is abandoned and surfaced as an ordinary error, rather than wedging the turn. This is the only provider timeout, and it applies uniformly to every platform.
A defining design choice: history reaches the model as literal text, not as a multi-turn array. Chalie renders previous conversation into a single text block inside a one-element message list, so it controls exactly what context the model sees on every turn — independent of any provider’s native chat format. That’s what makes swapping providers, or running a small local model, a configuration change rather than a rewrite.
Routing by purpose
Providers are routed by what the call is for, not by who’s calling. You can pin different models to different jobs: a main chat provider, a separate vision provider for images (which falls back to the main provider when it can see), and a delegate provider for sub-tasks like web search and browsing. Each falls back sensibly when no specific provider is pinned.
Interface agnosticism
Chalie’s core doesn’t know or care how a message arrived. The runtime is built around channels and a provider-neutral request, so the same turn processor serves a chat message from a browser tab, a fired reminder, and a turn started by another agent — each just carries a different channel config. The HTTP API and WebSocket are one front door among potential several; the infrastructure is also tool-agnostic, treating a built-in ability and an external tool exposed over a connector protocol the same way (see Tools & Abilities and MCP & Agents). This keeps Chalie portable across surfaces and keeps each surface thin.
One user, many surfaces
A Chalie instance serves a single user, but that user may have it open on a phone, a laptop, and several tabs at once. There is one conversation, and every surface mirrors it. The WebSocket broker holds every live connection and fans each event out to all of them, so a message sent or received on any surface appears on all of them. A dropped socket reconnects on its own and rebuilds state from the database — the conversation is reconstructed from persistence, not replayed from a buffer. The per-turn detail of how surfaces stay in sync lives in Message Flow.
Where to plug in
| You want to… | Do this |
|---|---|
| Add a tool the model can call | Add an ability — see Tools & Abilities |
| Add a new kind of LLM turn | Write a channel config and call the turn processor |
| React after a turn completes | Add a post-turn hook to the channel’s config |
| Connect an external tool server | Add an MCP server — see MCP & Agents |
| Add an HTTP endpoint | Add a route under the API layer |
For how memory accumulates and how the background mind works, read Memory & Cognition. For the turn lifecycle in detail, read Message Flow.