Module 40 · 45 min

Ship the Plugin

You can run `claude --plugin-dir .` in a plain terminal, get the same skills, agent and hook the app has, and name the three things that stay behind.

Surface
.claude-plugin/plugin.json · the manifest
Workbench tag
module-40
Claude Code
v2.1.251 (bundled)
claude-agent-sdk
v0.3.251
Docs checked
2026-08-30

Nothing in this module adds a feature. The app already has its skills, its one delegated agent, and its open-questions hook, and they have worked inside the app for ten modules. This module changes where they can run.

A plugin is a folder Claude Code can load from any directory, so the skills you wrote for this app become skills a teammate can use in a project that has nothing to do with chat. The repository has been a loadable plugin since module 30, the running server hands its own folder to the SDK as a local plugin every time it starts. What was never done is load it from somewhere else, on purpose, and see it work.

The one file that makes this possible is the manifest: .claude-plugin/plugin.json, the single file that names what the plugin ships. It sits in a .claude-plugin/ folder, but everything it points at lives at the repository root, the parent of the skills/, agents/ and hooks/ a plugin loads from. That root is the plugin root, and getting the two straight is half of what this module teaches. The other half is subtraction: three things this app leans on do not travel with the plugin, and naming them is how you learn what a plugin is for.

The mechanism is one command. Point a plain terminal at the folder and the plugin loads for that session:

claude --plugin-dir .

Its parts arrive under a namespace, a prefix, the manifest’s name, stuck on the front of every component so two plugins can each ship a skill called capture without colliding. This app’s name is chat-workbench, so its reader agent loads as chat-workbench:reader.

The manifest names three surfaces and nothing else

Read .claude-plugin/plugin.json top to bottom. It is short on purpose: a manifest should say what the plugin carries and stop. Three surfaces, the word for a kind of thing a plugin can ship, appear here, and they are every capability this plugin has.

chat-workbench/
├─ .claude-plugin/
└─ tools/
└─ hooks/
.claude-plugin/plugin.json

The whole manifest. Three surfaces, resolved from the plugin root, and nothing spurious.

{
"name": "chat-workbench",
"version": "0.40.0",
"description": "The chat app the Build the Chat Plugin track builds: a multi-agent chat workbench with a schema-governed knowledge base.",
"author": {
  "name": "Learn Claude Code"
},
"skills": ["./.claude/skills/"],
"agents": ["./.claude/agents/reader.md"],
"hooks": {
  "SessionStart": [
    {
      "hooks": [
        {
          "type": "command",
          "command": "node \"${CLAUDE_PLUGIN_ROOT}/tools/hooks/open-questions.mjs\"",
          "timeout": 10,
          "statusMessage": "Reading the blocking questions"
        }
      ]
    }
  ],
  "UserPromptSubmit": [
    {
      "hooks": [
        {
          "type": "command",
          "command": "node \"${CLAUDE_PLUGIN_ROOT}/tools/hooks/open-questions.mjs\"",
          "timeout": 10,
          "statusMessage": "Checking the open questions for this prompt"
        }
      ]
    }
  ]
}
}
The plugin's manifest at tag module-40, and the hook it points at. The manifest is shown whole; the hook is shown at the lines that decide where it looks for its data.

The official plugins reference is the source for the manifest fields, and it states the rule the manifest above follows: the surfaces live at the plugin root, not inside .claude-plugin/. The .claude-plugin/ folder holds the manifest; skills/, agents/ and hooks/ sit beside it, one level up from the JSON.

Prove it loads, twice

There are two claims here, and they are not the same. The first is that the manifest is well-formed. The second is that a session actually loads what it names. Check the first with the validator, which reads the manifest and every path it points at without needing a network or a key:

claude plugin validate --strict .

--strict turns every warning into an error, so a pass means no misspelled field and every declared surface resolves to a real file. Run against this repository on 2026-08-30, the bundled offline binary printed exactly this and exited 0:

Validating plugin manifest: E:\Projects\chat-workbench\.claude-plugin\plugin.json

✔ Validation passed

The second claim needs a real session. A claude --plugin-dir . -p "reply ok" session returned ok and exited 0. A second print session, asked which plugin-namespaced components it could see, reported chat-workbench:reader, chat-workbench:capture-fact and chat-workbench:kb-freshness-audit, the three surfaces, each under the namespace, none invented. That is the first time the load itself, rather than the manifest’s shape, was watched from this repository. The validator can pass on a manifest that then loads nothing useful; the namespaced list coming back complete is the check that tells the two apart.

