Skip to content

feat(phase-1): agent-native discovery and compatibility - #41

Closed
mrgoonie wants to merge 19 commits into
mainfrom
feat/phase-1-agent-native-discovery
Closed

mrgoonie wants to merge 19 commits into
mainfrom
feat/phase-1-agent-native-discovery

Conversation

@mrgoonie

@mrgoonie mrgoonie commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

What this does

Phase 1 of the marketplace roadmap (#33): agent-native discovery and compatibility. A listing can declare which agent runtimes it supports, every surface resolves that declaration through one shared engine, the declaration never claims more than it can prove, and an agent can read the catalog as Markdown, over MCP, in-page over WebMCP, or through two new landing docs — without scraping HTML.

All 15 implementation tasks of the issue are done, plus four hardening items found while finishing them.

The problem this solves first

skills had no compatibility column and nothing linked a listing to a package_releases row, so a release-only surface would have answered unknown for the entire catalog. Rather than ship a permanent no-op, this adds skills.compatibility_json as a transitional second declaration tier with a single resolver owner, documented as such. Phase 2 deletes that tier when listings can be linked to immutable releases.

The one rule

selectDeclaration in packages/contracts owns precedence, and web, CLI, MCP, and Markdown all delegate to it:

  1. A release declaration wins, and only it can reach verified.
  2. Otherwise a listing declaration applies — it can reach declared, never verified, because a mutable listing has no artifact digest to bind evidence to.
  3. Otherwise unknown.

A release declaration arriving without hasRelease is ignored. unknown is never actionable: only declared and verified count.

Slices

# Slice Evidence
1 Shared catalog resolver + skills.compatibility_json c1a6479, 95a169e — 16 tests
2 Compatibility-aware API (?compatible=, search + detail) d02a897 — 40 tests
3 CLI inspect, check, --compatible, JSON/exit-code envelope 033f2d2 — 32 tests
4 Skill page badge with its evidence tier aec3d1b — 5 tests
5 Markdown routes, llms.txt, llms-full.txt, rel=alternate, Copy c6aa7b9, f31c9ed — 71 tests
6 Remote read-only MCP server + WebMCP read tools 89b5e6f, b09f539 — 52 tests
7 Cross-surface parity tests f0ee904 — 15 tests
— Task 14: publisher and harness landing pages 8c3cd8c
— Hardening (below) af62ab3, 562de21, d62fccd, api.search tests, 335e4b5

Hardening found while finishing the issue

The CLI reported the wrong version (#24). src/index.ts hardcoded 0.1.2 while npm published 0.4.0. Because merge to main triggers release-please, the next publish would have shipped a binary announcing the wrong version. It now reads the manifest at runtime, and depth is load-bearing: the helper sits at src/version.ts beside the entry point rather than in src/lib/, because bundling inlines it into dist/index.js and both locations must be exactly one level below the package root. Verified by running the built CLI, not only in source.

Pull requests had no verification at all — the only workflow was release-cli.yml on push to main. .github/workflows/ci.yml now runs a frozen install, typecheck, tests, and both builds. Writing it found two real defects: pnpm-lock.yaml had drifted from the manifests (missing the @skillx/contracts entry for apps/web), so --frozen-lockfile would have failed; and the root typecheck script filtered to apps/web, so packages/cli and packages/contracts were never typechecked anywhere. Both are fixed.

llms.txt and llms-full.txt take ?limit= (caps 5000 and 1000) and name the knob when truncating. Two fixes came with it: Number replaces parseInt because parseInt("1e9") is 1 — a caller asking for more would have received less than the default — and the KV cache key now carries the origin, since the rendered document holds absolute URLs that were previously shared across hosts.

api.search.ts gained 19 route-level tests for the validation, the limit clamp, and the envelope the CLI, the search page, and the MCP tools all consume.

Things worth a reviewer's attention

The Markdown surfaces run through the resolver, not around it. renderSkillMarkdown takes payloadGranted as a required input rather than inferring it from whether content is a string — an inference would make a deliberate denial indistinguishable from a forgotten field. A withheld body renders as an explicit note, never a blank section that would read as an empty skill. The three Markdown routes read rows through one gated module (lib/catalog/public-catalog-index.ts), which the Phase 0 boundary guard covers under its "must actually call the gate" invariant.

llms.txt reports the catalog total from a count(*) query, not the size of its own slice. A truncated document that reports its own length tells an agent the catalog ends there.

Compatibility filtering cannot use the database. compatibility_json is a JSON column that FTS5 and Vectorize cannot index, so the search executor over-fetches, filters, truncates, and reports { evaluated, matched, undecided, refused, poolExhausted } plus an emptyFilterNote. Undecidable rows are never silently dropped.

MCP tool failures are JSON-RPC successes carrying isError, per MCP; only a malformed request or unknown method is a JSON-RPC error. A tool that cannot answer returns found: false with a reason code instead of throwing, because no_package is the expected answer for most of the catalog until the registry is populated, and "not in the registry" must be distinguishable from "the call failed". get_kit reports a private collection as not_found, identical to an unknown slug, so the tool cannot enumerate private slugs. The endpoint sets no CORS headers on purpose: MCP clients are not browsers and the WebMCP layer runs same-origin.

--compatible <runtime> is runtime-only per #33. Version-aware answers live on the detail surface (?target=runtime@version), which inspect and check use.

Three files hit the 200-line ceiling and were split: skill-detail.tsx, lib/mcp/handlers.ts, and the contracts test that gained the vocabulary pin.

Verification

  • pnpm typecheck — clean, now recursive over apps/web, packages/contracts, and packages/cli.
  • pnpm test — 396 passed / 30 files.
  • pnpm build (web) — ok; the Markdown routes, /api/mcp, and both new docs pages emit as expected.
  • pnpm build (CLI) — ok, and the built binary was executed to confirm it reports 0.4.0 from any working directory.
  • Route-level tests assert on real HTTP bodies for skill detail, the Markdown route, the search API, and the MCP endpoint, including that a protected listing's body is never served — anonymously or with a session but no entitlement.

Remote migration — applied

Migrations 0009 and 0010 are applied to remote skillx-db and verified independently through the D1 REST API. Remote was missing 0009 as well as 0010, so the Phase 0 registry tables did not exist in production until now.

Post-state: 28 tables (was 21), skills 130,309 rows unchanged, skills_fts intact, 11 migrations recorded, none pending, and all seven new tables empty as expected for additive changes.

Recovery points: time-travel bookmark 000049e3-00000000-000050ee-38c209ac471ef825f224801bdbf967b3, plus a schema capture at .wrangler/backups/skillx-remote-schema-PRE-0010-20260922-074943.json.

What this does not prove

Stated so its absence is not mistaken for coverage:

  • The CI workflow's first run on GitHub is not yet observed. It parses and every command in it passes locally; the Actions environment itself is unverified.
  • The parity suite compares contracts ↔ web adapter, web adapter ↔ MCP, and engine ↔ Markdown, but cannot import the CLI, for the rootDir/bundling reasons in refactor(cli): call isCompatibilityActionable instead of mirroring the rule #48. The CLI's rule is pinned by value instead, and the status vocabulary is now pinned at the source of truth so a new status fails a test.
  • No browser-level test of rel="alternate" hoisting, the clipboard write, or WebMCP against a real browser implementation — the WebMCP API is a draft and is covered against a fake navigator.modelContext.
  • SSR routes other than the Markdown and search routes rely on unit tests plus the static boundary guard.
  • The catalog is 130,309 skills, so even with ?limit= the default llms.txt covers under 1% and llms-full.txt about 0.15%.
  • @skillx/contracts has no own compile step and is typechecked through its own tsconfig plus the apps/web build.
  • No test writes to remote D1.

Out of scope

AgentKit's Go installer port, any AgentKit→SkillX dependency, commerce/payment runtime changes, and Phase 2–6 implementation (tracked as #34–#38). No new microservice — modular monolith under apps/web/app/lib/ and packages/.

Related

… rule

Phase 0 stores declarations on an immutable release, but every catalog row today
is a legacy listing with no release, no artifact and no digest. A read surface
built only on release declarations would answer `unknown` for the entire
catalog, so every compatibility filter and badge would be permanently inert.

Adds the one rule that bridges the two storage tiers: a release declaration wins
and only it can reach `verified`; otherwise the listing declaration is used, which
can reach at most `declared` because a listing has no digest to bind evidence to;
neither present yields `unknown`. A release declaration arriving without a
release is ignored rather than trusted, so no surface can claim release-grade
provenance it cannot prove.

The engine still decides; this only chooses the inputs, and the Phase 2 removal is
local to selectDeclaration.
Adds the listing-level compatibility declaration. A legacy listing has no release
and no digest, so this column is the only place a publisher can declare runtime
support for the catalog that exists today; it can reach `declared` but never
`verified`.

Adding the column pushed schema.ts to 237 LOC, over the project's 200 LOC rule, so
the five auth/API-key tables move to `auth-schema.ts` following the same
per-domain pattern as registry-schema.ts and collections-schema.ts. Nothing in
those tables references `skills`, so the split crosses no foreign key, and
schema.ts re-exports them so `~/lib/db/schema` stays the single import site.

Migration 0010 was generated against a backed-up local D1
(.wrangler/backups/skillx-db-PRE-0010-*.sqlite) and applied cleanly: skills went
from 28 to 29 columns. A second generate reports "No schema changes", which is
what proves the auth split is schema-neutral.
Adds the listing adapter over the shared precedence rule, and the
`--compatible <runtime>` filter.

The filter cannot run in the database: compatibility is a JSON column that FTS5
and Vectorize cannot index. It runs over a projected page instead, and it reports
what it did rather than filtering silently — a filter that hides undecidable
listings makes an agent believe the catalog is smaller than it is. Each run
returns `{ evaluated, matched, undecided, refused, poolExhausted }`, and an empty
page carries a note stating that a missing declaration is not support. When the
filter is active the executor over-fetches a candidate pool and truncates only
after matching, so a page does not come back short just because the top-ranked
rows were undeclared.

`GET /api/skills/:slug?target=runtime@version` now returns every runtime the
listing declares plus a version-aware answer for the requested target, so an agent
can read the requirement, the evidence and the refusal reason without scraping
HTML. Only `declared` and `verified` are actionable; `unknown` never is.
Agents drive this CLI, so the exit code is part of the interface: 0 when a target
may be acted on, 4 when it may not, 2 for a bad invocation, 3 for a missing skill,
5 for credentials, 6 for the network. `--json` emits one envelope shape
(`{ ok, data }` or `{ ok: false, error: { code, message, details?, hint? } }`) so a
caller never parses human text.

- `skillx inspect <id> [--target runtime[@Version]]` reports metadata, every
  declared runtime, the requirements, the evidence and the refusal reasons.
- `skillx check --target <runtime[@Version]> <id>` is the gate, and its exit code
  is the answer.
- `skillx search <query> --compatible <runtime>` adds the filter column and prints
  what the filter did, so a short page is never a mystery.

Identifier parsing and slug mapping move to `lib/identifier.ts` so `use`,
`inspect` and `check` cannot disagree about which skill an identifier names. That
also brings `use.ts` from 207 to 173 LOC, under the project's 200 LOC rule.

Reads stay read-only: `inspect` and `check` never register or scan.
A badge alone cannot tell a reader whether anyone ran the skill on the artifact
they are about to install, so the panel separates the two tiers. `verified` names
the probe and the date it was recorded against this artifact; `declared` says
plainly that no probe exists.

An undeclared listing renders an explicit warning instead of no badge. Silence
would read as support, and this catalog declares nothing today, so that is the
state almost every page is in.

The loader test pins the wiring, including that adding compatibility did not
weaken the protected payload boundary: a free listing still carries content and a
protected one still does not.
Agents should not have to parse HTML to read a skill, so the catalog now
answers in Markdown at three routes:

- GET /skills/:slug.md   one skill, with its SKILL.md body when granted
- GET /llms.txt          the catalog index, in one request
- GET /llms-full.txt     listed skills with their bodies, bounded to 200

The skill page advertises its Markdown variant through rel=alternate, and the
Markdown route answers with a canonical Link header.

These are the most payload-heavy surfaces in the product, so they run through
the protected resolver rather than around it. The renderer takes
`payloadGranted` as a required input instead of inferring it from whether
`content` is a string: an inference would make a deliberate denial
indistinguishable from a forgotten field. A withheld body renders as an
explicit note, never a blank section that would read as an empty skill.

llms.txt and llms-full.txt read their rows in one gated module
(lib/catalog/public-catalog-index.ts), which is now covered by the boundary
guard's "must actually call the gate" invariant. Both report the catalog total
from a count query rather than the size of their own slice, so a truncated
document cannot tell an agent the catalog ends where the slice does.

Verified: pnpm typecheck clean; pnpm test 286 passed / 22 files, including the
25 new Markdown-rendering tests and 6 for the gated index; pnpm build emits the
three routes as server-only resource routes.
"Copy as Markdown" fetches the page's own /skills/:slug.md resource instead of
assembling Markdown in the browser. That is the security property, not a
shortcut: the route applies the protected payload boundary server-side, so a
viewer without entitlement copies the withheld-body document and the client
cannot be handed a payload the server would not have served. It also keeps one
Markdown renderer rather than two that can drift.

The button reports a failed copy instead of doing nothing, since silence would
look like a successful copy of an empty document.

Also adds route-level tests for the Markdown loader. The renderer's unit tests
pin the rendering rules and the static guard proves every serve path calls the
gate, but neither exercises this handler end to end. The new tests drive the
real loader and assert on the real body: a free listing serves its body, a
protected one never does — anonymously or with a session but no entitlement —
a withheld listing is still fully described, malformed compatibility JSON never
becomes a claim, and an absent slug is 400 rather than a partial document.

skill-detail.tsx was sitting exactly at the 200-line ceiling, so the risk
banners moved to components/risk-warning-banner.tsx. They keep `danger` and
`caution` distinct rather than collapsing into one warning.

Verified: pnpm typecheck clean; pnpm test 296 passed / 23 files; pnpm build ok.
Slices 1-5 are done; 6 (MCP/WebMCP) and 7 (parity tests) have not started.
The notes state the two things slice 5 does not prove — no browser-level test
of rel=alternate hoisting or the clipboard write — so their absence is not
mistaken for coverage.
POST /api/mcp speaks JSON-RPC 2.0 over the MCP streamable-HTTP transport and
implements initialize, notifications/initialized, tools/list, and tools/call.
Everything else answers `method not found`, which is more useful to a client
than a stub that appears to work.

Tools: search_packages, get_package, get_release, check_compatibility, get_kit.
Each handler is a thin adapter over the same executors the HTTP API and the CLI
use, so MCP cannot drift into a second implementation.

Three decisions worth reviewing:

- A tool that cannot answer returns `found: false` with a reason code rather
  than an error. `no_package` is the expected answer for most of today's
  catalog because the registry tables are empty until a later phase, and an
  agent has to be able to tell "not in the registry" apart from "the call
  failed".
- A tool failure is a JSON-RPC *success* carrying isError, per MCP; only a
  malformed request or unknown method is a JSON-RPC error.
- get_kit reports a private collection as `not_found`, identical to an unknown
  slug, so the tool cannot be used to enumerate private slugs.

The endpoint deliberately sets no CORS headers. MCP clients are not browsers and
the WebMCP layer runs same-origin, so nothing in scope needs cross-origin access;
opening it would only widen the surface.

Tool descriptions are treated as load-bearing: each states what the tool will
not do, including that `unknown` compatibility is not support and that
`compatible` takes a runtime but not a version.

Verified: pnpm typecheck clean; pnpm test 330 passed / 25 files, including 24
tests for the protocol and dispatch and 10 driving the route over real HTTP
bodies (id 0 is a call, notifications get 202 and no body, unknown tool is a
success with isError). handlers.ts was 230 lines at first and is now split into
args/packages/kits/registry, all under 200.
Adds WebMCP as progressive enhancement: when navigator.modelContext exists the
page registers three read tools (search_skills, get_skill,
check_skill_compatibility); when it does not, the bridge renders nothing and the
site behaves identically.

WebMCP is a draft API, so detection checks the shape of the object rather than
trusting presence. A renamed or partially implemented modelContext falls back to
"unavailable" instead of throwing inside a page effect, and a registration that
throws is reported as `failed`, never as `registered` — claiming success for a
rejected registration would make the failure invisible.

Each tool calls the same public HTTP endpoint the page itself uses, rather than
importing server executors. One implementation means an in-page agent cannot get
a different answer from the API, and the protected payload boundary stays in
exactly one place: the server. `fetchImpl` is injected so the tools are testable
without a live network.

Verified: pnpm typecheck clean; pnpm test 348 passed / 26 files, including 18 for
detection, registration, and the tools' request shapes; pnpm build ok.
Phase 1's claim is that API, CLI, MCP, and Markdown give one answer for one
listing because they delegate to one engine. Unit tests inside each surface
cannot show that: they would keep passing if a surface quietly grew its own rule.
These tests compare the surfaces to each other, so a divergence fails here.

Covered against one fixture and one target:
- the web adapter is byte-identical to the engine for five declarations
  (none, in range, explicit unsupported, out of range, unparseable range),
  proving the adapter adds no rule of its own;
- the real MCP tool, driven through callTool with only storage stubbed, returns
  the engine's status and reasons unchanged, and reports a missing listing as
  `not_found` rather than collapsing it into `unknown`;
- the Markdown renderer prints the same status the engine returned, built
  through the real `toCompatibilitySummary` adapter, and never claims `verified`
  for a listing that has no evidence.

Two honest limits, recorded in the file rather than left implicit. The CLI is
not imported here because apps/web/tsconfig.cloudflare.json lists its own files
only, so a cross-package import fails the project boundary; the CLI's rule is
pinned by value in its own test. And that leaves the CLI as the one surface
holding a hand-written mirror of `isCompatibilityActionable` instead of calling
it, which is the drift this file cannot catch.

Verified: pnpm typecheck clean; pnpm test 362 passed / 27 files.
Slices 6 and 7 are done. Task 14 (publisher/harness landing docs) is the one
implementation task in #33 with no code behind it, so it is recorded as not done
rather than left ambiguous.

The remote D1 section is new and material: remote had been missing 0009 as well
as 0010, so the Phase 0 registry tables never existed in production until now.
It also records that the catalog holds 130,309 skills, not the ~5k assumed
earlier, which puts llms.txt under 1% coverage.
@mrgoonie mrgoonie changed the title feat(phase-1): agent-native discovery and compatibility (slices 1-5) feat(phase-1): agent-native discovery and compatibility Sep 22, 2026
`skillx --version` printed 0.1.2 while npm published 0.4.0, because the string was
hardcoded in index.ts and release-please bumps package.json only (issue #24).
The version now comes from the manifest at runtime, so it cannot drift.

Depth is load-bearing and the reason this helper sits at src/version.ts rather
than with the other helpers in src/lib/: `../package.json` is resolved against the
module's own URL, and bundling inlines the module into dist/index.js. Both
src/version.ts and dist/index.js are exactly one level below the package root, so
one relative path serves both; a copy under src/lib/ would be two levels deep and
would resolve outside the package once bundled.

Reading it can fail, so it falls back to "unknown" rather than refusing to start:
a wrong version string is a small problem, a CLI that will not run is a large one.

Verified at the artifact level, not just in source: pnpm build then
node bin/skillx.js --version prints 0.4.0, matching the manifest, and still does
when run from a different working directory. pnpm typecheck clean; pnpm test 366
passed / 28 files.
The repo had no verification on pull requests: the only workflow was
release-cli.yml, on push to main. This adds a job that runs the same commands a
developer runs locally — frozen install, typecheck, test, build web, build CLI —
on pull requests and on main.

Writing it immediately found two real defects.

pnpm-lock.yaml had drifted from the manifests: apps/web's `@skillx/contracts`
dependency had no entry, and packages/contracts' typescript devDependency was
never recorded. `pnpm install --frozen-lockfile` would have failed in CI, so the
frozen install stays as the first real gate.

The root `typecheck` script filtered to apps/web, so packages/cli and
packages/contracts were never typechecked anywhere. It is now recursive, and
packages/cli gained the `typecheck` script it was missing.

A build gate is included because a bundling-only failure is invisible to
typecheck and tests: the CLI version fix in the previous commit is exactly that
class of bug, and only running the built artifact catches it.

Actions are pinned to commit SHAs rather than tags, the job takes `contents:
read`, and checkout does not persist credentials. Verified: workflow parses, and
the full local chain (frozen install, typecheck over 3 packages, 366 tests, both
builds) passes. The workflow's first run on GitHub is not yet observed.
The catalog holds 130,309 skills, so llms.txt covered under 1% and llms-full.txt
about 0.15% with no way to ask for more. Both now accept `?limit=`, bounded by a
cap (5000 for the index, 1000 for the document that carries SKILL.md bodies), and
the truncation note names the knob instead of only pointing at the search API.

Two correctness fixes came out of writing it.

`Number` is used rather than `parseInt`, because `parseInt("1e9")` is 1: a caller
asking for far more than the cap would have silently received less than the
default. Clamping honours the requested magnitude. A test caught this.

The KV cache key now carries the origin. The rendered document contains absolute
URLs, so a single key shared across hosts would serve links to a different host —
which is reachable today via any preview or alternate hostname. The key also
carries the resolved limit, since that changes the body.

Unusable input falls back to the default rather than returning 400: a malformed
query parameter should not turn a readable document into an error. Also removes
an unused `siteUrl` parameter from the shared header helper.

Verified: pnpm typecheck clean over 3 packages; pnpm test 376 passed / 29 files.
The executor and the compatibility filter had unit tests, but the route itself —
argument validation, the limit clamp, and the response envelope that the CLI, the
search page, and the WebMCP tools all consume — was only covered indirectly. A
contract detail is exactly what drifts silently.

19 tests over real HTTP bodies: a missing or non-string query is 400; a
non-string `compatible` is rejected rather than coerced; the default limit is 20
and a request for 5000 is clamped to 100; zero, negative, and non-numeric limits
fall back to the default; `compatible` is parsed as runtime-only so a version
suffix never reaches the filter; the compatibility report and the empty-result
note travel only when a filter ran; a failure is a 500 envelope rather than an
empty success; and GET handles is_paid, limit, and compatible the same way POST
does.

executeSearch is stubbed, so these assert what the route forwards and returns,
not ranking quality.
Task 14 of #33, the last implementation task in the issue. Two pages, added to the
docs sub-nav (which now wraps, since five tabs overflow a narrow viewport).

/docs/publishers covers the publish command and the two things publishers get
wrong: ownership is verified by a GitHub collaborator check, and a listing can
reach `declared` but never `verified`, because a mutable listing has no artifact
digest to bind evidence to. It also states the package kinds and release states,
and that only `published` resolves.

/docs/harness covers what a runtime integrator needs: the Markdown surfaces and
`?limit=`, the remote MCP endpoint with its five tools and the reason a tool
failure is an `isError` result rather than a transport error, and the status
vocabulary — with the warning that treating `unknown` as compatible is the
commonest way an integration reports support it never verified.

Both pages document only what exists, and say so where a capability is still
ahead of the code: immutable releases and named versions are described as arriving
with the registry work rather than as available today.

Verified: pnpm typecheck clean; pnpm build emits both routes; 161 and 167 lines.
The parity suite can compare contracts, the web adapter, MCP, and Markdown, but it
cannot import the CLI: apps/web/tsconfig.cloudflare.json lists its own files, and
packages/cli/tsconfig.json sets `rootDir: ./src` while tsup does not resolve a
cross-package path. Making the CLI call `isCompatibilityActionable` would mean
changing how the published artifact is bundled, which is a poor trade for a
one-line predicate that both sides already pin by value.

The residual gap was narrow but real: adding a sixth status to
COMPATIBILITY_STATUSES would have left the CLI's mirror silently wrong with no
test failing. This pins the list so that change becomes a failing test pointing at
the mirror.

Verified: pnpm test passes with the new assertion.
Adds what was done after the slice-7 review and, more usefully, why one item was
refused. The CLI could stop mirroring the actionability rule by importing the
contract, but that means changing rootDir and the tsup externals on the npm
publish path, which release-please triggers automatically on merge. That is a poor
trade for a one-line predicate, so the vocabulary is pinned at the source of truth
instead and the refactor is tracked as issue #48.

Also records that pull requests had no verification at all until now, and the two
defects that adding it exposed: a drifted lockfile and a root typecheck script
that silently covered only apps/web.
@mrgoonie

Copy link
Copy Markdown
Contributor Author

Superseded by #90 (rebuilt on current main and merged as 40217d4).

@mrgoonie mrgoonie closed this Oct 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant