Publish to the Hub

Prepare a plugin, MCP server or skill for Cusp Hub. The hub.json sidecar, capability labels and grades, what the scanner checks, and how a version is reviewed and signed.

Cusp Hub is in preview and publishing is by invitation. The Cusp team creates publisher accounts and issues publish tokens; there's no self-service sign-up and no cusp hub publish command yet. You can check a package today: in the app, open Customize → Publish and drop the folder. It runs the same analysis the registry runs on upload.

This page is for authors of plugins, MCP servers and skills. It covers what the Hub reads from your package, how it labels and grades it, what blocks a version, and what users see when you ship an update.

What a Hub extension is

A Hub extension is a folder in a format Cusp already reads, plus an optional sidecar, .cusp-plugin/hub.json, for what only the Hub needs. Other tools ignore the sidecar, so the same folder keeps working as a plugin, MCP server or skill elsewhere.

FormatRecognised by
Plugin.cusp-plugin/plugin.json, .claude-plugin/plugin.json, .codex-plugin/plugin.json, .cursor-plugin/plugin.json or plugin.json, first found wins. A folder with only the plugin layout (skills/, commands/, agents/, hooks/…) works too
MCP serverserver.json in the MCP Registry format, an MCPB manifest.json, or .mcp.json inside a plugin
SkillA SKILL.md, or a folder of skill folders
Gemini CLI extensiongemini-extension.json

The Hub works out the extension's kind from what's inside: one skill is a skill, only MCP servers make an mcp-server, a mix makes a plugin. Set kind in hub.json to override it. The name, version, description, author and license come from your manifest (or from SKILL.md and server.json), not from hub.json.

Analysis never runs your code: labels, grades, scan results and context costs are computed from the files.

Quick start

The smallest useful package is one skill, a manifest, a sidecar and a README:

release-notes/
├── .claude-plugin/plugin.json
├── .cusp-plugin/hub.json
├── skills/release-notes/SKILL.md
└── README.md
.claude-plugin/plugin.json
{
  "name": "release-notes",
  "version": "1.0.0",
  "description": "Drafts release notes from the commits since the last tag.",
  "license": "MIT"
}
skills/release-notes/SKILL.md
---
name: release-notes
description: Draft release notes from the commits since the last tag. Use when the user asks for release notes or a changelog entry.
allowed-tools: Bash(git log *), Bash(git describe *)
---

1. Find the last tag with `git describe --tags --abbrev=0`.
2. List commits since then with `git log <tag>..HEAD --oneline --no-merges`.
3. Group them into Features, Fixes and Internal.
.cusp-plugin/hub.json
{
  "id": "acme/release-notes",
  "tagline": "Draft release notes from the commits since the last tag",
  "categories": ["development"],
  "capabilities": {
    "filesystem": { "read": ["${workspace}/**"] },
    "network": false
  }
}

Drop the folder on Customize → Publish. You'll see the detected kind, the capability label and its grade, scanner findings and a readiness checklist. This package grades B: it's offline and only reads the workspace, but its Bash(git …) tool rules are rated medium.

hub.json reference

Every field is optional. Unknown fields are errors, so a typo fails loudly instead of being ignored.

FieldTypeMeaning
idstringpublisher/name, lowercase. The publisher part (up to 39 characters) must be your publisher account; the name is letters and digits separated by - or .
kindstringOverrides the detected kind: plugin, mcp-server, skill, agent, command, hook-pack, output-style, workflow, lsp, theme, blueprint or integration
displayNamestring, ≤ 80The name in listings. Defaults to displayName in the plugin manifest
taglinestring, ≤ 120One line under the name. Defaults to the first sentence of the description
useCasesstring[], ≤ 5Short verb phrases, such as "Triage new issues"
categoriesstring[], ≤ 3From the list below
iconstringThe extension's icon
colorstringAccent for the extension's tile, as #rrggbb
mediaobject[], ≤ 12Screenshots or videos: src, alt, and optionally theme (light or dark) and kind (image or video)
capabilitiesobjectWhat the extension may do. See Capabilities and the label
evalsstringPath to the eval suite, such as ./evals/suite.json. See Evals
modelsobjectrecommended model ids or families, and requires: toolCalling, vision, minContextTokens
runtimesstring[]Agents that load the package unchanged: cusp, claude-code, codex, gemini-cli, cursor, copilot, opencode. Inferred from the formats when omitted
tuningobjectDescription variants per model family. Accepted and validated, but Cusp doesn't apply them yet
linksobjectdocs, support, changelog and sponsor URLs
blueprintobjectRequired when kind is blueprint: the extensions, integrations, crew seats, workflows and project settings it sets up
integrationobjectRequired when kind is integration: a remote MCP endpoint or a local command, and how it authenticates
editorialobjectReserved for Cusp's own collections

