Skip to content

Operating Shepherd

Shepherd runs as a systemd user service — as your own user, so it keeps your claude subscription login, ~/Work, and herdr. The installer sets this up on Linux. It binds to loopback only (SHEPHERD_HOST=127.0.0.1).

Terminal window
systemctl --user status shepherd # check it
systemctl --user restart shepherd # restart it

The unit runs straight from the working tree, so whatever is checked out is what runs.

Reach Shepherd over the network by putting it behind a trusted proxy — e.g. Tailscale:

Terminal window
tailscale serve --bg 7330 # → https://<host>.<tailnet>.ts.net proxies to 127.0.0.1:7330

A Tailscale-served HUD needs no allowlist step: at startup Shepherd folds every host that tailscale serve status shows fronting its port into the CSRF origin allowlist — the node’s own tailnet name (a direct tailscale serve, as above) as well as a Tailscale Service front (e.g. svc:shepherdshepherd.ts.net). Only a front that doesn’t appear in tailscale serve status — a non-Tailscale reverse proxy or custom-DNS name — still needs its hostname added to SHEPHERD_ALLOWED_HOSTS.

Access control is layered: the network reach is gated by tailnet membership, and the app itself is gated by a single-operator password. The password is exchanged for an HMAC-signed session cookie that covers every HTTP route plus the live /events and /pty WebSocket channels. Set it with SHEPHERD_PASSWORD; if you leave it unset, Shepherd generates a strong one on first boot and prints it to the log once (systemctl --user status shepherd / journalctl --user -u shepherd) — change it by setting SHEPHERD_PASSWORD in ~/.shepherd/env and restarting.

Per-deployment overrides (password, token, repo root, alternate hosts) go in ~/.shepherd/env (KEY=value lines), read by the unit if present. See Configuration for the full list.

The unit runs from the working tree, so to deploy local changes in one shot (install deps → build UI → restart → health check):

Terminal window
bun run update # deploy the current working tree (warns if dirty / off main)
bun run update --pull # fast-forward main from origin first (skip on a dev==prod box)

It is idempotent and safe to re-run — sessions survive the restart (herdr owns the PTYs). UI-only changes don’t strictly need it: a fresh cd ui && bun run build is served on the next request, since the core reads ui/build from disk per request.

On Linux the installer/provision step also enables a second systemd user timer — shepherd-backup.timer (OnCalendar=hourly) — that snapshots the SQLite state DB out-of-process from the server (read-only VACUUM INTO → integrity check → gzip → atomic rename → GFS rotation). Snapshots land in ~/.shepherd/backups/ by default (override SHEPHERD_BACKUP_DIR).

Terminal window
systemctl --user status shepherd-backup.timer # check the timer
journalctl --user -u shepherd-backup # per-run logs
deploy/shepherd-restore.sh # list snapshots, then restore one
deploy/shepherd-restore.sh <file> # restore a specific shepherd-*.db.gz

The server’s daily sweep also runs a read-only staleness probe: on a host that’s expected to back up, if the newest snapshot is missing or older than 3h it logs a warning, records a durable backup_stale signal, and sends a best-effort web-push alert. macOS / core-only hosts have no backup timer and stay silent. Full details are in the backups runbook.

Live previews depend on Shepherd being able to see the processes an agent starts. How it looks depends on the host: on Linux it reads /proc live; on macOS it runs a single lsof call per refresh and answers every probe from that short-lived snapshot. On any other OS there is no backend at all.

The Preview detection row in Settings → Diagnose makes that visible instead of leaving a preview that silently never appears. It warns when the backend can’t run on this host, when the OS isn’t supported, and when the snapshot has gone stale (the last inspection didn’t finish in time, so previews may be delayed or missing); it stays ok when detection works. It never escalates to an error — previews are a convenience, so a broken one shouldn’t turn the health pip red. If the check itself can’t complete, the row reports that it couldn’t be verified rather than degrading the pip. Starting a dev server from the UI on a host where detection is dead or stale still starts it, but you get an alert pointing here instead of a “started” toast, because the preview can never bind.

Detection also backs sweeps that are more consequential than previews. When process data is unknown, Shepherd skips rather than acting on it: session liveness stays unreported (no husk/stranded verdict, no auto-revive) and the boot/daily worktree reap is skipped — it logs worktree reap skipped (live cwds unknown) and still runs the package-manager store reclaim, which doesn’t depend on process data. Idle-stop behaves the same way; see SHEPHERD_PREVIEW_IDLE_STOP_MS in Configuration.

If claude or codex is managed by mise, Shepherd works with that install instead of planting a second one beside it.

