Plugins
A plugin is a folder that brings skills, commands, subagents, hooks and MCP servers into a session together. The plugins option loads one from a path on disk, and its contents are named after it.
Last updated: 20 Oct, 2026 · Claude Agent SDK
A skill folder travels one skill at a time. A plugin is how a team ships a set of them, with the hooks and servers they need, as one thing another project can point at.
Building a plugin folder
The manifest is optional. With one, its name is the plugin's name; without one, the folder's name is used and the components are found from the layout.
{
"name": "hilltop",
"description": "The Hilltop shop's own skills and commands.",
"version": "1.0.0"
}---
name: greet
description: Open a conversation the way the Hilltop shop opens one.
---
Say exactly: Hilltop Shop here. Which order can I look up for you?The folder is shop-plugin and the manifest says hilltop, which is worth doing once to see which of the two names the session uses.
Naming a plugin path in the options
from claude_agent_sdk import ClaudeAgentOptions
from flags import flags
# type must be "local": a marketplace plugin is downloaded first, then loaded by path
options = ClaudeAgentOptions(plugins=[{"type": "local", "path": "./shop-plugin"}])
print(flags(options))Each plugin becomes one --plugin-dir. A relative path is read against cwd, an absolute path is taken as it is, and a ~ is not expanded: plugins.
Teaching the desk to load one
shop_desk.py gains find_plugins, roots is replaced so that a loaded plugin's folder joins the list, and start is replaced again so that the init message carries the plugins. json is added to the imports.
def find_plugins(self):
"""The plugins that loaded. A path that is not there is skipped, as the SDK skips it."""
loaded = []
for spec in self.options.plugins:
path = Path(self.options.cwd or ".") / spec["path"]
if not path.is_dir():
continue
# the manifest is optional: without one the folder's own name is the plugin's
manifest = path / ".claude-plugin" / "plugin.json"
name = json.loads(manifest.read_text())["name"] if manifest.exists() else path.name
loaded.append({"name": name, "path": str(path.resolve())})
return loaded # a plugin's own folder, and its name in front of everything it carries
return found + [(Path(p["path"]), p["name"] + ":") for p in self.loaded] async def start(self, prompt):
self.said.append(prompt)
if not self.tools:
self.tools = await self.find_tools()
# plugins first: their folders are two of the places a skill can come from
self.loaded = self.find_plugins()
self.found = self.find_skills()
await self.send({"type": "system", "subtype": "init", "model": "shop-desk",
"session_id": self.session_id, "tools": list(self.tools),
"skills": sorted(self.found), "plugins": self.loaded,
"slash_commands": sorted(set(self.found) | set(self.command_files()))})Because roots is where both skills and command files are looked up, one added line gives the plugin both.
Two runs, one plugin apart
from claude_agent_sdk import ClaudeAgentOptions
from shop_tools import shop
from watch import watch
# one option apart: the same desk with the plugin folder named, and without it
loaded = ClaudeAgentOptions(mcp_servers={"shop": shop}, setting_sources=["project"],
plugins=[{"type": "local", "path": "./shop-plugin"}])
bare = ClaudeAgentOptions(mcp_servers={"shop": shop}, setting_sources=["project"])await watch("/hilltop:greet", loaded)system init assistant Hilltop Shop here. Which order can I look up for you? result success: Hilltop Shop here. Which order can I look up for you?
await watch("/hilltop:greet", bare)system init assistant Which order is this about? result success: Which order is this about?
- With the plugin loaded the name resolves and the skill's line comes back.
- Without it the same name matches nothing, so it is passed on as text and the desk asks which order this is about.
- The namespace came from the manifest, not the folder: the command is
/hilltop:greetalthough the folder isshop-plugin.
Confirming a plugin loaded
The init message carries a plugins list beside the skills, and a plugin's skills appear with the prefix. A path that is not there is skipped and the session carries on, which is why the list is worth reading.
from claude_agent_sdk import SystemMessage, query
from shop_desk import ShopDesk
# the init message is the one place that says which plugins were read
async def loaded_names(options):
async for message in query(prompt="Hello", options=options,
transport=ShopDesk(options)):
if isinstance(message, SystemMessage) and message.subtype == "init":
print([one["name"] for one in message.data["plugins"]], message.data["skills"])
await loaded_names(loaded)['hilltop'] ['hilltop:greet', 'refund-policy']
missing = ClaudeAgentOptions(mcp_servers={"shop": shop}, setting_sources=["project"],
plugins=[{"type": "local", "path": "./no-such-plugin"}])
await loaded_names(missing)[] ['refund-policy']
No error, no warning: an empty list and the project's own skill. The documentation adds a plugin_errors field on the init message, which names the reason when a path exists but could not be read.
What a plugin folder can carry
| Folder or file | What it adds | Does the desk read it |
|---|---|---|
skills/<name>/SKILL.md | A skill, named <plugin>:<name> | Yes |
commands/<name>.md | A command file, with the same prefix | Yes |
agents/<name>.md | A subagent the session can start | No |
hooks/hooks.json | Shell hooks on tool calls | No |
.mcp.json | MCP servers the plugin brings | No |
The last three are shown here and not run. Loading them means the real Claude Code process: it is the side that starts a shell hook and connects to an MCP server, and the stand-in desk reads neither file.
Where a plugin beats a folder of skills
- A team's shared way of working, kept in one repository other projects point at.
- A set of skills that needs hooks or an MCP server to be useful, so they travel together.
- A plugin installed through the CLI, reused from Python by passing its path under
~/.claude/plugins/.
/greet for a skill the manifest named hilltop matches nothing, and the session spends a model turn on it instead of telling you the name was wrong.Related
- Previous: Commands
- Next: Subagents
- Reference: Plugins in the SDK
- Delete the manifest and run the first example again. What is the prefix now?
- Add
shop-plugin/commands/stock-check.mdand send its namespaced name. - Pass the same plugin twice and read the init list.
This is what real progress feels like.