The break: a hook that reads the working directory

Here is the mistake most people make first when they ship a hook. The hook needs a file, this one reads the open questions from knowledge-base/, and the quick way to find that file is to look in the current directory:

const REPO_ROOT = process.cwd();   // the break

Inside the app this works every time, because the server runs from the repository root, so the working directory is the root and the file is right there. Then someone runs claude --plugin-dir . from their own project folder. Now the working directory is their project, not this repository. The file is not there. And because this hook fails open by design, a hook that guards nothing should never become a wall, it injects nothing, exits 0, and reports no error anywhere.

A break with no error is the worst kind, because nothing tells you it happened. The green validator does not tell you. The successful load does not tell you. The only symptom is a reminder that quietly stopped appearing. The shipped hook does not have this bug: line 9 in the file above computes REPO_ROOT from fileURLToPath(import.meta.url), the script’s own location, which does not change with the directory the user is standing in. The manifest’s ${CLAUDE_PLUGIN_ROOT} gets the command to the right script; the script’s own path resolution is how it then finds its data. Both have to be right. A hook that reads cwd throws away the location the manifest just went to the trouble of resolving.

You can see the break yourself without touching the shipped file: copy the hook, swap line 9 for process.cwd(), run it under --plugin-dir from any folder that is not this repository, and watch it inject nothing and say nothing.

Three things that stay behind

A plugin distributes capabilities. It does not distribute the guidance and policy that decide how those capabilities are used on a particular machine, because that is the installing machine’s call, not the author’s. Three things in this repository sit on the far side of that line, and each stays behind on purpose.

WhatWhere it livesWhy it cannot travel
The agent personasAppended to the system prompt at spawn by the server (module 31).A plugin has no rules/ directory and no field for project memory. There is no CLAUDE.md here, and even one would not auto-load under a plugin. The personas travel with the running app, not the plugin.
The Stop hook.claude/settings.json (module 38).The manifest does not carry it, and a plugin’s own settings.json could not either, it supports only agent and subagentStatusLine. It was placed here so the person a blocking hook happens to can see it and switch it off.
The permission rulesServer configuration: the approval policy and the SILENT_TOOLS / OUTBOUND_TOOLS lists (module 39).They decide what Claude may do on this machine, which is the machine’s decision, not the plugin’s. A teammate who loads the plugin keeps their own rules.

The mail server is absent too, for a different reason: it is built in-process by createSdkMcpServer and handed straight to the SDK, so there is no .mcp.json file for the manifest to point at. It is a capability, but it is not a file-shaped one, so it travels with the app that builds it.

chat-workbench/
└─ .claude/
.claude/settings.json

The Stop hook lives here, not in the plugin manifest, by the choice module 38 recorded.

{
"hooks": {
  "Stop": [
    {
      "hooks": [
        {
          "type": "command",
          "command": "node \"${CLAUDE_PROJECT_DIR}/tools/hooks/enforce-capture.mjs\""
        }
      ]
    }
  ]
}
}
The settings file that holds the turn-ending hook, the one capability that stays out of the manifest so the person it acts on can turn it off.

The three-way split is the same principle seen three times. Capabilities are the plugin’s to ship, the skills, the agent, the injecting hook, and in a plugin that had one, an MCP server. Guidance and policy are the installing machine’s to keep, the personas, the turn-ending hook, the permission lists. Trying to ship the second kind either fails quietly, like a CLAUDE.md with nowhere to load, or takes a decision away from the person who should be making it, like a blocking hook installed on everyone.

Does it transfer?

The manifest format and --plugin-dir are Claude Code’s, so the exact JSON does not move to another harness. The division under it does. Every agent framework that lets you package and share extensions runs into the same wall between what a package may do and what the host machine may allow, and the ones that ignore the wall are the ones that ship a blocking policy to everybody and call it a default. Learn module 12 states this verdict for the reference plugin in the first track: a teammate gets your skill, your agent, your hook and your MCP server, and keeps their own CLAUDE.md, permissions and sandbox. This module is that verdict made specific, three named things, each in the file that keeps it out of the plugin, each with a reason a beginner can repeat.