Installing. When a claude/codex check is red and mise which <tool> resolves, the Fix button symlinks the mise binary into ~/.local/bin rather than running the vendor installer. mise exposes tools through a shims dir that isn’t on the systemd unit’s PATH, and mise activate only runs in interactive shells — so the binary existed, it was just unreachable. ~/.local/bin is on the unit’s PATH. Hosts without mise, and tools mise doesn’t manage, get the vendor installer exactly as before.

Updating. When mise owns the codex on PATH — a shim, an install path, or a symlink resolving to one — the codex update runs mise upgrade codex and nothing else. mise upgrade respects the version request in your mise config, so a pinned codex won’t advance; Shepherd then reports the update as not converged and points you at mise use -g codex@latest rather than falling back to codex update or npm install -g, either of which would install a second codex shadowing the mise-managed one.

Claude Code has no Shepherd-side updater (it self-updates), so a mise-managed claude is upgraded by you: mise upgrade claude.

Claude Code self-updates by writing a native install into ~/.local/share/claude/versions and repointing ~/.local/bin/claude at it. On a host where mise also manages claude, that native copy shadows the mise-managed one: the two drift apart, and mise upgrade claude then moves only the install nothing runs.

Two things happen on a host where mise owns the claude Shepherd spawns:

  • Shepherd sets DISABLE_AUTOUPDATER=1 on agent spawns, so Claude Code stops re-planting the native copy. mise upgrade claude is the update path from then on. This only applies when mise really does own what runs — a host without mise keeps self-updating exactly as before, so nothing gets stranded on an old build.
  • The claude install row in Settings → Diagnose reports the state. It warns when the claude on PATH and the one mise manages report different versions (naming both, so you can see which one Shepherd actually spawns); when the versions agree but what runs is still the native copy, which mise can’t advance, so the two will drift again; and when mise really does own what runs but a leftover native tree is sitting on disk — those builds are several hundred MB each. The row is never ok unless the pin is in effect, so it can’t claim a pin that wasn’t applied — though the reverse doesn’t hold: the leftover-tree warning is a pinned state too, since mise does own what runs there and it’s only the old files that need clearing.

Ownership is decided by comparing what claude --version reports against the mise-managed binary’s own --version, not by inspecting paths — so a shim, a ~/.local/bin symlink and a launcher script that execs mise x claude all read correctly.

The row has no Fix button by design: repointing ~/.local/bin/claude would clobber a launcher script you may have put there on purpose, and deleting a several-hundred-MB install tree is your call, not Shepherd’s. On a host where mise doesn’t manage claude the row is absent entirely.

Shepherd’s startup self-test proves that bubblewrap can build a sandbox on this host — it runs node and git through the real membrane. It deliberately does not launch the agent binary, because a null verdict there means “run unconfined”, and folding a launcher fault into it would silently strip the sandbox from exactly the hosts that can sandbox.

A separate check answers the other question: does claude / codex actually start inside the membrane? A version manager that rebuilds its shims directory against a read-only bind, for instance, exits non-zero at launch — so every confined helper (plan reviewer, PR critic, doc agent, maintain diagnosis, standalone critic) died at launch, waited out its whole timeout and reported no verdict, while the sandbox row stayed green.

The Agent launch in sandbox row in Settings → Diagnose reports it, and a broken verdict also blocks: a wrapped helper spawn is refused up front with a stated reason instead of hanging to its timeout — per binary, so a broken codex doesn’t stop claude roles. It is fail-open: only a non-zero exit counts as broken; a probe that throws or times out is reported as un-inspectable and spawns proceed. The launcher’s own output holds host paths, so it goes to Shepherd’s log rather than into the row.

The row is guidance-only — no Fix button, because the repair is host-side toolchain surgery (which manager owns the binary, which path it rewrites). It is absent on a host with no sandbox backend (nothing is wrapped) and for an agent CLI that isn’t on PATH (the claude / codex rows already say so). Hitting Re-run probes fresh rather than reading a cached verdict, and the refusal path reads the same result — so a repaired host un-blocks its confined helpers as soon as the row goes green.

Shepherd keeps spawned agents’ Node compile cache off the /tmp tmpfs and runs an inode-guard sweep on startup + daily that, once /tmp inode use crosses a threshold, drops the compile cache and stale regenerable tool caches (but never a live session’s scratch). As a host-level belt on long-uptime hosts, raise /tmp’s nr_inodes in /etc/fstab:

tmpfs /tmp tmpfs nr_inodes=4194304 0 0

The relevant override env vars (SHEPHERD_NODE_COMPILE_CACHE, SHEPHERD_TMP_INODE_PCT, SHEPHERD_TMP_STALE_HOURS, SHEPHERD_TMP_SWEEP_DIR) are listed in Configuration.

