-
Notifications
You must be signed in to change notification settings - Fork 0
Extending shelldon
Recipes for the changes people most often want to make. Each one names the exact files, the edit, and how to verify it. They assume a local checkout — see Development for setup, and Architecture for why the boundaries are where they are.
Before pushing any change, run the two gates CI runs:
uv run lint-imports # the architectural import contracts
uv run pytest # the full suite (network excluded)Recipes on this page:
- Add an LLM provider
- Add a memory operation
- Add or customize a face
- Register a scheduled job
- Add a broadcast event kind
- Write or install a plugin
Where: shelldon/broker/chain.py. The chain is data-driven — most providers are one row.
If the provider speaks the OpenAI-compatible wire format (most do — Groq, Cerebras, Together, your own gateway), add one row to _OPENAI_COMPAT: name → (default_base_url, api_key_env, model_env):
_OPENAI_COMPAT = {
...
"myprovider": ("https://api.example.com/v1", "MYPROVIDER_API_KEY", "MYPROVIDER_MODEL"),
}That's the whole code change — _PRESETS picks it up automatically. Then select it at runtime, no code:
PROVIDER_CHAIN="glm,myprovider" # ordered fallback
MYPROVIDER_API_KEY=sk-...
MYPROVIDER_MODEL=some-modelIf it needs a different wire format or non-standard auth, write a small builder function (copy _glm / _ollama) that returns an AnthropicProvider, OpenAIProvider, or a new adapter, and register it in _PRESETS:
def _myprovider(env) -> LLMProvider:
return OpenAIProvider(api_key=env.get("MYPROVIDER_API_KEY"), base_url="...", model="...", name="myprovider")
_PRESETS = {"glm": _glm, "claude": _claude, "ollama": _ollama, "myprovider": _myprovider}An unknown preset name fails at startup (no silent degradation), and a preset missing its required credential raises at build time.
Verify: unit-test the builder offline, then PROVIDER_CHAIN="myprovider" uv run pytest -m live with real creds. See The Brain for the chain, retry, and fallback model.
A worker never writes memory directly — it returns proposed ops in Result.proposed_ops, and core applies the ones it recognizes. The op vocabulary is a closed set of typed structs, so adding one touches two places.
1. Declare the contract — add a tagged msgspec.Struct to shelldon/contracts/__init__.py and include it in the MemoryOp union (copy Remember / LogEpisode). Closed, fixed-arg fields only — no free-text deltas.
2. Apply it in core — add a branch to CuratedMemory.apply_memory_op in shelldon/core/memory.py and a private _apply_* writer that does an atomic write (temp → fsync → os.replace), like the existing _apply_remember:
def apply_memory_op(self, op: MemoryOp) -> None:
if isinstance(op, RewriteAbout): ...
elif isinstance(op, Remember): ...
elif isinstance(op, MyNewOp): self._apply_my_new_op(op)The worker then proposes your op like any other; core validates and applies it (single-writer rule, AD-5). For the prompt-side instructions that make the model emit it, see the prompt assembly in Memory & Learning.
Verify: a unit test that applies the op to a temp memory root and asserts the file contents + atomicity.
A face is a named expression that wins for a mood region (valence × arousal × energy ranges) and a token — the glyph(s) actually drawn. Selection is first-match-wins, so order matters and the broad content catch-all must stay last.
The easy way — no code, no restart: edit ~/.shelldon/faces.toml. The registry seeds this file on first run and is its sole writer, but it's a plain TOML table you can hand-edit (comments are preserved). Add a [[face]] with a name, the three ranges, and a token. Restart the pet to pick it up.
The easiest way — ask it: tell the pet (in chat) to give itself a new face. Self-modification of faces is a built-in capability — see The Screen.
In code (to change the shipped defaults): add a Face(...) to DEFAULT_FACES in shelldon/core/faces.py, keeping content last:
Face("mischievous", valence=(0.3, 1.0), arousal=(0.5, 1.0), energy=(0.5, 1.0), token="😏"),Verify: select_face(faces, valence, arousal, energy) returns your face name for a mood inside its ranges. See The Screen for how the token renders on the panel (and the font note in Configuration).
The core scheduler runs named, multi-cadence jobs — this is how reflexes, checkpoints, pruning, proactive turns, and the dream cycle are all driven. Jobs are registered in Core's constructor in shelldon/core/runtime.py (search for self.scheduler.register).
self.scheduler.register(
Job("my-job", Interval(period_s=300), CostTier.REFLEX, self._run_my_job)
)-
Cadence (from
core/scheduler.py):Interval(period_s)(every N seconds),Idle(period_s)(N seconds after the last interaction), orDaily(at=time(...)). -
CostTier:
CostTier.REFLEXfor a pure in-process job (no LLM, no fork, no budget) — itsruncallable executes directly. Higher tiers route through the arbiter and the daily budget (see Personality & Autonomy).
Verify: drive scheduler.due(now, last_interaction) and scheduler.tick(...) in a test with a controlled clock; assert your job fires when due and respects its cost tier.
Events are a closed enum broadcast over the bus to subscribed plugins (Plugins & Extensibility). Adding one is three parts:
-
Declare it — add a value to
EventKindinshelldon/contracts/__init__.py. -
Emit it — from core via
self._emit_event(EventKind.MY_KIND)(seecore/runtime.py), or from a plugin viahost.emit_event(...)(the plugin must declare it inmanifest.emits). -
Consume it — a plugin subscribes by listing it in
manifest.subscribesand handling it inon_event.
Note: a declared kind with no emitter is inert —
TOOL_USEDandDAY_ALIVEare declared and the XP plugin subscribes to them, but nothing emits them yet, so they never fire. If you add a kind, wire an emitter or it's dead weight.
Verify: a test that emits the kind and asserts a subscribed stub plugin's on_event receives it.
Plugins are the supported way to add optional capabilities (widgets, sensors, leveling) without touching core. The full contract, a working example, and the install model live on one page: Plugins & Extensibility — see Writing your own plugin and Installing a plugin.
shelldon — an E-Ink AI desk pet · docs generated from the project's design + implementation notes