Categories: development, code-review, testing, devops, security, data, databases, design, docs, productivity, project-management, communication, browser, cloud, monitoring, ai, mobile, web, finance, marketing, sales, support, writing, research, education, fun.

A Cusp-only extension can put the same object under a "cusp" key in .cusp-plugin/plugin.json instead. If .cusp-plugin/hub.json exists, it wins.

A fuller example, for a plugin that starts an MCP server with npx and needs an API key:

.cusp-plugin/hub.json
{
  "id": "acme/linear-triage",
  "kind": "plugin",
  "displayName": "Linear Triage",
  "tagline": "Label, estimate and assign new Linear issues",
  "useCases": ["Triage new issues", "Balance the team's queue"],
  "categories": ["project-management", "productivity"],
  "icon": "./assets/icon.png",
  "color": "#5e6ad2",
  "media": [
    { "src": "./assets/triage.png", "alt": "Triage summary for five new issues", "theme": "light" }
  ],
  "capabilities": {
    "network": { "hosts": ["api.linear.app", "registry.npmjs.org"] },
    "exec": { "programs": ["npx"] },
    "secrets": {
      "LINEAR_API_KEY": {
        "description": "Linear personal API key",
        "via": "token",
        "env": "LINEAR_API_KEY",
        "hosts": ["api.linear.app"]
      }
    },
    "data": {
      "collects": ["file-contents"],
      "sharedWith": ["Linear"],
      "telemetry": false,
      "retention": "Stored in your Linear workspace",
      "privacyPolicy": "https://linear.app/privacy"
    }
  },
  "evals": "./evals/suite.json",
  "models": { "recommended": ["claude", "glm"], "requires": { "toolCalling": true } },
  "runtimes": ["cusp", "claude-code"],
  "links": {
    "docs": "https://example.com/docs",
    "support": "https://example.com/support",
    "changelog": "https://example.com/changelog"
  }
}

With .mcp.json starting npx -y @acme/linear-mcp@2.1.0 and passing LINEAR_API_KEY, this package grades B with nothing undeclared.

Capabilities and the label

Before anyone installs your extension, they see a capability label: one row per kind of access, each item rated by risk, and a grade from A to E. The label combines two sources:

  • Declared: what you list under capabilities.
  • Inferred: what the Hub finds in the package. It reads MCP server commands, URLs, environment variables and headers; hooks; LSP servers, monitors and bin/ executables; allowed-tools and tools in skill, command and agent frontmatter; and sensitive userConfig options.

Anything inferred but not declared is marked undeclared on the label.

CapabilityWhat you declare
filesystem.read, filesystem.writePath patterns starting with ${workspace}, ${home}, ${data}, ${root}, ${tmp}, ~/ or /. * matches one segment, ** any depth; .. isn't allowed
networkfalse for no network, or { "hosts": [...] }: api.example.com, *.example.com, an IP address, an optional :port, or * for any host
envEnvironment variables its processes read
secretsCredentials by name, each with a description, via (token, oauth or env), env, the hosts it may be sent to, scopes and required
execprograms it starts, and shell: true if it runs arbitrary shell commands
toolsAgent tools its skills, agents and commands rely on, as permission rules such as Bash(git *)
hooksLifecycle hooks it intercepts, such as PreToolUse or PreToolUse:Bash
mcpMCP client features its servers use: sampling, elicitation, roots
dataData practices shown on the label: collects, sharedWith, telemetry, retention, privacyPolicy

${data} is the extension's own data folder and ${root} its installed files. Both are always granted.

Declared means enforced

Once you add a capabilities block, it's the whole grant:

  • Without network, the extension has no network. Its processes run in a sandbox that reaches only the declared hosts.
  • Reads and writes attributed to the extension outside the declared paths are refused.
  • Running a program you didn't declare asks the user first.

An extension without a capabilities block installs in compatibility mode: the default sandbox, network on request and the user's normal permission rules. Its label shows everything the Hub found as undeclared, so it grades C or lower once it starts a program or needs a credential.

How the grade is set

Each item on the label is rated:

