09/Skills

The Community Registry

Two tiers, two defaults: the built-in catalog arrives ENABLED, a registry install arrives DISABLED — and the body comes from GitHub, not from the index.


The Marketplace tab has two halves. **Built-in** is the 10 curated skills versioned alongside the code. **Community** is third-party SKILL.md files, searched through the `skillsmp.com` index and served from public GitHub repositories. They sit on the same page, but they are not the same thing: a built-in skill installs ENABLED, a community skill installs DISABLED.

TierWhere it comes fromOn install
Built-inThe catalog inside the code — 10 skills, curated by us.`source: template`, `is_active: true`. Today's behaviour is unchanged: you can bind it and it runs right away.
CommunityThe search comes from the `skillsmp.com` index, the BODY from `raw.githubusercontent.com`.`source: registry`, `is_active: false`. The row lands switched off; `source_ref` is the address the body was actually fetched from (an audit trail).
The different default is about trust, not caution theatre
A skill's body is written into the system prompt of real user traffic (see the "Injection Modes & Budget" page) — so enabling a community skill means putting a stranger's text into your own users' prompts. We wrote the built-in catalog; the index, by contrast, is unreviewed content scraped from SKILL.md files in public GitHub repositories. Skillsmp's own FAQ says the same thing: "Always review the code before installation", and the index "does not certify" anything. That is why "install" and "put into service" are two separate decisions: the install endpoint deliberately has NO `is_active` field, so no request can arm it; enabling is a separate, deliberate act on the Installed tab.

#The index searches; GitHub serves

search / installname, stars, routeURL rebuilt from the routecopy
PanelCommunity tab
routerGateway/admin/skills/registry/*
skillsmp.comsearch index only
raw.githubusercontent.comthe SKILL.md body
Skill rowis_active = false
The search and the content come from two different sources

The index is ONLY a search index; the body never comes from it. When you press "Install" on a card, the panel does not send the gateway an address — it sends the row's four route components (`owner`, `repo`, `branch`, `path`), and the download address is rebuilt on the gateway from those validated components. The row's own `githubUrl` field is never read: it is third-party free text and could just as well be a `javascript:` string. The repo link the panel shows is built from the same components too, and passes a second scheme check before it is even drawn.

The composed address goes through the SAME hardened path as a manual "Import from URL" — there is no second rule set: https only, an exactly-matched host allowlist, refusal of any redirect that changes host (at most 3 hops within the same host), a 256 KB cap enforced WHILE streaming, and a 10-second wall clock covering the whole redirect chain. The components themselves are validated as well (`..`, absolute paths, scheme/host injection, URL-encoded separators, empty or over-long components are all refused), and the composed address must fit the audit-trail column (512 characters). A rejected route ends the request with a 400, and no request leaves the machine.

The following were measured against the live index (2026-08-24), not taken from its documentation. The panel reads every one of them out of the response; it assumes none of them as a constant.

LimitWhat was measured
Page sizeAt most **48**. `limit=48` returns 200 with 48 rows; `limit=49` gets `HTTP 400` + `INVALID_LIMIT` upstream. The panel's page size is kept under that ceiling, because a clamp of our own that sat above upstream's would make a HEALTHY index look unreachable.
`total` is exact only sometimesWhen the whole result set FITS on the requested page the index says `totalIsExact: true` and `total` is the real count (measured at `limit=24`: searches returning 1, 8, 15 and 0 rows were all `true`). When it does not fit, `total` is NOT a match count — it is a `hasNext` probe equal to the rows delivered so far plus one (`search=security`: `limit=5`→6, `limit=24`→25, `limit=48`→49; page 2→49). So the panel prints the exact figure when there is one, and invents nothing when there is not: it states a **proven lower bound** from the rows actually delivered ("more than N matches").
Results are cappedAn empty search returns `total: 1200, isCapped: true, maxResults: 1200`; on a narrow search those two fields are ABSENT from the response. The panel prints the number only when the response carries it, and promises no infinite scroll — it tells you to narrow the search rather than page to the end.
Only two sorts are honest**Stars** and **Recent**. `sortBy=name` is accepted upstream but returns EXACTLY the same rows as stars; offering a "Name" button would lie to whoever clicks it, so the panel does not offer one.
No license fieldThere is no `license` field on an index row. See below.

The panel writes two smaller truths as well instead of swallowing them: if some rows on a page were dropped because they could not be read safely, it counts them ("… could not be read safely and were dropped"), and if the answer came from the short-lived cache (60 seconds) it says so. Upstream is not hit on every keystroke; the search box is debounced as well.

"Not declared" is NOT permission
Because the index carries no license, every card says "License not declared" BEFORE the install — today that field is identical on every row. The real license is known only after the SKILL.md is downloaded: the `license` key is read from the frontmatter and shown on the installed row; if the frontmatter has none, it stays undeclared there too. An undeclared license does not mean you have permission to use it — it means neither the index nor the file told you anything. So that the empty case is never drawn as "free", the panel does not leave it blank: it says so.

#Installing: one click, a switched-off row

  1. 1A card installs one skill. There is no bulk install, deliberately: bulk installing is the easiest way to scale up unreviewed content.
  2. 2The gateway fetches the body from the validated address, parses it as a SKILL.md and writes the row with `is_active=false`; `source_ref` is the address the body was actually downloaded from.
  3. 3The toast says the row arrived SWITCHED OFF and asks you to read its body before enabling it. The body is on the Installed tab and is editable like any installed skill.
  4. 4If the source does not return 200, or the body is not a SKILL.md, the install fails — no half record is written.

If the name collides, the endpoint returns a clean **409** and the panel does not make that a dead end: a rename dialog opens ("That name is taken" / "Nothing was overwritten"), suggests `{name}-community` and installs the same row under the new name. This is a real need — `code-review`, for instance, exists both in the built-in catalog and in the index. **Renaming is naming only:** it cannot arm the install, the row still arrives switched off, and the new name goes through the same validation as any SKILL.md name (lowercase/digits/hyphens, at most 64 characters, no "anthropic"/"claude").

An installed skill is a SNAPSHOT
The copy semantics are the same as the built-in catalog's and are unchanged: the row carries no link back to its source. If the repository changes tomorrow, the installed skill does not — and a bad change cannot walk into your traffic on its own. Automatic updating is OUT of scope. `source_ref` is an audit trail, not a subscription: it says "this body came from that address", not "follow it".

#"No results" and "index unreachable" are different states

An empty result is an ANSWER: the index spoke, and nothing matches. An unreachable index is not an answer: we do NOT know what is in the index right now. Showing both with the same sentence makes a working search read like an empty world. The panel separates three states with three separate texts, and none of them borrows another's sentence:

gateway unreachable
"We could not reach this gateway." — nothing is claimed about the index, because we do not know.
index unreachable
"The community index could not be reached." plus the server's own error text. The words "no results" do NOT appear on this branch, and it also states that built-in skills are unaffected.
empty answer
"The index answered, and no community skill matches …" — "the index answered" is deliberately part of the sentence: the emptiness IS the index's answer.

That is why the search endpoint never returns a 5xx: even if the index answers 500, times out or sends malformed JSON, the response is `200` + `index_available: false` + the error text, so the panel can tell the two states apart. **The Built-in tab does not depend on the remote index:** the two tabs are two separate queries with no data link between them, so browsing and installing from the built-in catalog keeps working while the index is entirely down.