Skip to content

Running on the Pi

Elliot Boney edited this page Jun 23, 2026 · 3 revisions

Running on the Pi

This page covers standing shelldon up on real hardware: the target board, the wiring, the one-shot installer, running it as a service, talking to it over Telegram, and the day-to-day operations of keeping it alive on a 416MB box.

If you just want to chat with it on a laptop or server with no hardware, you don't need this page — see Getting Started. This is the full desk-pet path.

Related pages: Architecture · The Screen · Configuration

Target hardware

shelldon is built for the Raspberry Pi Zero 2W and treats its tiny memory as a hard design constraint, not an afterthought. The reference build:

Part Notes
Raspberry Pi Zero 2W aarch64, ~512MB RAM (≈416MB usable after the GPU split). This is the constraint everything is designed around.
Waveshare 2.13" V4 E-Ink HAT The face. SPI-attached. Driven via SHELLDON_DISPLAY=waveshare. See The Screen.
PiSugar2 battery HAT (optional) Power + a physical button. Lets it run untethered. Autonomy logic is battery-aware so it won't spam you on low charge.
High-endurance microSD (32GB) It writes memory and a WAL sqlite db continuously — use an endurance card, not a bargain one.

The OS is Debian (the reference Pi runs Debian 13) with Python 3.13, matching the repo's .python-version pin exactly. Every Python dependency resolves from an aarch64 wheel — there is no compilation step and no extra dependency added for the Pi over what runs on a laptop.

Why a Pi Zero 2W and not something beefier? The 512MB ceiling is the load-bearing reason for half the architecture (fork-per-turn workers, RAM-resident state, WAL sqlite). The predecessor (openclawgotchi v1) accumulated RAM until it OOM-crashed on exactly this board. shelldon's whole runtime exists to not do that — see the memory-bounded turns section.

The one-shot installer

Everything below is automated by deploy/setup-pi.sh. It's idempotent — safe to re-run any time — and it detects whether it's on a real Pi (by looking for the SPI device) so the same script also works on a headless box.

git clone https://github.com/elliotboney/shelldon.git ~/shelldon
cd ~/shelldon
./deploy/setup-pi.sh

What it does, step by step

