Plugins in SDK sessions
After reading this page you can list the skills an installed plugin contributes to a served session, invoke one of them from your own client, and recognise plugin MCP tool calls and their approvals on the view stream. The plugin surface is part of the Developer Preview and may change.
What this page covers
Section titled “What this page covers”Plugins are installed and reviewed on the machine that runs muse serve,
with the muse plugins commands. The wire exposes their effects, not their
management: your client sees plugin skills in the skill catalog, plugin MCP
tools as ordinary tool calls, and nothing else. This page walks through each
of those effects and names what stays invisible.
If you have not written or installed a plugin yet, start with Quickstart: your first plugin. If you have never driven a session from code, start with the quickstart.
The host reads the same plugin store as the CLI
Section titled “The host reads the same plugin store as the CLI”muse serve and muse plugins read one plugin store under your user data
directory. A plugin you installed with muse plugins install is available to
the sessions that host serves, and a capability you approved with
muse plugins approve is active in new sessions. There is no separate
registration step for served sessions, and there is no way to install,
approve, enable or remove a plugin over the wire.
The host reads the store when it starts, so the order is: install, approve,
then start muse serve. A host that is already running is not guaranteed to
see a store change; restart it after you install, update, approve or remove a
plugin.
| Capability | What a served session needs |
|---|---|
| Skills and commands | The plugin installed and enabled before the host started. |
| Hooks, MCP servers, reminders and agent definitions | The same, plus muse plugins approve before the host started. |
To check what a host will load, run muse plugins list --json on that
machine before you start it;
The muse plugins command documents every field
of that output.
Start sessions with a workspaceRoot
Section titled “Start sessions with a workspaceRoot”Pass workspaceRoot on
session/start. The plugin
store is host-scoped, so plugin skills list even for a session without a
root, but such a session has no workspace: the host loads no project skills
and composes no workspace tooling for it. Pass an absolute root so the
session sees what the terminal UI would see in that directory. The field is
optional on the wire, which is why this is easy to miss.
const session = await client.startSession({ workspaceRoot: "/absolute/path/to/project" });Discover plugin skills with skill/list
Section titled “Discover plugin skills with skill/list”skill/list returns the
session’s user-invocable skills as one row per shortcut spelling. It is a
query, not a command, so it takes only a sessionId
(SkillListParams) and
returns a skills array
(SkillListResult).
Each row is a
SkillCatalogEntry:
| Field | Meaning |
|---|---|
selector |
The token you submit to invoke the skill, without the leading slash. Unique within one response. |
source |
Where the skill comes from, a SkillSource: bundled, user, project or plugin. Treat the set as open. |
pluginId |
The owning plugin’s id. Present when source is plugin. |
displayName |
The name a palette shows. |
description |
The palette summary. |
argumentHint |
Present when the skill declares an argument hint. |
For the plugin built in Quickstart: your first plugin the two rows are:
{ "selector": "forecast", "displayName": "forecast", "description": "Explain a weather forecast in plain language", "source": "plugin", "pluginId": "weather" }{ "selector": "weather:forecast", "displayName": "forecast", "description": "Explain a weather forecast in plain language", "source": "plugin", "pluginId": "weather" }When a skill declares metadata.short-description, that is what
description carries.
A plugin skill can appear twice. If it wins its bare name it has a row such
as deploy; every plugin skill also has a qualified row such as
acme:deploy, spelled <pluginId>:<skillId>. When a project, user or
built-in skill claims the same bare name, the plugin skill keeps only the
qualified row. Prefer the qualified selector in code you ship: it does not
change when a same-named skill appears in the workspace.
Filter on source to find plugin rows:
const result = await msp.connection.request("skill/list", { sessionId });const skills = result.skills as Array<{ selector: string; source: string }>;const fromPlugins = skills.filter((row) => row.source === "plugin");Invoke a plugin skill with a skill input part
Section titled “Invoke a plugin skill with a skill input part”Submit the selector as a skill part in the input array of
turn/start. A
TurnInputPart with
type skill carries the
selector and optional free-text arguments, the same text a user would type
after the shortcut. The host resolves and expands the skill; your client
never reads the skill body.
{ "type": "skill", "selector": "acme:deploy", "arguments": "staging" }The host enforces these rules at admission:
| Rule | What happens otherwise |
|---|---|
At most one skill part per submission. |
Rejected as invalid params. |
A skill part may be combined only with image parts. |
A text part alongside it is rejected as invalid params. |
skill parts are accepted on turn/start only. |
turn/steer rejects them as invalid params. |
A skill part cannot ride ifBusy: "steer". |
Rejected as invalid params. Use queue (the default) or replace. |
The selector must be one skill/list returned for this session. |
skillNotFound, code -32032. |
After admission the turn behaves like any other: turn/started, item events,
and a turn/completed terminal, as described in
Sessions and turns.
Handle skillNotFound and skill/changed
Section titled “Handle skillNotFound and skill/changed”A selector the session does not know fails the request with
skillNotFound. The
error’s data.selector echoes the token you sent. The right reaction is to
call skill/list again and pick from the fresh rows, because the catalog can
change while your process is running: project skills in the workspace can be
added or edited, and skills can be turned off on the host.
The host tells you when that happens with
skill/changed. Its
params (SkillChangedParams)
carry only the sessionId. The notification is advisory: it has no cursor,
the host may coalesce bursts, and it is not ordered relative to view events.
Do not fold it into state. Treat it as a trigger to call skill/list again.
It is only delivered to a connection subscribed to that session’s view.
Plugin MCP tools are ordinary tool calls
Section titled “Plugin MCP tools are ordinary tool calls”Once a plugin MCP server is approved, it starts with each new session and its
tools are registered before the first model request. On the view stream they
look like any other tool call: an Item
with kind toolCall, opened by
item/started and
closed by item/completed.
The item’s tool field carries the registered name, which follows this
pattern:
mcp__plugin_<pluginId>_<serverId>__<tool>For a plugin acme declaring a server docs that exposes a search tool,
the name is mcp__plugin_acme_docs__search. Nothing on the item says
“plugin” beyond that prefix.
Every character in the plugin id, server id or tool name that is not an ASCII
letter, digit or underscore is replaced by _ in the registered name: a
server forecast-api in plugin weather exposing get.forecast registers
as mcp__plugin_weather_forecast_api__get_forecast. Match on the registered
name, not on a name you rebuild from the ids.
Approvals arrive the way they do for every tool: an
approval/requested
view event whose subject has
kind tool and a toolName, answered with the ordinary decide flow in
Approvals. Your existing approval handler
needs no plugin-specific branch.
One exception: a tool that the approved server itself annotates as read-only
(readOnlyHint) is admitted without an approval request under the default
on-request approval mode, so your handler will not see it. An explicit deny
or prompt rule for the tool still wins.
Plugin MCP server entries have no env, headers or credentials, so
authenticated servers belong in settings.json instead.
What you will not see on the wire
Section titled “What you will not see on the wire”- Hooks. Plugin hooks run inside the host process. No method, notification or item names a hook, and a hook that blocks or adds context is visible only through its effect on the turn.
- Plugin slash commands. Command templates from a plugin are a
first-party composer feature. They are not rows in
skill/listand there is no input part for them. Send the expanded prompt as atextpart if you need the same effect. - Plugin management. There is no method to install, list, approve,
enable, disable, update or remove plugins. Use
muse pluginson the host machine, with--jsonif you script it. - Reminders and agent definitions. These are advanced, first-party oriented capabilities with no client-facing surface of their own.
Session mcpServers must not reuse a plugin server name
Section titled “Session mcpServers must not reuse a plugin server name”session/start accepts
config.mcpServers, a map of
SessionMcpServerConfig
entries added only to that session. Sending it requires the sessionMcp
capability: pass capabilities: { requestedCapabilities: ["sessionMcp"] }
on initialize, or the whole session/start fails with
capabilityRequired (code -32010) before any name check runs. The map keys
are checked against the host’s server names. A key that equals a server declared in the host’s
settings.json, a plugin server’s own id (such as docs), or its runtime key
(plugin:<pluginId>:<serverId>) fails the whole session/start with
commandRejected and the reason session_mcp_name_conflict. Pick keys that
cannot collide, for example by prefixing them with your application name.
Raw calls today
Section titled “Raw calls today”Neither SDK has a typed helper for skill/list yet. Call it through the
connection’s generic request, and put the skill part in the input you
already pass to sendUserTurn or send_user_turn. Both SDKs forward input
to the wire unchanged. clientInfo.name is a machine identifier matching
^[a-z0-9_]+$; a hyphen fails the handshake with invalid params.
@muse-code/sdk does not export the SkillCatalogEntry type yet; declare
the row shape yourself as below.
TypeScript
Section titled “TypeScript”MuseClient.spawn keeps its connection private, so build the client around a
connection you own when you also need raw requests.
import { MuseClient, MspError, readSessionDurability, spawnMspConnection } from "@muse-code/sdk";
// Shape of one `skill/list` row; see the SkillCatalogEntry reference page.type SkillCatalogEntry = { selector: string; source: string; pluginId?: string; displayName: string; description: string; argumentHint?: string;};
const handshake = spawnMspConnection({ command: museBin, args: ["serve"] });const msp = await handshake.initialize({ clientInfo: { name: "my_app", version: "1.0.0" },});const client = new MuseClient(msp.connection, { durability: readSessionDurability(msp.initializeResult),});const session = await client.startSession({ workspaceRoot });
// List, then pick a plugin row.const listed = await msp.connection.request("skill/list", { sessionId: session.sessionId });const skills = listed.skills as SkillCatalogEntry[];const deploy = skills.find((row) => row.source === "plugin" && row.selector === "acme:deploy");if (deploy === undefined) throw new Error("plugin skill not installed on this host");
// Invoke it. One skill part, nothing but image parts beside it.try { const turn = await session.sendUserTurn({ input: [{ type: "skill", selector: deploy.selector, arguments: "staging" }], }); const outcome = await turn.completed; console.log(outcome);} catch (error) { if (error instanceof MspError && error.kind === "skillNotFound") { console.warn(`re-list: ${String(error.data.selector)} is gone`); } else { throw error; }}
await client.close();Python
Section titled “Python”from muse_code import ( MuseClient, MuseClientOptions, SendUserTurnOptions, StartSessionOptions, read_session_durability,)from muse_code.connection import MspError, spawn_msp_connection
handshake = await spawn_msp_connection(muse_bin, args=("serve",))msp = await handshake.initialize( {"clientInfo": {"name": "my_app", "version": "1.0.0"}})client = MuseClient( msp.connection, MuseClientOptions(durability=read_session_durability(msp.initialize_result)),)session = await client.start_session(StartSessionOptions(workspace_root=workspace_root))
# List, then pick a plugin row.listed = await msp.connection.request("skill/list", {"sessionId": session.session_id})plugin_rows = [row for row in listed["skills"] if row["source"] == "plugin"]deploy = next((row for row in plugin_rows if row["selector"] == "acme:deploy"), None)if deploy is None: raise RuntimeError("plugin skill not installed on this host")
# Invoke it. One skill part, nothing but image parts beside it.try: turn = await session.send_user_turn( SendUserTurnOptions( input=[{"type": "skill", "selector": deploy["selector"], "arguments": "staging"}] ) ) outcome = await turn.completed print(outcome)except MspError as error: if error.kind != "skillNotFound": raise print(f"re-list: {error.data.get('selector')} is gone")
await client.close()When a tested recipe for this flow lands in the cookbook, prefer it over the snippets above.
Common mistakes
Section titled “Common mistakes”| Symptom | Cause | Fix |
|---|---|---|
skill/list returns no plugin rows although muse plugins list shows the plugin. |
The plugin was installed after the host started, or it is disabled. | Restart muse serve; check enabled=true active=true in muse plugins list. |
| Plugin MCP tools never appear. | The server still needs review, or the host started before the approval. | Run muse plugins approve <plugin-id>, restart muse serve, and start a new session. |
turn/start fails with invalid params. |
A text part next to the skill part, two skill parts, or ifBusy: "steer". |
Send one skill part with at most image parts, on turn/start, with queue or replace. |
skillNotFound for a selector that worked earlier. |
The plugin was updated, disabled or removed on the host. | Call skill/list again and use a current selector. |
session/start fails with capabilityRequired. |
config.mcpServers was sent without requesting the sessionMcp capability on initialize. |
Add sessionMcp to requestedCapabilities. |
session/start fails with session_mcp_name_conflict. |
A config.mcpServers key reuses a host or plugin server name. |
Rename the key. |
Next steps
Section titled “Next steps”- Quickstart: your first plugin to have something to list and invoke.
- Plugins for the capability families and the review model.
- Approvals for the decide flow plugin MCP tools share with every other tool.
- Sessions and turns for what
happens after
turn/startis admitted. - Cookbook for tested recipes you can copy.
- The
skill/listandturn/startreference pages for the full field lists.