Interpretive guidance for designing Claude Code plugins. Helps you understand plugin architecture, marketplace distribution, and when plugins are the right pattern. Use when creating or reviewing plugins.
Inherits all available tools
Additional assets for this skill
This skill inherits all available tools. When active, it can use any tool Claude has access to.
readme-style.mdThis skill provides interpretive guidance and best practices for creating Claude Code plugins. It helps you understand what the docs mean and how to create excellent plugins.
| If you need to... | Go to section |
|---|---|
| Understand plugin fundamentals | Core Understanding |
| Configure plugin.json | Plugin Manifest |
| Write the plugin README | readme-style.md |
| Bundle MCP servers with plugin | MCP Server Configuration |
| Set up marketplace distribution | Marketplace Distribution |
| Decide if plugin is right pattern | Decision Framework |
| Avoid common mistakes | Common Pitfalls |
| Validate before publishing | Quality Checklist |
Minimal plugin structure:
my-plugin/
├── .claude-plugin/
│ └── plugin.json # {"name": "my-plugin", "version": "1.0.0"}
├── commands/
│ └── my-command.md
├── agents/
│ └── my-agent.md
└── skills/
└── my-skill/
└── SKILL.md
Critical rule: Components (commands/, agents/, skills/, hooks/) MUST be at plugin root, NOT inside .claude-plugin/.
Validation:
claude plugin validate .
Claude Code changes rapidly and is post-training knowledge. Fetch these docs when creating plugins to ensure current syntax:
Key insight: Plugins don't add new capabilities to Claude Code. They package existing extension points (commands, agents, skills, hooks, MCP servers) for distribution.
What this means:
Decision test: If you're asking "should this be a plugin?", you're asking the wrong question. Ask "should I package these existing components for sharing?"
The #1 mistake that causes plugins to install but not work:
✅ Correct:
plugin-name/
├── .claude-plugin/
│ └── plugin.json ← Only metadata here
├── commands/ ← At plugin root
├── agents/ ← At plugin root
├── skills/ ← At plugin root
└── hooks/ ← At plugin root
❌ Wrong (won't work):
plugin-name/
└── .claude-plugin/
├── plugin.json
├── commands/ ← Won't be found!
└── agents/ ← Won't be found!
Official docs say: "All other directories (commands/, agents/, skills/, hooks/) must be at the plugin root, not inside .claude-plugin/."
Skills use subdirectories with a SKILL.md file:
skills/
├── skill-one/
│ ├── SKILL.md ← Skill definition
│ └── helpers.py ← Supporting files (optional)
└── skill-two/
└── SKILL.md
Located at .claude-plugin/plugin.json:
| Field | Type | Purpose |
|---|---|---|
| version | string | Semantic versioning (e.g., "2.1.0") |
| description | string | Brief plugin purpose explanation |
| repository | string | Source code location |
| Field | Type | Purpose |
|---|---|---|
| author | object | Author details: {name, email, url} |
| homepage | string | Documentation URL |
| license | string | License identifier (MIT, Apache-2.0, etc.) |
| keywords | array | Discovery tags for categorization |
Override default locations:
Note: Custom paths supplement default directories, they don't replace them.
Best practice: Always include description and version even though they're optional - improves discoverability and user trust. Do NOT include author, repository, homepage, license, or keywords unless explicitly specified.
Use ${CLAUDE_PLUGIN_ROOT} in paths to reference the plugin's absolute directory.
Specify additional locations for components:
{
"name": "my-plugin",
"commands": ["./extra-commands", "./legacy/commands"],
"agents": "./custom-agents"
}
All paths must be relative to plugin root and start with ./
Define hooks or MCP servers directly in plugin.json instead of separate files:
{
"name": "my-plugin",
"hooks": {
"PreToolUse:Bash": "./hooks/bash-guard.sh"
},
"mcpServers": {
"custom-server": {
"command": "node",
"args": ["./server.js"]
}
}
}
CRITICAL: When configuring MCP servers in plugins, follow these security, transport, and maintainability patterns.
Note: This section covers bundling MCP servers WITH plugins. For adding MCP servers to any project (not plugins), load the mcp-config skill instead.
For current MCP configuration syntax, fetch:
Claude Code supports three MCP transport types. Choose based on server location:
| Transport | Use Case | Example |
|---|---|---|
| HTTP | Remote/cloud servers (preferred) | https://mcp.notion.com/mcp |
| Stdio | Local processes | npx -y @modelcontextprotocol/server-github |
| SSE | Legacy remote servers (deprecated) | Use HTTP instead |
Key insight: Claude Code supports HTTP transport natively - no proxy needed for remote servers (unlike Claude Desktop which requires mcp-proxy).
For plugins bundling remote services:
{
"cloud-service": {
"type": "http",
"url": "${SERVICE_MCP_URL}",
"headers": {
"Authorization": "Bearer ${SERVICE_TOKEN}"
}
}
}
For plugins bundling local tools:
{
"local-tool": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}"
}
}
}
Decision framework:
type: http with URLcommand with stdio/mcp after plugin installFor MCP servers requiring OAuth (not API keys):
.mcp.json/mcp to authenticate"Plugin config (no credentials needed):
{
"oauth-service": {
"type": "http",
"url": "https://mcp.service.com/mcp"
}
}
README instructions:
## Authentication
After enabling plugin, run `/mcp` in Claude Code to authenticate with Service Name.
CRITICAL: The .mcp.json file alone does nothing. You MUST reference it in plugin.json:
// plugin.json - mcpServers field REQUIRED
{
"name": "my-plugin",
"mcpServers": "./.mcp.json"
}
// .mcp.json (at plugin root) - wrapped format per official docs
{
"mcpServers": {
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}"
}
}
}
}
Format note: Official docs show wrapped format. Empirically, Claude Code also accepts flat format ({ "server-name": {...} } without mcpServers wrapper). Recommend wrapped to match official docs.
Common mistake: Creating .mcp.json without adding "mcpServers": "./.mcp.json" to plugin.json. The file will be ignored.
Bug: #9427 - url field doesn't interpolate ${VAR} in plugin .mcp.json files.
| Field | Plugin Interpolation |
|---|---|
url | ❌ Broken |
args | ✅ Works |
env | ✅ Works |
Workaround: Use mcp-proxy via stdio instead of native HTTP transport:
// BROKEN
{
"mcpServers": {
"my-server": {
"type": "http",
"url": "${SERVER_URL}api/mcp"
}
}
}
// WORKAROUND
{
"mcpServers": {
"my-server": {
"command": "npx",
"args": ["-y", "@anthropic-ai/mcp-proxy", "http", "${SERVER_URL}api/mcp"]
}
}
}
Remove this workaround when #9427 is fixed. Revert to native HTTP transport: "type": "http", "url": "${VAR}..."
❌ WRONG - Inline configuration:
{
"name": "my-plugin",
"mcpServers": {
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": ""
}
}
}
}
Why external files are better:
✅ CORRECT - Environment variable reference:
{
"server-name": {
"command": "server-binary",
"env": {
"API_KEY": "${API_KEY}",
"AUTH_TOKEN": "${AUTH_TOKEN}",
"DATABASE_URL": "${DATABASE_URL}"
}
}
}
❌ WRONG - Hardcoded empty strings:
{
"server-name": {
"env": {
"API_KEY": "",
"AUTH_TOKEN": "",
"DATABASE_URL": ""
}
}
}
❌ VERY WRONG - Hardcoded actual secrets:
{
"server-name": {
"env": {
"API_KEY": "sk_live_abc123..."
}
}
}
Why environment variables are critical:
${VAR_NAME} is the established MCP patternIn your README, always include:
## MCP Server Setup
This plugin includes the [Server Name] MCP server.
### Prerequisites
- [Tool requirements, e.g., Node.js, Python]
- [Service account or API access]
### Configuration
1. Obtain credentials:
- Visit [credential URL]
- Create [token/API key] with `scope1`, `scope2` permissions
2. Set environment variables:
```bash
export API_KEY="your_key_here"
export AUTH_TOKEN="your_token_here"
**Required documentation elements:**
- Prerequisites (tools, accounts, permissions)
- How to obtain credentials with specific scopes/permissions
- Exact environment variable names to set
- Example export commands
- When the server starts (usually: when plugin enabled)
#### Common MCP Server Types
**GitHub:**
```json
{
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}"
}
}
}
Filesystem:
{
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/allowed/path"],
"env": {}
}
}
Database:
{
"database": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-postgres"],
"env": {
"DATABASE_URL": "${DATABASE_URL}"
}
}
}
Custom server using plugin root:
{
"custom": {
"command": "${CLAUDE_PLUGIN_ROOT}/servers/custom-server",
"args": ["--config", "${CLAUDE_PLUGIN_ROOT}/config.json"],
"env": {
"CUSTOM_API_KEY": "${CUSTOM_API_KEY}"
}
}
}
Before publishing plugin with MCP servers:
Security:
${ENV_VAR} references (never hardcoded)Structure:
.mcp.json or custom path)./Documentation:
Testing:
Located at .claude-plugin/marketplace.json:
Required fields:
".." since marketplace.json is in .claude-plugin/)Example marketplace:
{
"name": "marketplace-name",
"owner": { "name": "owner-name", "url": "https://..." },
"metadata": {
"pluginRoot": ".."
},
"plugins": [...]
}
Plugin entry fields:
{
"name": "plugin-name",
"source": "./plugins/plugin-name",
"description": "Optional override",
"strict": true
}
Path resolution: Plugin source paths are resolved relative to pluginRoot. Since marketplace.json lives in .claude-plugin/, the ".." tells Claude Code to go up one level to the repository root before resolving ./plugins/... paths.
Local path (development):
"source": "./plugins/my-plugin"
GitHub repository:
"source": {
"source": "github",
"repo": "owner/repo"
}
Git URL:
"source": {
"source": "url",
"url": "https://gitlab.com/user/plugin.git"
}
strict Fieldstrict: true (default): Plugin must have its own plugin.jsonstrict: false: Marketplace entry serves as complete manifestUse strict: false when: Packaging third-party content without modifying it.
Create plugin when:
/plugin install)Don't create plugin when:
Good plugin scope:
Bad plugin scope:
mcp-config skill)The docs show the mechanics. Here's the philosophy:
.claude/ directories for iterationclaude plugin validate . command/plugin marketplace add ./path/to/marketplaceKey insight: Don't start with plugin structure. Build components, then package them.
Key constraint: Claude Code plugins distribute via git clone - you can't exclude files like npm's .npmignore or Python's pyproject.toml exclusions.
Keep tests in the repo but minimize their footprint:
my-plugin/
├── .claude-plugin/plugin.json
├── hooks/
│ └── my-hook.py
├── tests/ # Development tests
│ └── test_my_hook.py # Lightweight, focused
└── README.md # Document that tests/ is for dev
Guidelines:
tests/ directory at plugin roottests/ is for development onlyThe disk space impact is negligible for most plugins:
For Python hook scripts using UV:
# Run tests directly
python -m pytest tests/
# Or test the hook script itself
echo '{"tool_name": "Write", "tool_input": {"file_path": "test.py"}}' | ./hooks/my-hook.py
For bash hooks:
# Test with sample input
echo '{"tool_name": "Bash"}' | ./hooks/my-hook.sh
echo $? # Check exit code
Best practice: Test hooks with realistic stdin JSON matching what Claude Code provides.
Validate plugin structure:
claude plugin validate .
Checks JSON syntax and directory structure.
Add local marketplace for testing:
/plugin marketplace add ./absolute/path/to/marketplace
Install from local marketplace:
/plugin install plugin-name@marketplace-name
Verify installation:
Check /help for newly installed commands and agents.
Problem: Putting commands/agents inside .claude-plugin/
Why it fails: Claude Code only looks at plugin root for components
Solution: Keep .claude-plugin/ for metadata only, put all components at root
Problem: Creating plugin for single command or concept
Why it fails:
Better: Share the markdown file directly, create plugin later if collection grows
Problem: "All my utilities" plugin with unrelated components
Why it fails:
Better: Focused plugins (testing tools, git helpers, docs generators)
Problem: Plugin without README or usage examples
Why it fails:
Solution: Focused README with:
Anti-pattern: Don't over-document. No roadmaps, planned features, or verbose error handling sections.
Problem: Breaking changes in minor/patch versions
Why it fails: Users expect:
1.0.0 → 1.0.1 - Bug fixes only, safe to auto-update1.0.0 → 1.1.0 - New features, backward compatible1.0.0 → 2.0.0 - Breaking changes, needs migrationSolution: Follow semver strictly, document breaking changes
Embedded (monorepo approach):
marketplace/
├── .claude-plugin/marketplace.json
└── plugins/
├── plugin-one/
└── plugin-two/
Pros:
Cons:
External (distributed approach):
{
"plugins": [
{
"name": "plugin-one",
"source": {
"source": "github",
"repo": "org/plugin-one"
}
}
]
}
Pros:
Cons:
Official mechanism: Configure .claude/settings.json at repository level:
{
"extraKnownMarketplaces": ["owner/marketplace-repo"],
"enabledPlugins": ["plugin-name@marketplace-name"]
}
Best practices to consider:
Before publishing:
Structure (from official docs):
plugin.json at .claude-plugin/plugin.json.claude-plugin/)claude plugin validate . passes./Metadata (best practices):
Documentation (best practices):
Design (best practices):
Basic (what docs show):
{
"name": "my-plugin",
"description": "A plugin",
"version": "1.0.0"
}
Issues:
Excellent (applying best practices):
{
"name": "python-testing-suite",
"version": "1.0.0",
"description": "Comprehensive Python testing tools with pytest integration, coverage reporting, and failure analysis",
"repository": "https://github.com/username/python-testing-suite",
}
Improvements: