installs.me
< cd ~/blog

·4 min read#marketplace#how-to

Your Claude Marketplace Can Live on Any URL (the Content-Negotiation Trick)

The URL you paste into /plugin marketplace add does not have to end in .json, live in a GitHub repo, or look like an API endpoint. Claude Code fetches whatever you give it, verbatim, and checks if the response parses as a marketplace manifest. That means one clean URL, installs.me/lautaro, can be a profile page when a human clicks it and a marketplace.json when the CLI requests it. Same path, two payloads, picked by content negotiation.

What the CLI actually sends

When you run /plugin marketplace add https://installs.me/lautaro, Claude Code (verified on CLI 2.1.x) issues a plain GET with two tells:

  1. A User-Agent identifying the plugin manager, along the lines of Claude-Code-Plugin-Manager. Not Chrome, not Safari, not curl's default.
  2. An Accept header that does not include text/html. Browsers always lead with text/html in Accept because they are about to render a page. The plugin manager wants JSON and says so, or says nothing useful, but it never claims to want HTML.

It does no path rewriting on your behalf beyond checking the obvious well-known locations. If the body it gets back is a valid manifest, it registers the marketplace under the name field inside the JSON. The URL is just transport.

Those two headers are all the signal you need to serve a split response.

The trick

Put a route handler at the vanity path and branch on the request. Accept-based negotiation is the principled check, the UA sniff is the belt-and-suspenders fallback:

// app/[handle]/route.ts (Next.js, but any server works)
export async function GET(req: Request) {
  const ua = req.headers.get("user-agent") ?? "";
  const accept = req.headers.get("accept") ?? "";

  const wantsManifest =
    ua.includes("Claude-Code") || !accept.includes("text/html");

  if (wantsManifest) {
    return Response.json(await buildManifest(handle), {
      headers: { Vary: "User-Agent, Accept" },
    });
  }

  return htmlProfilePage(handle, {
    headers: { Vary: "User-Agent, Accept" },
  });
}

The Vary: User-Agent, Accept header is not optional. If a CDN sits in front of you and caches the HTML response, the CLI gets a web page back, fails to parse it as JSON, and your user gets a confusing "invalid marketplace" error. Vary tells the cache these are distinct responses. If your CDN normalizes User-Agent (Cloudflare does in some cache modes), key on Accept instead, or bypass cache for this route entirely. It is one small JSON document per creator; caching buys you nothing worth the debugging session.

The manifest itself is the standard shape:

{
  "name": "lautaro-installs",
  "owner": { "name": "Lautaro Schiaffino" },
  "plugins": [
    {
      "name": "lautaro",
      "description": "Thinks, writes, and advises like Lautaro",
      "source": {
        "source": "git",
        "url": "https://github.com/installs-me/personas.git",
        "path": "lautaro"
      }
    }
  ]
}

The catch: plugin sources still need git

Content negotiation gets you a marketplace on any URL. It does not get you plugin files on any URL. When Claude Code installs a plugin, it clones the plugin's source, it does not fetch files over HTTP. So each entry's source must point at a git-backed location: a repo, or a subdirectory of one via the git-subdir form shown above (url plus path).

The failure mode here is nasty because it is silent. If your marketplace came over HTTP and a plugin uses a relative path source ("source": "./plugins/lautaro"), there is no local checkout to resolve it against. Relative paths are meaningful when the marketplace itself is a cloned repo, meaningless when it arrived as a JSON body over the wire. The install does not throw a loud protocol error, it just fails to materialize the plugin. Always use absolute git sources in an HTTP-served marketplace.

So the architecture is: dynamic manifest over HTTP, static plugin content in git. Which turns out to be the right split anyway. The manifest is per-creator, generated, cheap to serve fresh. The plugin content, the SKILL.md files with their YAML frontmatter and references/ directories, is versioned artifact material that belongs in a repo with history.

How installs.me/lautaro does both

installs.me leans on this fully. Every creator gets one handle URL and it is the only thing they ever share:

  • Browser hits installs.me/lautaro: HTML profile. Who Lautaro is, what the persona knows (Sirena exit, Darwin AI, the angel portfolio), the two install commands in a copy button.
  • CLI hits installs.me/lautaro: generated marketplace.json naming the lautaro-installs marketplace, with the lautaro plugin pointing at a git-subdir source where the synthesized skills live.

When the persona regenerates (new blog posts ingested, new calls transcribed), the pipeline commits updated SKILL.md files to the git source and bumps the plugin version in the manifest. The URL never changes. /plugin update picks up the new content because the source repo moved, not because anyone re-shared a link.

The alternative designs are all worse. A separate installs.me/lautaro/marketplace.json path works but now every tweet, README, and bio needs to explain two URLs and when to use which. Redirecting the CLI to a raw GitHub URL works but leaks your storage layout into every user's marketplace list forever. The negotiated single URL means the thing you print on a conference slide is also the thing you paste into a terminal.

Test it like the CLI does

Before shipping, impersonate both audiences from your shell:

curl -s -A "Claude-Code-Plugin-Manager" -H "Accept: application/json" \
  https://installs.me/lautaro | jq .name

curl -s -H "Accept: text/html,application/xhtml+xml" \
  https://installs.me/lautaro | head -c 200

First command should print your marketplace name. Second should print a doctype. If both return the same body, check your cache layer before your code. Then run the real thing end to end, because jq parsing and the CLI accepting the manifest are two different bars.

Install a person

installs.me turns your files, calendar and calls into a Claude Code plugin that thinks like you. Anyone installs it with two commands:

/plugin marketplace add https://installs.me/lautaro
/plugin install lautaro@lautaro-installs