-
Notifications
You must be signed in to change notification settings - Fork 0
Plugins and Extensibility
Shelldon's capabilities split into two layers. The core owns the pet's soul — its mood, memory, faces, and the chat/turn loop. Everything optional — a battery widget, an XP counter, a physical button, BLE presence sensing — lives in a plugin. A plugin is a small, self-contained bus client that the core knows nothing about. You can add or delete a plugin file and the core is byte-for-byte unchanged.
This page documents the plugin model and the plugins that ship with shelldon today.
Related pages: Architecture · The Screen · Personality & Autonomy
A plugin is a pure bus client. It never imports core/ — that boundary is mechanically enforced by import-linter, so a plugin that reaches into core won't pass CI. It declares a typed manifest stating everything it touches: the events it subscribes to, the events it emits, the display regions it draws to, and the hardware resources it claims. It is auto-discovered as a module in the shelldon.plugins package. The plugin-host (a single bus actor) discovers every plugin, validates their claims, rejects conflicts at load time, owns the one bus connection, and drives each plugin: it fans broadcast events out to subscribers and hands each plugin a scoped handle for drawing and emitting.
Source files:
-
shelldon/plugins/manifest.py— the contract:PluginManifest, thePluginandHostprotocols,BasePlugin. -
shelldon/plugins/host.py— the plugin-host: discovery, claim validation, the read loop, the fan-out, the per-plugin draw/emit seam. -
shelldon/plugins/xp.py,battery.py,sensing_button.py,sensing_ble.py— the shipped plugins.
Every plugin module exposes a module-level constant MANIFEST: PluginManifest. The manifest is a frozen msgspec.Struct — the same typed-struct style as the rest of shelldon's contracts/. Because the fields reference closed enums (EventKind, Region), a typo is a construction-time error, not a silent misconfiguration. There is no TOML, no parser, no hand-rolled validation.
# shelldon/plugins/manifest.py
class PluginManifest(msgspec.Struct, frozen=True, forbid_unknown_fields=True):
name: str
subscribes: tuple[EventKind, ...] = () # broadcast events this plugin reacts to
emits: tuple[EventKind, ...] = () # broadcast events this plugin is allowed to publish
resources: tuple[str, ...] = () # opaque hardware claims, e.g. "gpio:17", "ble:adapter"
regions: tuple[Region, ...] = () # display regions this plugin draws to (single-writer)-
subscribes— the closedEventKindvalues the host will deliver to this plugin'son_event. -
emits— the closedEventKindvalues this plugin is allowed to publish. The host validates every emit against this list; an undeclared emit is dropped and logged. -
resources— opaque claim strings. The host doesn't interpret them — it only checks that no two plugins claim the same string. The actual hardware access lives inside the plugin. -
regions— the display regions this plugin draws to. The host enforces single-writer-per-region (see The Screen for how the display composites regions).
The closed vocabularies live in shelldon/contracts/__init__.py:
class Region(StrEnum):
FACE = "face" # core-owned (the pet's expression)
STATUS_BAR = "status-bar" # plugin-claimed widget region
CAPTION = "caption" # core-owned (the bottom caption strip)
BATTERY = "battery" # plugin-claimed (the PiSugar widget)
class EventKind(StrEnum):
MESSAGE_ANSWERED = "message-answered" # core emits at turn completion
TOOL_USED = "tool-used" # declared, no emitter yet
DAY_ALIVE = "day-alive" # declared, no emitter yet
BUTTON_PRESSED = "button-pressed" # sensing plugins emit
PRESENCE_ARRIVED = "presence-arrived"
PRESENCE_LEFT = "presence-left"
NUDGE_POSITIVE = "nudge-positive" # affect kinds (see Mood-nudge below)
NUDGE_NEGATIVE = "nudge-negative"
NUDGE_EXCITED = "nudge-excited"
NUDGE_CALM = "nudge-calm"The host drives three lifecycle hooks. BasePlugin provides no-op defaults, so a plugin overrides only what it needs.
class Plugin(Protocol):
manifest: PluginManifest
async def on_start(self, host: Host) -> None: ... # called once, after connect
async def on_event(self, event: Event) -> None: ... # called per delivered broadcast event-
on_start(host)— called once after the host connects to the bus, before the read loop. The host hands the plugin a scopedHosthandle (below). This is where a plugin draws its initial widget or starts a background sense loop. -
on_event(event)— called when a broadcast event of a kind the plugin subscribed to arrives. The host fans events out sequentially on its single read loop, soon_eventmust be fast and non-blocking — offload real work to a spawned task, neverawaita long operation here.
A plugin never reads the socket itself. This is deliberate: an early design had each plugin reading the shared bus connection, which corrupts framing when several plugins read concurrently. The host owns the one reader and the one writer; plugins are pure reactors.
on_start receives a Host — the plugin's only door to the bus. It never builds an Envelope or touches the connection. The handle is scoped to that one plugin: draw is limited to the regions it claimed, emit_event to the kinds its manifest declared.
class Host(Protocol):
async def draw(self, region: Region, face: str) -> None: ... # push a widget render
async def emit_event(self, kind: EventKind) -> None: ... # publish a broadcast event
def spawn(self, coro) -> None: ... # run a host-owned background task-
draw(region, face)— push a render to a display region the plugin claimed. The host validatesregion in manifest.regions(an unclaimed region is dropped and logged), manages a per-region monotonic sequence number, and writes aStateSnapshotto the display. The display's latest-wins compositor renders it with no special-casing — see The Screen. -
emit_event(kind)— publish a broadcast event the plugin declared inmanifest.emits(an undeclared kind is dropped and logged), as anEventenvelope withdst=None. The hub broadcasts it. -
spawn(coro)— run a background producer loop (a sensing poll, a battery poll) that the host owns: it's tracked and cancelled on teardown, so a plugin can never leak a task.
A subtle but important property: a draw (from the read loop's on_event) and an emit_event (from a spawned sense loop) both write on the host's single writer concurrently. This is frame-safe — write_frame buffers the whole length-prefixed frame synchronously before awaiting drain, so frames never interleave at the byte level.
The host discovers plugins by walking the shelldon.plugins package with pkgutil.iter_modules, importing each submodule, and collecting those exposing a MANIFEST. Infrastructure modules (host, manifest, __init__) expose no MANIFEST, so they self-exclude — discovery never special-cases anything by name.
# shelldon/plugins/host.py
def discover_plugins(package) -> list[Plugin]:
found = []
for info in pkgutil.iter_modules(package.__path__, package.__name__ + "."):
try:
module = importlib.import_module(info.name)
except Exception:
log.warning("skipping plugin module %r — failed to import", info.name, exc_info=True)
continue # one broken plugin must not crash the host
plugin = plugin_from_module(module)
if plugin is not None:
found.append(plugin)
return foundA module that exposes a MANIFEST becomes a plugin. It may also export a PLUGIN instance (a real behavioral or hardware plugin with custom hooks); a manifest-only module is wrapped in a stay-alive BasePlugin. A broken import, or a PLUGIN that isn't actually a valid Plugin, is logged and skipped — a bad plugin kills only itself, never the host or the pet.
Because discovery is automatic, dropping a .py file into shelldon/plugins/ ships the plugin on by default. The shipped sensing plugins handle this safely by idling when no hardware source is configured (below).
After discovery, validate_claims builds three things from the manifests and rejects conflicts before the host ever connects to the bus:
- A region-claim map — which plugin owns each display region.
- A resource-claim map — which plugin owns each hardware claim string.
- The subscription registry —
EventKind → [plugins], used later to fan out events.
def validate_claims(plugins) -> LoadedPlugins:
for plugin in plugins:
for region in plugin.manifest.regions:
if region is Region.FACE:
raise PluginLoadError(f"plugin claims the FACE region, which core owns (AD-5)")
if region in regions:
raise PluginLoadError(f"display region claimed by two plugins (no two writers)")
...The rules:
-
Two plugins claiming the same region →
PluginLoadError. The host does not start. No two writers ever target one region. -
Two plugins claiming the same resource string (e.g.
"gpio:17") →PluginLoadError. -
A plugin claiming
Region.FACE→PluginLoadError. The face belongs to core (see Personality & Autonomy). -
Two plugins subscribing to the same event kind → fine. Broadcast is one-to-many; many subscribers to
message-answeredis the normal case.
This is fail-fast: a conflict is a load-time crash with a message naming the claim and both plugins, never a silently-clobbered second writer at runtime.
Events flow over the bus as broadcast Event envelopes (dst=None) — the second of the bus's two routing modes (point-to-point is the first; see Architecture). The closed EventKind set is declared in contracts/; nothing self-registers a new kind at runtime.
The flow, end to end:
-
A source emits an event. Core emits
message-answeredwhen it successfully answers a turn. A plugin emits viahost.emit_event(kind)(validated against itsmanifest.emits). -
The hub broadcasts it. The hub's broadcast branch delivers the
Eventto the plugin-host. If no plugin-host is connected, it's a harmless debug-level drop — a no-subscriber broadcast is the normal zero-plugin steady state. -
The host fans out. The host's single read loop reads the
Event, looks it up in the subscription registry (loaded.subscriptions[event.event]), and callson_eventon exactly the plugins that subscribed — no others.
# the host's read loop, simplified
while True:
env = await read_frame(reader)
if env is None: # hub gone
return
if env.kind is MsgKind.EVENT and isinstance(env.body, Event):
for plugin in loaded.subscriptions.get(env.body.event, []):
await _safe_on_event(plugin, env.body) # isolated per pluginEach on_event call is isolated: a plugin that raises is logged and skipped, and the other subscribers still receive the event. Today the one live core emitter is message-answered; tool-used and day-alive are declared but have no source yet. Sensing plugins emit the button-pressed / presence-* and the nudge-* kinds.
Four plugins ship in shelldon/plugins/. All are bus-only, LLM-free, and import nothing from core/.
The first behavioral plugin, and the proof that a real capability ships with zero core changes. It earns XP from the pet's lifecycle events and draws a status-bar widget.
-
Subscribes:
MESSAGE_ANSWERED,TOOL_USED,DAY_ALIVE. Claims:Region.STATUS_BAR. -
Rules:
+10XP per answered message;level = 1 + xp // 100; widget text"Lv{level} · {xp} XP". (It subscribes to all three kinds for parity even though onlymessage-answeredhas a live emitter — the registry simply never delivers an unemitted kind.) -
Private state: its own JSON file at
~/.shelldon/plugins/xp/state.json, written atomically (temp → fsync →os.replace). It reads no core state and writes nothing under core's memory, state, faces, or history. Onlyxpis persisted;levelis a derived property, so it can't drift out of sync. -
Drawing: on
on_startit loads its state and draws once (so the widget shows on boot); on eachmessage-answeredit awards XP, persists, and redraws.
class XpPlugin(BasePlugin):
async def on_start(self, host) -> None:
await super().on_start(host)
self.state = _load_state(self._state_path)
await self._draw()
async def on_event(self, event) -> None:
award = _AWARDS.get(event.event, 0)
if award == 0:
return
self.state = XpState(xp=self.state.xp + award)
_save_state(self._state_path, self.state)
await self._draw()A hardware plugin that reads the PiSugar2 UPS charge and draws a top-right battery widget.
-
Claims:
Region.BATTERYand the resource"pisugar:8423". -
How it reads: the PiSugar power server speaks a tiny line protocol on
127.0.0.1:8423(get battery→battery: 100). The plugin reads it with a plain asyncio socket — zero new dependencies, no subprocess. -
Polling: on
on_startit spawns a host-owned poll loop (host.spawn) that re-reads every 60 seconds. Battery moves slowly and an E-Ink full refresh is ~2 seconds, so polling lazily avoids flashing the panel for no new information. - Graceful absence: on a box with no PiSugar (a laptop, an unplugged HAT) the connect fails, the tick is skipped, and the widget stays blank. Nothing crashes.
class BatteryPlugin(BasePlugin):
async def on_start(self, host) -> None:
await super().on_start(host)
host.spawn(self._poll_loop())
async def _poll_loop(self) -> None:
while True:
text = await read_battery()
if text is not None and self._host is not None:
await self._host.draw(Region.BATTERY, text)
await asyncio.sleep(POLL_INTERVAL_S)Two sensing plugins that emit events when something physical happens. They are the event-emit half of the contract — where the battery and XP plugins consume and draw, these produce. The hardware is gated: the laptop test suite injects stub sources, and the real adapters run only on the Pi, so no bleak or PiSugar dependency is forced.
Both ship on by default but idle when no hardware source is configured — they log "idling" and emit nothing, so shipping them on never disturbs the chat pet.
Button (sensing_button.py) — emits BUTTON_PRESSED on each press. Declares emits=(BUTTON_PRESSED, NUDGE_EXCITED) and claims "pisugar:button". It reads from an injectable ButtonSource (an async iterator of presses) via a host-spawned sense loop. The real PiSugar2 button adapter is a lazily-imported, gated stub until wired on the Pi.
async def _sense_loop(self, host: Host) -> None:
async for _ in self._source:
await host.emit_event(EventKind.BUTTON_PRESSED) # the fact
await host.emit_event(EventKind.NUDGE_EXCITED) # the affect (the face reacts)BLE presence (sensing_ble.py) — emits PRESENCE_ARRIVED / PRESENCE_LEFT when a paired device moves in or out of range. Declares emits=(PRESENCE_ARRIVED, PRESENCE_LEFT, NUDGE_POSITIVE, NUDGE_NEGATIVE) and claims "ble:adapter". It is constructed with a set of previously-paired device ids.
The security rule is pair-first: only paired devices are ever tracked, never any nearby device. The filter is the first thing each scan does, so an unpaired id never reaches any code path that could record, emit, or even log it.
async def _sense_loop(self, host: Host) -> None:
present: set[str] = set()
async for scan in self._source:
seen = {device for device in scan if device in self._paired} # pair-first filter
for _ in seen - present: # a paired device arrived
await host.emit_event(EventKind.PRESENCE_ARRIVED)
await host.emit_event(EventKind.NUDGE_POSITIVE)
for _ in present - seen: # a paired device left
await host.emit_event(EventKind.PRESENCE_LEFT)
await host.emit_event(EventKind.NUDGE_NEGATIVE)
present = seenNotice the sensing plugins emit two kinds per trigger: a fact (BUTTON_PRESSED) and an affect (NUDGE_EXCITED). This is the bounded plugin→core channel that lets a plugin nudge the pet's mood — the one place where a plugin event reaches into the soul, and it does so through a deliberately narrow, safe seam.
The split is the whole point: plugins own the meaning, core owns the magnitude.
- A plugin emits a semantic affect — "get excited," "warm up," "dim down" — never a raw mood number. A plugin can't reach into the soul's dynamics; it can only express intent.
- Core maps each affect kind to a small, clamped mood patch via the pure policy module
core/reactions.py, then re-renders the face through its existing mood→face compositor.
| Affect kind | Mood delta |
|---|---|
NUDGE_POSITIVE |
valence +0.3 |
NUDGE_NEGATIVE |
valence −0.3 |
NUDGE_EXCITED |
arousal +0.3, valence +0.1 |
NUDGE_CALM |
arousal −0.3 |
How the channel stays safe:
-
The hub delivers broadcast events to core too (not just the plugin-host), guarded by
src != COREso core never re-consumes its own emitted events. This is the single structural core change the channel needed — one guarded line. -
The handler is reflex-tier — it runs on the main loop with no LLM, no fork, no budget, no arbiter admission. It mutates mood through the same single-writer
apply_patchthe reflex loop uses. - Per-kind cooldown + hard clamp — a flood of one kind from a buggy plugin applies once per 30-second window and is always clamped to the mood bounds. Decay is free: the existing reflex loop settles mood back to baseline when the pet is idle.
-
A nudge moves mood only — it never touches
last_interaction, so a presence or button nudge can't silently suppress the proactive idle clock through the mood channel.
End to end: a paired device arrives → the BLE plugin emits NUDGE_POSITIVE → the hub broadcasts it to core → core maps it to a clamped valence bump → the mood→face compositor re-renders → the pet's face brightens. See Personality & Autonomy for how mood drives the face.
A minimal plugin is one file in shelldon/plugins/ exposing a MANIFEST and a PLUGIN. Here's a widget that subscribes to message-answered and draws a counter to a claimed region:
# shelldon/plugins/hello.py
from shelldon.contracts import EventKind, Region
from shelldon.plugins.manifest import BasePlugin, PluginManifest
MANIFEST = PluginManifest(
name="hello",
subscribes=(EventKind.MESSAGE_ANSWERED,), # react to answered turns
regions=(Region.STATUS_BAR,), # draw to a claimed widget region
# emits=(...) declare any kinds you publish; resources=(...) claim hardware
)
class HelloPlugin(BasePlugin):
def __init__(self, manifest):
super().__init__(manifest)
self.count = 0
async def on_start(self, host) -> None:
await super().on_start(host) # stores the host handle on self._host
await self._host.draw(Region.STATUS_BAR, "hi")
async def on_event(self, event) -> None:
self.count += 1
await self._host.draw(Region.STATUS_BAR, f"answered {self.count}")
PLUGIN = HelloPlugin(MANIFEST)That's the whole thing. The host discovers it, validates that no other plugin claims STATUS_BAR, connects, calls on_start with a scoped handle, and delivers every message-answered event to on_event.
The rules to keep in mind:
-
Never import
shelldon.core— onlyshelldon.contractsandshelldon.plugins.manifest. Import-linter enforces this in CI. - Declare everything in the manifest — you can only draw to regions you claim and emit kinds you declare; the host drops anything undeclared.
-
Keep
on_eventfast — the host fans out sequentially. For real work or hardware I/O, usehost.spawn(...)to run a host-owned background loop. -
Own your state privately — persist to your own directory under
~/.shelldon/plugins/, never under core's memory or state. - Idle gracefully — if your plugin needs hardware that may be absent, idle (emit/draw nothing) rather than crash, so it's safe to ship on by default.
For the file-level edits that pair with this (adding an event kind to emit/subscribe, a face, a provider), see Extending shelldon.
shelldon's plugin model is source-tree, auto-discovered — there is no plugin store, no package format, and no enable/disable switch today. Installing a plugin (your own or someone else's) means putting its module into the shelldon/plugins/ package of your checkout:
cp their_plugin.py shelldon/plugins/On the next start, the plugin-host walks the shelldon.plugins package with pkgutil.iter_modules, imports every module exposing a MANIFEST, validates claims, and runs it. Discovery is automatic, so a plugin in that directory is on by default — there's nothing to register or enable.
Confirm it loaded. Watch the logs at startup; a plugin that fails to import is logged and skipped (one bad plugin never crashes the host or the pet):
journalctl -u shelldon -f # on the Pi / as a service
# or run in the foreground and read stderrA load-time claim conflict (two plugins drawing to the same region, or claiming the same hardware resource) is a fast crash with a message naming both plugins — fix it by removing one. See Single-writer conflict rejection at load.
To remove a plugin, delete its file from shelldon/plugins/ and restart. Because plugins never import core, adding or deleting one leaves the core byte-for-byte unchanged.
Trust note: a plugin runs in the plugin-host process with your privileges. It can't import core or a model SDK (CI's import-linter enforces that — see Development), but it can run arbitrary Python in the host. Only install plugins you've read, the same as any code you'd add to your tree.
These are honest, current limitations of the install model, not features hidden elsewhere:
-
No third-party package install (
pip install some-shelldon-plugin) — discovery only scans the in-treeshelldon.pluginspackage. - No enable/disable config — presence in the directory is the toggle. To disable a shipped plugin, delete or move its file.
- No "list loaded plugins" command — read the startup logs to see what loaded.
shelldon — an E-Ink AI desk pet · docs generated from the project's design + implementation notes