Skip to content

Building and distributing a plugin or a private marketplace

A coding-agent plugin is a directory with a manifest (.claude-plugin/plugin.json, .codex-plugin/plugin.json or .cursor-plugin/plugin.json) plus skills, hooks and MCP configuration, published through a marketplace: a git repository with a catalogue file. Skills and MCP servers carry across Claude Code, Codex and Cursor; hooks do not, so each tool needs its own hook file and its own test.

Your team has a migration skill in one repository, a PreToolUse hook in someone’s ~/.claude/settings.json, and a staging-database MCP server that three people configured three different ways. A new hire gets none of it, and a fix to the hook reaches nobody. This page is for the developer who packages that setup once and the tech lead who owns the private marketplace it ships from.

What you get from packaging your team’s agent setup as a plugin

Section titled “What you get from packaging your team’s agent setup as a plugin”
  • A working plugin, acme-db-guard, that bundles a skill, a blocking hook and a read-only MCP server, with every file shown
  • A verification routine that proves the plugin works before anyone installs it: validate --strict, a hook test, claude plugin eval against a no-plugin baseline, and a context-cost reading
  • A private marketplace that Claude Code and Codex both install from, plus the managed settings that pin it for the whole team
  • A release rule for versions, a CI job that enforces it, and a table of what does and does not port between tools

Everything marked as run below was run on 2026-09-26 against Claude Code 2.1.283 (the scaffold step re-checked on 2.1.286) and codex-cli 0.157.1 in a scratch home directory. Cursor details come from Cursor’s first-party cursor/plugins repository; cursor.com was not reachable, so no Cursor step was run.

Should this be a plugin, a skill or an MCP server?

Section titled “Should this be a plugin, a skill or an MCP server?”

Build a plugin only when you need to ship parts that depend on each other under one version. A lone procedure is a skill; a lone connection is an MCP server.

Your situationBuild
One reusable procedure, used across several agentsA skill, installed with npx skills
One live connection to an internal APIAn MCP server, added per project
A procedure that only works with a hook or server beside itA plugin
The same setup must arrive on every laptop, and a fix must reach everyoneA plugin in a team marketplace
Vendor-specific behaviour, such as a PR botNone of these: configure the vendor product

The plugins overview covers installing other people’s plugins. This page covers building your own.

What goes into a plugin in Claude Code, Codex and Cursor?

Section titled “What goes into a plugin in Claude Code, Codex and Cursor?”

The component folders are the same idea in all three tools. The manifest location and the hook format differ.

acme-plugins/ # the marketplace repository
├── .claude-plugin/marketplace.json # catalogue for Claude Code (Codex also reads it)
├── .agents/plugins/marketplace.json # catalogue for Codex (optional)
└── plugins/acme-db-guard/
├── .claude-plugin/plugin.json # Claude Code manifest
├── .codex-plugin/plugin.json # Codex manifest (optional)
├── skills/new-migration/SKILL.md # Agent Skills format: portable
├── hooks/hooks.json # Claude Code hook format: not portable
├── scripts/guard-applied-migrations.sh
└── .mcp.json # MCP servers
PartClaude CodeCodexCursor
Manifest.claude-plugin/plugin.json.codex-plugin/plugin.json.cursor-plugin/plugin.json
Marketplace file.claude-plugin/marketplace.json.agents/plugins/marketplace.json, or the Claude file.cursor-plugin/marketplace.json
Skillsskills/<name>/SKILL.md, found automaticallyskills/; a "skills" path in the manifest adds to it"skills": "./skills/" in the manifest
Hookshooks/hooks.json, found automaticallyscaffolded by the plugin-creator skill; format not verified here"hooks": "./hooks/hooks.json", a different schema
MCP servers.mcp.json"mcpServers": "./.mcp.json"mcp.json (per cursor/plugins)
Plugin root variable${CLAUDE_PLUGIN_ROOT}not verified${CURSOR_PLUGIN_ROOT} (per cursor/plugins hooks.json)

Build the acme-db-guard plugin step by step

Section titled “Build the acme-db-guard plugin step by step”

The example guards database migrations. The skill tells the agent how to write a new migration, the hook blocks edits to migrations git already tracks, and the MCP server lets the agent inspect staging without write access.

  1. Scaffold the plugin. Start from each tool’s scaffolder, then replace the placeholder files with the ones below.

    Terminal window
    claude plugin init acme-db-guard --with skills hooks mcp --description "Migration guard"

    This writes a plugin to ~/.claude/skills/acme-db-guard/ that loads next session as acme-db-guard@skills-dir. With --with skills hooks mcp it also writes example files: delete the generated SKILL.md at the plugin root, skills/example/ and hooks-handlers/ (a SessionStart handler) before adding the files below, or you ship an example skill and a hook you never wrote (checked on 2.1.286). Move the folder into your marketplace repository when it works. For a guided build, plugin-dev@claude-plugins-official adds a /plugin-dev:create-plugin skill and a validator subagent; it costs about 2,349 always-on tokens, so enable it only while you author.

  2. Write the manifest. name becomes the namespace for every skill and command, so /acme-db-guard:new-migration is how a user calls the skill. Never rename a published plugin.

    plugins/acme-db-guard/.claude-plugin/plugin.json
    {
    "name": "acme-db-guard",
    "version": "0.1.0",
    "description": "Migration skill, schema guard hook and read-only Postgres MCP",
    "author": { "name": "Acme Platform Team" }
    }
  3. Add the skill. The description decides when the agent loads the skill, so name the trigger phrases.

    plugins/acme-db-guard/skills/new-migration/SKILL.md
    ---
    name: new-migration
    description: Use when the user asks to change the database schema, add a column, add an index or write a migration. Writes a new numbered file in db/migrations/ and never edits an applied one.
    ---
    # New migration
    1. List db/migrations/ and pick the next number.
    2. Write the forward migration only; create indexes with CREATE INDEX CONCURRENTLY in a migration of its own, because it cannot run inside a transaction.
    3. Run the migration against a scratch database and the test suite, and paste both results.
  4. Add the hook. The skill asks; the hook enforces. Exit code 2 blocks the tool call and sends the message on stderr back to the agent.

    plugins/acme-db-guard/hooks/hooks.json
    {
    "hooks": {
    "PreToolUse": [
    {
    "matcher": "Edit|Write",
    "hooks": [
    { "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}/scripts/guard-applied-migrations.sh\"" }
    ]
    }
    ]
    }
    }
    plugins/acme-db-guard/scripts/guard-applied-migrations.sh
    #!/usr/bin/env bash
    command -v jq >/dev/null && command -v git >/dev/null || { echo "acme-db-guard: jq and git are required" >&2; exit 1; }
    path=$(jq -r '.tool_input.file_path // empty')
    case "$path" in
    */db/migrations/*)
    if git ls-files --error-unmatch "$path" >/dev/null 2>&1; then
    echo "Blocked: $path is already committed. Write a new migration instead." >&2
    exit 2
    fi ;;
    esac
    exit 0

    Mark the script executable (chmod +x) before you commit it. The script needs jq and git on PATH; a plugin cannot install them, so the first line exits 1 with a message when either is missing. Exit 1 surfaces the error without blocking every tool call.

  5. Add the MCP server. Reference secrets as ${VARIABLE} so no credential enters the repository. Postgres MCP Pro is the PyPI package postgres-mcp, run through uvx; the npm package of the same name is unrelated. Its last PyPI release, 0.3.0, dates from 2025-05-16, so pin the version (postgres-mcp==0.3.0) and swap in another database server if it stays unmaintained.

    plugins/acme-db-guard/.mcp.json
    {
    "mcpServers": {
    "staging-db": {
    "command": "uvx",
    "args": ["postgres-mcp==0.3.0", "--access-mode=restricted"],
    "env": { "DATABASE_URI": "${ACME_STAGING_DATABASE_URI}" }
    }
    }
    }
  6. Load it for one session. claude --plugin-dir ./plugins/acme-db-guard loads the plugin without installing it. After an edit, /reload-plugins picks up the change without a restart.

Prove the plugin works before anyone installs it

Section titled “Prove the plugin works before anyone installs it”

A plugin runs on every teammate’s machine with their credentials, so “it loaded on mine” is not evidence. Run these four checks; together they replace reading the plugin line by line.

  1. Validate strictly. --strict turns warnings into a non-zero exit, so it catches unrecognised fields and missing metadata that the runtime tolerates.

    Terminal window
    claude plugin validate --strict ./acme-plugins
    claude plugin validate --strict ./acme-plugins/plugins/acme-db-guard

    Validate the marketplace and each plugin separately. Validation of the marketplace does not catch a source directory that does not exist; only the install fails.

  2. Test the hook without an agent. Feed the script the JSON a PreToolUse event sends and check the exit codes:

    Terminal window
    echo '{"tool_input":{"file_path":"'"$PWD"'/db/migrations/001_init.sql"}}' \
    | plugins/acme-db-guard/scripts/guard-applied-migrations.sh; echo "exit=$?"
    # Blocked: …/db/migrations/001_init.sql is already committed. Write a new migration instead.
    # exit=2

    A new, untracked file must return exit=0. The command above works only in a repository where db/migrations/001_init.sql is committed, and the CI job below runs in the marketplace repository, which has no such file. So the plugin’s own test script builds that fixture in a temporary repository and asserts both cases. Make it executable; the CI job runs it on every pull request.

    plugins/acme-db-guard/tests/hook.sh
    #!/usr/bin/env bash
    set -u
    guard="$(cd "$(dirname "$0")/.." && pwd)/scripts/guard-applied-migrations.sh"
    tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
    git -C "$tmp" init -q
    mkdir -p "$tmp/db/migrations"; echo x > "$tmp/db/migrations/001_init.sql"
    git -C "$tmp" add -A
    git -C "$tmp" -c user.name=t -c user.email=t@t commit -qm init
    cd "$tmp"
    check() { # $1 = file, $2 = expected exit code
    echo '{"tool_input":{"file_path":"'"$tmp/$1"'"}}' | "$guard" 2>/dev/null
    got=$?; [ "$got" -eq "$2" ] || { echo "FAIL $1: exit $got, want $2"; exit 1; }
    }
    check db/migrations/001_init.sql 2
    check db/migrations/002_add_index.sql 0
    echo "hook tests passed"

    Break the guard (change its exit 2 to exit 0) once and confirm the script prints FAIL and exits 1; a test that cannot go red proves nothing.

  3. Measure what it costs. Install from the local marketplace and read the inventory:

    Terminal window
    claude plugin marketplace add ./acme-plugins
    claude plugin install acme-db-guard@acme-plugins
    claude plugin details acme-db-guard
    Component inventory
    Skills (1) new-migration
    Hooks (1) PreToolUse (harness-only — no model context cost)
    MCP servers (1) staging-db (tool schemas resolved at runtime; not counted)
    Projected token cost
    Always-on: ~71 tok added to every session
    …

    The output above is trimmed; the full one also lists empty component types and a per-component table. Expect about 70 tokens (2.1.283); a figure a few tokens off on another version is normal. MCP tool schemas are not in that figure, so also check /context in a session with the server connected.

  4. Score it against a baseline. claude plugin eval runs cases from the plugin’s evals/ directory with and without the plugin and reports the difference. claude plugin eval init --bare new-migration-numbering creates evals/new-migration-numbering/prompt.md (the task, max_turns, allowed_tools) and graders/criteria.md (an LLM-graded definition of success).

    Terminal window
    claude plugin eval ./acme-plugins/plugins/acme-db-guard --runs 3 --threshold 0.8 --no-publish

    The command exits 1 when any case scores below the threshold. Eval runs the plugin on your machine as you; pass --trust-plugin only for plugins you wrote.

    Eval scores the skill. Under the default --mocks record it does not start staging-db unless you record a mock or pass --allow-real-servers, and the hook fires only if a case can call Edit or Write (--allow-tools Edit Write). The piped-JSON test in step 2 remains the hook’s proof (checked on 2.1.283).

Who signs off: the plugin’s owner (a named person in CODEOWNERS for plugins/acme-db-guard/) approves a release only when all four checks pass in the pull request. Reviewers read the eval deltas and the cost figure, not every line of the skill.

The marketplace is the catalogue file at the repository root. Its name is what users type after @, so pick it once.

.claude-plugin/marketplace.json
{
"name": "acme-plugins",
"description": "Acme platform team plugins",
"owner": { "name": "Acme Platform Team" },
"plugins": [
{
"name": "acme-db-guard",
"source": "./plugins/acme-db-guard",
"description": "Migration skill, schema guard hook and read-only Postgres MCP"
}
]
}

validate --strict fails without the top-level description. Push the repository to a private GitHub or GitLab repository. Teammates then install it per tool:

Terminal window
claude plugin marketplace add acme/acme-plugins
claude plugin install acme-db-guard@acme-plugins

Claude Code clones with the machine’s git credentials and never prompts. For HTTPS on GitHub, run gh auth login and then gh auth setup-git; a GITHUB_TOKEN in the environment does nothing without a credential helper. Set CLAUDE_CODE_PLUGIN_PREFER_HTTPS=1 to skip the SSH attempt.

To pin the plugin to one repository, run both commands with --scope project. That writes .claude/settings.json:

.claude/settings.json
{
"extraKnownMarketplaces": {
"acme-plugins": { "source": { "source": "github", "repo": "acme/acme-plugins" } }
},
"enabledPlugins": { "acme-db-guard@acme-plugins": true }
}

Commit that file. The marketplace registers after each teammate trusts the folder, and a plugin with an external source still needs one claude plugin install … --scope project per machine.

To limit the marketplaces a team can add in Claude Code to an allowlist that includes yours, a platform admin sets managed settings (server-managed, MDM or managed-settings.json):

managed-settings.json
{
"strictKnownMarketplaces": [
{ "source": "github", "repo": "anthropics/claude-plugins-official" },
{ "source": "github", "repo": "acme/*" },
{ "source": "skills-dir" }
],
"extraKnownMarketplaces": {
"acme-plugins": { "source": { "source": "github", "repo": "acme/acme-plugins" }, "autoUpdate": true }
},
"enabledPlugins": { "acme-db-guard@acme-plugins": true }
}

Keep the { "source": "skills-dir" } entry: without it the policy also blocks the claude plugin init scaffolds that plugin authors test with. Third-party marketplaces do not auto-update until a user or an admin turns it on, which is why autoUpdate is set here. One policy for every coding agent covers the Codex and Cursor equivalents. To run the marketplace over time (private-repo authentication, rollout, retiring a plugin), see Publishing a private plugin marketplace for your team.

Version and release a plugin so fixes reach everyone

Section titled “Version and release a plugin so fixes reach everyone”

A pushed commit does not update anyone while plugin.json still carries the old version. Choose one rule and write it in the marketplace README:

  • Semantic versions. Bump version in plugin.json on every release. Do not also put a version in the marketplace entry; one source avoids a mismatch.
  • Commit tracking. Omit version everywhere, and users track the latest commit.

Tag each release so a teammate can pin or roll back:

Terminal window
# after bumping "version" to 0.1.1 in plugin.json and committing
claude plugin tag --dry-run ./plugins/acme-db-guard
# … Dry run — would create tag acme-db-guard--v0.1.1 at HEAD in …
claude plugin tag --push ./plugins/acme-db-guard

claude plugin tag checks that plugin.json and the marketplace entry agree before it creates the <name>--v<version> tag. Teammates then pick up the release:

Terminal window
claude plugin marketplace update acme-plugins
claude plugin update acme-db-guard@acme-plugins
# Plugin "acme-db-guard" updated from 0.1.0 to 0.1.1 for scope user. Restart to apply changes.

Codex keeps each installed version in its own cache directory (plugins/cache/acme-plugins/acme-db-guard/0.1.0/ on 0.157.1). Run codex plugin marketplace upgrade acme-plugins, then codex plugin add acme-db-guard@acme-plugins again. During local development, OpenAI’s plugin-creator skill changes a +codex.<token> suffix on the version rather than the version number itself.

Enforce the rule in CI. This job holds no secrets and needs none, because validation makes no model call:

.github/workflows/plugins.yml
name: plugin-marketplace
on:
pull_request:
paths: ['plugins/**', '.claude-plugin/**', '.agents/**']
permissions:
contents: read
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: 22
- run: npm install -g @anthropic-ai/claude-code@2.1.283
- run: claude plugin validate --strict .
- run: for p in plugins/*/; do claude plugin validate --strict "$p" || exit 1; done
- run: for s in plugins/*/scripts/*.sh; do [ -e "$s" ] || continue; [ -x "$s" ] || { echo "$s not executable"; exit 1; }; done
- run: for t in plugins/*/tests/*.sh; do [ -e "$t" ] || continue; [ -x "$t" ] || { echo "$t not executable"; exit 1; }; "$t" || exit 1; done
- name: Claude and Codex manifests carry the same version
run: |
for p in plugins/*/; do
[ -f "$p/.codex-plugin/plugin.json" ] || continue
a=$(jq -r .version "$p/.claude-plugin/plugin.json")
b=$(jq -r .version "$p/.codex-plugin/plugin.json" | cut -d+ -f1)
[ "$a" = "$b" ] || { echo "$p: $a vs $b"; exit 1; }
done

