Skip to main content
The marketplace ships a catalog of 436 agents and 85 teams, organised as nineteen packs: JSON documents under the repository’s top-level catalog/ folder. Packs are not compiled into the app and are not in the npm tarball. The dashboard fetches them at runtime through its own API, verifies each pack against a published digest, and renders from a thin browse index. One pack is different. The seed (catalog/packs/clawboo/builtin, the five built-in teams and their fifteen agents) is compiled into the build, and the index endpoint merges it unconditionally. That is what makes first-run onboarding work with no network at all. This page documents the pack format, the layout of catalog/, the fetch and verification path, the seed, and the content gates. It does not enumerate individual entries; browse those in the marketplace UI (the Agents / Teams tabs) or read the committed packs.

At a glance

The unit tests assert lower bounds (>= 100 agents, >= 90 agency, >= 15 clawboo, >= 10 teams) rather than exact counts, so adding an entry does not break a test. The quality assertions are the exact ones.

Where the content lives

catalog/ sits outside the workspace deliberately. A pull request that adds a pack is content, not code, and putting it through turbo lint, turbo typecheck and turbo test buys nothing. website/ has the same posture. The CI consequence is described under Content pull requests. catalog/dist/ is committed. That is what makes the fallback URL work with zero infrastructure: a raw githubusercontent.com URL against main serves the same bytes a CDN would, with no deploy step between merging a pack and it being installable.

The pack format

packages/pack-format is the TypeScript home of the versioned pack shape: the v1 types, the zod validators, the version ladder, and parseAgentPack. It is private: true and never published - the artifact meant for the outside world is the specification, committed as JSON Schema 2020-12: A third party writes a pack against those two files and installs nothing.

The manifest

provenance lives once per pack and replaces the per-entry source + sourceUrl pair the old TypeScript catalog carried: sourceId, label, color, repo, ref (the pinned commit), license, authors, adaptation, and importedAt. An entry whose upstream differs from the pack’s can override it with an origin block. A pack that sets provenance.repo must ship a NOTICE.md. The SPDX id on its own is a claim with nothing behind it, so pnpm catalog:verify fails without the notice.

Listings and bodies

A listing is what a card renders. A body is what a detail sheet or a deploy needs. They are separate files because IDENTITY.md and SOUL.md are the overwhelming majority of the bytes and no card reads either one.
A team listing denormalises its roster, so a card renders member count and roles with no body fetch:
files is a map, not named fields, so adding a fifth agent file is a no-op for every reader instead of a shape change each one has to follow. The deploy path overlays on this map; it does not pass it through. AGENTS.md and CLAWBOO.md are synthesized per-deploy from the team topology (lib/teamProtocol.ts) and never exist in a pack, and IDENTITY.md is rewritten with the agent’s final, deduped name. AgentFiles and toFilePayload in lib/createAgent.ts are unchanged for exactly that reason: handing the pack’s map straight to createAgent would drop the team protocol docs the orchestration contract depends on.

Skills

An agent’s skillIds resolve against the merged registry: the host’s BUILTIN_SKILLS plus whatever the pack’s own skills array adds. A pack may reference a builtin without redeclaring it, and must not redeclare one - the builtin wins the merge, so the copy would be silently discarded (assertNoBuiltinSkillCollision). Seven packs declare skills of their own, 45 in total; the other twelve, including both first-party packs, ship skills: []. pack-format cannot see the host registry, so this reference check lives where the registry does: scripts/catalog/validate.ts in the repo, buildSkillRegistry at runtime.

The two open unions

SourceId and CategoryId are open unions, re-exported into the app as TemplateSource and TemplateCategory:
The listed values autocomplete; any other string is legal, because a third-party pack must be able to bring a pack id or a category without a Clawboo release. The cost is that Record<TemplateSource, …> and Record<TemplateCategory, …> are no longer exhaustive maps. Nothing indexes one directly any more. Display metadata resolves through two total functions in features/marketplace/registry.ts:
  • metaFor(categoryId) → { label, color }, falling back to a title-cased label and a deterministic colour drawn from a 12-entry hex palette.
  • sourceMetaFor(packId) → the same for a pack.
Both always return a value and the colour is always #RRGGBB, because the cards build alpha variants by string concatenation (${color}18). The predecessors of these functions were TEMPLATE_CATEGORIES, SOURCE_META, and AGENT_DOMAIN_META; an unguarded index on the last of those white-screened the Agents tab on an unrecognised value. The counterweight to an open taxonomy is the manifest’s newCategories: a pack that introduces a category outside KNOWN_CATEGORY_META must declare it there, so a typo’d enginering becomes a reviewable line in the content PR rather than a silent 20th filter chip.

