A plugin extends Shofer's behavior, not just its data. Where the custom-tool registry adds tools, a plugin is a self-contained bundle that can contribute modes, skills, slash commands, MCP servers, and rules declaratively, and — if it ships code — register tools, transform the system prompt, hook the task/tool lifecycle, run a background service, call the host LLM, and render UI into the app.
Plugins are host-agnostic: the same plugin runs in the VS Code extension and the CLI (and any future host). Everything a plugin can touch goes through a permission-checked, restricted surface — a plugin only gets the capabilities its manifest declares and the user grants.
This is the practical guide to authoring one. For the internal design rationale see
todos/plugin_system.md.
A plugin is a directory containing a plugin.json manifest and, optionally, a code entry point
and asset directories (skills/, commands/, rules markdown, etc.):
my-plugin/
plugin.json # the manifest — required, at the root
index.ts # optional code entry ("main")
skills/ # optional SKILL.md files
commands/ # optional slash-command .md files
rules/ # optional rules markdown
Shofer scans two locations and loads a <name>/plugin.json from each subdirectory:
| Scope | Directory | Meaning |
|---|---|---|
global |
~/.shofer/plugins/<name>/ |
available in every workspace |
project |
<project>/.shofer/plugins/<name>/ |
scoped to one workspace (the current cwd) |
If a plugin of the same name exists in both, the project one (scanned later) wins. A plugin is
disabled by default after install — the user enables it per-plugin (that toggle is the consent
gate; see §7).
A third, lowest-precedence scope is bundled — the first-party plugins shipped inside the
extension build itself (<extension>/dist/plugins/). Hosts that supply their entire plugin set
out-of-band can build an extension without any of them: SHOFER_NO_BUNDLED_PLUGINS=1 at bundle
time skips the bundled-plugins packaging entirely, and the runtime treats the absent directory as
"no built-in tier" (see the build-flavor section of
docs/plugin_system.md).
A minimal code plugin that contributes one tool. Create ~/.shofer/plugins/hello/:
plugin.json
{
"name": "hello",
"version": "1.0.0",
"shoferPluginApiVersion": "1.0.0",
"description": "Adds a greeting tool.",
"main": "index.ts",
"permissions": {
"tools": true
}
}index.ts
import { defineCustomTool, parametersSchema as z } from "@shofer/types"
import type { PluginContext, ShoferPlugin } from "@shofer/types"
const plugin: ShoferPlugin = {
name: "hello", // MUST equal manifest `name`
registerTools(_ctx: PluginContext) {
return [
defineCustomTool({
name: "greet",
description: "Greet someone by name.",
parameters: z.object({
who: z.string().describe("Who to greet"),
}),
async execute({ who }) {
return `Hello, ${who}!`
},
}),
]
},
}
export default pluginThen, from the CLI:
shofer plugin install ~/.shofer/plugins/hello --enable
shofer plugin listThe plugin's default export must be a ShoferPlugin object whose name matches the manifest. The
entry may be .ts/.tsx/.js/.mjs; TypeScript is transpiled with the same esbuild path custom
tools use (Worker built-ins external, deps bundled).
A purely declarative plugin needs no
mainand no code — just a manifest with acontributesblock. See §4.
The manifest is validated fail-closed: every level is .strict(), so an unknown key is a hard
error and the plugin is skipped with a warning. Fields:
| Field | Type | Notes |
|---|---|---|
name |
string required | Unique id. Must match ^[a-zA-Z0-9][a-zA-Z0-9._-]*$. Used for ordering, dedupe, namespacing. |
version |
string required | Free-form version string. |
shoferPluginApiVersion |
string | The plugin-API semver you target (current host API: 1.0.0). Incompatible ⇒ refused at load. |
description |
string | Shown in the Plugins UI and plugin list. |
author |
string | |
homepage |
string | |
license |
string | |
shoferVersion |
string | Minimum Shofer version (semver range). Not yet enforced. |
main |
string | null | Code entry, relative to the plugin dir. Absent/null ⇒ purely declarative. |
permissions |
object | The security contract. See below. |
contributes |
object | Declarative modes/skills/commands/mcpServers/rules, plus ui bundles. See §4, §6. |
dependencies |
string[] | Other plugins that must be installed. (Discovery records unmet deps; not fully enforced yet.) |
config |
object | JSON-Schema-ish description of user settings. See below. |
defaultEnabled |
boolean | Bundled (first-party) scope only. Ship enabled instead of waiting to be opted into — for a plugin that IS a Shofer feature. Ignored for global/project plugins. If you also declare permissions.ai, gate every hook on ctx.ai.hasConsent(): enabling is not consent to bill, so an unconsented plugin must contribute nothing rather than a tool that only fails (see plugins/live-memory/main.ts). |
hookTimeoutMs |
number | Override the 500 ms per-hook budget for this plugin's lifecycle hooks (max 60000). Only for a hook the agent must genuinely wait for. |
unqualifiedContributions |
boolean | Bundled (first-party) scope only. Register contributes.modes and contributes.commands under their authored names instead of <plugin>:<name>, at the built-in precedence tier. Exists for a plugin shipping the platform's own defaults, whose names are a contract (plugins/builtin-config/ — the built-in modes must stay code, architect, …; plugins/basics/ — /merge-worktree). Ignored for global/project plugins: an unqualified third-party name could shadow a built-in. |
Every capability defaults to denied. A contribution is only surfaced, and a code capability only reachable, when its permission is present. All keys are optional:
| Key | Type | Gates |
|---|---|---|
tools |
boolean | registerTools contributions. |
systemPrompt |
boolean | transformSystemPrompt. |
modes |
boolean | contributes.modes. |
skills |
boolean | contributes.skills. |
commands |
boolean | contributes.commands. |
rules |
boolean | contributes.rules. |
mcpServers |
boolean | contributes.mcpServers. |
mcpInvoke |
boolean | Invoking tools on connected MCP servers via ctx.mcp. Separate from mcpServers: invoking reaches every server the host has, not just yours. |
ui |
region[] | UI regions the plugin may render into. See §6. |
lifecycle |
boolean | The lifecycle hooks. Without it, none of them ever fire. |
events |
boolean | onEvent observation. |
network |
string[] | Allowed network origins/prefixes for ctx.host.fetch. |
filesystem |
string[] | Allowed paths for ctx.host.fs and ctx.host.watch (relative entries resolve to plugin root and workspace). |
ai |
boolean | Host LLM/embeddings via ctx.ai. Necessary but not sufficient — also needs the AI-billing consent (§7). |
agent |
boolean | Deliver messages into a task's mailbox (ctx.agent.deliver), register a mailbox transport, and start/cancel tasks (spawn). Billed/behavioral. |
task |
boolean | Task control via ctx.task: timeline markers, rewind, setCwd, openTask. Each changes what a task IS (rewind destroys history), so it is its own grant. |
telemetry |
boolean | Report product events via ctx.host.telemetry. Telemetry LEAVES the machine, so it is a grant — the user's global telemetry opt-in still gates it underneath. |
editor |
boolean | The host's multi-file diff viewer via ctx.host.editor. |
A JSON-Schema-ish object ({ type, properties: { <key>: { default, ... } } }). Shofer does a
shallow default-merge: any properties.<key>.default seeds ctx.config[key] unless the user has
stored a value (stored values always win). Full type/enum validation of stored values is not yet
enforced — treat ctx.config as best-effort typed.
Credentials: "secret": true. A property marked secret is stored in the host's secret store
(the OS keychain), never in plain state, and its value is never sent to the settings webview — the
panel renders it as a password field showing only whether one is stored. Your plugin reads it from
ctx.config[key] like any other property; the split is the host's job:
"config": {
"type": "object",
"properties": {
"qdrantUrl": { "type": "string", "default": "http://localhost:6333" },
"qdrantApiKey": { "type": "string", "secret": true, "description": "Vector-store token" }
}
}Saving an empty secret field deletes the stored value; leaving it untouched keeps it (the panel cannot round-trip a value it is never shown). "Reset to defaults" clears the plain config and leaves credentials alone — losing a key to a button labelled "reset defaults" would be a surprise.
{
"name": "acme-ci",
"version": "2.1.0",
"shoferPluginApiVersion": "1.0.0",
"description": "ACME CI helpers: a status tool, a guardrail, and a CI mode.",
"author": "ACME",
"main": "index.ts",
"permissions": {
"tools": true,
"lifecycle": true,
"modes": true,
"network": ["https://ci.acme.example"],
"filesystem": ["./ci-config"]
},
"contributes": {
"modes": [
{
"slug": "ci",
"name": "CI",
"roleDefinition": "You help diagnose and fix CI failures.",
"groups": ["read", "command"]
}
]
},
"config": {
"type": "object",
"properties": {
"baseUrl": { "type": "string", "default": "https://ci.acme.example" },
"blockForcePush": { "type": "boolean", "default": true }
}
}
}Declared under contributes and gated by the matching permission. The physical assets live in the
plugin directory; the manifest entry is the declaration.
modes— array of mode configs (same shape as aModeConfig, minussource/pluginName, which Shofer assigns). Each must providetoolsortools_allowed.skills—{ name, description }. TheSKILL.mdlives under the plugin'sskills/dir.commands—{ name, description?, argumentHint? }. The.mdlives undercommands/.mcpServers— a map ofname → server config(validated byMcpHub's own schema before connecting; kept loose in the manifest).rules—{ path, modes? }, wherepathis a rules-markdown file relative to the plugin root, optionally scoped to specific modes.
Plugin-contributed modes, commands, and skills are addressed under a
<plugin-name>:<name> identifier, so a plugin item can never shadow a built-in/user item or
another plugin's item — collisions are impossible by construction (there is no
"last-installed-wins" tie-break between plugins). How the qualification surfaces differs slightly:
| Contribution | Addressed as | On-disk / authored name |
|---|---|---|
| modes | <plugin-name>:<slug> |
The emitted mode slug is the qualified form; the authored slug in your manifest stays natural (no :). Attribution is carried on source: "plugin" + pluginName. |
| commands | <plugin-name>:<command> |
The command is registered and invoked under the namespaced name; the bare name is never resolvable on its own. |
| skills | <plugin-name>:<name> |
Namespaced purely at the resolution/addressing layer — the on-disk directory name and the SKILL.md frontmatter name stay spec-compliant (no :). The model lists and invokes the skill by its qualified name. |
The only residual collision is a single plugin declaring the same slug/name twice — a manifest bug, surfaced with a defensive warning (later entry wins, deterministically).
A mode, command, or skill can be marked private: true. A private contribution is fully
registered and agent-invocable by its qualified name (<plugin-name>:<name>), but is hidden
from every user-facing surface — the mode selector/picker, the slash-command menu, the skills
list, and the Plugins panel. Use it for an item the agent drives but the user never picks directly —
e.g. a plugin's verifier mode that a task switches into programmatically. Absent/false ⇒ a
normal, user-visible contribution. (private is a field on the mode, command, and skill
contribution schemas; a private mode still governs its subtask's tools once switched into.)
Implement any subset of the ShoferPlugin hooks. All are optional.
initialize(ctx)— run once when the plugin is registered.registerTools(ctx)— returnCustomToolDefinition[]added to the tool set. Requirespermissions.tools.transformSystemPrompt(prompt, ctx)— return a new prompt string. Plugins run in registration order, each receiving the previous output. Requirespermissions.systemPrompt.onEvent(event, ctx)— observe lifecycle/telemetry events. Read-only; must not throw.onUiMessage(message, ctx)— receive a message from this plugin's UI (see §6).lifecycle— the task/tool lifecycle hooks below. Only fires withpermissions.lifecycle.
Plugins run in registration order. Each hook is bounded by a 500 ms per-hook timeout with per-plugin error isolation: a hook that throws or exceeds the budget is skipped with a shown+logged warning — it can never stall or crash the agent loop, and its would-be mutation is not applied.
| Hook | Return | Effect |
|---|---|---|
beforeToolCall |
{ allow, modifiedArgs?, reason? } |
Allow / modify / block. modifiedArgs threads into later hooks and the tool; the first allow:false short-circuits the tool (surfaced like a denied tool, with reason). |
afterToolCall |
string | void |
Observe / transform the result string. A returned string replaces it for later hooks and the model. |
beforeAsk |
{ decision?, text? } | void |
Modify / auto-answer an ask. text edits the surfaced ask; decision of "approve"/"deny" auto-answers (short-circuit); "ask"/absent lets it proceed to the user. |
afterAsk |
ignored | Observer. How that ask ended: outcome (answered/superseded/aborted), and on answered the response, decidedBy (user/auto-approval/plugin) and autoApproved. The only way to see the host's own auto-approval verdict — beforeAsk runs before it. |
beforeTaskStart |
ignored | Observer. Fire-and-forget (off the latency-critical path). ctx.prompt carries the initial prompt. |
afterTaskComplete |
ignored | Observer. ctx.reason is "completed" or "aborted". |
onUserMessage |
ignored | Observer. The user sent a message into a task — the step boundary the tool hooks cannot see. Fires for a message into a RUNNING task and for the one that resumes a task from history, so a conversation's every turn is visible. info.trace carries the W3C trace context the delivering request stated, when it stated one. |
onAssistantMessage |
ignored | Observer. The agent completed a narration text block — its prose between tool calls, which no other hook carries. Never fired for streaming partials. |
onApiRequestStart |
ignored | Observer. An LLM request is about to be issued: { taskId, requestIndex, model, apiProtocol, retryAttempt }. The real wall-clock start of a model call. |
onApiRequestFinish |
ignored | Observer. That request finished. Carries the host's own record — time-to-first-byte, the offset at which generation began (end of the reasoning phase), retries, tokens, cost, tool spans. |
onTimelineRewind |
ignored (awaited) | The chat is about to be rewound to info.ts. Runs BEFORE the messages go, so state anchored to them can be rolled back. info.restoreState: false ⇒ chat-only, don't touch the workspace. |
onTaskDeleted |
ignored | Observer. A task was deleted — drop per-task state kept outside its task dir. |
ctx.turn (a per-task turn counter) lets a hook that fires per tool call act once per
turn. A hook that legitimately needs longer than 500 ms declares hookTimeoutMs in its
manifest — see §3.
Every hook receives a PluginContext. Which fields are populated depends on the host and the
plugin's grants:
| Field | Availability |
|---|---|
workspacePath |
Active workspace path, if any. |
mode |
Current mode slug. |
taskId |
Id of the task the hook runs for, if applicable. |
parentTaskId |
The spawning parent's task id, when the task is a subtask — how an observer attributes a child's events. |
rootTaskId |
The delegation tree's root task id, when the task is a subtask. |
cwd |
Current working directory. |
config |
This plugin's validated, default-merged settings. |
host |
The restricted host surface (below). Present when the host wired its bridge. |
ai |
Host LLM/embeddings. Present only with permissions.ai + a wired AI seam. |
agent |
Proactive agent-steering. Present when the host wired its agent seam; denying stub without permissions.agent. |
mcp |
Invoke tools on connected MCP servers. Present when the host wired its MCP seam; denying stub without permissions.mcpInvoke. |
task |
Timeline markers + rewind. Present when the host wired its task seam; denying stub without permissions.task. |
turn |
The task's current turn index (one per assistant turn), for once-per-turn hook behaviour. |
toolCallId |
The provider's own id for the tool call being observed. Present on beforeToolCall / afterToolCall — a turn issues several calls, so taskId + turn do not identify one. |
askId |
The host's id for the ask being observed. Present on beforeAsk / afterAsk; the join key to whatever surface decided it. |
trace |
(beforeTaskStart only) W3C trace context of the request that created the task, when its creator supplied one. Its per-message twin is UserMessageInfo.trace on onUserMessage, which is where a conversation's later turns carry theirs. |
storage |
Per-plugin persistent dir. Present when the host wired a storage base dir. |
registerService(svc) |
Register a background service. Present when the host wired the supervisor. |
Permission-scoped; the shape is always the same, but an out-of-scope call is denied at runtime (throw + shown/logged warning), not hidden from the type. So you always get a clear error, never a missing API.
-
host.fs—readFile/writeFile/exists/mkdir/delete/findFiles, scoped topermissions.filesystem. Relative allowlist entries resolve against both the plugin root and the workspace. -
host.fetch(input, init?)— HTTP, scoped topermissions.networkorigins/prefixes. -
host.notifier—info/warn/error. Always available (surfacing messages is safe). Pops a user-facing toast; use it sparingly for things the user must see. -
host.log—debug/info/warn/error. Always available (logging is safe). Writes to the plugin's own Log categoryPlugin:<name>(Settings → Logging), not a user toast. Each loaded plugin automatically gets its category, so a user can view and filter one plugin's output independently. Prefer this overconsole.*for anything diagnostic.ctx.host?.log.info("reindexed", { files: 12 }) // shows in Settings → Logging under the "Plugin:my-plugin" category
-
host.metrics—increment/gauge/observe. Always available (a number in a local registry is as harmless as a log line), and a no-op on a host with no metrics pipeline. Metric names are yours: a plugin that owns a subsystem publishes the numbers an operator watches. -
host.telemetry.capture(event, properties?)— report a product event, gated onpermissions.telemetry. Three things the host does for you, and none of them is optional: your event name is namespaced under the singlePlugin Eventcatalog entry withplugin/eventproperties (a plugin cannot name a top-level event, or shadow a core one); properties are scrubbed to primitives with strings truncated (you see workspace content, and this leaves the machine — anError.stackor a file's text must not become an analytics payload); and the user's telemetry opt-in still applies, so a granted plugin on a machine with telemetry off reports nothing. Ungranted ⇒ warns and drops. It never throws: reporting a failure must not fail differently because reporting was refused.ctx.host?.telemetry.capture("indexing_error", { subsystem: "OpenAiEmbedder", attempts: 3 })
-
host.env— read-only host/environment metadata. Always available. -
host.watch(pattern, onChange)— watch a glob for create/change/delete, scoped topermissions.filesystem(watchespatternunder each granted root). The callback receives the event{ path, type }—pathis the absolute path of the changed file (always inside a granted root) andtypeis"create" | "change" | "delete"— so you can act on which file changed, not just that something did. Ungranted ⇒ deny + no-op disposable. Dispose to stop (the manager also disposes it on plugin disable). Present only when the host wired a watcher.ctx.host.watch("**/*.json", (event) => { // event.path e.g. "/abs/ws/ci-config/status.json", event.type "change" reindex(event.path) })
ctx.ai (PluginAi) — requires permissions.ai and the user's "uses AI (billed)" consent.
buildHandler(profileRef?)→Promise<Handler>— build the sameApiHandlerthe main agent uses (the host's default profile whenprofileRefis omitted). The plugin never sees raw API keys.embed(texts, profileRef?)→Promise<number[][]>— one embedding vector per input text.hasConsent()→boolean— read-only consent check:truewhen calls will actually run,falseon the denying stub. Becausectx.aiis present in both the live and unconsented cases, use this to word prompt/UI copy for the consent state without making a billed call to find out. It cannot grant consent — only the user can.
Granted but not consented ⇒ ctx.ai is present but a denying stub (buildHandler/embed throw +
warn, hasConsent() returns false), so the user is never silently billed. Ungranted ⇒ ctx.ai is
absent entirely.
ctx.agent (PluginAgent) — proactive agent-steering, requires permissions.agent. Lets a
plugin put a message in front of a task (rather than only reacting to it) — from a background
service, a ctx.host.watch callback, or a lifecycle hook.
There is ONE delivery door, and no delivery mode to choose:
-
deliver(envelope)→Promise<Envelope>. The message becomes an envelope in the target task's mailbox (docs/task_messaging.md): persisted, deadline-bounded, listed in theenvironment_detailsdigest on the task's next request, and read by the agent withwait.kindsays whether an answer is expected (notification/request/reply),wakesays whether a task whose loop has STOPPED is resumed for it, anddeadline(absolute epoch ms) says how long it is worth delivering at all. The host fillstoandsent_atand mintsidwhen you supply none — pass an upstream idempotency key asidif you own one, because a mailbox already holding that id acknowledges without appending, which is what makes a retry safe.taskIdtargets a specific task; otherwise the host's current task.It resolves once the box has persisted the envelope, so an upstream ack sent afterwards means "in the box". It rejects when the box refuses (full, expired) and — deliberately — when there is no target task: a delivery never silently becomes a billed spawn. Use
spawnfor that. -
registerMailboxTransport({ canRoute, send })→ an unregister function. The mirror ofdeliver: how an envelope addressed to a task this node does not hold LEAVES the node. The core resolves an outboundtolocally first; an address that resolves to nothing is offered to each registered transport in turn, and the first whosecanRoute(to)accepts it owns the delivery — which is what lets the agent's onesend_messagetool address a remote peer without knowing a mesh exists.canRouteis synchronous (it runs on the send path; no I/O), and a plugin whose service stops must call the returned unregister function. -
spawn(prompt, opts?)/cancel(taskId)— job control (§14): a task that does not exist yet, with an awaitable, cancellable handle.
// e.g. inside a registered service watching a deploy log
await ctx.agent?.deliver({
from: "deploy-watcher",
kind: "notification",
subject: "deploy failed",
body: "The deploy just failed — see /var/log/deploy.log for the trace.",
deadline: Date.now() + 600_000,
wake: false, // it will be read on the agent's next turn
plane: "local",
})Ungranted (host wired the seam) ⇒ ctx.agent is a denying stub (every call throws + warns). No
agent seam (headless/pure-core) ⇒ ctx.agent is absent entirely.
ctx.mcp (PluginMcp) — invoke a tool on any MCP server the host has connected,
requires permissions.mcpInvoke. This is the counterpart of permissions.mcpServers,
which only lets you CONTRIBUTE a server; invoking reaches every server the host is
configured with — the user's, the project's, the org's and other plugins' — so it is a
separate, larger grant that mcpServers never implies.
callTool(serverName, toolName, args?, opts?)→Promise<McpToolCallResponse>— the server's raw result (contentarray +isError), unshaped: no truncation, no image extraction, no chat row, because your plugin is a program and not a transcript.opts.taskIdattributes the call to a run (it travels in_metaand is what a per-call header resolver keys a run credential off) — pass it when you are acting on behalf of a task; it is never guessed for you, because the wrong run's credential is worse than none.opts.signalcancels the call.
const res = await ctx.mcp?.callTool("memory", "search", { query: "deploy" }, { taskId: ctx.taskId })The call rides the host's own MCP hub, so it reaches the same server processes the agent's
use_mcp_tool does. It does not raise an approval ask: your code is trusted (the user
installed and granted it) and may run headless with nobody to ask, so the manifest grant is
the gate. Ungranted (host wired the seam) ⇒ ctx.mcp is a denying stub whose callTool
throws and warns. No MCP seam ⇒ ctx.mcp is absent entirely.
ctx.task (PluginTaskControl) — task control, requires permissions.task.
For a plugin whose feature belongs in the conversation rather than in a side panel, and
for one that decides where a task runs:
marker({ kind, text, data?, restorable?, suppress?, taskId? })— append a row the plugin's ownchat-message-addoncomponent renders (the host never interpretskind/data).suppresspersists it without rendering it (an anchor the user doesn't need to see);restorablemakes the delete/edit dialog offer to roll your state back.listMarkers(taskId?)— your markers, oldest first: how you recover anchors after a restart without a second, drift-prone copy inctx.storage. Scoped to your plugin.rewind(ts, { includeTargetMessage? })— truncate the conversation totsand restart the task. Roll back anything outside the conversation yourself, first.setCwd(cwd, taskId?)— re-point a task at another working directory. The host throws when there is no task to re-point, rather than silently doing nothing.openTask({ cwd?, name?, text?, images?, mode? })— open and focus a NEW task, resolving with its id. With notextit lands idle, waiting for the user: this is for a plugin that has prepared a place to work (thebasicsplugin's worktrees feature opens a task in a worktree it just created), not a prompt. Usectx.agent.spawninstead when you want an agent RUN — that one takes a prompt, returns an awaitable handle, and is gated onpermissions.agentbecause it bills.
await ctx.task?.marker({ kind: "snapshot", text: commitHash, restorable: true })Ungranted (seam wired) ⇒ a denying stub; no task seam ⇒ absent.
ctx.host.editor (permissions.editor) — showMultiFileDiff(title, changes) opens
the host's native multi-file diff view for a set of before/after file contents.
ctx.ui.openSettings() — reveal Settings → Plugins, where this plugin's toggle,
config form and (with permissions.ai) its billed-AI consent live. For the case where
your UI has to say "I need your approval before I can do anything": send the user to the
approval rather than describing where it is. Fire-and-forget; a warned no-op on a host
with no settings surface.
ctx.storage (PluginStorage) — the plugin's own persistent dir at
<globalStorage>/plugins/<name>/, independent of permissions.filesystem. readFile/writeFile/
exists/delete/list, all resolved under dir and traversal-blocked (a .. escape is
denied). Created lazily, survives restart, removed on uninstall.
ctx.registerService(service) — register a supervised background service
{ name, start, stop? }. start() runs when the plugin is enabled+active; stop() on
disable/uninstall/deactivate. Each start/stop is bounded by a 5 s timeout and error-isolated,
so a hanging or throwing service can never crash the host. Returns a disposable that stops + removes
the service.
onUiMessage is fire-and-forget; when your UI needs an answer, implement
handleRequest(method, params, ctx) and call it with api.request(method, params?, opts?)
from the component. Errors propagate to the caller (they are not swallowed like observer
hooks), so a failure surfaces instead of looking like an empty result.
The host answers the request on the machine that OWNS the focused task — a remote executor for a task running there — so a plugin-owned feature works the same locally and remotely. Three conventions:
- prefix a method
local:to force it onto the host the UI runs on (opening an editor/viewer, which a headless executor cannot do); - pass
{ mutates: true }for a state-changing request: the host refuses to route it to an executor while a local task shares the workspace; - return
{ rewound: true }if you rewound the task's conversation, so the controller resyncs its view of a remote task.
// ui/row.tsx
const result = await api.request("diff", { id }) // answered where the task runs
await api.request("local:show-diff", result, { mutates: false }) // rendered hereThe host asks too. handleRequest also receives Shofer's own broadcast questions —
core needs a fact that a feature owns, and asks every plugin rather than knowing which one
provides it. Throw for a method you do not recognise; that is what marks you silent. The
questions and their answer shapes are tabulated in
docs/plugin_system.md (§ "Broadcast requests"); one worth
knowing about here is "resolve-mcp-call-headers", asked before every MCP tool call:
async handleRequest(method, params) {
if (method !== "resolve-mcp-call-headers") throw new Error("not mine")
const q = params as { serverName: string; source: "global" | "project"; taskId?: string }
// Answer only for a server you actually recognise — a header is a credential
// handed to whatever URL that server names. "Nothing to add" is `{ headers: {} }`;
// there is no error channel, because the call must go out either way.
return isMine(q) ? { headers: { Authorization: `Bearer ${tokenFor(q.taskId)}` } } : { headers: {} }
}This is the seam for anything that belongs to the RUN rather than to the host: a transport's own headers are fixed when it connects, and one connection serves every task.
"resolve-model-call-headers" is the same question one seam over, asked before every MODEL
request (createMessage and completePrompt alike), with { operation, provider, model, taskId, parentTaskId, rootTaskId } and the same { headers } answer. A provider's SDK
client is built once from the API configuration and shared by every task, so the same
argument applies — but with one difference worth knowing before you answer it: the host
refuses authorization, x-api-key and the other credential names outright, and never lets
an answered header displace one the provider already set. An answer there annotates a
model call and cannot re-authenticate it, which is why the question names no endpoint.
A plugin can render React components into named webview regions. Declare the regions in
permissions.ui — this is the grant. A granted region renders either a first-party/co-bundled
component (default) or your own compiled UI bundle when you point contributes.ui at a built
module (see Shipping your own UI bundle). Regions:
chat-input-toolbartask-headersettings-tabchat-message-addonchat-footersidebar-panel
The component is loaded into the webview by dynamic import (not a sandboxed iframe), so it shares
the host's React and theme. It receives only a PluginUIApi:
postMessage(message)— send to this plugin's extension-side code. Every message is tagged with the plugin name, so it's routed only to that plugin.onMessage(listener)— subscribe to messages addressed only to this plugin (namespaced — a plugin can neither observe nor spoof another's channel). Returns an unsubscribe fn.context— read-only{ region, pluginName, task?, config?, theme? }.
The extension side receives your UI's messages via the onUiMessage(message, ctx) hook and pushes
back with the host-side sender. No vscode API and no parent-DOM access are exposed.
A third-party plugin ships its own compiled UI bundle. Point a granted region at a built module
with contributes.ui:
{
"permissions": { "ui": ["chat-input-toolbar"] },
"contributes": { "ui": [{ "region": "chat-input-toolbar", "entry": "ui/toolbar.js" }] }
}permissions.uiis still the grant — a region must be listed there or the contribution is refused (fail-closed).contributes.ui[].regionmust be one of those granted regions.entryis the built ESM module, relative to your plugin root (e.g.ui/toolbar.js). The extension serves it as a localvscode-webview://resource (its dir is added to the webview'slocalResourceRoots) and the webview dynamic-imports it. A granted region without acontributes.uientry falls back to a first-party/co-bundled component (unchanged behavior).
The build contract — externalize React and the kit. Your bundle must not bundle its own
React: the host injects an import map
so that react, react-dom, react/jsx-runtime (and react/jsx-dev-runtime, react-dom/client)
resolve to the host's running React instance. Sharing one instance is what keeps hooks and
context working — a second copy silently breaks them. The same map resolves @shofer/plugin-ui
(next section) to the host's component kit. So build the entry as an ES module and mark those
packages external. With esbuild:
esbuild ui/toolbar.jsx --bundle --format=esm --jsx=automatic \
--external:react --external:react-dom --external:react/jsx-runtime \
--external:@shofer/plugin-ui \
--outfile=ui/toolbar.js(or, with Vite/Rollup, build.lib + rollupOptions.external: ["react", "react-dom", "react/jsx-runtime", "@shofer/plugin-ui"]).
The module must default-export a React component taking a single { api: PluginUIApi } prop:
import { useEffect, useState } from "react"
export default function Toolbar({ api }) {
const [reply, setReply] = useState("")
useEffect(() => api.onMessage((m) => setReply(String(m))), [api])
return <button onClick={() => api.postMessage({ deploy: api.context.task?.taskId })}>Deploy {reply}</button>
}The bundle loads under the webview CSP without weakening it: script-src uses strict-dynamic +
a nonce, so the nonced host script may dynamic-import your same-origin (vscode-webview://) module.
Arbitrary external hosts remain blocked — only files under the plugin dirs are served. If your
component throws while rendering, it is caught by an error boundary and unmounted; the host UI keeps
working.
Your UI shares more than React. Import the host's kit and render the same components the product does, instead of look-alikes that drift from it and behave differently under keyboard and focus:
import {
Button,
Popover,
PopoverContent,
PopoverTrigger,
StandardTooltip,
cn,
usePluginTranslation,
} from "@shofer/plugin-ui"
export default function Toolbar({ api }) {
const t = usePluginTranslation()
return (
<StandardTooltip content={t("toolbar.deployTooltip")}>
<Button variant="ghost" size="icon" onClick={() => api.postMessage({ deploy: true })}>
<span className="codicon codicon-rocket" />
</Button>
</StandardTooltip>
)
}Available: Button, Badge, Checkbox, Input, Textarea, ToggleSwitch, Progress,
Separator, StandardTooltip, Popover*, Dialog*, Collapsible*, SearchableSelect,
useShoferPortal, cn, usePluginTranslation. The full signature list is
plugins/plugin-ui.d.ts — map it in your plugin's tsconfig paths to
typecheck your UI:
{ "compilerOptions": { "jsx": "react-jsx", "paths": { "@shofer/plugin-ui": ["../plugin-ui.d.ts"] } } }Styling needs nothing: your component renders inside the host document, so its CSS and VS Code theme
variables already apply (text-vscode-descriptionForeground, codicon codicon-*, …). Icons are not
re-exported — bundle lucide-react yourself if you want them.
Translations. Ship locales/<lang>.json in your plugin root and usePluginTranslation() reads
them, with the host's interpolation, plural rules and language switching:
my-plugin/
├── plugin.json
├── locales/en.json { "toolbar": { "deployTooltip": "Deploy this task" } }
├── locales/de.json
└── ui/toolbar.js
They are registered as the i18next namespace plugin:<your-plugin-name>, so your keys can never
collide with the host's or another plugin's. A key with no translation renders as the key itself —
visible, rather than silently blank. A plugin with no locales/ directory simply gets no
translations.
Two independent gates decide what a plugin can do:
-
Enable = per-plugin consent. A plugin is disabled by default. Enabling it (Plugins tab, or
--enableon install) is the user's consent to run it at all. Disabling unregisters it: its tools/transforms/observers stop firing, its watchers and services are torn down. -
Manifest permissions gate each capability. A declarative contribution is only surfaced, and a code capability only reachable, when the matching
permissionskey is granted.fs/network/filesystemcalls are checked at runtime against the allowlists;lifecyclehooks don't fire withoutpermissions.lifecycle; UI regions must be inpermissions.ui.
A deployment can decide both, by env. SHOFER_DISABLED_PLUGINS suppresses plugins (a
suppressed one never loads and the panel toggle refuses); SHOFER_ENABLED_PLUGINS activates them (on
without a user enabling, for a host that was provisioned with the plugin — a headless pod whose job
is to run it). Suppression wins if a name is in both. Activation is not a way around anything else:
permissions, AI consent and dependency resolution are unchanged, and there is no way for a plugin to
put its own name in either list.
And it can decide where the code comes from. SHOFER_PLUGIN_DIRS (a PATH-style list of
absolute directories) names extra roots to scan, read-only and last — after the user and
project roots, so nothing in either can shadow a name the deployment provisioned. It exists because
the three standard roots are all writable by the person the plugin may be there to constrain: a
plugin under ~/.shofer/plugins can be edited, replaced, or moved aside by anyone holding a shell in
that home directory, and a plugin its subject can rewrite enforces nothing. A host that mounts the
code somewhere the user cannot write, and names that path here, closes all three. Plugins found this
way cannot be uninstalled (the directory is read-only, and the code is not the user's to delete); the
enable toggle still applies, and SHOFER_DISABLED_PLUGINS still wins over everything.
AI billing is a third, separate consent. permissions.ai alone is not enough: ctx.ai only
becomes live after the user grants the "uses AI (billed)" consent in the Plugins panel (a
distinct toggle from enable). Granted-but-unconsented gives a denying ctx.ai stub; consent can be
revoked at any time. Plugins never receive raw API keys. One exception to the default: a bundled
plugin declaring defaultEnabled: true is consented by default — a plugin the product ships on
should not sit inert behind a second approval — and the user's explicit revocation in the Plugins
panel still wins, exactly like the explicit-off that beats defaultEnabled itself.
The plugin-API version is also enforced: a plugin whose shoferPluginApiVersion is incompatible with
the host (major mismatch, or the host is older than the minor/patch it needs) is refused at load,
before any of its code runs.
A distributable plugin is a .shofer-plugin file — a gzip tarball of the plugin directory's
contents (the plugin.json, code, and asset dirs sit at the archive root; there is no wrapping
directory). The archive must contain a valid plugin.json at its root. Unpacking is hardened against
zip-slip (absolute paths, .. segments, symlink/hardlink entries are all rejected).
# Install from a .shofer-plugin archive, an unpacked plugin directory, OR a direct http(s) URL
shofer plugin install <source> [--enable] [--overwrite] [--allow-insecure-http]
# List installed plugins and their state (enabled / disabled / inactive)
shofer plugin list [--json]
# Remove a plugin (deletes its dir, drops it from the enabled list)
shofer plugin remove <name><source> may be a local .shofer-plugin archive, an unpacked plugin directory, or a direct
http(s) URL to a .shofer-plugin archive. A URL source is downloaded and unpacked through the
same manifest-validation / zip-slip-hardened path as a local archive; https is required unless
the host is loopback or you pass --allow-insecure-http (see the https/size policy below).
The CLI installs into the global dir (~/.shofer/plugins/<name>) and shares the enabled
allow-list with the running app, so a plugin installed/enabled here is picked up by the extension and
the Plugins settings tab unchanged. Without --enable, a freshly installed plugin stays disabled
(the per-plugin consent gate). --overwrite replaces an already-installed plugin of the same name
(the upgrade path).
Install/uninstall are CLI verbs and declarations only — there is no webview
install surface. The Settings → Plugins tab shows the discovered-plugin list
with enable/disable toggles, schema-driven config editing, the settings-tab UI
region, and the "uses AI (billed)" badge + consent affordance
({ action: "setAiConsent" }). Freshly installed plugins stay disabled (the
per-plugin consent gate), so you enable them there afterward.
Direct-URL install is supported; a remote registry is not. There is no
shofer plugin search/install name@version/ hosted directory — install is a local archive, a local directory, or a direct archive URL. Registry lookup + a trust/signing chain stay deferred.
The CLI (shofer plugin install <URL>) goes through the host-agnostic core helper:
httpsonly by default. A plainhttp://URL is refused unless the host is loopback (localhost/127.0.0.1) or, on the CLI,--allow-insecure-httpis passed.- Size-capped — the download is bounded (default 64 MiB) to prevent an oversized-response DoS.
- Validated + hardened — the fetched bytes are unpacked through the same
.shofer-pluginpipeline: a validplugin.jsonat the archive root is required, and unpacking rejects absolute paths,..segments, and symlink/hardlink entries (zip-slip hardened).
The discovery directories in §1 and the CLI installs
in §8 cover a plugin whose code is already
present as an installed directory. A .shofer/plugins.json declaration covers
the complementary need — stating which plugins a scope wants, from where,
and at which version — without committing the plugin bytes. "Declare, don't
vendor": only the declaration lives in .shofer/, so the tree stays text-only,
reproducible, and zip/overlay-able. Plugin config already flows through
.shofer/settings.json (pluginConfigs); the declaration adds the missing
source/version/enablement lockfile.
.shofer/plugins.json is validated fail-closed (pluginDeclarationSchema in
plugin-declaration.ts):
{
"version": 1,
"plugins": {
"git-guard": { "source": "./plugins/git-guard", "version": "1.0.0", "enabled": true },
"acme-ci": {
"source": "/opt/plugins/acme-ci.shofer-plugin",
"version": "2.1.0",
"config": { "baseUrl": "https://ci.acme.example" }
}
}
}Each entry is { source, version, config?, enabled? }:
source— a local directory path or a local.shofer-pluginarchive path, or anhttp(s)URL to such an archive resolve. A URL is downloaded under the same policy asshofer plugin install <URL>(https-only unless the host is loopback, size-capped) and unpacked through the same hardened path, so manifest validation and zip-slip protection apply wherever the bytes came from. A content-addressed URL — one whose filename issha256-<hex>.shofer-plugin— additionally pins the bytes: the resolver verifies the digest and refuses a mismatch, so a URL that starts serving different code fails the load instead of silently swapping it. A pinned URL is also the one case where plainhttpis accepted: the digest IS the integrity proof, so tampering in transit fails the check and installs nothing. An unpinnedhttpURL is still refused — there, nothing would detect a swap. The pin rides in the filename because this schema is.strict()and parsing fails closed — an unknowndigestkey would discard every declaration in the file, not just be ignored. A source that cannot be materialized at all (missing path, unreachable URL, digest mismatch) raisesPluginResolveError, isolated per-declaration so it never blocks discovery of the physically-present plugins.version— the version the resolver materializes under.config— the user's config overrides, merged with the manifest'sconfigdefaults (see §3).enabled— defaults totrue.
The three scopes' declarations are cross-merged per plugin name by
mergePluginDeclarations, under the same locked.json engine as the rest of
the layered config (see
configuration.md):
- Unlocked (or global does not declare it) → more-specific wins:
project ?? user ?? global. A user/project may always add plugins the global scope did not declare. - Locked (
plugins/<name>in the global scope'slocked.json) → the global scope's entry wins and is final; user/project entries for that name are dropped, and the plugin is force-enabled with its declared config authoritative per key. This is the governance payoff: a read-only org-global.shofer/can mandate "these plugins, these versions, this config — non-negotiable", while users still add their own unlocked plugins.
resolvePluginDeclaration materializes each declared source@version into a
content-addressed cache dir <globalStorage>/plugins-cache/<name>@<version>/ — a
directory source is copied, a .shofer-plugin archive is unpacked, and an
already-materialized dir is reused idempotently. The materialized plugin.json is
validated against the manifest schema and its name checked against the declaration
key; a mismatch skips that one plugin with a warning.
The host loader (pluginDeclarationLoader.ts)
then folds the resolved plugins into PluginManager discovery: each cache dir is
appended to the scan list (alongside the bundled, global ~/.shofer/plugins/, and
project <ws>/.shofer/plugins/ dirs), its declared config seeds pluginConfigs
(the user's stored values win per key for an unlocked plugin; the declaration wins
per key for a locked one), and an enabled !== false (or locked) plugin is enabled.
The path is purely additive — with no plugins.json anywhere it resolves
nothing and discovery is unchanged. A declared plugin still passes through the same
enable/permission/AI-consent gates as any other (§7).
A guardrail plugin: a status tool, a beforeToolCall hook that blocks force-pushes, and a config
flag. It uses permissions.tools + permissions.lifecycle + permissions.network.
plugin.json
{
"name": "git-guard",
"version": "1.0.0",
"shoferPluginApiVersion": "1.0.0",
"description": "Blocks risky git commands and reports CI status.",
"main": "index.ts",
"permissions": {
"tools": true,
"lifecycle": true,
"network": ["https://ci.acme.example"]
},
"config": {
"type": "object",
"properties": {
"blockForcePush": { "type": "boolean", "default": true }
}
}
}index.ts
import { defineCustomTool, parametersSchema as z } from "@shofer/types"
import type { PluginContext, ShoferPlugin } from "@shofer/types"
const plugin: ShoferPlugin = {
name: "git-guard",
async initialize(ctx: PluginContext) {
ctx.host?.notifier.info("git-guard active")
},
registerTools(ctx: PluginContext) {
return [
defineCustomTool({
name: "ci_status",
description: "Fetch the latest CI status for a branch.",
parameters: z.object({ branch: z.string().describe("Branch name") }),
async execute({ branch }) {
// host.fetch is scoped to permissions.network
const res = await ctx.host!.fetch(`https://ci.acme.example/status?branch=${branch}`)
return await res.text()
},
}),
]
},
lifecycle: {
// Allow / modify / block — here we block `git push --force`.
async beforeToolCall(toolName, args, ctx) {
const blocking = ctx.config?.blockForcePush !== false
const cmd = typeof args.command === "string" ? args.command : ""
if (blocking && toolName === "execute_command" && /\bpush\b.*--force\b/.test(cmd)) {
return { allow: false, reason: "git-guard: force-push is blocked by policy." }
}
return { allow: true }
},
},
}
export default pluginInstall and enable it:
shofer plugin install ./git-guard --enableNow the model sees a ci_status tool (network-scoped), and any attempt to run git push --force via
execute_command is short-circuited with the guard's reason — unless the user sets
blockForcePush: false in the plugin's config.
plugins/basics/ (its checkpoints feature) is per-task undo history — shadow-git snapshots, the timeline
row with diff/restore, cleanup on task deletion — implemented entirely on the public
surface. It is the reference for a plugin that owns a feature rather than adding a tool:
| What it does | Extension point |
|---|---|
| Snapshot before a file-mutating tool | lifecycle.beforeToolCall, awaited, once per ctx.turn, under a manifest hookTimeoutMs |
| An anchor per user message | lifecycle.onUserMessage |
| The checkpoint row in the chat | ctx.task.marker + a chat-message-addon bundle |
| Diff / restore from that row | handleRequest + api.request (incl. the local: and mutates conventions) |
| Rewinding the conversation | ctx.task.rewind |
| Rolling the workspace back on a message delete/edit | lifecycle.onTimelineRewind |
| Dropping a deleted task's shadow repo | lifecycle.onTaskDeleted |
| Rendering a computed diff | ctx.host.editor.showMultiFileDiff |
| Being on out of the box | manifest defaultEnabled (bundled scope only) |
It also shows the packaging end: build-ui.mjs bundles both the UI and main.mjs (with
simple-git inlined), so the plugin ships with no node_modules and packs to a single
.shofer-plugin archive.
The repo ships plugins/live-memory/ — a real, first-party plugin that re-implements the core of
Shofer's built-in Live Memory using only the public plugin surface (no reach into
@shofer/core internals). It is the reference for the Phase-6/7 capabilities, exercising most of
them in one plugin. Read its source; it is the canonical worked example.
Its plugin.json grants tools, systemPrompt, lifecycle, events, ai, and
filesystem: ["."], and each capability maps to a public extension point:
| What it does | Extension point |
|---|---|
The ask_live_memory tool |
registerTools |
| A memory section appended to the prompt | transformSystemPrompt (gated on ctx.ai?.hasConsent(), not a bare !!ctx.ai) |
| Answering / summarizing from memory | ctx.ai.buildHandler (never sees keys) + the billed-calls consent |
| Persisting observations + Q&A across restarts | ctx.storage (its own traversal-blocked dir) |
| Observing external edits | ctx.host.watch(glob, cb) — uses the path-carrying cb(event: { path, type }) to record which file changed |
| Observing Shofer's own file activity | lifecycle.afterToolCall (+ beforeTaskStart / afterTaskComplete, onEvent) |
| Periodic background compaction of the memory log | ctx.registerService (a supervised { name, start, stop } service) |
Key files: plugins/live-memory/main.ts (the ShoferPlugin), memory-store.ts (the ctx.storage
wrapper), memory-llm.ts (the ctx.ai calls), system-section.ts (the prompt section), and
plugin.json (the manifest + config schema). plugins/live-memory/DOGFOOD.md documents the
full built-in → plugin-API mapping, the reduced-fidelity notes, and the one genuine gap (the
external-edit watch granularity) that Phase 7's path-carrying watch closed.
- Manifest schema,
ShoferPlugin,PluginContext,PluginHost,PluginAi,PluginStorage,PluginService,PluginUIApi:packages/types/src/plugin.ts - Runtime (manager/loader/registry/sandbox/ai/storage/services/pack):
packages/core/src/plugins/ - CLI:
apps/cli/src/commands/plugin/ - UI:
webview-ui/src/components/settings/PluginsSettings.tsx,webview-ui/src/components/plugins/(PluginSlot, component resolver) - Worked examples:
plugins/live-memory/(+plugins/live-memory/DOGFOOD.md) andplugins/basics/(+ itsDESIGN.md) — the latter is a whole feature set (per-task undo history) living outside core onbeforeToolCall+ctx.task+onTimelineRewind+handleRequest.