Module 12 · 45 min

Build the plugin

Assemble the eight directories you already wrote into one installable plugin and validate it.

Surface
.claude-plugin/plugin.json
Ships to your plugin
the manifest

Everything you have written since module 4 is already sitting in the right shape. A commands/ directory, a skills/ directory with a SKILL.md inside it, a theme, an output style, an agent, a hooks.json, and an .mcp.json. Those are not eight unrelated exercises. They are the eight directories a Claude Code plugin loads, and the only thing missing is a file that gives them a name.

That file is .claude-plugin/plugin.json. It is one JSON object with one required key, and the name for a file like it, one that names a package and describes what is inside, is a manifest.

The assembled tree

example-plugin/
├─ .claude-plugin/
├─ commands/
├─ skills/
│ └─ review-changes/
│ ├─ scripts/
├─ themes/
├─ output-styles/
├─ agents/
├─ hooks/
.claude-plugin/plugin.json ships to your plugin

Module 12. The only required key is name. Everything else is metadata or a path override.

{
"name": "diff-review",
"displayName": "Diff Review",
"version": "1.0.0",
"description": "Reviews uncommitted changes before you commit them.",
"author": {
  "name": "Learn Claude Code",
  "url": "https://github.com/learn-claude-code/diff-review"
},
"license": "MIT",
"keywords": ["review", "git", "diff", "pre-commit"],
"experimental": {
  "themes": "./themes/"
}
}
Read more

The name is kebab-case and becomes the namespace for every component in the plugin, so the skill below is invoked as /diff-review:review-changes. Themes sit under experimental.themes because the docs warn that the top-level themes key still works today, warns under plugin validate, and will be required to move in a future release.

The reference build. Every directory here came out of an earlier module. Click plugin.json first.

The manifest

name is the only required field. A manifest of {"name": "diff-review"} is valid and loads every directory above, because all of them sit at their default locations.

Everything else falls into two groups. Fields that describe the plugin to a person reading about it and change nothing about how it runs, which is what metadata means: displayName, description, version, author, homepage, repository, license, keywords. And path fields, which change where Claude Code looks for your files: skills, commands, agents, hooks, mcpServers, outputStyles, experimental.themes.

The path fields have one asymmetry worth knowing before you reach for them.

FieldEffect on the default directory
skillsAdds to the default skills/ scan
commandsReplaces default commands/
agentsReplaces default agents/
outputStylesReplaces default output-styles/
experimental.themesReplaces default themes/

Point commands at a second directory and the files in commands/ stop loading. Point skills at one and both directories load. That difference is not a typo in the docs, and it is the kind of thing --strict will not catch for you because both spellings are legal.

Two fields depend on which Claude Code version the user is running. defaultEnabled decides whether the plugin starts enabled on install and needs v2.1.154 or later; older versions ignore it and enable anyway. version pins the plugin to that string, so users receive an update only when you bump it.

Namespacing

Once a component lives in a plugin, meaning any of the skills, commands, agents, themes or servers the tree holds, it answers to a prefixed name. Giving names a prefix so that two sets of them can coexist is namespacing, and the prefix is the namespace. Here it is the manifest’s name, not the directory the plugin happens to be checked out into.

ComponentOutside a pluginInside diff-review
Skill/review-changes/diff-review:review-changes
Command file/diff-stat/diff-review:diff-stat
Agent@agent-risk-reviewer@agent-diff-review:risk-reviewer
Themecustom:review-duskcustom:diff-review:review-dusk
MCP toolmcp__repo-files__read_filemcp__plugin_diff-review_repo-files__read_file

The docs are explicit that plugin skills are always namespaced, and the reason is collision: two plugins can both ship a skill called deploy and neither has to care. It also means a plugin skill can never be shadowed by a project skill of the same short name, while a plugin agent can be, because project and user .claude/agents/ definitions override same-named plugin agents.

Running it before you package it

There is no install step for local development. Point the CLI at the directory:

claude --plugin-dir ./example-plugin

The plugin loads for that session. You can pass the flag more than once, and you can pass a .zip or a --plugin-url pointing at one. When a --plugin-dir plugin shares a name with an installed marketplace plugin, the local copy wins for that session, which makes this the way to test a change against a version your team already has.

Reload behaviour is uneven and it will confuse you once. Edits to a plugin’s SKILL.md take effect immediately. Edits to hooks/, .mcp.json, agents/, and output-styles/ do not; run /reload-plugins or restart.

There is a third route that skips both the flag and the marketplace. Any folder under a skills directory that contains .claude-plugin/plugin.json loads on the next session as a plugin named <name>@skills-dir. Drop this directory into ~/.claude/skills/ and it is available in every project with no install and no manifest registration anywhere.

Path variables

Two variables exist inside a plugin that you have not had before, and they answer different questions.

${CLAUDE_PLUGIN_ROOT} is the full path, written from the top of the filesystem down, to the directory the plugin was installed into. It changes on every update, so it is where your code lives, never where your data lives. Hook commands, monitor commands, and MCP and LSP server subprocesses all substitute it.

${CLAUDE_PLUGIN_DATA} is a persistent directory under ~/.claude/plugins/data/{id}/ that survives plugin updates. Caches, downloaded dependencies, and anything a user would be annoyed to lose belong there.

${CLAUDE_PROJECT_DIR} still resolves to the project root, and is what the .mcp.json above uses to scope the filesystem server.

Quote them. The docs write the hook command as "\"${CLAUDE_PLUGIN_ROOT}\"/scripts/format-code.sh" because the shell form is a single string and an installation path with a space in it splits into two arguments otherwise.

Distribution