The Temp filesystem inodes row in Settings → Diagnose surfaces this live: it warns at SHEPHERD_TMP_INODE_PCT (the same threshold that gates the sweep) and errors at 95% by default. The bands stay ordered: if you raise the knob above 95 the error band rises with it, so the row never alarms below the line you set. This matters because inode exhaustion is easy to misdiagnose — writes start failing with “no space” errors while df -h still shows the volume mostly empty. df -i is what shows the real cause.

Its Fix button runs the sweep immediately, ignoring the usage threshold. It reclaims what Shepherd owns — the compile cache and stale tool caches — so the row can legitimately stay non-OK right after you click it: the two largest consumers when an agent has run a dependency install in the temp filesystem — a leftover git worktree and the forked package-manager (pnpm) store it pinned — are not touched by the on-demand Fix button. They are reclaimed by the background sweep that runs at boot and daily: abandoned agent worktrees are reaped, then the forked pnpm store is partially reclaimed — under inode pressure it unlinks the store content nothing still references and prunes the emptied bucket dirs, while keeping content a surviving worktree still hardlinks. So a store that is still partly pinned frees its unlinked fraction rather than nothing at all.

Settings → DIAGNOSE includes a Host capacity check. On a systemd-managed host it warns when Shepherd’s unit has no memory or CPU guardrails (MemoryMax / MemoryHigh / CPUQuota), so a runaway fan-out of sessions can starve the box. Add limits to the unit — or to a dedicated slice such as shepherd.slice — before running many concurrent sessions.

On a user-scoped install the check covers both units: Shepherd’s own unit and herdr.service, which runs your agent sessions. It stays ok only when Shepherd is bounded and herdr is not positively unbounded, and it raises a distinct warning for the case where Shepherd is bounded but herdr is not. A herdr that isn’t a loaded user unit (absent, masked, or system-scoped) can’t be read, so it’s excluded from the verdict rather than reported as unbounded. The check also errors when the kernel reports dangerous live memory/IO pressure (PSI), a cue to pause or reduce active agent sessions until the host recovers. On non-systemd or local dev hosts it stays quiet. Because sustained pressure is steady-state, the background re-check is not accelerated on this error — use the Diagnostics Re-run button for an on-demand live reading.

On a user-scoped install with at least 6 GiB of RAM, the warning carries a Fix button. It proposes a conservative, host-derived pair — MemoryHigh leaving clamp(15%, 2 GiB, 8 GiB) of RAM headroom, CPUQuota leaving min(1 core, 15% of cores) for the OS — and shows the exact values and units in a confirm modal before anything is applied. Applying runs systemctl --user set-property on only the units that are currently unbounded, so a limit you set deliberately is never overwritten. Like the copy-paste command below, it is live and persistent with no restart and no interrupted sessions.

It bounds each unit on its own; it does not create the shared cap across Shepherd and herdr that a slice gives you — for that, use the slice setup below.

Shepherd installs as a systemd user service (shepherd.service), so you can add a live, persistent guardrail without sudo or a restart. set-property writes a drop-in under ~/.config/systemd/user.control/ and applies it immediately:

Terminal window
# Tune the values to the host — e.g. MemoryHigh a few GB below total RAM,
# CPUQuota to leave headroom for the OS and other services. 300% = 3 cores.
systemctl --user set-property shepherd.service MemoryHigh=6G CPUQuota=300%

Setting any of MemoryHigh, MemoryMax, or CPUQuota on a unit marks that unit bounded. MemoryHigh throttles and reclaims before the harder MemoryMax OOM-kill ceiling, so it’s the safer first lever.

On a user-scoped install the command above is not enough on its own to clear the Host capacity warning: agent sessions run under herdr, a separate unit, so a limit on shepherd.service alone bounds Shepherd but not the sessions that actually consume the box — and where herdr is a loaded user unit, the check keeps warning until it is bounded too. Repeat the set-property for it:

Terminal window
systemctl --user set-property herdr.service MemoryHigh=6G CPUQuota=300%

For real protection, prefer a shared slice — a per-unit limit on each of the two still lets them add up to twice the ceiling. Put both units in one slice and limit the slice instead:

~/.config/systemd/user/shepherd.slice
[Slice]
MemoryHigh=12G
CPUQuota=600%
# ~/.config/systemd/user/shepherd.service.d/slice.conf (same for herdr.service)
[Service]
Slice=shepherd.slice

Slice= is assigned at unit load, not via set-property, so this pair needs a reload + restart to take effect (the restart briefly drops the HUD):

Terminal window
systemctl --user daemon-reload
systemctl --user restart herdr.service shepherd.service

A system-level install (unit under /etc/systemd/system/) takes the same properties via sudo systemctl set-property … or an equivalent drop-in there.