The version ladder

Every pack declares an integer schemaVersion as its first key, and it is never assumed. MIN_SUPPORTED_SCHEMA_VERSION and CURRENT_SCHEMA_VERSION are both 1 today, and UPGRADES is deliberately empty - there is one version, so there is nothing to upgrade. The scaffold exists now so that adding v2 is “write the upgrade and add two entries” rather than inventing a migration story under deadline. parseAgentPack(raw, { staleVersionPolicy }) never throws on bad input; it returns a result, so a caller can reject the pack and keep the catalog. That matters because onboarding renders SelectTeamStep with allowStartFromScratch={false}: an empty catalog bricks first-run, so one malformed third-party pack must not be able to empty it. Its six reject paths: staleVersionPolicy is 'allow' at host runtime, 'warn' at a future publish endpoint, and 'error' in the repo gate. It defaults to 'allow', which means a call site that forgets to pass it silently permits stale content rather than failing loudly.

Serving, fetching, verifying

Three tiers, in order

apps/web/server/lib/catalogIndex.ts resolves the index:
  1. The local filesystem. If catalog/dist/v1/ exists above the server module, it is used verbatim. A repo checkout, a fresh branch, and pnpm dev therefore work with no network - including on a branch whose catalog has never been pushed anywhere.
  2. CLAWBOO_CATALOG_INDEX_URL. An operator override.
  3. The default URL, a raw githubusercontent.com path against main.
CLAWBOO_CATALOG_INDEX_URL is not a feature flag - Clawboo has none. It is an endpoint override, the same class of setting as CLAWBOO_ALLOWED_ORIGINS: both tiers serve the identical generated file, so there is no behavioural fork, only a different host.
The fetch is timeout-bounded at 5 s, sends If-None-Match, caches for 6 hours in memory, and never throws. Offline, firewalled, 404, corrupt bytes - every one of them returns the seed.

The index is mutable, each row is immutable

index.json is rewritten on every build. Each packs[] row inside it names one immutable bundle by version and carries integrity, a sha256-<base64> value computed over that bundle’s exact bytes. The integrity value is the trust anchor, not the URL. The server recomputes the digest over the bytes it received and compares it before JSON.parse ever sees them. Bytes that fail are discarded unparsed. Verified bundles are cached on disk at ~/.clawboo/catalog/<hash>.json, keyed by integrity - a sha-pinned raw URL still only advertises max-age=300, so this cache, not the CDN, is what makes a second launch free.

Hashing rule