A marketplace is a repository that lists plugins other people can install, and it declares that list in .claude-plugin/marketplace.json at its root. Three required fields: name, owner (an object whose name is required), and plugins (an array). Each entry needs name and source.

{
  "name": "learn-claude-code",
  "owner": { "name": "Your Team", "email": "[email protected]" },
  "plugins": [
    {
      "name": "diff-review",
      "source": "./example-plugin",
      "description": "Review uncommitted changes before you commit them",
      "version": "1.0.0"
    }
  ]
}

source accepts a relative path, a github object, a git url, a git-subdir, an npm package, an archive with a sha256, or a command that produces the plugin. Your users then add the marketplace once and install by name:

claude plugin marketplace add your-org/claude-plugins
claude plugin install diff-review@learn-claude-code

Or a team pins both in .claude/settings.json under extraKnownMarketplaces and enabledPlugins, and nobody runs a command at all.

Install scope decides who ends up with the plugin, and it is the same layering you met in module 6. user is the default and covers every project. project writes to .claude/settings.json and is shared through git. local stays in this repository and out of the commit. managed is read-only and comes from an administrator.

Validation

claude plugin validate ./example-plugin
claude plugin validate ./example-plugin --strict

Without the flag you get ✔ Validation passed or ✔ Validation passed with warnings, and warnings do not fail the run. --strict turns every warning into an error, and the docs name its purpose directly: catching misspelled field names in continuous integration, the automatic checks a project runs on every push, usually shortened to CI. A descrpition key is not an error to a JSON parser and it is not an error to the loader either. It is a field that silently does nothing until --strict tells you about it.

Run the same command against a marketplace directory and it checks JSON syntax, duplicate plugin names, whether a source path climbs out of the marketplace directory, which the docs call path traversal, the validity of each plugin’s own plugin.json, and version mismatches between a plugin.json and its marketplace entry.

On claude plugin eval

You will see this command referenced. It does not appear anywhere in the public documentation: not in the plugins pages, not in the plugins reference, not in the docs index. Treat it as undocumented or early-access, and do not build a CI gate on it.

claude plugin validate --strict is the gate that exists and is documented. Wire that one into CI.

Build

Create .claude-plugin/ at the root of the directory you have been filling since module 4, and write plugin.json with name, description, version, and author. Start there and add nothing else.

Run claude --plugin-dir ./your-plugin. Invoke your skill by its namespaced name and confirm the old bare name no longer resolves. Type /plugin and read the Errors tab even when nothing looks wrong.

Then move your hook’s script into the plugin, rewrite its command to use ${CLAUDE_PLUGIN_ROOT} with the quotes, and run /reload-plugins. Trigger the hook and confirm it still fires from the new path.

Finish with claude plugin validate ./your-plugin --strict and fix whatever it names.

The mistake most people make first

You have a .claude-plugin/ directory holding the manifest, so you put the rest of the plugin in there too. .claude-plugin/commands/, .claude-plugin/skills/, .claude-plugin/hooks/. It reads like the tidy choice.

The plugin loads. Validation passes. No error appears anywhere, and the Errors tab in /plugin is empty. Your skills are not there, and the first sign is a slash command that does not autocomplete.

The docs call this out under the heading “Common mistake”: only plugin.json goes inside .claude-plugin/, and every other directory sits at the plugin root. The root is the directory you pass to --plugin-dir or the one containing .claude-plugin/plugin.json. It is never ~/.claude/.

The reason this bites so hard is that a plugin with zero components is a legal plugin. Nothing in the system can tell the difference between a manifest you meant to be empty and one whose directories are one level too deep.

Does this travel?

The plugin as an artifact travels worse than anything else in this course, with one real exception. Five plugin ecosystems launched in the last year and they are mutually incompatible: Cursor’s marketplace reads .cursor-plugin/plugin.json, Codex has codex plugin marketplace add, Gemini CLI has extensions that bundle skills and a hooks/hooks.json, and Cline ships a plugin software development kit, a set of TypeScript tools for writing one. Same concept, incompatible manifests.

The exception is Copilot. Its Agent Plugins system became generally available on 2026-08-12 and it loads .claude-plugin/plugin.json manifests directly, with claude-code-plugins listed as an addable marketplace. That is the only documented cross-vendor plugin bridge in the survey.

What actually transfers is the layer underneath. Your SKILL.md directories are read unmodified by four harnesses, your MCP server is a real program that works in all nine harnesses that support MCP, and the hook contract of lifecycle event to shell command to exit 2 blocks converged across six of them. Package it as a plugin for Claude Code, and unbundle the same directories anywhere else.

What a plugin cannot carry

Look at the tree again and count the modules. Two are missing.

Module 3 wrote CLAUDE.md and .claude/rules/. There is no rules/ directory in a plugin and no manifest field that points at one. Module 11 wrote permission rules and sandbox settings. A plugin can ship a settings.json at its root, and exactly two keys in it are supported: agent and subagentStatusLine. Everything else is silently ignored.

A plugin is a thing you install into someone else’s machine, and the question of what Claude is allowed to do on that machine is not yours to answer. Capabilities are distributable. Guidance and policy are not.

So when you hand this plugin to a teammate, they get your skill, your agent, your hook, and your MCP server. They do not get your CLAUDE.md, and they keep their own deny rules. The hook you shipped still runs, and it still cannot escape their permission settings, for exactly the reason module 11 gave: a hook is not a security boundary.

Check yourself

  1. Your manifest sets "commands": "./extra/". What happens to the four files in commands/?
  2. A teammate installs your plugin and has their own .claude/agents/risk-reviewer.md. Which agent runs when they type @agent-risk-reviewer?
  3. Your CI runs claude plugin validate ./plugin and it passes. You then rename description to descrption in the manifest. Does CI still pass?