ItemRisk
Paths under ${data}, ${root} or ${tmp}; reading ${workspace}Low
Writing ${workspace}; reading other pathsMedium
Writing other pathsHigh
All of ${home}, /, or credential folders such as .ssh, .aws or .kubeCritical
A specific hostLow
A *.domain wildcardMedium
Any host (*)Critical
A program it starts; bin/ executablesMedium
A shell (sh, bash, zsh, pwsh, sudo…) or arbitrary shell commandsHigh
A credentialMedium (oauth: low)
Tool rules Bash(…), Write, Edit, WebFetchMedium (Bash, Bash(*), *: high; other tools: low)
A hookMedium (PreToolUse, PermissionRequest, UserPromptSubmit, SessionStart, ConfigChange: high)
MCP samplingMedium
Data collected or sharedMedium (source-code, credentials, transcripts: high)
Code loaded into the agent process itselfCritical
Environment variablesLow

The grade starts from the highest risk on the label, then moves down:

RuleGrade
Highest risk none or lowA
Highest risk mediumB
Highest risk highC
Highest risk criticalD
Any undeclared item rated medium or higherC at best
Three or more such items, with a high or critical item on the labelD at best
Four or more such items, or code that runs inside the agent processE
Hosts, credentials and programs that together suggest untrusted input, private data and a way to send it outD at best

Undeclared items rated high or critical also hold the version for review when you publish. Undeclared medium items only lower the grade.

Getting declarations right

  • List programs the way they start the command: npx, node, uvx. A path under the plugin root, such as "${CLAUDE_PLUGIN_ROOT}/bin/server", is written ${root}/bin/server.
  • Package launchers fetch before they run: npx, bunx and pnpm reach registry.npmjs.org; uvx and pipx reach pypi.org and files.pythonhosted.org. Declare those hosts. They're rated low, but with a capabilities block the sandbox only allows declared hosts.
  • Environment variables passed to MCP servers whose names contain TOKEN, SECRET, PASSWORD, API_KEY, ACCESS_KEY, PRIVATE_KEY, CREDENTIAL or AUTH, and Authorization or API-key headers, are inferred as credentials. Declare them under secrets. Sensitive userConfig options and isSecret inputs in server.json already count as declared.
  • A hook command with shell syntax (|, ;, &&, $(…), redirects) is inferred as arbitrary shell, rated high. Point the hook at a script instead: "${CLAUDE_PLUGIN_ROOT}/hooks/format.sh".
  • Frontmatter allowed-tools and agent tools count as declarations; they're never undeclared.

What the scanner checks

Every version is scanned at publish and again at install. Rules depend on where a file's text ends up:

TargetFiles
Model-visible textMarkdown, .txt and .toml files: skills, agents, commands, rules, and tool definitions
CodeSource files (.js, .ts, .py, .sh, .ps1, .rb, .go, .rs…) and everything in bin/
Configplugin.json, hooks.json, .mcp.json, server.json, manifest.json, package.json, hub.json and similar
DocsREADME, CHANGELOG, LICENSE and similar at the package root: only hidden characters and embedded secrets

Embedded base64, hex and URL-encoded blobs are decoded and scanned again. Binary files and text files over 2 MB aren't text-scanned.

The worst finding decides the verdict:

Worst findingVerdictWhen you publishWhen someone installs
CriticalrejectThe version is rejectedInstall is blocked
HighreviewThe version is held for reviewThe user must acknowledge the findings
MediumwarnPublishedFindings are shown
None or lowpassPublished

Critical findings include:

  • Invisible Unicode (tag characters, variation selector supplements), and bidirectional controls in Markdown or config.
  • Text telling the model to keep something from the user.
  • Remote code piped to an interpreter (curl … | sh), or an embedded payload decoded and executed.
  • Code that writes agent configuration (.claude/settings.json, .mcp.json, .cusp/settings.json…) or persistence locations (shell startup files, launch agents, cron, git hooks).
  • Package publishing commands (npm publish) in code or config, or an AI agent CLI run with permission-bypass flags.
  • Destructive commands such as rm -rf / in instructions.
  • MCP servers that set variables such as NODE_OPTIONS, LD_PRELOAD, *_BASE_URL or proxy variables.
  • Tool names outside [A-Za-z0-9_.-].

High findings include instruction-override phrasing or fake system tags in model text, references to credential stores (~/.ssh, .env) in model text, known capture or tunnelling hosts and raw-IP URLs, unpinned npx, uvx or docker run in MCP server commands, preinstall, postinstall or prepare scripts, compiled binaries without source, agent, IDE or CI configuration shipped in the package (.claude/, .github/workflows/), hooks that reach the network on every call, embedded credentials, tools named like built-ins (Read, Bash), tool descriptions over 4,096 characters, and instructions to paste commands into a terminal.