The version-parity step is our suggestion: claude plugin tag checks only the Claude files, so nothing else notices a Codex manifest left behind. Run claude plugin eval locally or in a separate job on the default branch, because it calls a model and needs a key.

How portable is one plugin across Claude Code, Codex and Cursor?

Section titled “How portable is one plugin across Claude Code, Codex and Cursor?”

Codex 0.157.1 installed acme-db-guard straight from the Claude-format marketplace, with no Codex manifest, and copied every folder, hooks included, into its cache. Copying is not activation. This table separates the two.

ComponentClaude CodeCodexCursorPortable?
Skills (SKILL.md)YesYesYes, via "skills" in the manifestYes: Agent Skills is an open standard
MCP servers.mcp.json"mcpServers" pointing at the same .mcp.jsonmcp.jsonMostly: same server, different file or key
Hookshooks/hooks.json, PreToolUse and other Claude eventsnot verified whether Claude-format hooks activate; Codex asks you to trust a plugin’s hooks in /hooks before they run (0.157.1)"version": 1 schema with camelCase events such as stop and afterAgentResponseNo: write and test one hook file per tool
Slash commands, subagentsYesnot verified from a Claude-format pluginCursor has its own commands/ and agents/ foldersNo
Namespacing/<plugin>:<skill>not verifiednot verifiedTest in each tool

