MastertheMesh
agentregistry · agentgateway · claude code
Field guide

Publishing a Claude Code plugin through AgentRegistry

TO
Tom O'Rourke
EMEA Field CTO · Solo.io

A Claude Code plugin is a bundle of harness extensions: skills, slash commands, sub-agents, lifecycle hooks, MCP servers, and executables. Every developer who wants one today either clones a repo by hand or trusts a public marketplace. AgentRegistry gives you a third option, which is the one a platform team actually wants: the plugin becomes a governed catalogue entry, pinned to an exact commit, with a full inventory of what it will run on a laptop.

This page walks the whole loop. Author a bundle locally, push it to git, register the pointer with arctl, watch the controller pin and scan it, then install it into Claude Code from the registry's own marketplace URL served through agentgateway. Everything below was run end to end on a kind cluster.

Preview. The Plugin kind and the marketplace compatibility endpoint are newer than the current release, so this ran on a development build of AgentRegistry Enterprise. Treat the field names as settled and the packaging as ahead of the release train.

The registry holds a pointer, not the bytes

The first thing to understand about Plugin is what it does not do. AgentRegistry does not store the bundle. The spec is a pinned pointer at an external source, which is the same model Agent and Skill already use, and the reason is that the alternative makes the registry a binary artifact store: storage to run and secure, upload idempotency, garbage collection of orphans, and a decision about what happens when storage is not configured.

So the spec is user intent only, and everything the server works out for itself lands in status:

spec (you write this)

title, description, iconUrl, harnesses, and a source. For a git source that is a repository URL plus at most one of branch or a full 40-character commit, with an optional subfolder for a monorepo.

status (the controller writes this)

resolvedSource with the concrete commit the ref pinned to, manifest parsed from the bundle's plugin.json, and inventory, which is a scan of the files the bundle actually ships rather than a repeat of what the author claimed.

Keeping the resolution out of the spec has a practical payoff: a status write never changes the spec's content hash, so re-applying identical intent is a no-op rather than a new revision.

What goes in the bundle

The bundle is a normal Claude Code plugin. The layout is what the registry scans, so it is worth being deliberate about it: each directory below maps onto one line of the inventory the registry will show your security reviewer.

PathInventory fieldWhat it means for a reviewer
.claude-plugin/plugin.jsonmanifestName, version, description. The version becomes the marketplace entry's version.
skills/<name>/SKILL.mdskillsName and description read from the frontmatter, not from the manifest.
commands/<name>.mdcommandsSlash commands the plugin adds to the session.
agents/<name>.mdagentsSub-agents. Markdown prompt files, not manifest entries.
hooks/hooks.jsonhooksLifecycle event and handler type. This is arbitrary code on a developer machine.
.mcp.jsonmcpServersMCP servers the harness will connect to once the plugin is enabled.
bin/<name>executablesShipped binaries or scripts. Also arbitrary code.

The plugin used here reviews agentgateway and kgateway policy YAML. Its manifest:

{
  "name": "agentgateway-policy-review",
  "version": "0.3.0",
  "description": "Reviews agentgateway and kgateway policy YAML before it reaches a cluster: checks every document carries apiVersion and kind, flags templating that the CEL engine will not evaluate, and warns on a kubectl apply of an unreviewed policy file.",
  "author": {
    "name": "Solo.io field engineering"
  }
}

It ships a hook so there is something with real teeth in the inventory. The hook runs on every Bash call, but only acts on a kubectl apply -f whose file fails the bundled linter, in which case it exits 2 and the tool call never runs:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PLUGIN_ROOT}/bin/guard-apply"
          }
        ]
      }
    ]
  }
}

Registering the pointer

Push the bundle to a repository whose root is the bundle root, then hand the registry a pointer to it. Only github.com resolves today, and the clone runs with whatever credentials the registry pod carries, so a private repository needs the chart's git credential wired in.

apiVersion: ar.dev/v1alpha1
kind: Plugin
metadata:
  name: agentgateway-policy-review
  namespace: default
spec:
  title: agentgateway policy review
  description: >-
    Reviews agentgateway and kgateway policy YAML before it reaches a cluster.
    Ships a skill, a slash command, a read-only auditor sub-agent, a PreToolUse
    hook, two executables, and an MCP server pointing at the registry catalogue
    through the gateway.
  harnesses:
    - claude-code
  source:
    type: git
    git:
      repository:
        url: https://github.com/tjorourke/agentgateway-policy-review
        branch: main