Medium findings include dynamic code execution, obfuscated code, skills that pre-approve Bash or *, skill descriptions that issue global directives ("always", "every time") or run past 1,024 characters, and tool descriptions that are padded, past 2,048 characters, or steer built-in tools.

Don't ship minified JavaScript. Several rules match line by line, and a minified bundle puts unrelated code on one line: a config path in one place and a file write in another become a critical "writes agent configuration" finding. Hand-written code that looks obfuscated is also flagged unless it sits in dist/, build/ or vendor/ or carries a source map.

More practical advice:

  • Don't write curl … | bash in a skill, command or any Markdown the model reads, even as install instructions for the user. It's a critical finding there. Link to your install docs from the top-level README instead.
  • Pin what you launch: npx -y @acme/server@2.1.0, uvx server==2.1.0, container images by @sha256: digest.
  • Publish a clean folder. Leave out .github/, .vscode/, .claude/ and anything else that configures an agent, editor or CI.
  • Keep real tokens out of examples and tests.

Context cost and tool annotations

Each listing shows what the extension costs the model's context, estimated at four characters per token:

  • Every turn: what's loaded into each request once installed: descriptions of skills the model can invoke, agents and commands, MCP tool definitions (name, description and input schema), output styles and context files.
  • When invoked: the largest skill or command body pulled in when it's used.

The Hub can't start your MCP server, so it only knows your tools if you ship a snapshot. Without one, the cost is marked as partial and tools aren't counted. Put the output of your server's tools/list in .cusp-plugin/tools.json, keyed by the server name your package uses (the key in .mcp.json, or name in server.json):

.cusp-plugin/tools.json
{
  "linear": [
    {
      "name": "list_issues",
      "description": "List issues in a team, newest first.",
      "inputSchema": {
        "type": "object",
        "properties": { "team": { "type": "string" } },
        "required": ["team"]
      },
      "annotations": { "readOnlyHint": true, "openWorldHint": true }
    },
    {
      "name": "update_issue",
      "description": "Change an issue's labels, estimate or assignee.",
      "inputSchema": {
        "type": "object",
        "properties": { "id": { "type": "string" }, "labels": { "type": "array", "items": { "type": "string" } } },
        "required": ["id"]
      },
      "annotations": { "readOnlyHint": false, "destructiveHint": false, "openWorldHint": true }
    }
  ]
}

The snapshot is scanned like any model-visible text, and it's what installs pin (see Updates and re-consent). An MCPB manifest.json that lists its tools counts too, for names and descriptions.

Listings also count tools by what their MCP tool annotations say. The MCP defaults apply to missing hints, so an unannotated tool counts as destructive and open-world:

AnnotationsCounted as
readOnlyHint: trueRead
destructiveHint: falseWrite
NeitherDestructive
openWorldHint not falseOpen-world, in addition to the above

Evals

An extension can ship an eval suite: prompts with expectations about what a good run looks like. No eval runner exists yet, so nothing is benchmarked today. Include a suite now and per-model results will appear on your listing once suites are run.

Put the suite at evals/suite.json and point evals in hub.json at it:

evals/suite.json
{
  "name": "linear-triage",
  "runs": 3,
  "cases": [
    {
      "id": "labels-new-bug",
      "prompt": "Triage the new issues in the ENG team.",
      "expect": [
        { "type": "tool_called", "tool": "mcp__*__list_issues", "args": { "team": "ENG" } },
        { "type": "skill_invoked", "skill": "triage" },
        { "type": "tool_not_called", "tool": "mcp__*__delete_issue" }
      ]
    }
  ]
}
Suite fieldMeaning
name, descriptionThe suite's name and summary
runsRuns per case per model, 1 to 20 (default 3)
baselineAlso run each case without the extension, to measure the difference it makes (default true)
thresholdThe pass rate you expect, 0 to 1 (default 0.8)
cases1 to 500 cases