The practical rule: put the procedure in the skill and the connection in the MCP server, because those two travel. Keep hooks thin, one small script that each tool’s hook file calls, so the logic lives once even though the wiring does not. In Cursor’s "version": 1 hooks file, call the same guard as "${CURSOR_PLUGIN_ROOT}/scripts/guard-applied-migrations.sh", not as a path relative to the working directory.

When a plugin release breaks, and how to recover

Section titled “When a plugin release breaks, and how to recover”
  • Teammates still run the old hook after your fix. The version did not change, or auto-update is off. Bump version, tag, and ask them to run claude plugin marketplace update acme-plugins and claude plugin update acme-db-guard@acme-plugins, then restart.
  • The hook blocks every edit. A hook that exits 2 on every call locks the agent out. Disable the plugin at once with claude plugin disable acme-db-guard@acme-plugins, reproduce with the piped-JSON test above, and add that input as a test case before you re-release.
  • The hook does nothing on one laptop. jq or git is missing, or the script lost its executable bit in a zip. Check ls -l on the installed copy and make the script fail loudly when a tool is missing, as the script above does.
  • Install fails with Source path does not exist. A plugin was renamed or moved without updating source. Fix the entry, and validate each plugin directory in CI, not only the marketplace.
  • The MCP server starts with an empty connection string. The teammate never exported ACME_STAGING_DATABASE_URI. Document required variables in the skill and in the marketplace README; never fall back to a literal credential.
  • The plugin works in Claude Code and silently does nothing in Codex. Codex used the other catalogue, or the component is not one Codex activates. Run codex plugin list to see which marketplace.json it read, and test each component in a real Codex session.
Section titled “How popular plugin building is, and what it costs in context”

As of 2026-09-26, Anthropic’s plugin-dev authoring plugin had 67,663 installs on the claude.com/plugins directory. The official catalogues held 314 entries in claude-plugins-official, 65 in Codex’s openai-curated and 94 in Cursor’s cursor-plugins (counted from each repository’s marketplace file). The acme-db-guard example adds about 71 always-on tokens per session, against about 2,349 for plugin-dev itself, so keep authoring kits disabled outside authoring sessions.