arctl apply -f yaml/plugin.yaml

Note what is missing from that spec. No version, no skill list, no hook declaration. You declare where the bundle is; the registry works out what it contains. Four things happen next, out of band of your write:

  1. The controller resolves the ref with git ls-remote, no clone, and records the concrete commit SHA.
  2. It shallow-clones that exact commit and reads the tree into memory, with ceilings on file count and total bytes so a hostile repository cannot exhaust the controller.
  3. It parses .claude-plugin/plugin.json into the typed manifest.
  4. It scans the files into the inventory, then sets Ready=True with reason Resolved.

Failures are classified rather than retried blindly. A ref that does not exist and a git host that is not supported are terminal; a network blip is retryable.

The inventory is the governance surface

This is the part worth showing a security reviewer, because it is derived from the bundle rather than asserted by its author:

arctl get plugin agentgateway-policy-review -o yaml
status:
  conditions:
  - lastTransitionTime: "2026-08-07T13:48:24.700154442Z"
    reason: Resolved
    status: "True"
    type: Ready
  inventory:
    agents:
    - policy-auditor
    commands:
    - policy-review
    executables:
    - guard-apply
    - policy-lint
    hooks:
    - event: PreToolUse
      type: command
    mcpServers:
    - agentregistry-catalog
    skills:
    - description: Review an agentgateway or kgateway policy YAML file against the
        field checklist and report findings with the bundled linter. Read-only, never
        applies anything to a cluster.
      name: agentgateway-policy-review
  manifest:
    author:
      name: Solo.io field engineering
    description: 'Reviews agentgateway and kgateway policy YAML before it reaches
      a cluster: checks every document carries apiVersion and kind, flags templating
      that the CEL engine will not evaluate, and warns on a kubectl apply of an unreviewed
      policy file.'
    name: agentgateway-policy-review
    version: 0.3.0
  resolvedSource:
    commit: db6885891222d407c22c1a508e85170d763258de
    type: git

Two entries there deserve a policy conversation before anyone installs this: hooks shows a PreToolUse handler of type command, and executables shows two shipped scripts. Both are arbitrary code that will run on a developer's machine. The registry does not stop you shipping them, it makes sure nobody can ship them quietly.

Serving the catalogue as a marketplace

Claude Code installs plugins from a marketplace.json, and it accepts a bare URL to one. AgentRegistry can re-expose its resolved plugins in exactly that shape. The endpoint is read-only and off by default, so turn it on at install:

--set extraEnvVars[0].name=AGENT_REGISTRY_PLUGIN_MARKETPLACE_COMPAT_ENABLED \
--set-string extraEnvVars[0].value=true

That mounts GET /plugin-marketplace/marketplace.json. Set AGENT_REGISTRY_PLUGIN_MARKETPLACE_COMPAT_PATH_PREFIX if you need it under a base path instead of the root.

curl -s http://agentregistry.localtest.me/plugin-marketplace/marketplace.json
{
    "$schema": "https://json.schemastore.org/claude-code-marketplace.json",
    "name": "agentregistry",
    "owner": {
        "name": "agentregistry"
    },
    "plugins": [
        {
            "name": "default.agentgateway-policy-review",
            "source": {
                "source": "url",
                "url": "https://github.com/tjorourke/agentgateway-policy-review",
                "sha": "db6885891222d407c22c1a508e85170d763258de"
            },
            "description": "Reviews agentgateway and kgateway policy YAML before it reaches a cluster: checks every document carries apiVersion and kind, flags templating that the CEL engine will not evaluate, and warns on a kubectl apply of an unreviewed policy file.",
            "version": "0.3.0"
        }
    ]
}

Four details in that document matter:

  1. The entry name is namespace-qualified with a dot, default.agentgateway-policy-review, because the marketplace schema forbids a slash in a plugin name and a bare name would collide across namespaces.
  2. The source carries the resolved sha, not the branch you registered. Claude Code checks out that commit directly, so two developers who install on different days get identical bytes.
  3. A plugin with a subfolder comes out as a git-subdir source instead, which makes Claude Code do a sparse clone of just that directory.
  4. Anything not yet Ready, or resolved to a source with no representation in this schema, is skipped rather than half-emitted. The document never contains a broken entry.