Bundles and the index are emitted as canonical bytes: keys sorted recursively, no insignificant whitespace, LF, and no trailing newline. Those exact bytes are written, served, and hashed. This is deliberately different from the retired ingest manifest, which hashed a Prettier-canonical form. It had to: its inputs were TypeScript files a pre-commit hook restyles. A pack bundle has no formatter round-trip - it is generated, committed, fetched, and verified by a client that has never seen Prettier. .prettierignore already matches catalog/dist/ through its **/dist/ entry, and that exclusion is load-bearing rather than incidental. Pack source under catalog/packs/** is the opposite: hand-edited, reviewed in a diff, and therefore Prettier-formatted like any other JSON in the repo. Only dist/ is canonical.

Compatibility

An index whose top-level schemaVersion is newer than the build reads shows the seed only, plus one quiet log line. A single pack the build cannot read drops out and the rest of the catalog renders. That lets a v2 pack shape and v1 packs coexist in one index.

The three routes

All three are same-origin, so the existing origin guard covers them unchanged. The pack bundle stays the distribution and integrity unit; the server flattens it to entries so the browser needs no integrity logic, no second origin, and no knowledge that packs exist.

The seed

SelectTeamStep renders with allowStartFromScratch={false}. An empty catalog is therefore not a degraded browse experience, it is a first run with nothing to click. So the builtin pack ships in the binary. scripts/catalog/build-seed.ts generates it into two places, from one pass over the same bytes: Two copies because apps/web/server and apps/web/src are separate build targets and an eslint boundary forbids either importing the other. pnpm catalog:verify fails if either drifts from the pack. Both are generated and committed. Never edit them by hand. The payload is one JSON string constant rather than an object literal: it is agent prose, so a literal is a file Prettier reflows on every regeneration and a reviewer cannot read, while a string constant diffs as a single line and V8 parses it faster.

Resolving a team into deployable agents

resolveTeamAgents(index, profile, routing?) (in teamCatalog.ts) is the single consumer-facing resolver. For a first-party team it walks agentIds, fetches each member’s body, and returns a ResolvedAgent[] carrying the merged files map - with the team’s routing[agentId] already overlaid onto AGENTS.md. A dangling id is silently skipped; teamCoverage.test.ts guards against that. The resolver also handles two legacy input shapes (inline agents, and the deprecated TeamProfile with shared skills[]).
The member bodies are fetched through one Promise.all before the deploy step begins. That is deliberate: doing it inside the per-agent creation loop would turn a 12-agent deploy into 12 serial fetches with a partial-failure mode halfway through creating a team. The agency workflow teams use hub-and-spoke routing: the first member is the leader, every other member routes work to @<Leader>, and the leader’s AGENTS.md lists all members. This is what produces the dependency edges visible in the Ghost Graph after a team deploys. Two gates keep it honest: every team names at least two members, and every member has routing.

ID prefix convention

Every entry id is globally unique across packs, and its prefix is the pack’s idPrefix ?? id. The pack format states it as id === \prefix−{prefix}-`; validate.ts` adds the cross-pack half - no two packs may emit the same prefix, and no two packs may claim the same id.
The five builtin team ids gained their pack prefix in this move: dev became clawboo-dev, marketing became clawboo-marketing, and so on. The old ids are recorded in the clawboo pack’s renames map, which is entry bookkeeping and not schema migration. A team row stored before the rename keeps its old templateId; nothing reads it for behaviour.
Agent names are unique across the whole catalog, which is what lets an @mention in a team’s routing resolve to exactly one agent.

The IDENTITY.md invariant

Every entry’s IDENTITY.md is that agent’s complete instruction body, never a condensed summary and never an excerpt. What the agent-detail modal renders is exactly what is written on deploy, so you read the whole spec before you commit to it. Adapted, not verbatim. Every entry drawn from an upstream repository was pruned, renamed, re-described, and re-formatted; the upstream YAML frontmatter is stripped, because the listing already stores name, description, emoji, and color as structured fields and feeding them back in as prompt text was pure waste. SOUL.md is the shorter distilled version. scripts/catalog/validate.ts holds one rule per defect the old ingest pass produced: no body opens with ---, no description ends in an ellipsis, no body exceeds 50,000 characters, no entry repeats a tag. It also scans every string in every listing and body against a denylist of competing registries, competing installers, and chat invites, and runs the full prompt-injection evaluation over each field a deploy would write.

Sources

Attribution lives in THIRD_PARTY_NOTICES.md and in each pack’s NOTICE.md. Every pack records its upstream repository and pinned commit in its own provenance block. Most adapted entries additionally carry an origin.url: a GitHub blob URL at that pinned commit, so the entry can be compared against what it was derived from. Entries written from scratch carry origin.adaptation: "original" instead, and the wshobson-agents pack records provenance at the pack level only. The pin is historical provenance, not an automated dependency: nothing re-fetches it.

Content pull requests

A pull request that touches only catalog/ skips the full CI matrix and runs .github/workflows/catalog-ci.yml instead: roughly two minutes rather than roughly fifty. The filter job in ci.yml makes that decision, with one deliberate carve-out - a change under catalog/packs/clawboo/** alters bytes that ship in the tarball, so it is a product change and gets the full matrix.
catalog-ci.yml must never be a branch-protection required check. It is paths-filtered, so on a PR that touches no catalog file it never reports, and GitHub treats a required check that never reports as pending forever. The catalog-verify job in ci.yml runs the same command on every code PR; that is the one to require.

Adding or editing a pack

Commit the regenerated catalog/dist/** and, if you touched the seed pack, the regenerated seed modules. catalog:verify fails when they are not byte-for-byte what a rebuild would write, which is what keeps committed generated output honest.

Guards

  • scripts/catalog/validate.ts - schema, licence and NOTICE, referential integrity, quality rules, taxonomy declarations, denylist, injection scan.
  • scripts/catalog/budget.mjs - the seed under 128 KB, the index under 512 KB.
  • catalogDist.test.ts - the committed index matches the packs, and each bundle’s bytes hash to the integrity the index publishes.
  • scripts/check-entry-chunk.mjs - post-build, by value: a seed body must appear in an emitted chunk, and a non-seed pack body must appear in none.
  • entryImportGraph.test.ts - the seed and catalogClient.ts are the only two catalog data seams in the SPA.

See also

Last modified on September 2, 2026