The script runs five numbered phases:

  1. Installs uv — the package manager and runner. Skipped if uv is already on PATH or at ~/.local/bin/uv.
  2. uv sync --locked — installs the locked Python dependencies from uv.lock (aarch64 wheels, no compilation, ~30s on the Pi).
  3. Installs the Pi-only E-Ink stackonly if /dev/spidev0.0 exists (i.e. you're on a real Pi with SPI enabled). This is the part with the gotcha — see below. On a headless box this phase is skipped and the display stays off.
  4. Creates .env from .env.example if you don't already have one, then tells you which keys to fill in.
  5. Installs and enables the systemd service — writes /etc/systemd/system/shelldon.service, runs daemon-reload, and enables it so it starts on every boot.

After it finishes, edit .env and start the service:

nano ~/shelldon/.env          # fill in GLM_API_KEY, SHELLDON_TELEGRAM_BOT_TOKEN, ALLOWED_USERS
sudo systemctl start shelldon
journalctl -u shelldon -f     # watch it boot and think

The Pi-only hardware deps (and the gotcha that bites everyone)

Phase 3 installs the display stack with two installers:

# apt (system libraries)
sudo apt-get install -y swig liblgpio-dev fonts-unifont

# uv pip — these are NOT in pyproject.toml / uv.lock
uv pip install pillow spidev gpiozero lgpio rpi-lgpio

Why aren't pillow/spidev/gpiozero/lgpio/rpi-lgpio in the lockfile? Because they don't build cross-platform. They're Pi/Linux-GPIO-specific and would break uv sync resolution on macOS, in CI, and on any non-Pi machine. So they live outside pyproject.toml and uv.lock, and the setup script installs them separately with uv pip install only when it detects a Pi.

⚠️ Operational gotcha: a bare uv sync deletes the E-Ink stack

Because those five packages aren't in uv.lock, uv sync considers them foreign and prunes them. If you run a plain uv sync on the Pi (e.g. after pulling new code, or out of habit), the next time shelldon tries to draw a face it dies with:

ModuleNotFoundError: No module named 'spidev'

The fix — reinstall the hardware deps:

cd ~/shelldon
uv pip install pillow spidev gpiozero lgpio rpi-lgpio

Or just re-run the idempotent installer, which does the same thing plus re-checks everything else:

./deploy/setup-pi.sh

Rule of thumb: on the Pi, prefer ./deploy/setup-pi.sh over uv sync. Use uv sync --locked (what the script runs) rather than a bare uv sync if you must, but always re-add the hardware deps afterward.

Running as a systemd service

The installer writes shelldon.service so the pet is a permanent fixture, not a manual python invocation in a terminal you have to keep open. The unit it generates:

[Unit]
Description=shelldon — an E-Ink AI pet
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=<your-user>
WorkingDirectory=/home/<your-user>/shelldon
EnvironmentFile=/home/<your-user>/shelldon/.env
Environment=SHELLDON_TRANSPORT=telegram
Environment=SHELLDON_DISPLAY=waveshare        # only when a panel is detected
Environment=GPIOZERO_PIN_FACTORY=lgpio        # only when a panel is detected
ExecStart=<uv> run python -m shelldon
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
MemoryMax=400M
MemoryHigh=350M

[Install]
WantedBy=multi-user.target

Key properties:

  • Restart=always / RestartSec=10 — if it crashes, systemd brings it back after 10s. It survives reboots via WantedBy=multi-user.target.
  • EnvironmentFile=.env — your secrets (brain key, bot token, allowlist) load from .env, never baked into the unit.
  • SHELLDON_TRANSPORT=telegram — the service talks over Telegram (the CLI/stdin transport doesn't work as a background service; see Telegram).
  • MemoryMax=400M / MemoryHigh=350M — the hard guardrail. The whole service cgroup (5 processes + the ephemeral fork worker) is capped under the Pi's 416MB so a runaway can't take down the box. This cap held through real turns end-to-end with NRestarts=0 (no OOM-kill, no crash loop) and RAM settling around 279MB.

The display Environment= lines are only written into the unit when the installer detects a panel. On a headless box the service runs the brain with no face.

Talking to it: Telegram

The service is driven over Telegram — you message a bot from your phone and shelldon replies with its live LLM brain (and, on a Pi, its mood shows on the panel). The transport is a raw Bot-API long-poll over httpx — no python-telegram-bot, no extra dependency.

Setup

  1. Create a bot. Message @BotFather on Telegram, send /newbot, give it a name and a username ending in bot. It replies with a bot token.
  2. Message your new bot once (/start) so it's allowed to see you.
  3. Get your user id. Message @userinfobot to get your numeric Telegram user id.
  4. Fill in .env:
GLM_API_KEY=...                       # your brain (Z.ai/GLM by default)
SHELLDON_TELEGRAM_BOT_TOKEN=...       # from @BotFather
ALLOWED_USERS=123456789               # your numeric user id (comma-separated for more)

The allowed-users gate

ALLOWED_USERS is the security boundary on the transport. Only the listed Telegram user ids reach the brain; a message from anyone else is dropped and logged, never forwarded. This matters — the bot is reachable by anyone who finds it, and this is what stops a stranger from driving your pet (and spending your API budget).

  • Set ALLOW_ALL_USERS=1 to disable the gate for an open/demo setup.
  • Replies route back to the chat of the last permitted message (single-owner model).

A dedicated v2 bot

shelldon reads SHELLDON_TELEGRAM_BOT_TOKEN in preference to a plain TELEGRAM_BOT_TOKEN. If you're migrating from openclawgotchi v1 (which uses the plain name), this lets shelldon run a separate bot with no message overlap between the two.

See Configuration for the full list of environment variables.

How a turn stays RAM-bounded

This is the whole reason shelldon survives on the Pi where v1 didn't.

Each conversational turn is handled by an ephemeral fork worker: core fork()s a child, the child runs that single turn (assemble prompt → call the brain → produce a result), applies the result, and then dies. Nothing accumulates across turns, because the process that did the work no longer exists.

What this buys you on the hardware:

  • A real turn against the live brain peaked at 244MB used (~80MB over idle baseline) and settled flat — measured on the 416MB Pi.
  • The full 5-process app plus a fork worker ran at ~295MB used / 120MB free, and under the MemoryMax=400M cgroup cap settled to ~279MB with zero restarts.
  • v1's signature OOM-crash failure mode does not reproduce.

The fork worker model is what makes the MemoryMax=400M guardrail safe rather than a slow-motion crash loop: memory spikes per turn for the worker, then is reclaimed when the worker exits. For the architecture behind this, see Architecture.

Common operations

# Status — is it running? when did it last start? memory in use?
systemctl status shelldon

# Logs — follow live, or read recent
journalctl -u shelldon -f                 # live tail
journalctl -u shelldon -n 200 --no-pager  # last 200 lines
journalctl -u shelldon --since "10 min ago"

# Lifecycle
sudo systemctl start shelldon
sudo systemctl stop shelldon
sudo systemctl restart shelldon           # after editing .env or pulling code

# Autostart on boot
sudo systemctl enable shelldon            # on (the installer does this)
sudo systemctl disable shelldon           # off

# Confirm the cgroup memory cap is in effect
systemctl show shelldon -p MemoryMax -p MemoryHigh

After you edit .env or git pull new code, sudo systemctl restart shelldon to pick it up. If you pulled code and ran any uv install step, re-read the E-Ink dep gotcha before restarting.

Troubleshooting

Symptom Likely cause Fix
ModuleNotFoundError: No module named 'spidev' (or gpiozero/lgpio/PIL) A bare uv sync pruned the Pi-only deps uv pip install pillow spidev gpiozero lgpio rpi-lgpio or re-run ./deploy/setup-pi.sh
Bot doesn't reply at all Token/allowlist wrong, or service down systemctl status shelldon; check SHELLDON_TELEGRAM_BOT_TOKEN and that your id is in ALLOWED_USERS; make sure you /started the bot
Service flapping / restarting Crash on boot — bad config or a real error journalctl -u shelldon -n 100 to see the traceback
No face on the panel, but it replies E-Ink deps missing or panel not detected Confirm /dev/spidev0.0 exists (SPI enabled in raspi-config); re-run the installer
Panel keeps the last face after systemctl stop Expected — E-Ink is persistent and holds the last image with no power Cosmetic; a clean-shutdown sleep is a known follow-on

Known operational caveats

A couple of honest, documented rough edges on the Pi today:

  • Short-term conversational recall can degrade on the Pi. The forked worker's read-only history open can hit a sqlite locking-protocol error during prompt assembly. It's fail-soft — the turn still replies and still writes durable memory — but recent in-conversation context may be dropped for that turn. Long-term memory (the markdown knowledge tree) is unaffected.
  • The worker runs as your user. Real uid-drop / vault isolation for the fork worker is a hardening follow-on; today the service runs as the installing user.

Next: wire up and tune the face on The Screen, or review every environment variable on Configuration. For the design behind the fork-worker memory model, see Architecture.

Clone this wiki locally