The endpoint is anonymous by design: it is registered as a public path, so a request arrives with a public session rather than credentials, and the enterprise build then scopes what that public session may see through the same list filter the native read path uses.

Where agentgateway sits in this

It is worth being precise about which leg of this flow goes through the gateway, because it is not all of them.

Laptop agentgateway AgentRegistry GitHub 1 git push the bundle 2 arctl apply Plugin routed to the registry 3 ls-remote, clone commit + files 4 marketplace.json catalogue read 5 clone the pinned commit, straight to the origin 6 MCP from .mcp.json registry MCP bridge

Steps 2, 4 and 6 pass through agentgateway, which is where a policy can sit. Step 5 does not: Claude Code clones the bundle from its origin with the developer's own git credentials, so the gateway never sees those bytes. That is a feature for bandwidth and a constraint for anyone who expected the registry to be the single egress point.

The gateway earns its place on the catalogue leg and on the MCP leg. The plugin here ships an .mcp.json pointing at the registry's own MCP bridge through the gateway, so installing the plugin is also what wires the harness to that endpoint. Claude Code registers it under the plugin's name, plugin:agentgateway-policy-review:agentregistry-catalog, and the endpoint is protected: an unauthenticated request gets a challenge naming where to authenticate, so the harness has to hold a registry identity before the entry will serve tools.

curl -s -i http://mcp.localtest.me/mcp | grep -i www-authenticate
Www-Authenticate: Bearer resource_metadata="http://agentregistry.localtest.me/.well-known/oauth-protected-resource/mcp"

That is the shape worth planning for. A bundle can carry MCP wiring, and the endpoint it names can be one your gateway fronts and your identity provider gates, so a developer installing a plugin does not also get an unauthenticated path to your tools.

Installing from Claude Code

Two commands on the laptop. The marketplace URL is the registry's, and the plugin name is the namespace-qualified one from the catalogue:

claude plugin marketplace add http://agentregistry.localtest.me/plugin-marketplace/marketplace.json
claude plugin install default.agentgateway-policy-review@agentregistry

Use --scope local on both if you want the marketplace and the plugin confined to one project directory rather than declared for your user.

The installed cache is keyed by the manifest version, and the executable bits survive the round trip, which matters for a bundle that ships bin/ entries:

~/.claude/plugins/cache/agentregistry/default-agentgateway-policy-review/0.3.0/
├── agents/policy-auditor.md
├── bin/guard-apply
├── bin/policy-lint
├── commands/policy-review.md
├── hooks/hooks.json
└── skills/agentgateway-policy-review/SKILL.md

The slash command is namespaced by the plugin name, so it is the qualified name again:

claude -p "/default.agentgateway-policy-review:policy-review /tmp/policy-broken.yaml"
agentgateway-policy-review · policy-lint 0.3.0
file: /tmp/policy-broken.yaml

document 1 (line 1)
FAIL  envelope    document is a fragment: add apiVersion and kind
FAIL  expressions {{ jwt.team }} is carried through literally; write it as a CEL expression
PASS  apiGroup    no apiVersion to check
PASS  targetRefs  not a policy document, no target needed

RESULT: FAIL (2 checks failed)

And the hook fires on the tool call rather than on the model's intent, so it stops the apply before kubectl is invoked at all:

guard-apply blocked this apply: /tmp/policy-broken.yaml does not pass
agentgateway-policy-review.

Moving the pin

A branch in the spec is resolved once, at reconcile, and the commit it resolved to is then immutable in status. Pushing new commits to that branch does not silently move what the catalogue serves. The controller is level-triggered on generation, so a resync re-lists plugins and enqueues the ones whose status is behind their spec.

That is the right default for a catalogue, and it means an update is a deliberate act:

  1. Change the bundle, bump the version in plugin.json, push.
  2. Change the Plugin spec and apply it, or publish it under a new tag.
  3. The controller re-resolves, and marketplace.json starts serving the new sha and version.
  4. On the laptop, claude plugin marketplace update agentregistry then claude plugin update <name>@agentregistry.
✔ Plugin "default.agentgateway-policy-review" updated from 0.2.0 to 0.3.0

The old version stays in the cache alongside the new one, so a rollback is a version change rather than a re-fetch.

If you want a plugin that can never move, put a full 40-character commit in the spec instead of a branch. A short SHA is rejected, because it would never resolve and would retry forever.

What to take from this

Plugins in AgentRegistry, the short version