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_DIR — without 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.
Location & loading
Section titled “Location & loading”- Plugins live in
~/.shepherd/plugins/(override withSHEPHERD_PLUGINS_DIR). This sits alongside the Shepherd data dir, so plugins survivebun run updateredeploys and can never leak into the public repo. - Each plugin is a self-contained folder: a
plugin.jsonmanifest + an entry module (+ its ownpackage.json/node_modulesif 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 callsregister(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 itsconfig.json(read at load), and unloading one whose folder you removed. A plugin changing its own config throughctx.setConfigdoes not need a restart — that write updatesctx.configlive. - Bundled plugins ship inside Shepherd (
src/plugins/bundled/) and load after the plugins dir, so an installed plugin with the sameidwins. They keep their settings inctx.state(their folder is the source tree, soctx.configis{}andctx.setConfigrejects) and can’t be uninstalled. Today: Sentry and CI Watch, each off until enabled in its panel. - 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.
Installing from the UI
Section titled “Installing from the UI”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>. Onlygithub.comHTTPS URLs are accepted (no credentials in the URL); the clone runs withGIT_TERMINAL_PROMPT=0so a private/typo’d URL fails fast. The clonedplugin.jsonis validated up front — a missing/invalid manifest, anapiVersionmismatch, or anidthat collides with an already-installed/loaded plugin or the reservedmanagesegment is rejected and the clone removed. v1 clones the repo only — a plugin that ships its own dependencies still needs a manualbun installin 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 installin 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
managesegment):GET /api/plugins/manage/installed,POST /api/plugins/manage/install({ url }),POST /api/plugins/manage/activate({ folder }),DELETE /api/plugins/manage/installed/<folder>.
Updates — in place, one click
Section titled “Updates — in place, one click”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
repositoryin itsplugin.json(the primary path for acp -rinstall 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’splugin.json); or - its folder is a git checkout with an upstream (the symlink-to-checkout dev workflow) —
the upstream tip’s
plugin.jsonis the candidate, read after agit fetchthat 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).
Manifest (plugin.json)
Section titled “Manifest (plugin.json)”{ "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. |
Entry contract
Section titled “Entry contract”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. Theimport typeline 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 realSpawnPatch, has routes that read/writestate, 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 (itsonSpawnreturns 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/).
The ctx capability seam
Section titled “The ctx capability seam”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.issues |
Create / close / read forge issues: create(repo, {title, body, labels?, untrusted?}), close(repo, n, comment?), get(repo, n). Untrusted text is fenced by core. Additive. |
ctx.forge.runs |
Default-branch CI runs: listDefaultBranchRuns(repo, {sinceId?, limit?}), getRun(repo, id), failedJobLogs(repo, id, {maxLinesPerStep?}), rerunFailed(repo, id). Additive. |
ctx.judge.choice(opts) |
Ask the operator’s decision model (“JEV”) to pick one of N options; returns { choice, probabilities }. Off unless the judge is on; shares its daily ceiling. Additive. |
ctx.agents.runReadonly(opts) |
Run one read-only diagnosis agent over a managed repo and get back its schema-validated JSON result (see below). Capped per plugin. Additive. |
ctx.repos.list() |
Read-only list of repos under the repo root: { path, name, slug?, autoLabel, lightweight }[]. path is the form ctx.issues / ctx.agents accept; slug is the forge owner/repo (absent for lightweight/no-remote repos). Additive. |
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.schedule(ms, fn) |
Run an async fn on a server-owned interval; paused during herdr maintenance, cleared on teardown. Returns a cancel fn. Additive — see below. |
ctx.secrets |
Per-plugin secret store: get(key) / set(key, value | null). 0600 file, never served to the UI. Additive — see below. |
ctx.abortSpawn(reason) |
Hard-block the in-flight spawn from inside an onSpawn hook (throws). |
Reading sessions (ctx.sessions)
Section titled “Reading sessions (ctx.sessions)”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, ornullwhen no such session exists.list()→ every session core holds, archived rows included (filter onstatus === "archived"/archivedAtif 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.
Filing issues (ctx.issues)
Section titled “Filing issues (ctx.issues)”A plugin that turns an outside signal (an error tracker, a monitor) into backlog work files it
as a forge issue, which the normal drain then picks up. repo is a repo path under
Shepherd’s repo root — the same value as PluginSessionSnapshot.repoPath.
const { number, url } = await ctx.issues.create("/home/me/Work/app", { title: `Sentry ${shortId}: unhandled TypeError in checkout`, // plugin-authored, NOT event.message body: "Filed by the Sentry plugin. 42 events in 1h.", labels: ["sentry"], untrusted: [ { label: "sentry message", content: event.message }, { label: "stack trace", content: trimmedFrames }, ],});
const issue = await ctx.issues.get(repo, number); // { number, title, body, url, labels, state } | nullawait ctx.issues.close(repo, number, "Resolved in Sentry."); // comment first, then closetitleandbodyare TRUSTED. The drain uses the title verbatim as the spawned agent’s task, outside any fence. Compose it yourself from values you control (ids, counts, your own wording) and never paste third-party text into it. Core collapses newlines/control characters in the title to spaces and scrubs fence markers, but that is a backstop, not a fence.untrustedis fenced by core, never by you.bodycomes first; each section follows in its own⟦UNTRUSTED:<label>:<nonce>⟧ … ⟦/UNTRUSTED:<label>:<nonce>⟧fence with a nonce the content can’t predict. Anything a third party wrote (error messages, user reports, stack traces) belongs here, not inbody. Fence markers already present inbodyor a section are scrubbed, so nothing can close a fence early. When the drain later spawns from the issue, core fences the whole issue body again, and the inner markers show up as[fence-token removed]. The content is still fenced.- Labels are best-effort. Each one is stamped after creation (and created on the host if missing). A label failure is logged. The issue is still returned.
getreturnsnullwhen the issue is gone or the forge read failed transiently. Don’t treatnullalone as proof of deletion.stateis"open"/"closed", ornullwhen the host didn’t report one.- Limits: title 1–200 chars; ≤ 20 labels of 1–50 chars without
,or newlines; ≤ 20 untrusted sections, labels matching[A-Za-z0-9_ .#:-]{1,64}; composed body ≤ 60 000 chars.
Refusals reject with an error whose name is "PluginIssuesError" (plugins can’t import
core, so match on the name) and whose code says why:
code |
Meaning |
|---|---|
invalid-repo |
repo is not an existing directory under the repo root. |
invalid-input |
Bad title, labels, sections, issue number or comment. Nothing was sent. |
no-forge |
No forge resolves for the repo (or this core has no issue wiring). |
lightweight |
A lightweight (local-only git) repo. It has no issue backlog. |
unsupported |
The host lacks the API, e.g. close with a comment on a host that can’t comment. |
Forge failures (network, auth, rate limit) propagate as ordinary errors.
Additive API. Guard with
typeof ctx.issues?.create === "function"if your plugin must run on older cores.
CI runs (ctx.forge.runs)
Section titled “CI runs (ctx.forge.runs)”Inspect GitHub Actions (or Gitea Actions) runs on a repo’s default branch, e.g. to notice a
scheduled job that started failing. repo is a repo path, as on ctx.repos.list().
const { runs, cursor } = await ctx.forge.runs.listDefaultBranchRuns(repo, { sinceId: ctx.state.get("cursor") ?? 0,});for (const run of runs) { // run: { id, workflowName, workflowFile, event, status, conclusion, attempt, headSha, // createdAt, url, jobs: [{ id, name, conclusion }] } if (run.conclusion !== "failure") continue; const logs = await ctx.forge.runs.failedJobLogs(repo, run.id); // [{ job, step, lines, truncated }] // …fence `logs` as untrusted before it reaches an issue or an agent}ctx.state.set("cursor", cursor);- Cursor.
listDefaultBranchRunsreturns only completed runs with an id abovesinceId, ascending, at mostlimit(default 20, max 50). Storecursorand pass it back assinceId. The cursor never moves past a run that is still in flight: newer completed runs are held back until it finishes, so a slowtestrun that ends after a fastlintrun from the same push is never skipped. A run still in flight after 24 hours stops holding the cursor and is skipped. - Bounded history. GitHub is read one page per call (100 runs); Gitea pages back up to 500 jobs, and a run whose jobs may continue past that window is left out rather than reported half-seen. Poll often enough that less than that completes between calls, or the oldest runs are missed.
- Raw strings.
event(push,schedule,workflow_dispatch, …),statusandconclusion(failure,cancelled,startup_failure, …) are the host’s own values.conclusionisnullfor a job that hasn’t finished. getRun(repo, id)re-reads one run with its jobs, ornullwhen unknown. A rerun keeps the runidand bumpsattempt, so use this to watch a rerun finish.failedJobLogsreturns the lastmaxLinesPerSteplines (default 200, max 500) of every failed step. ANSI codes and timestamps are stripped and secret-shaped text (tokens,*_KEY=…, bearer headers, URL credentials) is masked on top of the host’s own***masking. The lines are still untrusted CI output: pass them asuntrustedsections toctx.issues.createorctx.agents.runReadonly, never into a title, body or prompt.rerunFailed(repo, id)reruns the run’s failed jobs (and their dependents).- Gitea:
listDefaultBranchRunsandgetRunwork (run id = the run number;attemptis always 1).failedJobLogsandrerunFailedrefuseunsupported.
Refusals reject with name "PluginForgeError" and the same codes as ctx.issues:
invalid-repo, invalid-input (bad run id, sinceId, limit or maxLinesPerStep),
no-forge, lightweight (local-only repos have no CI) and unsupported (the host lacks the
API). Forge failures propagate as ordinary errors.
Additive API. Guard with
typeof ctx.forge?.runs?.listDefaultBranchRuns === "function"if your plugin must run on older cores.
Read-only diagnosis agents (ctx.agents.runReadonly)
Section titled “Read-only diagnosis agents (ctx.agents.runReadonly)”Spawn one transient agent that reads a repo and answers in JSON, e.g. to diagnose a production error from its stack trace:
const result = await ctx.agents.runReadonly({ repo: "/home/me/code/app", // a repo Shepherd manages (or Shepherd's own checkout) prompt: "Find the most likely root cause of this error and the file to fix.", untrusted: [{ label: "sentry event", content: eventJson }], // external text — fenced as data schema: { type: "object", properties: { cause: { type: "string" }, file: { type: "string" } }, required: ["cause", "file"], }, timeoutMs: 10 * 60_000,});What runs. An interactive claude on the operator’s subscription (never claude -p),
in a disposable detached worktree at origin/<default branch>, never the live checkout. It
runs with the same posture as Shepherd’s own reviewers: read-only git/Read/Grep/Glob tools
plus Write for its result file, --permission-mode dontAsk, --safe-mode, hooks off, the
sandbox membrane when one is available, and your plugins’ onSpawn hooks (descriptor
kind: "plugin"). It gets no network or gh tools and cannot commit. The pane, the worktree and
the cost row are torn down on every outcome.
Options.
| Field | Rules |
|---|---|
repo |
Absolute path of a managed repo or Shepherd’s own checkout. |
prompt |
Your task, non-empty, ≤ 16 000 chars. Trusted: it comes from your code. |
untrusted |
Optional { label, content }[] (same shape as ctx.issues), ≤ 20 items, ≤ 48 000 chars in total. Each item is fenced. |
schema |
A JSON Schema (draft-07, ajv). Shown to the agent and enforced on its answer. |
model |
Optional Claude model alias (opus, sonnet, …). Omit it for the CLI default. |
timeoutMs |
Hard deadline, clamped to 1–30 minutes. |
Errors. The promise rejects with a PluginAgentError. You cannot import core, so check
err.name === "PluginAgentError" and switch on err.code:
code |
Meaning |
|---|---|
invalid-args |
An option broke the rules above, or the schema does not compile. Nothing ran. |
cap-exceeded |
This plugin already has 2 runs in flight, or started 20 in the last 24 hours. |
unavailable |
Could not start: api-key mode without a key, no origin/<default>, spawn failed. |
timeout |
No result by timeoutMs. |
no-output |
The agent exited without writing a result. |
invalid-output |
The result is not JSON. |
schema-violation |
The result is JSON but does not match schema (message has ajv’s errors). |
Cost. Every run is recorded like Shepherd’s other helper agents and shows up as
Plugin agents in the usage views, attributed to plugin:<your id>.
Treat the result as untrusted. The agent read external text and repo contents; a schema bounds the shape of its answer, not the truth of it.
Additive API. Guard with
typeof ctx.agents?.runReadonly === "function"if your plugin must run on an older core.
Decision-model judge (ctx.judge)
Section titled “Decision-model judge (ctx.judge)”A cheap, fast classifier over short text — use it as a pre-filter in front of a costlier step, never as the only path to a decision:
try { const { choice, probabilities } = await ctx.judge.choice({ instructions: "Classify this CI failure.", options: { regression: "a code change broke it", infra: "runner or network trouble" }, context: "workflow: CI, job: test", // trusted, plugin-authored untrusted: [{ label: "log tail", content: tail }], // fenced by core }); if (probabilities[choice] >= 0.6) { /* … */ }} catch (err) { // fall back to the non-judge path}Limits: instructions ≤ 4 000 chars, 2–8 options, ≤ 20 untrusted items, ≤ 32 000 chars in
total. Rejections are a PluginJudgeError (err.name === "PluginJudgeError") with code
invalid-args, unavailable (the operator has the judge off or no key), ceiling (the shared
daily spend ceiling is reached) or error (the call failed). Spend is booked against the same
daily ceiling as Shepherd’s own judge callers. Gate on probabilities, which is the full
distribution; no vendor confidence is exposed.
Additive API. Guard with
typeof ctx.judge?.choice === "function".
The onSpawn hook
Section titled “The onSpawn hook”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.envis 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 thetrustedprofile 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. |
modelis deliberately not patchable in v1. Overriding the spawn model would diverge the storedsession.modelfrom 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).
Failure behavior
Section titled “Failure behavior”- Fail-open by default. If a hook throws or exceeds its 5-second timeout, that
patch is dropped, the plugin is marked
errored/timed-outin 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
abortSpawnthere 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 single-loop discipline (important)
Section titled “The single-loop discipline (important)”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 synchronousexec/fson the hot path. onSpawnis 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.
Status panel
Section titled “Status panel”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.
Declarative UI panel (publishUI)
Section titled “Declarative UI panel (publishUI)”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." } }, });}nullclears the last-published view.slotmust 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
propsare 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
publishUIcall land in one bucket, and everysubmit: truebutton in that view sends the whole bucket. A button that omitssubmitis 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. namemust 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.valueseeds, 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.selectalways submits a valid option. An absent or unknownvaluefalls back to the first entry inoptions.numbersubmitsnullwhen the field is empty or not a number — neverNaN. Range is yours to validate in the route handler; the host does not clamp.secretmasks, 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.- A
secretfield is write-only. The host strips anyvalueyou seed on it, so it always renders empty. Treat an empty submitted string as “unchanged”, store the rest withctx.secrets.set, and show “set / not set” as separate text. - 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
autocompletedisabled, a meaninglessname(the value you receive is still keyed on thenameyou declared — the rendered one is decorative), and the opt-out attributes 1Password, Bitwarden, LastPass, Dashlane and Proton Pass honour. Without this asecretfield 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 annpubonce 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. labelis verbatim plugin data, never an i18n key. Omit it and the field’snamebecomes its accessible name.
Writing config (ctx.setConfig)
Section titled “Writing config (ctx.setConfig)”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.configupdates in place. The object you captured duringregister()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 whenconfig.jsonexists 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.
Scheduling (ctx.schedule)
Section titled “Scheduling (ctx.schedule)”ctx.schedule(intervalMs, fn) runs fn every intervalMs on a timer the server owns — use it
for a poller instead of your own setInterval, so core can pause and clean it up.
if (typeof ctx.schedule === "function") { const cancel = ctx.schedule(60_000, async () => { const res = await fetch(url, { signal: AbortSignal.timeout(10_000) }); ctx.publishStatus({ lastPoll: new Date().toISOString(), ok: res.ok }); }); // return cancel from register() if you want explicit teardown; core clears it anyway}- The first run is one interval after the call, not immediately.
fnmust be async and yield. It runs on the single event loop (see the single-loop discipline) — no sync I/O.- Paused during herdr maintenance. Ticks that fall while herdr is mid-update are skipped, like every core periodic loop.
- No overlap. A tick is skipped while the previous run is still in flight, so a slow poll never stacks. Bound your own I/O with a timeout.
- Failures surface, then it keeps going. A throw/rejection is caught, the plugin’s health
becomes
erroredwith the message as its last error (live in the status panel), and the next tick still runs. Health stayserroreduntil restart, the same as a failedonSpawnhook. - Cleared on teardown (shutdown) and when
register()throws. The returned fn cancels it earlier. intervalMsmust be ≥ 1000; anything lower (or not a finite number) throws.
Secrets (ctx.secrets)
Section titled “Secrets (ctx.secrets)”ctx.secrets stores credentials (API tokens, DSNs with keys) outside config.json and
ctx.state, in ~/.shepherd/plugin-secrets.json (mode 0600, override with
SHEPHERD_PLUGIN_SECRETS), keyed by plugin id — one plugin can’t read another’s.
ctx.route("POST", "token", async (req) => { const { token } = await req.json(); if (typeof token === "string" && token !== "") await ctx.secrets.set("token", token); // "" = unchanged publishPanel(); // shows `ctx.secrets.get("token") ? "set" : "not set"` — never the value return new Response("Saved");});get(key)is sync (served from memory, loaded once at boot) and returnsnullwhen unset.set(key, value)persists atomically (temp file + rename, re-chmoded to 0600) and rejects on failure;set(key, null)unsets. Values must be strings.- Never served by core. No core route or UI payload carries a secret: it is not in your
config, state or
PluginInfo, asecrettext-input never gets a seeded value, and core replaces any of your secret values (4+ characters) that appear in your published status, UI view, gear item or last error with[redacted]. Your own route handlers are your code — don’t return a secret from them. - An unreadable/unparseable secrets file is never overwritten:
getreturnsnullandsetrejects until you fix or delete the file. - Not encrypted at rest — protection is the file mode, like
~/.claude/.credentials.json.
Gear-menu item (publishGearItem)
Section titled “Gear-menu item (publishGearItem)”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" }, });}Three action kinds
Section titled “Three action kinds”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>/statsurl — opens an absolute URL in a new browser tab.
action: { kind: "url", href: "https://your-dashboard.example.com" }Validation & security
Section titled “Validation & security”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 withhttp:orhttps: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.
Additive guard
Section titled “Additive guard”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.
HTTP routes
Section titled “HTTP routes”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.
A fuller example: spawn-labeler
Section titled “A fuller example: spawn-labeler”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.