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