Plugins
This page explains how to install and manage plugins in sMind by Spacing Mind (a preview feature): what a plugin can add, how to see what it does before enabling it, and how plugin processes run. It is for users and administrators.
sMind by Spacing Mind, version 0.17.0. This page describes the 0.17.0 release.
Plugins are a preview. A plugin is a directory with a smind-plugin.json file. Today a plugin can add five things: MCP servers, skills, agent profiles, prompt commands and themes, and it can declare a long-lived process that smind starts (see Plugin processes). A plugin can also show declarative panels in the web app (see Panels). Nothing else a manifest declares (providers) has an effect yet.
A plugin is trusted code you chose to run. An MCP server it adds starts as you, with your files and credentials, exactly like an MCP server you add by hand. smind shows what a plugin would run before you turn it on, but it does not sandbox anything.
Turn plugins on
Plugins are off until you set this in config.yaml:
plugins:
enabled: trueThe setting is read on every request, so a change applies without a restart. With it off, no plugin adds anything, even one that is enabled.
Installing, enabling, disabling, removing, updating and reloading a plugin are human actions. They need a loopback connection to the daemon, which includes a reverse proxy on the same machine. To manage plugins over a network login, set plugins.allowRemoteManage: true; it is off by default. Agents and paired phones cannot do any of this whatever the setting. A phone can still see the lists, offer plugin prompt commands and apply plugin themes; see Plugins on the phone.
smind does not stop an agent that runs as your user from reading the daemon's token or config.yaml. What an agent may read is decided by the agent's own sandbox and permission settings, so keep those strict if you run agents you do not trust.
Install and enable
smind plugin install ./acme
smind plugin enable acmeinstall copies the directory, records the plugin as disabled and prints what it would do. enable prints the same summary and asks you to confirm; --yes confirms without asking.
The summary lists each MCP server's command or URL and environment, each skill and each profile. A profile is flagged when it raises the permission mode, skips permission prompts, turns on orchestration tools or may launch child agents. An update that adds such a profile, or changes what a plugin runs, waits for you to confirm it again.
To work on a plugin without copying it, use smind plugin link <dir>. A linked plugin is read from its directory as it is, so the summary carries the warning "live directory: changes after consent are not re-reviewed", and smind plugin enable prints it.
smind plugin list, show, disable, remove, update and reload do what their names say. Disabling or removing a plugin takes its servers, skills and profiles away at once.
The Plugins page
Settings → Plugins shows what is installed and lets you manage it in the web app and the desktop app. The page cannot turn plugins on: with plugins.enabled off it shows Plugins are off, how to set it in config.yaml, and every action stays disabled until you edit the file.
Each plugin is a row with its name, version, where it came from (dir, link, git or archive) and its status: Enabled or Disabled, plus Pending update and Error when they apply. An error row shows the last error the daemon recorded. A linked plugin carries the line "Live directory: changes after consent are not re-reviewed". With no plugins the page says No plugins installed and shows smind plugin install <path>.
- Details (the arrow on the row) show the same summary
smind plugin enableprints: whether anything runs, the capabilities, each command, the hook events, the skills and the agents, with an agent that raises the permission mode, skips prompts, gets orchestration tools or may launch children marked Escalates. They also list what the plugin contributes, anything that was not applied, the last error, and when it was installed and updated. - Enable opens that summary in a confirmation (with the live-directory warning for a linked plugin). Nothing is enabled until you confirm. Disable needs no confirmation.
- The row's menu has Reload, Check for update (or Review update when one is pending) and Remove. Remove asks first and has a Keep plugin data box, off by default; a linked directory is never deleted.
- An update that changes what a plugin runs shows Update available with each change; Review and apply asks you to confirm the new summary.
- Install plugin takes a plugin directory, a Git URL pinned to a full commit or an archive pinned with
#sha256=. The plugin is installed disabled, and its details open so you can review it before enabling.
These actions are refused on a relay connection, and on a remote login unless plugins.allowRemoteManage is set. The page shows the daemon's message and becomes read-only. The page does not show process logs yet (it says "Process logs arrive with plugin processes."); use smind plugin logs <id>.
Anything a plugin added is labelled "from acme" (its id) in Settings → MCP servers and Settings → Agents, in the agent picker of the composer, and next to the plugin's skills when you choose skills for an agent. These rows cannot be edited, disabled or deleted there; the badge opens the plugin in Settings → Plugins.
The manifest
{
"id": "acme",
"name": "Acme review tools",
"version": "1.0.0",
"api": 1,
"engines": { "smind": ">=0.1.0" },
"description": "A reviewer seat, a skill and a notes server",
"contributes": {
"mcpServers": [
{ "name": "notes", "transport": "stdio", "command": ["npx", "-y", "notes-mcp"], "env": { "MODE": "readonly" } }
],
"skills": ["skills/review"],
"profiles": ["profiles/reviewer.json"],
"commands": [
{ "name": "review", "description": "Review the staged diff", "prompt": "commands/review.md" }
],
"themes": [{ "id": "dusk", "label": "Dusk", "file": "themes/dusk.json" }]
}
}id: lowercase letters, digits and-, starting with a letter.apimust be1.engines.smindis a version range the running smind must satisfy.mcpServers:name,transport(stdio,httporsse),commandas a list forstdioorurlforhttpandsse, and optionalenv(stdio) orheaders(http,sse).skills: each entry is a directory holding aSKILL.md. The directory name is the skill name.profiles: each entry is a JSON file withname,providerand, optionally,permissionMode,autoAccept,thinkingLevel,model,notes,onRateLimit(noneorwait),orchestrationTools,instructions,skills,childPolicyandchildProfiles(names of the plugin's other profiles).
{ "name": "reviewer", "provider": "glm", "instructions": "You review changes.", "skills": ["acme:review"] }commands: each entry has aname, an optionaldescriptionand aprompt, the path of a UTF-8 text file (up to 16 KiB). See Prompt commands.themes: each entry has anid, an optionallabel(up to 48 characters) and afile, a JSON color set. See Themes.
smind rejects a manifest at install time and names the field that is wrong.
What an enabled plugin adds
Everything a plugin adds is shown with its owner and cannot be edited or deleted while the plugin is enabled.
- MCP servers appear in
smind mcp lsasacme:notes, in the OWNER column asplugin:acme. New runs receive them like any other MCP server; the agent sees the server namedacme__notes. - Profiles appear in
smind profile lsasacme:reviewerand work anywhere a profile does, for examplesmind task send <taskId> "check the diff" --profile <profileId>. You cannot create a profile with the same name as a plugin's. - Skills are named
acme:review. A profile may listacme:reviewin itsskills. Every enabled plugin skill is also offered to new runs, read-only: smind never writes a plugin skill into~/.claude/skills, a repository's.claude/skillsor.agents/skills. Codex runs get no plugin skills.
A run keeps what it was started with. Disabling a plugin, removing it or turning the global setting off changes what the next run gets.
If you already have a profile with the name a plugin would use, yours wins and smind plugin show <id> --json lists the plugin's profile under conflicts.
Prompt commands
A prompt command puts text in the message box. Type / as the first character of the box and the list shows the commands of every enabled plugin, each with a quiet "from acme". The arrow keys move, Enter or Tab picks, Esc closes. Picking a command replaces the /word you typed with the command's file, exactly as it is written, and puts the caret after it. It never sends anything: you read the text, edit it and press send yourself. The command palette lists the same commands as "Insert command: /review", which insert into the message box you last used.
{
"id": "acme-demo",
"name": "Acme demo",
"version": "1.0.0",
"api": 1,
"engines": { "smind": ">=0.1.0" },
"contributes": {
"commands": [
{ "name": "review", "description": "Review the staged diff", "prompt": "commands/review.md" },
{ "name": "plan", "description": "Plan before coding", "prompt": "commands/plan.md" }
]
}
}with commands/review.md holding Review the staged diff. List bugs first, then risks, then style nits. Every command can be typed as /acme-demo:review. The short form /review goes to a built-in command with that name if there is one, otherwise to the first plugin, by id, that has a command called review; the others keep only the long form, and smind plugin show <id> --json lists the short name under conflicts. With no plugin commands the message box behaves as it always did. A command file is only text: it starts nothing and gets no access to anything. A command that asks a plugin process for its text is described under Plugin RPCs.
Panels
A plugin can declare panels: small pages made of a fixed set of building blocks (headings, text, markdown, lists, tables, forms, buttons, progress bars, code and diffs). The plugin sends data; the app draws it. A plugin never ships code that runs in the app.
What the web app shows for an enabled plugin:
- A panel with
where: "task"appears in the + menu of a task's tab strip and opens as a tab (Hellonext to your chats and files). The tab comes back after a reload, and closes by itself when the plugin is disabled or removed. - A sidebar item shows under Plugins above the footer and opens its panel as a full page with a back button.
- Every panel, whatever its
where, has an Open <title> entry in the command palette (Ctrl-K or Cmd-K). With a task selected it opens as a tab, otherwise as the full page; awhere: "sidebar"panel always opens as the full page. - A panel refreshes when its plugin asks for it, when you press the refresh button in its header, and after one of its buttons calls the plugin.
Buttons do one of five things, and only those:
| Button | What the app does |
|---|---|
| call the plugin | runs one of the plugin's own RPCs, then reloads the panel; a form adds the field values as values |
| insert a prompt | puts the text in the message box of the chat you are in. It is never sent: you read it and press send. With no chat open it tells you so |
| run a command | inserts a plugin prompt command the same way |
| open a panel | opens another panel of the same plugin |
| open a link | asks first: "Open example.com in your browser?" shows the host and the full address; nothing opens until you confirm |
The app never loads a remote image, runs a script or shows raw HTML from a panel. Markdown images appear as their alt text, links open only through the confirm above, and colors are the app's own theme colors, so a panel follows your light or dark theme. An element this version of the app does not know shows a dashed "Unsupported in this app version" note instead of breaking the panel, and a panel that needs a newer app version is not listed at all.
Themes
A theme file is JSON with a light and/or a dark object. Each object must set every color token of the web UI, 115 in all, and nothing else:
{
"dark": {
"--background": "rgb(26 22 37)",
"--foreground": "rgb(217 212 232)",
"--card": "rgb(39 32 54)"
}
}(That is the shape, not a working theme: all the tokens are required.) You do not need the list up front. Install a plugin whose scheme is {}, such as { "dark": {} }, and smind plugin install rejects it and lists every token that scheme is missing; install and update reject any incomplete scheme the same way, so a plugin never leaves part of the interface unstyled. A value is a hex color (# and 3, 4, 6 or 8 digits), rgb(), rgba(), hsl(), hsla() or oklch() with plain numbers, a plain length such as 12px, or transparent. Anything else (url(), var(), calc(), names, quotes, semicolons, braces) rejects the plugin.
An enabled plugin's themes appear in Settings, Appearance, under "Plugin theme", next to the plugin's name, on the web interface. A theme applies in the schemes it declares and leaves the other alone: pick a dark-only theme and the light theme stays the stock one. Choosing "Default" goes back, and so does disabling or removing the plugin or turning the global setting off: the choice is dropped and the stock theme returns.
Settings and secrets
A manifest may declare typed settings:
"settings": [
{ "key": "token", "type": "secret", "label": "API token", "required": true },
{ "key": "region", "type": "enum", "label": "Region", "options": ["eu", "us"], "default": "eu" },
{ "key": "retries", "type": "number", "label": "Retries", "default": 3 }
]The types are string, secret, boolean, number and enum (with options). string and secret are at most 1024 bytes unless maxLength says up to 8192. A secret cannot have a default: a manifest is not a place for one.
You set values with the plugin.settings.set request ({"id": "acme", "values": {"token": "..."}}; a null clears a key back to its default); plugin.settings.get lists the settings and which are set. There is no settings command or page yet. Setting values is a human action with the same limits as enable: loopback only, or a network login with plugins.allowRemoteManage; never an agent or a phone. A phone may read: it sees each secret as set: true and nothing else, and so does every other reader, you included. A value is validated against its declared type; an error names the setting, never the value, and a patch with one bad key changes nothing.
Values are stored in smind's database the way provider credentials and MCP environment values are, not encrypted separately. A value goes to exactly two places: the plugin's own plugin.initialize (see below) and ${setting.NAME} in the plugin's MCP server env or headers, and nowhere else. It never appears in plugin list, plugin show, events, errors or logs; a plugin's stderr is scrubbed of the secret values of the plugins you have set (values shorter than 8 characters cannot be scrubbed by match, so use long tokens). Removing a plugin deletes its values, even with --keep-data.
Changing a setting restarts the plugin's process, so it starts with the new values. A server that uses a placeholder whose setting has no value is listed but not given to runs; smind plugin show <id> --json says which setting is missing.
Plugin processes
A manifest may declare "process": { "command": ["node", "server.js"] }. While the plugin is enabled and plugins.enabled is on, smind starts that command as you, in the plugin's own directory, and keeps it running. It is a program you chose to run: the process can read your files and open network connections, and smind shows its command before you enable the plugin.
- No shell. The command is an argument list. A first entry with a slash (
./bin/server) is a program inside the plugin; a bare name (node) is looked up on a fixed systemPATH(/usr/local/bin,/opt/homebrew/bin,/usr/bin,/bin,/usr/sbin,/sbinon macOS and Linux), not on yours. - A clean environment. The process does not inherit smind's environment: no
SMIND_*value, no provider key, nothing from your shell. It gets thatPATH,HOMEset to the plugin'sdata/directory (and the temp directory inside it), and your locale (LANG,LC_*) andTZ. This hides smind's secrets from the environment only: a same-user process can still read files such as~/.smind/token. - Handshake. smind sends
plugin.initializewithapi,smindVersion,dataDirandsettings(your values with secrets in the clear, the defaults for what is unset) on the process's stdin, inContent-Length-framed JSON-RPC 2.0 (the framing language servers use), and the plugin has 10 seconds to answer it. Hooks are described under Hooks. - Limits. A frame body is at most 1 MiB and a header line 1 KiB. A malformed or oversize frame closes the connection and kills the process; it counts as a crash.
- Crashes. A process that exits is restarted after 1 s, 2 s, 4 s, ... up to 30 s. Five crashes within ten minutes mark the plugin failed: it stays enabled but is not restarted,
smind plugin showand the Plugins page show why, andsmind plugin reload <id>(or disable and enable) tries again. - Stopping. Disable, remove, reload, an applied update, switching
plugins.enabledoff and stopping the daemon sendplugin.shutdown, then SIGTERM to the process's whole group, wait 5 seconds, then kill the group (a job object on Windows), so nothing the plugin started is left behind. If the daemon itself is killed (SIGKILL, power loss) a Unix plugin process is not stopped by smind; on Windows the job object ends it. - Logs.
smind plugin logs <id>prints what the process wrote to stderr plus smind's own[smind]lines (started, ready, crashed, restarting). The log is kept in memory, at most 1000 lines or 256 KiB, each line cut at 2 KiB; control characters are removed and secrets are redacted before a line is stored. It is gone when the daemon restarts or the plugin is removed.
What a plugin may call
A plugin process can send requests back to smind. There is one table of what it may call, grouped by the capabilities its manifest declares and you confirmed:
| Capability | Methods |
|---|---|
tasks.read | task.list, task.get, task.tree, run.list, chat.list, workspace.list, space.list, space.get |
mcp.read | mcp.list, mcp.get |
profiles.read | profile.list, profile.get |
events.observe | events.subscribe, events.unsubscribe |
tasks.write | task.send (also needs tasks.read) |
Everything else is refused. A method in the table whose capability the plugin did not declare fails with forbidden (code -32001) naming the capability; any other method, including one smind adds later, fails with method not found. Whatever the plugin declares, it can never call the plugin, provider, account, relay, automation or settings methods, anything that writes your tasks, runs, MCP servers or profiles, or anything that answers or changes a permission request. Results are redacted copies: token shapes and home paths are removed and a task's setup output is left out. files.read and ui.notify can be declared but grant nothing yet; hooks.beforeRun grants no call, it lets the run.beforeStart hook run (see Hooks).
This is mediation, not isolation: a process running as you can still read your files, including smind's token.
Events. After events.subscribe {"topics": [...]} a plugin with events.observe receives event notifications {topic, seq, payload}. Only these topics are allowed: task.status, task.created, task.updated, task.archived, task.deleted, task.stalled, run.status, run.failover, permission.pending, permission.resolved, workspace.created, workspace.deleted, space.created, space.deleted, chat.created, chat.updated, chat.archived and plugin.updated. Delivery is live and best effort: nothing is replayed, at most 256 events wait per plugin, and a plugin that falls behind gets event.dropped {count}; one that stops reading never slows smind or other subscribers (after 10 seconds without reading, the connection is closed and the process counts as crashed). A plugin may observe that a permission request is pending, with its ids and option names, but not its summary, and it has no way to answer it.
task.send. With tasks.write (and tasks.read: a plugin may only message a task it could read) a plugin can queue one prompt: task.send {taskId, chatId?, prompt, whenBusy?, model?}. whenBusy is queue (the default) or reject; the chat must already have a provider. The message is stored with source plugin:<id> and delivered to the agent with a [message from plugin <id>] header, so the agent and you can tell it from your own text. The send cannot set the provider, the permission mode, auto-accept or a profile (those params are ignored), cannot create a task (so orchestration.maxDepth cannot be exceeded), and the run it starts is under the per-tree concurrency guard and the tree budget. It is limited to 6 sends a minute and 60 an hour per plugin and 20 an hour per task tree, and a plugin whose own messages started the last 3 runs of a chat may not send into it again until something else has run there: that is what stops a run.finished hook that answers every run with a send. A refusal is an error to the plugin (code -32003, saying which limit and when it frees up) and shows as the plugin's error in smind plugin show and the Plugins page.
Hooks
A plugin with a process can subscribe to two events in contributes.hooks; smind tells it with a request or notification on the same stdio connection, and no hook can approve, deny or answer a permission request (smind has no auto-approval), or rewrite a tool call.
run.finished(observe only).hook.run.finishedis a notification with{runId, taskId, chatId, provider, model, origin, status, stopReason?, failureKind?, toolCalls}: ids and states, never the prompt or the output, with secret shapes redacted. Delivery is best effort from a per-plugin queue (64 events, the newest is dropped when it is full), so a slow plugin never delays smind. System runs (learning, probes) are not reported.run.beforeStart(needs thehooks.beforeRuncapability). Just before an agent starts, smind sendshook.run.beforeStart {runId, taskId, chatId, workspaceId, provider, model, permissionMode, origin}and waits at most 10 seconds. The answer may hold exactly three fields and smind checks every one:additionalContext(string, at most 8 KiB): appended to the prompt the agent gets, in a block labeled[plugin <id>]...[end plugin <id>]. The run's stored prompt stays the sender's text, and the run's timeline says how many bytes were added, never the text.env(object of strings, at most 16 entries, values at most 4 KiB): added to the agent's environment. Names are upper case letters, digits and_; names smind, a provider or the loader use are refused (SMIND_*,ANTHROPIC_*,CLAUDE*,OPENAI_*,GIT_*,LD_*,DYLD_*,PATH,HOME, proxies and anything ending_KEY,_TOKEN,_SECRET,_PASSWORD), and a key that is already set is never overridden. Codex runs take no per-run environment.mcpServers(list of names): servers this same plugin contributes, by manifest name or<id>__<name>. Any other name, another plugin's included, is refused. Any other field (provider,cwd,model,profile,permissionMode,autoAccept, a permission decision, a rewritten input, anything unknown) fails the hook with a typed error naming the field; nothing from a failed answer is applied. With several plugins, hooks run in plugin id order and then in the order of the manifest; instructions are appended in that order, the first plugin to set an env key keeps it, and nothing is merged deeper than that.
Each hook has onError: open (the default) runs the task anyway and shows the failure on the plugin (lastError, the log and a note on the run), closed fails the start naming the plugin and the reason. A plugin that is not running (crashed, restarting, failed) or lacks the capability is skipped and the skip is shown; smind never waits for it. A hook that answers with a JSON-RPC error is a failure, which is how a closed hook stops a start. Names follow Claude Code's hook vocabulary where they mean the same (additionalContext; run.finished is its Stop); there are no exit codes, since a plugin answers over JSON-RPC.
Plugin RPCs
A plugin process may declare methods a client can ask it to run, in contributes.rpc:
"contributes": {
"rpc": [{ "name": "echoPrompt", "description": "Build a prompt", "timeoutMs": 5000 }],
"commands": [{ "name": "hello", "description": "Insert a greeting", "rpc": "echoPrompt" }]
}The wsapi method plugin.call {pluginId, rpc, params} sends rpc.<name> to the process and returns its JSON result. Only names declared in contributes.rpc are ever sent; anything else fails unknown_rpc without reaching the process.
- Who can call. You, in the web or desktop app or over a remote login, and a paired phone. Agents cannot, and one plugin cannot call another (a plugin's own calls to smind never include
plugin.*).plugins.allowRemoteManagedoes not apply: it governs installing and enabling, not asking an enabled plugin to do what it declared. - Limits. Params at most 64 KiB, a result at most 256 KiB (a bigger result is dropped and the call fails
call_too_large), JSON only, 4 calls in flight per plugin (the fifth failsplugin_busyat once), 30 s per call. A manifest may lower the timeout withtimeoutMsand can never raise it. - Errors are typed and name the plugin:
unknown_rpc,plugin_unavailable(off, disabled, not running, failed or gone during the call),call_timeout,call_too_large,plugin_busy,bad_call, andplugin_error(the process answered with an error; its text is cut to 300 characters and scrubbed of secrets). Each call is logged insmind plugin logs <id>with a scrubbed preview. - RPC-backed slash commands. A command with
"rpc"appears in the/list like a prompt command (web and phone), "from <plugin>". Picking it calls the RPC with{"command": "<plugin>:<name>", "args": ""}; the result must be{"prompt": "<text>"}(at most 16 KiB). The text is put in the message box as plain characters, in place of the/word, and is never sent; a failure shows under the list and leaves the box alone. The same name and collision rules as prompt commands apply. The command palette does not list RPC commands yet.
A small working plugin is in plugin-examples/hello-process/ in the repository; Writing a plugin walks through it.
Further reading
- Writing a plugin: the manifest reference, hooks, RPCs and a worked example.
- Plugin system decision record