-
Notifications
You must be signed in to change notification settings - Fork 0
Architecture
shelldon is built as a multi-process actor model over a typed message bus, around a hexagonal LLM-free core. This page describes that structure: the processes, how they talk, and the invariants that hold the whole thing together. Once you understand the topology and the handful of rules below, the rest of the codebase reads cleanly.
If you want to follow a single message through the system instead, start with How a Turn Works. For the brain itself (fork-server, prompt assembly, providers) see The Brain, and for the memory layers see Memory & Learning.
Raspberry Pi Zero 2W (~512MB RAM)
┌──────────────────────────────────────────────────────────────────────────────────┐
│ │
│ chat-transport display plugin-host │
│ (Telegram / CLI) (E-Ink face) (sensors + behaviors) │
│ │ ▲ │ │
│ │ inbound-message │ state-snapshot │ event / state-snapshot │
│ │ outbound-message │ │ │
│ ▼ │ ▼ │
│ ╔════════════════════════════════════════════════════════════════════╗ │
│ ║ ENVELOPE BUS (Unix domain sockets) ║ │
│ ║ msgspec frames · 4-byte length prefix · hub-routed ║ │
│ ╚════════════════════════════════════════════════════════════════════╝ │
│ ▲ ▲ ▲ │
│ │ hub │ │ │
│ ▼ │ │ │
│ ┌─────────────────────────────────────┐ │ │
│ │ core (LLM-FREE — import-linter) │ │ │
│ │ bus hub · arbiter · scheduler · │ │ │
│ │ reflexes · state · memory (writer) │ │ │
│ └─────────────────────────────────────┘ │ │
│ │ fork() per turn │ Job │ │
│ ▼ ▼ │ │
│ ┌──────────────┐ ┌──────────────────────────────┐ │
│ │ fork-server │ ──fork─▶ │ broker (sole trust boundary)│ │
│ │ (warm LLM │ worker │ creds · provider chain · │ │
│ │ libs, no │ (ephem- │ tool egress · safety │ │
│ │ creds) │ eral) └──────────────┬───────────────┘ │
│ └──────────────┘ │ │
│ │ model / tool call │
└────────────────────────────────────────────┼───────────────────────────────────────┘
▼
remote LLM providers
GLM (Z.ai) · Ollama-LAN · Claude · OpenAI · OpenRouter · …
Every box except the worker is a long-lived process (an actor with a mailbox). The worker is ephemeral — one is forked per turn and dies when the turn ends. Every arrow that crosses a process boundary is an Envelope on the bus; nothing reaches into another process's internals.
Each actor is a process named for its role. The source tree mirrors this exactly: one top-level package per actor, plus contracts/ for the shared wire types.
| Process | Package | Role |
|---|---|---|
| core | shelldon/core/ |
The LLM-free domain center. Hosts the bus hub, owns personality state and memory, runs the arbiter, the scheduler, and the resident reflexes. The only process that mutates state or memory. |
| broker | shelldon/broker/ |
The single trust boundary. Holds model/tool credentials, owns the ordered provider chain with fallback, and is the only egress to any LLM or tool. |
| fork-server / worker | shelldon/worker/ |
The brain. A parent process pre-imports the LLM libraries and os.fork()s one short-lived worker per turn; the worker assembles the prompt, runs the tool loop, and dies. |
| chat-transport | shelldon/transport/ |
The conversation surface. A pluggable adapter (Telegram or local CLI) that translates between an external chat service and core's transport-agnostic message contract. |
| display | shelldon/display/ |
The screen. A region compositor that renders the E-Ink face and any plugin-claimed widgets. |
| plugin-host | shelldon/plugins/ |
Loads optional plugins — hardware (PiSugar button, BLE presence) and behavioral (the XP/leveling widget) — under one bus-only plugin contract. |
The orchestration that brings these up lives in shelldon/core/runtime.py (the largest single module — it wires the hub, the arbiter loop, the scheduler, the fork-server handshake, and the memory writer together).
The split is not decoration — each boundary buys a specific guarantee that v1 (a single 1500-line process) could not give:
- The brain is isolated so its RAM can be reclaimed. Putting LLM prompt-assembly in a forked, ephemeral worker means each turn's memory dies with the turn. This is the direct fix for v1's OOM crashes on the 512MB Pi.
- Credentials live in exactly one place. Splitting the broker into its own process means a prompt-injected worker can't reach a single secret — it can only ask the broker to make a call.
- The pet stays alive when the brain is busy or offline. Reflexes (blink, mood drift) run in core, independent of the worker and the network, so the face never freezes.
-
The conversation surface is swappable. A transport that crashes degrades to reflex-only; swapping Telegram for a CLI or a web adapter is a new file in
transport/, not a core change.
All cross-process communication is a versioned msgspec Envelope over a Unix domain socket, framed with a 4-byte big-endian length prefix, and hub-routed through core. There are no ad-hoc point-to-point channels — every actor connects to the hub and addresses others only through it. This matters most for the ephemeral worker, which is a connect-do-die client: it can't maintain a mesh of long-lived connections, so a single hub it dials on startup is the only model that works.
The wire vocabulary is defined entirely in shelldon/contracts/__init__.py. The framing and socket transport are in shelldon/core/bus/ (frame.py for the length-prefix codec, server.py for the hub).
Every message is an Envelope with a closed header wrapping a typed body:
class Envelope(msgspec.Struct, frozen=True, forbid_unknown_fields=True):
id: str
kind: MsgKind # what the hub routes on
src: Actor # sender process
dst: Actor | None # recipient; None = broadcast
body: Job | Result | Completion | InboundMessage | OutboundMessage | StateSnapshot | Event
v: int = SCHEMA_VERSION
turn_id: str | None = None # core fences turns on this (AD-12)The header is closed in two senses. forbid_unknown_fields=True rejects any field not in the schema. And __post_init__ rejects any Envelope whose kind disagrees with the type of its body — so the value the hub routes on can never drift from the body it carries. Schema version v is checked on every decode in contracts.decode(): a message from an incompatible build is a decode failure, not a silently-accepted message.
The hub supports exactly two routing modes, both declared in contracts/:
-
Point-to-point — a static
MsgKind → Actortable (ROUTING_TABLEincontracts/__init__.py). AJobalways goes to the broker; aResultalways goes to core; aStateSnapshotalways goes to the display. This is the default and covers the whole turn path. -
Broadcast / subscription — an
Eventwithdst=Nonethat the hub fans out to every plugin subscribed to that event kind. The set of event kinds is closed (EventKindincontracts/), and the subscription registry is built at load time from plugin manifests — no process invents a new kind or self-registers at runtime.
The addressable processes are the Actor enum (core, broker, worker, chat-transport, display, plugin-host); the message kinds are the MsgKind enum. Because both are closed enums, a typo can't mint a new actor or message kind that slips past routing.
A few of the typed bodies, so the rest of the codebase reads:
-
InboundMessage/OutboundMessage— the transport-agnostic message contract. A CLI, Telegram, or web adapter all emitInboundMessage; core emitsOutboundMessagewithout knowing which surface renders it. -
Job/Completion— the worker → broker request and the broker's reply. AJobcarries the prompt (or, on the tool-calling path, the runningmessagesandtools) and — crucially — no credentials. -
Result— the worker's outcome back to core: the user-facingpayloadplusproposed_ops, the closed list of memory/face/tool changes the worker proposes but never applies. -
StateSnapshot— a face/widget snapshot core pushes to the display, carrying a monotonic per-regionseqso the display can render latest-wins and drop stale frames. -
Event— a broadcast lifecycle signal (message-answered,tool-used,button-pressed, …) the hub fans out to subscribed plugins.
These are the rules that keep the system coherent. Several are enforced mechanically — by CI, by a test, or by the OS — rather than by convention, because a constraint that matters should be impossible to break by accident.
shelldon/core/ imports no LLM or provider library. Core holds the soul — state, memory, the arbiter, reflexes, the scheduler, the bus hub — but nothing that calls a model. This keeps the always-on process auditable and small, and keeps the brain's concerns from leaking into the domain center.
This isn't a guideline; it's a CI contract. The import-linter config in pyproject.toml declares core forbidden from importing openai, anthropic, google, litellm, zhipuai, or ollama. The build fails if anything in core/ reaches for one. The same mechanism enforces two sibling rules: transport/ may not import any provider SDK or the broker (it holds only its own connection credential), and plugins/ may not import a provider SDK or anything from core/ except the shared bus client core.bus — the same seam every adapter uses.
A fork-server parent pre-imports the LLM libraries only (never credentials) and os.fork()s exactly one worker per turn. The worker assembles its prompt against the warm libraries, proxies the authenticated call to the broker, and then dies — its RAM is reclaimed by the OS. Nothing accumulates across turns, which is the structural cure for v1's OOM death.
Two details make the RAM win real and are easy to break:
-
Copy-on-write pre-warming requires
gc.disable()+gc.freeze()in the parent beforeos.fork(). Without it, CPython's refcount writes dirty the shared pages on every access and the memory saving evaporates. - The forked child must not inherit core's live SQLite/WAL connection. The child closes inherited file descriptors before it runs; doing this carelessly corrupted history reads on the real Pi until it was fixed. (See The Brain for the fork-server internals.)
The fork-server lives in shelldon/worker/forkserver.py; the per-turn worker logic is shelldon/worker/worker.py.
A single arbiter in core (shelldon/core/arbiter.py) governs the brain. It enforces ≤1 worker turn in flight — a hard bound, and a required test from the first milestone, because two concurrent forks would blow the RAM budget. Events that arrive during a turn coalesce into a single pending catch-up slot: the next turn folds in everything since it started, so a poke-stampede can never grow into a backlog of queued turns. Proactive turns are gated by a cooldown plus a daily credit budget and battery-aware backoff (budget.py, power.py), and if every provider in the chain fails, the arbiter degrades to a reflex behavior so the pet never freezes.
Core is the sole writer of all state and memory. Reflexes mutate the in-RAM personality struct in-process; everything else — the markdown memory tree and the SQLite store — is written only by core. A worker never writes: it reads history read-only and returns proposed changes in its Result.proposed_ops, which core validates and applies. The proposed ops are a closed, fixed-arg vocabulary (Remember, RewriteAbout, LogEpisode, CaptureLearning, …, all tagged structs in contracts/) — there are no free-text deltas a malformed proposal could smuggle through.
The same single-writer rule extends to the display. The screen is a compositor of regions (the closed Region enum: face, caption, status-bar, battery). Core owns face and caption; a plugin may claim a widget region like status-bar or battery. The plugin-host rejects two plugins claiming the same region at load — exactly like a GPIO or BLE pin claim — so no two writers ever target one region. Each region carries its own monotonic seq and the display renders latest-wins, tolerating slow E-Ink refreshes under reflex churn.
The broker (shelldon/broker/) is a separate process and the only holder of model/tool credentials and safety policy, and the only egress to any model or tool. Job envelopes carry no credentials — the broker injects them internally. It owns the ordered provider chain with retry and fallback (broker/chain.py): GLM by default, with Ollama-LAN, Claude, OpenAI, and OpenRouter as alternates, reorderable by config alone. Two wire-format adapters (anthropic_provider.py, openai_provider.py) cover the whole supported set.
The one carve-out: a chat-transport adapter holds its own connection credential for its own surface (e.g. a Telegram bot token), but it never touches a model or tool credential — and the import-linter proves it can't reach the broker's cred path.
Every turn carries a turn_id, and core fences on it. A Result whose turn_id is already closed — timed out, superseded by a newer turn, or resolved by a reflex fallback — is discarded. Turn close is idempotent. This keeps a late or zombie Result from a dead worker from racing the fallback or polluting the next turn.
The architecture is pinned by a set of numbered decisions (the "AD-" rules). In plain terms:
| Decision | What it says |
|---|---|
| AD-1 | Core imports no LLM code; CI enforces it. |
| AD-2 | The broker is the only process with credentials and the only path to a model or tool. |
| AD-3 | One ephemeral worker is forked per turn and dies after it, so RAM never accumulates. |
| AD-4 | The Envelope bus over Unix sockets is the only way processes talk. |
| AD-5 | Core is the sole writer of state, memory, and each display region. |
| AD-6 | Memory is hybrid: SQLite for conversation history + learnings, markdown for curated knowledge, no vectors. |
| AD-7 | Volatile state lives in RAM, checkpointed periodically, to spare the SD card. |
| AD-8 | One bus-only plugin contract covers both hardware and behavioral plugins; a crashed plugin never takes down core. |
| AD-9 | The arbiter allows ≤1 turn in flight, coalesces events, and degrades to a reflex on failure. |
| AD-10 | Contracts are versioned typed structs with a test harness from the first milestone. |
| AD-11 | The Envelope header is closed; the hub has exactly two routing modes (point-to-point and broadcast). |
| AD-12 | Turns are fenced by turn_id with idempotent close. |
| AD-13 | The chat transport is a pluggable first-class adapter holding only its own connection credential. |
| AD-14 | A core-resident scheduler runs named multi-cadence jobs, cost-tiered and battery-aware. |
| AD-15 | Dreaming is a scheduled worker turn that consolidates and promotes learnings — it reuses the normal turn machinery, not a separate subsystem. |
The thread running through all of them: 512MB is a design constraint, not an excuse. The fork-server (AD-3), RAM-resident state (AD-7), WAL SQLite with batched commits (AD-6), and atomic markdown writes are all load-bearing because of the Pi Zero's memory and SD-wear limits — and designing around those limits produced a cleaner system than ignoring them would have.
Two core-resident subsystems give the pet a life of its own, both built on the invariants above rather than alongside them:
-
The scheduler (
shelldon/core/scheduler.py, AD-14) owns the pet's self-driven behavior as named jobs, each with its own cadence (interval, cron-style, or idle-triggered). Jobs are tagged by cost tier: reflex jobs (blink, mood drift) run in-core with no LLM; turn jobs (reflection, proactive pings, dreaming) each cost a fork+LLM and go through the arbiter's ≤1-worker bound and budget gate. The heartbeat is just one job now, not the engine. Incoming messages bypass the scheduler entirely — they're immediate, not cadence-driven. -
Dreaming (AD-15) is a scheduled introspective worker turn, not a separate subsystem. In one dream turn the worker consolidates the recent conversation window, classifies the pending
learningsrows, proposes promoting the durable ones into curated markdown (sensitive ones gated tovault/), and prunes the rest. It reuses the fork-server, broker, and arbiter exactly like a normal turn — and, like every turn, it only proposes memory-ops; core applies them. See Memory & Learning.
- How a Turn Works — one message traced end to end across the bus.
- The Brain — the fork-server, prompt assembly, the tool-calling loop, and the provider chain.
- Memory & Learning — the SQLite + markdown hybrid, capture-learning, and the dream cycle.
shelldon — an E-Ink AI desk pet · docs generated from the project's design + implementation notes