Claude Agent SDKclaude-agent-sdk 0.2.157 · Python 3.10+
Dashboard
0%
1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
46 small wins to finish your pathNext lesson →

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.

A plugin folder holds an optional plugin.json manifest that names the plugin, a skills folder whose SKILL.md files become namespaced commands Claude may also invoke, a commands folder of flat Markdown files, an agents folder of subagent definitions, a hooks file and an MCP server file; a plugin path that is not there is skipped and is missing from the init message.

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.

Exampleshop-plugin/.claude-plugin/plugin.json
{
  "name": "hilltop",
  "description": "The Hilltop shop's own skills and commands.",
  "version": "1.0.0"
}
Exampleshop-plugin/skills/greet/SKILL.md
---
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

python
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.

Exampleshop_desk.py, a new method
    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
Exampleshop_desk.py, the line that now ends roots
        # 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]
Exampleshop_desk.py, start
    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

python
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"])
Example
await watch("/hilltop:greet", loaded)
Example
await watch("/hilltop:greet", bare)
  • 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:greet although the folder is shop-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.

Example
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)
Example
missing = ClaudeAgentOptions(mcp_servers={"shop": shop}, setting_sources=["project"],
                             plugins=[{"type": "local", "path": "./no-such-plugin"}])

await loaded_names(missing)

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 fileWhat it addsDoes the desk read it
skills/<name>/SKILL.mdA skill, named <plugin>:<name>Yes
commands/<name>.mdA command file, with the same prefixYes
agents/<name>.mdA subagent the session can startNo
hooks/hooks.jsonShell hooks on tool callsNo
.mcp.jsonMCP servers the plugin bringsNo

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/.
Watch out. A plugin skill is invoked with its prefix. Sending /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.
Try it yourself
  • Delete the manifest and run the first example again. What is the prefix now?
  • Add shop-plugin/commands/stock-check.md and send its namespaced name.
  • Pass the same plugin twice and read the init list.
PreviousCommands

This is what real progress feels like.