Each case has a lowercase id, a prompt, and 1 to 32 expect entries. It can also set files (workspace files created before the run), mocks (recorded tool responses, so remote services aren't called), config (userConfig values; never real secrets), maxTurns, weight and tags.

ExpectationPasses when
tool_calledA tool was called, optionally with arguments that include args, at least min times. * matches any characters in the tool name
tool_not_calledA tool wasn't called
skill_invokedOne of your skills was loaded
output_matchesThe final answer matches a regular expression
file_matchesA workspace file matches a regular expression
command_succeedsA shell command exits 0 after the run
judgeA model grading against your rubric scores at least threshold
max_turns, max_tool_errorsThe run stayed within the limit

Every expectation takes an optional weight.

Publishing

Accounts and tokens

A publisher account is the first half of your extension ids (acme in acme/linear-triage). During the preview the Cusp team creates accounts and issues publish tokens. Tokens start with cusph_, are shown once, carry a publish scope and can have an expiry date. A token publishes only under its own account.

What happens on upload

The registry checks a version in this order:

  1. The token, and that the id's publisher matches it.
  2. The version is exact semver (1.2.3) and doesn't exist yet. A published or held version can't be replaced; a rejected version number can be reused after you fix it.
  3. The archive hashes to the integrity you sent, and the package loads.
  4. The id in hub.json, if set, matches; the manifest version, if set, matches.
  5. The package is analyzed: label, grade and scan.
  6. The version gets a status.
StatusWhenWhat happens
publishedNothing below appliesListed, countersigned and added to the transparency log
heldScan verdict review; it ships hooks, bin/ executables or in-process modules; an undeclared high or critical capability; or your publisher account is less than 24 hours oldNot installable until a reviewer releases it. The response lists every reason
rejectedA critical scan findingThe archive isn't kept. The response lists the findings

Signing and the transparency log

The registry countersigns every version it publishes. The signature is a DSSE envelope with an Ed25519 key, holding an in-toto Statement v1 whose subject is publisher/name@version with the package's SHA-512 tree digest. The predicate type is https://cusp.run/hub/publish/v1, and it records the registry, id, version, publish time, your publisher id and whether it's verified, the scan verdict, rules version and risk score, a hash of the capability label, and a hash of the tool pins. Each attestation is appended to an append-only Merkle log, and the registry serves its public keys and inclusion proofs.

You can attach your own DSSE envelope to a publish request; the registry stores it with the version.

The predicate has room for source provenance from CI (repository, commit, workflow and issuer), but the registry doesn't record it yet. Today an attestation says who published a version, not where it was built.

Ship every change as a new version, and keep version in your manifest in step with it. What users go through depends on what changed:

  • New access. If the new version's label adds anything rated above none (a host, a program, a credential, a hook, a broader path), the update waits until the user approves it with the difference on screen. Removing access needs no approval.
  • New scanner findings. If an update brings review findings the user hasn't acknowledged before, they're asked again.
  • Changed tools. At install, Cusp pins each tool in your snapshot: its name, description, input schema and annotations. If the running server later returns a tool that differs from its pin, or a new tool, that tool is hidden from the model until the user approves the change. So keep .cusp-plugin/tools.json exactly in step with what the server returns, and change tools only in a new version with an updated snapshot. A server whose behaviour can change without a new version, such as a remote server or an unpinned launcher, will see its tools hidden whenever they drift.

First installs need the user's approval when the label's highest risk is above low; the label and grade are shown.

Configuration and secrets

Ask for settings with userConfig in your plugin manifest. Mark credentials sensitive:

.claude-plugin/plugin.json
{
  "name": "linear-triage",
  "version": "2.1.0",
  "userConfig": {
    "api_key": {
      "type": "string",
      "title": "Linear API key",
      "description": "Create one in Linear under Settings → API.",
      "required": true,
      "sensitive": true
    }
  }
}

Sensitive values go to the OS keychain. Other values are saved in the install lockfile, so never leave a secret unmarked. A sensitive option can't have a default.

How users refer to your extension

Hub extensions are referred to as publisher/name, optionally with a semver range: acme/linear-triage@^2. The same installer also accepts MCP Registry servers (mcp:io.github.acme/server), GitHub repositories (github:owner/repo), npm packages (npm:@scope/pkg, without install scripts), archives and local folders.

Checklist before publishing

  • The manifest has a semver version, a description of a sentence or two, and a license.
  • Paths in plugin.json start with ./ and stay inside the package. Plugin names starting with claude- or anthropic- are reserved.
  • A README.md at the root. It's shown on the extension page.
  • .cusp-plugin/hub.json has your id, a tagline and categories.
  • capabilities declares every program, host, credential and hook, and nothing on the label is undeclared.
  • Hosts your launchers reach are declared.
  • MCP servers ship .cusp-plugin/tools.json, with annotations on every tool.
  • Launchers and images are pinned to exact versions.
  • No minified code, no curl … | sh in Markdown, no agent, editor or CI configuration in the package.
  • Secrets are sensitive options or declared secrets, never defaults or examples.
  • Optional: an eval suite in evals/suite.json, referenced from hub.json.
  • Customize → Publish shows the grade you expect and no findings above warn.

On this page