Skip to content

Extending shelldon

Elliot Boney edited this page Jun 24, 2026 · 2 revisions

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

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-model

If 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.


Add a memory operation

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.


Add or customize a face

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).


Register a scheduled job

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), or Daily(at=time(...)).
  • CostTier: CostTier.REFLEX for a pure in-process job (no LLM, no fork, no budget) — its run callable 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.


Add a broadcast event kind

Events are a closed enum broadcast over the bus to subscribed plugins (Plugins & Extensibility). Adding one is three parts:

  1. Declare it — add a value to EventKind in shelldon/contracts/__init__.py.
  2. Emit it — from core via self._emit_event(EventKind.MY_KIND) (see core/runtime.py), or from a plugin via host.emit_event(...) (the plugin must declare it in manifest.emits).
  3. Consume it — a plugin subscribes by listing it in manifest.subscribes and handling it in on_event.

Note: a declared kind with no emitter is inert — TOOL_USED and DAY_ALIVE are 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.


Write or install a plugin

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.

Clone this wiki locally