Skip to content
Developer Preview

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.

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.

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" });

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.

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.

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.

  • 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/list and there is no input part for them. Send the expanded prompt as a text part if you need the same effect.
  • Plugin management. There is no method to install, list, approve, enable, disable, update or remove plugins. Use muse plugins on the host machine, with --json if 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.

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.

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

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.