Skip to content

Plugins

Shepherd can load private, out-of-repo extensions that run in-process inside the server. This lets you customize Shepherd for yourself — the motivating example is a claude-swap-style credential/account switcher that points each spawned agent at a different CLAUDE_CONFIG_DIRwithout committing that code to the public repo.

The plugin system (this loader, the ctx API, the status panel, this doc) is public and documented. Specific plugin implementations stay private under ~/.shepherd/plugins/.

Trust model. Plugins are trusted, single-author, in-process code — they run with the same privileges as the server. There is no sandboxing, signing, or capability enforcement in v1. Only run plugins you wrote or fully trust.

  • Plugins live in ~/.shepherd/plugins/ (override with SHEPHERD_PLUGINS_DIR). This sits alongside the Shepherd data dir, so plugins survive bun run update redeploys and can never leak into the public repo.
  • Each plugin is a self-contained folder: a plugin.json manifest + an entry module (+ its own package.json / node_modules if it needs dependencies; Bun resolves them locally).
  • At boot — after all core services exist and before the HTTP server accepts requests — Shepherd scans the dir (alphabetically), reads each manifest, import()s the entry, and calls register(ctx) once.
  • Load-at-boot, plus in-process activation for newly installed plugins. Every plugin loads once at boot (above). A freshly installed folder can also be activated in-process from Settings → Plugins (the Activate button / POST /api/plugins/manage/activate) — register(ctx) wires it into the live registry with no restart, since a never-imported folder has no module cache. What still needs a restart (systemctl --user restart shepherd): editing an already-loaded plugin’s code (no hot reload — its module is cached), hand-editing its config.json (read at load), and unloading one whose folder you removed. A plugin changing its own config through ctx.setConfig does not need a restart — that write updates ctx.config live.
  • A missing or empty plugins dir is a clean no-op: no hooks and /api/plugins/<id>/* returns 404 — a fresh clone behaves exactly as a stock Shepherd. The Settings → Plugins tab still renders (so you can install the first plugin), just with an empty list.

Settings → Plugins lists every plugin folder on disk and installs new ones from a GitHub URL — the same git clone … ~/.shepherd/plugins/ the terminal flow uses, reachable without a terminal and then activated in-process (no restart, below). It adds no capability a shell couldn’t already do; the trust model is unchanged, so install is gated behind a confirm dialog.

  • Install — paste an https://github.com/<owner>/<repo> URL and confirm. Shepherd shallow-clones it into ~/.shepherd/plugins/<repo>. Only github.com HTTPS URLs are accepted (no credentials in the URL); the clone runs with GIT_TERMINAL_PROMPT=0 so a private/typo’d URL fails fast. The cloned plugin.json is validated up front — a missing/invalid manifest, an apiVersion mismatch, or an id that collides with an already-installed/loaded plugin or the reserved manage segment is rejected and the clone removed. v1 clones the repo only — a plugin that ships its own dependencies still needs a manual bun install in its folder before it will load.
  • Activate in-process — usually no restart. A freshly installed plugin shows as installed · activate to load; click Activate to load it immediately — its routes, hooks and gear/UI go live with no restart. A plugin that ships its own dependencies fails to activate until you run bun install in its folder and then restart (the panel surfaces the failure + a restart hint).
  • Uninstall removes the folder. A symlinked install is unlinked (the link only — your source checkout is untouched). Uninstalling a still-loaded plugin removes the folder but it keeps running until the next restart (shown as loaded · removed, with the restart banner).
  • Management API (behind operator auth, reserved manage segment): GET /api/plugins/manage/installed, POST /api/plugins/manage/install ({ url }), POST /api/plugins/manage/activate ({ folder }), DELETE /api/plugins/manage/installed/<folder>.

Shepherd periodically checks each installed plugin for a newer released version and surfaces an update badge + a Plugin updates modal. A plugin is checkable when a source resolves one of two ways:

  • it declares a repository in its plugin.json (the primary path for a cp -r install with no local .git) — the highest semver tag on that repo is the candidate, read without a full clone (git ls-remote --tags + a checkout-less fetch of the tag’s plugin.json); or
  • its folder is a git checkout with an upstream (the symlink-to-checkout dev workflow) — the upstream tip’s plugin.json is the candidate, read after a git fetch that never touches the working tree.

The installed version is compared to the candidate by real semver; only a strictly-newer, apiVersion-compatible candidate is offered (a version bump that would be rejected at load is shown as incompatible, not update-available). Detection is read-only — it never mutates a folder on its own.

Applying (the modal’s Update / Update all buttons, or POST /api/plugin-update/apply { id }) is a re-verified fetch-and-swap on disk: a git checkout fast-forwards to its upstream (untracked config.json preserved); a repository install clones the latest tag into a scratch dir beside the folder, carries config.json over, and swaps it in behind a backup (a mid-swap failure restores the original). A symlinked install is refused — its source lives outside the plugins dir and is yours to update. After the swap Shepherd re-activates the plugin: if it wasn’t loaded it goes live immediately; if it was already running its old module is cached, so the panel reports a restart is owed (systemctl --user restart shepherd) to run the new code.

To ship an update your users can pick up: bump version in plugin.json (semver), then either push a matching git tag (for repository-declared installs — a leading v is fine, v1.3.0 and 1.3.0 both parse) or push to the branch your users’ checkout tracks (for the git-checkout workflow).

{
"id": "my-plugin",
"name": "My Plugin",
"version": "0.1.0",
"apiVersion": 1,
"capabilities": ["spawn", "events", "state", "routes", "status"],
"enabled": true
}
Field Required Meaning
id yes Stable unique id; namespaces routes (/api/plugins/<id>/…) and state.
name yes Display name in the status panel.
version yes Your plugin’s version string (semver). Drives the update check — bump it to publish an update.
repository no Declared update source, an https://github.com/<owner>/<repo> URL. Lets a cp -r install (no local .git) be update-checked against its highest semver tag.
apiVersion yes Plugin API version. Must equal the current PLUGIN_API_VERSION (1). A mismatch is skipped + surfaced as errored in the panel.
capabilities no Declared intent (spawn, events, state, routes, status, …). Unenforced in v1 — advisory/documentation; the hook a permission model bolts onto later.
enabled no Soft off-switch. false skips the plugin at load without removing the folder.

The entry module (index.ts/index.js, or package.json main) exports register, called once at boot. It may return an optional teardown function for clean shutdown.

import type { PluginContext } from "shepherd/plugins"; // (in-repo: src/plugins/types)
export function register(ctx: PluginContext): void | (() => void) {
ctx.log.log("hello");
return () => ctx.log.log("goodbye"); // optional teardown
}

Types. Out-of-repo plugins can author untyped (the entry runs fine without type imports) or vendor the type definitions from src/plugins/types.ts. The import type line is erased at runtime, so it never affects loading.

Two examples ship with the repo:

  • examples/plugins/spawn-labeler/ — the recommended copy-me reference: a fuller, documented plugin that returns a real SpawnPatch, has routes that read/write state, and publishes a non-trivial status payload. Start here. See the walkthrough below.
  • test/fixtures/example-plugin/ — the minimal skeleton: the bare-minimum wiring as a pure observer (its onSpawn returns nothing). It exists mainly to back the loader tests — reach for it only when you want the smallest possible starting point.

Neither auto-loads from the repo (the loader only ever scans ~/.shepherd/plugins/).

ctx is the sole seam between your plugin and core. Never import core modules — everything goes through ctx, so a future Shepherd can swap the implementation (curated / permission-scoped / out-of-process) without changing your call sites.

Capability What it does
ctx.onSpawn(fn) Mutate how an agent launches (see below). The load-bearing capability.
ctx.events.subscribe(fn) Observe the read-only core event stream (session:hold, session:status, …). Returns an unsubscribe fn. Plugins cannot emit core events.
ctx.publishStatus(json) Push a small free-form JSON blob to the status panel (rendered verbatim).
ctx.publishUI(view) Push a declarative UI view to the Settings → Plugins panel (null clears). Additive.
ctx.publishGearItem(item) Add a single item to the top-bar gear menu (null clears). Additive.
ctx.state Durable, per-plugin-scoped key/value: get/set/delete/keys. Values are JSON. Backed by a plugin_state table — you never touch the session schema.
ctx.sessions Read-only session lookup: get(id) / list() → a curated PluginSessionSnapshot. Resolves the bare ids that session:* events carry. Plugins cannot write sessions.
ctx.route(method, path, handler) Register an HTTP route under /api/plugins/<id>/<path>. Sits behind operator auth.
ctx.log Namespaced logger into shepherd.log (ctx.log.log / ctx.log.warn).
ctx.config Your plugin’s own config.json (parsed; {} when absent). Updated in place by setConfig, so the object you capture stays live.
ctx.setConfig(patch) Shallow-merge patch into your config.json and persist it (atomic; re-reads the file first). Throws on failure — never fails open. Additive.
ctx.abortSpawn(reason) Hard-block the in-flight spawn from inside an onSpawn hook (throws).

The event stream tells you that something happened, not what it happened to. Treat a session:status payload as { id, status } and nothing more — most emitters send exactly that, and while one currently sends the whole session row, a plugin that reads the extra fields is relying on an emitter detail, not on the contract. ctx.sessions is how you turn that id into something you can render.

ctx.events.subscribe((event, data) => {
if (event !== "session:status") return;
const { id, status } = data as { id: string; status: string };
if (status !== "done") return;
const s = ctx.sessions.get(id);
if (!s) return; // pruned between the event and the read
notifyMyChannel(
`${s.desig} (${basename(s.repoPath)}) is done` + (s.pr ? ` — PR #${s.pr.number}` : ""),
);
});
  • get(id) → the snapshot, or null when no such session exists.
  • list() → every session core holds, archived rows included (filter on status === "archived" / archivedAt if you only want live ones).

Reads are live, not a boot-time freeze — hold the object from register() and keep calling it. Snapshots are point-in-time copies; mutating one does nothing.

The snapshot is curated, not the internal row. It carries id, desig, name, repoPath, baseBranch, branch, status, model, agentProvider, issueNumber, haltReason, createdAt, updatedAt, archivedAt, and a pr block (state/number/url/title/checks/isDraft) projected from the cached forge state. It deliberately withholds prompt, worktreePath, spawnAccountDir and launchMetadata — task text and account paths widen the trust surface with no read-side use case, and returning the row verbatim would make every future internal field an accidental part of this contract.

pr is null when there is no cached git state at all (never polled, or archived — the cache holds only non-archived sessions). That is not the same as a polled pr.state === "none", which means “we looked, there is no PR yet”.

Additive API. Older cores don’t expose it — guard with typeof ctx.sessions?.get === "function" if your plugin must run on both.

onSpawn fires just before each agent launches, on both initial create and resume (autopilot/automerge/manual). It receives a read-only descriptor and may return a bounded patch.

ctx.onSpawn((d) => {
// d: { sessionId, repoRoot, model, agentProvider, argv, env, isolated }
return { env: { CLAUDE_CONFIG_DIR: pickAccountDir(d.sessionId) } };
});

Descriptor (SpawnDescriptor) is a copy — mutating it does nothing.

  • d.env is advisory: it’s the explicit env overlay Shepherd will set on top of the inherited process environment, not the full environment the agent ends up seeing. Under the trusted profile the agent additionally inherits the parent env.

Patch (SpawnPatch) — return one (or nothing):

Field Effect
env Shallow-merged into the spawn env, last — so it wins over Shepherd’s defaults, including api-key mode’s credential-less-mirror CLAUDE_CONFIG_DIR. Reaches the agent under every sandbox profile.
extraArgs Appended to the inner agent argv. Cannot rewrite core argv (the structural flags that make Shepherd’s spawn work).
credentialDir Convenience for env.CLAUDE_CONFIG_DIR; overrides it when both are set.

model is deliberately not patchable in v1. Overriding the spawn model would diverge the stored session.model from the actually-spawned model and break cost replay. It is a documented future field, not yet implemented.

Multiple plugins run in registration order (load order, then within-plugin order); patches merge sequentially, last-write-wins on conflicting keys (logged).

  • Fail-open by default. If a hook throws or exceeds its 5-second timeout, that patch is dropped, the plugin is marked errored/timed-out in the panel, and the spawn proceeds — Shepherd stays resilient.
  • Opt into hard-blocking with ctx.abortSpawn(reason): the spawn is refused. On create the request fails (and the worktree is rolled back); on a non-forced resume it resolves to “can’t resume” (the session’s existing state is preserved). Use this when running under the wrong footing is worse than not running — e.g. “if I can’t set the right credentials, do not spawn under the default account.”
  • Caveat — forced resume. A forced resume tears down the live agent before hooks run, so an abortSpawn there leaves the session stopped (there’s no live agent left to preserve). That’s intended: a forced resume is an explicit “replace the live agent”, and aborting it honors “don’t run under the wrong footing” by not spawning a replacement.

The server is one Bun event loop that also pumps the web terminal. A synchronous, blocking call (heavy execFileSync/readFileSync, a tight CPU loop) freezes typing for every connected operator. Your plugin runs on that same loop, so follow the same rule core services do:

  • Do async I/O. Use await/promises, not synchronous exec/fs on the hot path.
  • onSpawn is async and 5-second-bounded — do credential prep (copying token files, etc.) with async I/O inside that budget.
  • Shepherd guards against a slow/throwing hook (timeout + try/catch), but it cannot protect against a plugin’s own synchronous infinite loop — that’s a bug you’d fix.

Loaded plugins appear under Settings → Plugins (hidden entirely when none are loaded): one row per plugin with its name, version, a health badge (ok / errored / timed-out, derived by core and unspoofable — a plugin cannot report its own health), the last error, and the expandable JSON from your last ctx.publishStatus(...).

publishStatus emits a plugin:status event over the existing /events WebSocket, so the panel updates live.

ctx.publishUI(view) pushes a declarative UI descriptor to the plugin’s card in Settings → Plugins. The view must conform to PluginUIView:

// Guard — additive API, absent on older cores.
if (typeof ctx.publishUI === "function") {
ctx.publishUI({
schemaVersion: 1,
slot: "settings-panel",
title: "My plugin stats",
root: { type: "text", props: { text: "Hello from plugin." } },
});
}
  • null clears the last-published view.
  • slot must be "settings-panel" (v1 renders only this slot; the other two reserved values — "session-sidebar" and "dashboard-card" — pass validation but are not yet rendered; any other string fails validation and the view is dropped).
  • String props render verbatim — plugin data, not i18n keys.
  • Validation is fail-open: size-capped (64 KB), max depth 16, max 256 nodes, max 500 children per node; array values in props are also capped at 500 entries. Invalid views are silently dropped; the prior view is kept.

Editable settings — input nodes + a submitting button

Section titled “Editable settings — input nodes + a submitting button”

Four input nodes let an operator type a setting: text-input, select, checkbox and number. They never POST on their own. Each one contributes a named field to the body of an action-button that opts in with submit: true — so “POST a plugin-authored body to your own route” stays the only network shape, and the button’s namespace scoping applies unchanged.

ctx.publishUI({
schemaVersion: 1,
slot: "settings-panel",
title: "Bridge settings",
root: {
type: "stack",
children: [
{ type: "text-input", props: { name: "relayUrl", label: "Relay URL", value: cfg.relayUrl } },
{ type: "text-input", props: { name: "keyEnv", label: "Token env var", value: cfg.keyEnv } },
{
type: "select",
props: {
name: "verbosity",
label: "Verbosity",
value: cfg.verbosity,
options: [
{ value: "quiet", label: "Quiet" },
{ value: "loud", label: "Loud" },
],
},
},
{
type: "action-button",
props: {
label: "Save settings",
submit: true, // ← fold the fields above into the body
route: { method: "POST", path: "config" },
body: { section: "relay" }, // optional constants
},
},
],
},
});
// The route your own plugin already owns receives them:
ctx.route("POST", "config", async (req) => {
const { section, relayUrl, keyEnv, verbosity } = await req.json();
// …validate, then persist — see ctx.setConfig below.
return new Response("Saved");
});
Node Props Submits
text-input name, label?, value?, placeholder?, secret? a string
select name, label?, value?, options: { value, label? }[] a string
checkbox name, label?, value? a boolean
number name, label?, value?, placeholder? a number, or null

Contract details worth knowing before you build a panel:

  • Scope is the whole view. All fields in one publishUI call land in one bucket, and every submit: true button in that view sends the whole bucket. A button that omits submit is unaffected — that is how you keep an unrelated action (“Test connection”) from carrying them.
  • Fields win over body. On a key collision the operator’s field replaces the static constant, because the field is the live value.
  • name must match /^[A-Za-z0-9_.-]{1,64}$/ and be unique across the view. A duplicate makes the body non-deterministic, so the whole view is rejected.
  • value seeds, and re-seeds only on change. Re-publishing your panel on a timer will not clobber what the operator is typing; re-publishing with a different value (e.g. right after a save) does snap the field to the stored truth. So publish the freshly-saved config back and the panel corrects itself.
  • select always submits a valid option. An absent or unknown value falls back to the first entry in options.
  • number submits null when the field is empty or not a number — never NaN. Range is yours to validate in the route handler; the host does not clamp.
  • secret masks, it does not protect. The value still travels as plaintext JSON to your route (as does everything else); it only keeps a token off the screen.
  • Autofill is off on every input node, and you need do nothing to get it. A panel is a configuration surface, so the host renders each control with autocomplete disabled, a meaningless name (the value you receive is still keyed on the name you declared — the rendered one is decorative), and the opt-out attributes 1Password, Bitwarden, LastPass, Dashlane and Proton Pass honour. Without this a secret field beside a plain one looks exactly like a sign-in form, and a manager fills the plain field with a stored username — which is how an npub once landed in a field meant to hold an environment variable’s name. Do not try to re-implement this from plugin side; you cannot set attributes on host controls.
  • label is verbatim plugin data, never an i18n key. Omit it and the field’s name becomes its accessible name.

ctx.setConfig(patch) shallow-merges patch into your plugin’s own config.json and persists it. This is what a “Save settings” route handler should call — so ctx.config stays the single source of truth and you never need a ctx.state overlay shadowing it.

// Guard — additive API, absent on older cores.
ctx.route("POST", "config", async (req) => {
const body = await req.json();
if (typeof body.relayUrl !== "string") return new Response("bad relayUrl", { status: 400 });
if (typeof ctx.setConfig !== "function") return new Response("core too old", { status: 501 });
try {
await ctx.setConfig({ relayUrl: body.relayUrl });
} catch (e) {
return new Response(`Could not save: ${(e as Error).message}`, { status: 500 });
}
ctx.config.relayUrl; // ← already the new value
publishPanel(); // re-publish so the fields re-seed from the persisted truth
return new Response("Saved");
});
  • ctx.config updates in place. The object you captured during register() stays live — never cache a copy of it and read that instead.
  • The file is re-read before merging, so an edit an operator made by hand since boot is merged, not silently overwritten. Keys you do not mention are untouched.
  • It throws; it does not fail open. Unlike publishUI (where a dropped push is cosmetic), a config write that silently no-ops is data loss. Expect a rejection for a non-object or non-serializable patch, an oversized result (64 KB), or a write error — and deliberately when config.json exists but is unparseable, so a half-edited file is never clobbered.
  • Writes are atomic and serialized per plugin: temp file + rename, and concurrent calls queue rather than interleaving their read-modify-write.
  • Only config goes live this way. Changing your plugin’s code still needs a restart — the module stays cached.

ctx.publishGearItem(item) contributes one item to the top-bar gear menu. Each plugin may publish at most one item; the latest publish wins; null clears it.

// Guard every publishGearItem call — additive API, absent on older cores.
if (typeof ctx.publishGearItem === "function") {
ctx.publishGearItem({
label: "My plugin", // required; ≤ 80 chars, non-empty
icon: "🔧", // optional; ≤ 8 chars
action: { kind: "panel" },
});
}

panel — opens Settings → Plugins, scrolled to this plugin’s card. Works even if the plugin publishes no publishUI view, since the card always renders.

action: {
kind: "panel";
}

route — calls the plugin’s own /api/plugins/<id>/<path> route (must be registered via ctx.route) and shows the response text (≤ 200 chars) as a toast. method is "GET" or "POST".

action: { kind: "route", method: "GET", path: "stats" }
// fires GET /api/plugins/<your-plugin-id>/stats

url — opens an absolute URL in a new browser tab.

action: { kind: "url", href: "https://your-dashboard.example.com" }

Validation is fail-open — an invalid item is silently dropped and the prior item kept. Validation rules (enforced by the server; plugins are trusted authors but bad items are ignored):

  • label — required; non-empty after trim; ≤ 80 chars.
  • icon — optional; if present, must be a string ≤ 8 chars.
  • route path — non-empty, ≤ 256 chars; only [A-Za-z0-9._/-]; no leading /; no .. segments.
  • url href — must parse as a valid URL with http: or https: protocol. javascript:, data:, and relative paths are rejected.
  • Total payload: ≤ 8 KB.

Label, icon, and route response text are verbatim plugin DATA — they render as-is, are never i18n keys, and receive the same treatment as PR titles and tool-use summaries.

Always guard with typeof ctx.publishGearItem === "function" — older Shepherd builds that predate this capability simply don’t expose the method; the guard makes the plugin forward- and backward-compatible without a version check.

ctx.route("GET", "status", handler) serves at GET /api/plugins/<id>/status. All plugin routes sit behind operator auth (the same cookie/token gate as the rest of /api). An unknown plugin or sub-route returns 404.

The skeleton fixture shows the wiring; examples/plugins/spawn-labeler/ shows the seam doing real work. It stamps every spawned agent with a per-repo label env var (e.g. SHEPHERD_SPAWN_LABEL=shepherd#3 — “the 3rd agent spawned in the shepherd repo”). It’s a deliberately benign, public-safe analog of the private claude-swap env-injection seam — same onSpawn → { env } mechanic, no credential logic. Read it end to end; the highlights:

A real SpawnPatch from onSpawn. It increments this repo’s spawn count, formats a label, and returns an actual env overlay the agent then sees. The label is built only from SpawnDescriptor fields{repo} = basename(d.repoRoot), {n} = the per-repo count, {session} = d.sessionId:

ctx.onSpawn((d): SpawnPatch => {
const repo = basename(d.repoRoot);
const n = (repoCounts[repo] ?? 0) + 1; // noUncheckedIndexedAccess → guard the read
repoCounts = { ...repoCounts, [repo]: n };
const label = template
.replaceAll("{repo}", repo)
.replaceAll("{n}", String(n))
.replaceAll("{session}", d.sessionId);
ctx.state.set("repoCounts", repoCounts); // spawns are infrequent → a write here is fine
return { env: { [envVar]: label } };
});

Routes that read and write state. GET stats returns the live counters; POST reset clears them — so persisted state is both surfaced and mutated over HTTP, behind operator auth:

GET /api/plugins/spawn-labeler/stats → { envVar, labelTemplate, totalSpawns, repos, lastSpawn }
POST /api/plugins/spawn-labeler/reset → { ok: true, cleared: true }

A non-trivial publishStatus payload. Instead of a bare counter it publishes the config in effect plus live totals, the per-repo breakdown, and the last spawn — all rendered in the Settings → Plugins panel:

{
"envVar": "SHEPHERD_SPAWN_LABEL",
"labelTemplate": "{repo}#{n}",
"totalSpawns": 4,
"repos": { "shepherd": 3, "ui": 1 },
"lastSpawn": { "sessionId": "", "repoRoot": "", "label": "shepherd#3", "at": "" },
}

Driven by config.json. ctx.config (the folder’s config.json, parsed) overrides the env var name (envVar) and label template (labelTemplate); both default sensibly when absent.

Copy it to run it. cp -r examples/plugins/spawn-labeler ~/.shepherd/plugins/ (or ln -s "$PWD/examples/plugins/spawn-labeler" ~/.shepherd/plugins/ to run it straight from a checkout — the loader follows symlinked plugin dirs, so git pull keeps it current), then — because the example’s import type uses a repo-relative path that won’t resolve out-of-repo — drop the import type line or vendor src/plugins/types.ts (the import is erased at runtime, so loading is unaffected either way), then load it — Activate it in Settings → Plugins (no restart) or restart Shepherd. See examples/plugins/README.md.