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.
| Format | Recognised 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 server | server.json in the MCP Registry format, an MCPB manifest.json, or .mcp.json inside a plugin |
| Skill | A SKILL.md, or a folder of skill folders |
| Gemini CLI extension | gemini-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{
"name": "release-notes",
"version": "1.0.0",
"description": "Drafts release notes from the commits since the last tag.",
"license": "MIT"
}---
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.{
"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.
| Field | Type | Meaning |
|---|---|---|
id | string | publisher/name, lowercase. The publisher part (up to 39 characters) must be your publisher account; the name is letters and digits separated by - or . |
kind | string | Overrides the detected kind: plugin, mcp-server, skill, agent, command, hook-pack, output-style, workflow, lsp, theme, blueprint or integration |
displayName | string, ≤ 80 | The name in listings. Defaults to displayName in the plugin manifest |
tagline | string, ≤ 120 | One line under the name. Defaults to the first sentence of the description |
useCases | string[], ≤ 5 | Short verb phrases, such as "Triage new issues" |
categories | string[], ≤ 3 | From the list below |
icon | string | The extension's icon |
color | string | Accent for the extension's tile, as #rrggbb |
media | object[], ≤ 12 | Screenshots or videos: src, alt, and optionally theme (light or dark) and kind (image or video) |
capabilities | object | What the extension may do. See Capabilities and the label |
evals | string | Path to the eval suite, such as ./evals/suite.json. See Evals |
models | object | recommended model ids or families, and requires: toolCalling, vision, minContextTokens |
runtimes | string[] | Agents that load the package unchanged: cusp, claude-code, codex, gemini-cli, cursor, copilot, opencode. Inferred from the formats when omitted |
tuning | object | Description variants per model family. Accepted and validated, but Cusp doesn't apply them yet |
links | object | docs, support, changelog and sponsor URLs |
blueprint | object | Required when kind is blueprint: the extensions, integrations, crew seats, workflows and project settings it sets up |
integration | object | Required when kind is integration: a remote MCP endpoint or a local command, and how it authenticates |
editorial | object | Reserved 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:
{
"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-toolsandtoolsin skill, command and agent frontmatter; and sensitiveuserConfigoptions.
Anything inferred but not declared is marked undeclared on the label.
| Capability | What you declare |
|---|---|
filesystem.read, filesystem.write | Path patterns starting with ${workspace}, ${home}, ${data}, ${root}, ${tmp}, ~/ or /. * matches one segment, ** any depth; .. isn't allowed |
network | false for no network, or { "hosts": [...] }: api.example.com, *.example.com, an IP address, an optional :port, or * for any host |
env | Environment variables its processes read |
secrets | Credentials by name, each with a description, via (token, oauth or env), env, the hosts it may be sent to, scopes and required |
exec | programs it starts, and shell: true if it runs arbitrary shell commands |
tools | Agent tools its skills, agents and commands rely on, as permission rules such as Bash(git *) |
hooks | Lifecycle hooks it intercepts, such as PreToolUse or PreToolUse:Bash |
mcp | MCP client features its servers use: sampling, elicitation, roots |
data | Data 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:
| Item | Risk |
|---|---|
Paths under ${data}, ${root} or ${tmp}; reading ${workspace} | Low |
Writing ${workspace}; reading other paths | Medium |
| Writing other paths | High |
All of ${home}, /, or credential folders such as .ssh, .aws or .kube | Critical |
| A specific host | Low |
A *.domain wildcard | Medium |
Any host (*) | Critical |
A program it starts; bin/ executables | Medium |
A shell (sh, bash, zsh, pwsh, sudo…) or arbitrary shell commands | High |
| A credential | Medium (oauth: low) |
Tool rules Bash(…), Write, Edit, WebFetch | Medium (Bash, Bash(*), *: high; other tools: low) |
| A hook | Medium (PreToolUse, PermissionRequest, UserPromptSubmit, SessionStart, ConfigChange: high) |
| MCP sampling | Medium |
| Data collected or shared | Medium (source-code, credentials, transcripts: high) |
| Code loaded into the agent process itself | Critical |
| Environment variables | Low |
The grade starts from the highest risk on the label, then moves down:
| Rule | Grade |
|---|---|
| Highest risk none or low | A |
| Highest risk medium | B |
| Highest risk high | C |
| Highest risk critical | D |
| Any undeclared item rated medium or higher | C at best |
| Three or more such items, with a high or critical item on the label | D at best |
| Four or more such items, or code that runs inside the agent process | E |
| Hosts, credentials and programs that together suggest untrusted input, private data and a way to send it out | D 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,bunxandpnpmreachregistry.npmjs.org;uvxandpipxreachpypi.organdfiles.pythonhosted.org. Declare those hosts. They're rated low, but with acapabilitiesblock the sandbox only allows declared hosts. - Environment variables passed to MCP servers whose names contain
TOKEN,SECRET,PASSWORD,API_KEY,ACCESS_KEY,PRIVATE_KEY,CREDENTIALorAUTH, andAuthorizationor API-key headers, are inferred as credentials. Declare them undersecrets. SensitiveuserConfigoptions andisSecretinputs inserver.jsonalready 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-toolsand agenttoolscount 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:
| Target | Files |
|---|---|
| Model-visible text | Markdown, .txt and .toml files: skills, agents, commands, rules, and tool definitions |
| Code | Source files (.js, .ts, .py, .sh, .ps1, .rb, .go, .rs…) and everything in bin/ |
| Config | plugin.json, hooks.json, .mcp.json, server.json, manifest.json, package.json, hub.json and similar |
| Docs | README, 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 finding | Verdict | When you publish | When someone installs |
|---|---|---|---|
| Critical | reject | The version is rejected | Install is blocked |
| High | review | The version is held for review | The user must acknowledge the findings |
| Medium | warn | Published | Findings are shown |
| None or low | pass | Published |
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_URLor 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 … | bashin 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):
{
"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:
| Annotations | Counted as |
|---|---|
readOnlyHint: true | Read |
destructiveHint: false | Write |
| Neither | Destructive |
openWorldHint not false | Open-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:
{
"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 field | Meaning |
|---|---|
name, description | The suite's name and summary |
runs | Runs per case per model, 1 to 20 (default 3) |
baseline | Also run each case without the extension, to measure the difference it makes (default true) |
threshold | The pass rate you expect, 0 to 1 (default 0.8) |
cases | 1 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.
| Expectation | Passes when |
|---|---|
tool_called | A tool was called, optionally with arguments that include args, at least min times. * matches any characters in the tool name |
tool_not_called | A tool wasn't called |
skill_invoked | One of your skills was loaded |
output_matches | The final answer matches a regular expression |
file_matches | A workspace file matches a regular expression |
command_succeeds | A shell command exits 0 after the run |
judge | A model grading against your rubric scores at least threshold |
max_turns, max_tool_errors | The 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:
- The token, and that the id's publisher matches it.
- 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. - The archive hashes to the integrity you sent, and the package loads.
- The
idinhub.json, if set, matches; the manifest version, if set, matches. - The package is analyzed: label, grade and scan.
- The version gets a status.
| Status | When | What happens |
|---|---|---|
published | Nothing below applies | Listed, countersigned and added to the transparency log |
held | Scan 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 old | Not installable until a reviewer releases it. The response lists every reason |
rejected | A critical scan finding | The 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.
Updates and re-consent
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
reviewfindings 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.jsonexactly 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:
{
"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 alicense. - Paths in
plugin.jsonstart with./and stay inside the package. Plugin names starting withclaude-oranthropic-are reserved. - A
README.mdat the root. It's shown on the extension page. .cusp-plugin/hub.jsonhas yourid, ataglineandcategories.capabilitiesdeclares 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 … | shin Markdown, no agent, editor or CI configuration in the package. - Secrets are
sensitiveoptions or declaredsecrets, never defaults or examples. - Optional: an eval suite in
evals/suite.json, referenced fromhub.json. - Customize → Publish shows the grade you expect and no findings above
warn.