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

  • 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.
  • Bundled plugins ship inside Shepherd (src/plugins/bundled/) and load after the plugins dir, so an installed plugin with the same id wins. They keep their settings in ctx.state (their folder is the source tree, so ctx.config is {} and ctx.setConfig rejects) 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.

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

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.

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 } | null
await ctx.issues.close(repo, number, "Resolved in Sentry."); // comment first, then close
  • title and body are 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.
  • untrusted is fenced by core, never by you. body comes 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 in body. Fence markers already present in body or 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.
  • get returns null when the issue is gone or the forge read failed transiently. Don’t treat null alone as proof of deletion. state is "open"/"closed", or null when 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.

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. listDefaultBranchRuns returns only completed runs with an id above sinceId, ascending, at most limit (default 20, max 50). Store cursor and pass it back as sinceId. The cursor never moves past a run that is still in flight: newer completed runs are held back until it finishes, so a slow test run that ends after a fast lint run 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, …), status and conclusion (failure, cancelled, startup_failure, …) are the host’s own values. conclusion is null for a job that hasn’t finished.
  • getRun(repo, id) re-reads one run with its jobs, or null when unknown. A rerun keeps the run id and bumps attempt, so use this to watch a rerun finish.
  • failedJobLogs returns the last maxLinesPerStep lines (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 as untrusted sections to ctx.issues.create or ctx.agents.runReadonly, never into a title, body or prompt.
  • rerunFailed(repo, id) reruns the run’s failed jobs (and their dependents).
  • Gitea: listDefaultBranchRuns and getRun work (run id = the run number; attempt is always 1). failedJobLogs and rerunFailed refuse unsupported.

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.

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

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.
  • A secret field is write-only. The host strips any value you seed on it, so it always renders empty. Treat an empty submitted string as “unchanged”, store the rest with ctx.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 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.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.
  • fn must 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 errored with the message as its last error (live in the status panel), and the next tick still runs. Health stays errored until restart, the same as a failed onSpawn hook.
  • Cleared on teardown (shutdown) and when register() throws. The returned fn cancels it earlier.
  • intervalMs must be ≥ 1000; anything lower (or not a finite number) throws.

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 returns null when 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, a secret text-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: get returns null and set rejects until you fix or delete the file.
  • Not encrypted at rest — protection is the file mode, like ~/.claude/.credentials.json.

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.