The problem
A catalog has to answer three questions that pull in different directions. Who sees an entry? Where do its bytes come from? What does a workspace that installed it actually run? The obvious design answers all three with one global table. Every workspace sees everything, the entry’s body sits in the row, and an install copies that body. That fails as soon as a customer wants a private source. It also fails once a public source turns out to be mostly pointers: a crawled community index publishes a description and a GitHub link, not the skill. An install that copies the row copies a stub, and nothing ever fetches the realSKILL.md. And a body
that is copied on install and overwritten on sync leaves no way to say which
version a workspace runs.
How AgentArea approaches it
A registry has a scope, a mode, a source and a trust level
A workspace registry holds
skills or agents. The other types materialize
platform-wide entities at sync, so only the platform may publish them. Registry
names are unique per scope: a workspace’s registry may share a name with a
platform registry or with another workspace’s.
A skills registry read with credentials is always mirrored. Credentials are
resolved for a sync only, so an install of an upstream item could not
authenticate.
The index is a snapshot
A sync reads the source only when it changed. For aurl source that means the
HTTP ETag; for a github source, the head commit of source_ref (the default
branch when unset). The stored value is prefixed with a parser format version, so
a release that changes parsing re-reads every source once. force re-reads
regardless.
When a source changed, the sync upserts every entry it publishes. Entries the
source dropped leave the catalog. One that nothing references is deleted. One
that an install, a fork, a connection or a materialized MCP server still points
at stays, marked out of source; its installed copies keep working and show
“Removed upstream”. A source that parses to zero entries retires nothing, because
an empty index is far likelier a broken source than an emptied one.
A sync that skipped an item, such as one that failed validation, does not store
the new ETag, so the next sync reads the source again.
Skill content is immutable and addressed by digest
A catalog skill’s package is stored once under the sha256 of its files. A platform registry’s packages share one area,skills/catalog/<digest>/. A workspace registry’s packages live under that
workspace’s prefix, skills/<workspace>/catalog/<digest>/, so private content
never lands where another tenant reads.
An index entry names its content in one of two ways:
- A GitHub pointer:
repo,path, and optionally therefcommit and the folder’stree_sha. Fetching downloads that folder at that commit and checks the tree SHA. The tree SHA is also the item’s version. Agithubregistry always produces pointers with both. A pointer withouttree_shahas the versionlatest; fetching reads the folder atrefor the branch head and computes its tree SHA from the files. - An inline
contentbody, which becomes a singleSKILL.md.
github.com/<repo>.git, one ls-refs
request), the files come from the commit’s archive at codeload.github.com, and
the folder’s tree SHA is computed from them as git computes it. An archive is
read once per commit and process, so a sync that stores several folders of one
commit downloads it once. A registry with credentials reads through the REST
API with its token instead.
In mirror mode the sync stores each item’s package and records where in the
item’s spec, so an install needs no network. In upstream mode the first install
or run of a version fetches and stores it; later ones reuse the stored package.
Each process also caches catalog packages by path, up to 64 MB; a digest never
changes, so the cache is never invalidated.
Skills in the database carry metadata: name, description, source, and the
location of the package. Activation reads SKILL.md from the package, and
materialization copies the package into the task’s sandbox. Once a version has
been stored, runs read it from object storage and never from GitHub.
An install is a pin
Installing a catalog skill creates a workspace skill row that points at the item’s package and records the item’s version inregistry_item_installs. For a
GitHub pointer that version is the tree SHA of the folder the install fetched,
also when the index names none. Attaching a catalog skill to an agent installs
it the same way.
When a later sync gives the item a different version, the installed skill shows
“update available”. Nothing changes until a workspace admin updates it, one skill
at a time or all at once. An update moves the pointer to the new package.
For an installed pointer whose index names no tree SHA, every sync of an
upstream registry asks the source which tree the folder is now and keeps that
as the item’s version, even when the index itself did not change. The checks run
after the sync has committed, within their own 120-second budget, so a slow
GitHub costs checks and never the sync. They cover at most 50 installed items a
sync, in external_id order: one ls-refs request per repository and branch,
and the commit’s archive, unpacked once for every folder of it, when the branch
moved. A mirror registry versions such a pointer by the tree it stored.
Editing an installed skill stores the edited files as a new package of the
workspace’s own and points the skill at it. The catalog package is never
written. The skill still records its catalog item, so it still shows
“update available”, but it now reads “Customized”: update-all skips it, and a
single update replaces the edit only when the admin passes discard_changes, and
the edit stays in the skill’s history to roll back to.
The same copy-on-write applies to catalog agents. A catalog agent’s spec carries
preferred_models (model slugs) and never a model_id, because model instances
are per-workspace; the model is resolved at install.
A workspace’s own skill has a history
A skill authored, uploaded, imported or edited in a workspace is stored the same way as catalog content: an immutable package under the sha256 of its files, in the workspace’s own prefix,skills/<workspace>/packages/<digest>/. An edit
never rewrites a package; it stores a new one and moves the skill’s pointer.
Every move is a row of the skill’s history, with its reason (create, edit,
install, update, rollback or migrate), who made it and when.
GET /v1/workspaces/{workspace}/skills/{skill_id}/versions lists it, oldest
first, the current one marked. POST .../versions/{version_id}/rollback points
the skill at that version’s package again; the rollback is itself a new row, so
it can be undone the same way. A move locks the skill’s row and writes the
pointer and its history row together, so concurrent edits are ordered and the
history ends where the pointer is; a run already started keeps reading the
package it was configured with. Rolling an edited install back to the version it
was installed at re-pins it to that catalog version, after the same policy check
an install makes, and it reads as not customized again.
Policy decides where installs may come from
Two policy targets govern installs:registry:<id> for one registry, and
registry_trust:<level> for every registry of that trust level. Both take
allow or deny and bind to the workspace or to a user. In each layer a deny
wins, and once a layer has any allow rule it admits only what an allow names.
With no rules, every install is allowed, as with every other policy decision.
A skill imported straight from a GitHub URL passes through no registry. Install
rules name it as the pseudo-registry registry:github, with trust community,
so deny registry_trust:community refuses direct imports too.
The check runs everywhere a catalog item becomes a workspace’s own: install,
attaching a catalog skill to an agent, editing a catalog item (which forks it),
updating an installed skill, and a direct GitHub import. It also runs when a task
uses a catalog skill or agent that the workspace never installed. A refusal
answers 403 through the API and fails the task as non-retryable.
Changing a live registry’s mode
Changingmode, source_type, source_url, source_ref or source_path
clears the stored ETag, so the next sync reads the source in full. For a
platform registry the reconcile applies such a change from the chart values. Going from upstream to
mirror, that sync stores every item’s package. Going from mirror to
upstream, nothing is deleted: the packages stay in object storage, installed
skills keep pointing at theirs, and an install of a version that was already
stored reuses it instead of fetching.
Other types still materialize at sync
skills, agents, bundles and source_types are catalog-only: a sync writes
the item and nothing else. mcp_servers creates an MCP server spec, and
llm_providers and llm_models upsert provider and model specs. For those
types a changed version sets update_available on the item, and the platform
applies it with POST /v1/workspaces/{workspace}/registries/catalog/items/{item_id}/update
or POST /v1/workspaces/{workspace}/registries/{registry_id}/update-all. Both
are reserved to the platform workspace.
Why not one global catalog with bodies in the rows
The previous design was exactly that, and measured on a production deployment it went wrong in every direction the problem section names. The catalog table grew past a gigabyte, 94% of it rows the source no longer published but that sync never deleted. Installed community skills were stubs, because the index carried no body. A customer could not add a private source, because only the platform could write the catalog. Splitting the index (small, published whole, replaced on sync) from the content (immutable, addressed by digest, fetched once) and from the install (a pinned reference) is what npm, OCI registries, the MCP Registry and Claude Code plugin marketplaces converge on. The cost is a second store to run: a skill cannot be installed or run on a deployment without object storage, and amirror sync
needs the storage credentials.
Why not copy every item into each workspace
Seeding each tenant with every built-in turns each upstream change into N row migrations. It makes an untouched copy indistinguishable from an edited one. It replays every deletion per workspace. Reference-and-instantiate (Docker images, AMIs, GitHub templates, Backstage templates) avoids all three. Provenance is a link instead: an installed skill, agent or MCP server recordsregistry_item_id.
Limits
- Trust is a label, not a review. Nothing scans a registry’s content. A
verifiedregistry is one the operator marked so; there is no signature check on an index or a package. - Installed copies are not re-checked against policy at run time. A new deny rule stops further installs, updates and runs of uninstalled catalog items. A skill installed before the rule keeps running until someone removes it.
- A pointer without a tree SHA is not pinned by the index. Its catalog card
and previews read the folder at the branch head, cached per process for an
hour. An install is pinned, and its updates are found by the sync’s check
above, not by the index; past the 50-a-sync budget they wait for a later sync.
Installs made before installs recorded the fetched tree hold the version
latest, so they show “update available” once; updating pins them. - A token-free read is the archive, not the tree. A repository whose
.gitattributesusesexport-ignoreorexport-subst, or a folder holding a submodule, archives differently from its git tree: the computed tree SHA then differs and a pointer that names one is refused. An archive over 25 MB compressed is refused too. Give such a registry credentials. - An inline body stays in the catalog row. An index that publishes
contentinstead of a pointer has that body stored in the item’s spec, in addition to the package built from it. - Packages are never deleted. Deleting a skill or rolling it forward keeps every package its history names, and a package can be shared by any skill with the same files. Nothing yet removes a package no skill or history row points at.
- The history has no UI yet. Versions and rollback are API calls only.
- Version comparison is string inequality. Any different version, a downgrade included, sets “update available”.
- Skills stored before packages are moved lazily. A row that still holds its
body in
skills.content, or that points at the mutable copy a workspace skill used to be rewritten in (skills/<workspace>/<skill id>/), is moved into a package the first time a request that may write reads it, which starts its history; a read on a read-only session (the MCP skill tools, or a replica) reads the old copy or the row’s body as it is and moves nothing.agentarea-api skills move-bodies-to-packagesandmove-own-copies-to-packagesdo it for every row at once. The old copies stay untilagentarea-api skills delete-moved-own-copies, which deletes only copies moved at least--min-age-hoursago (default 168;--dry-runcounts first). Run it once every pod runs this release and no task started before the move is still running: such a run reads the copy it was configured with. - Nothing in the chart schedules a sync. See configure the platform registries.
Related
Add a skill registry
Add a GitHub repo or index URL to a workspace and govern installs
Configure the platform registries
The registries every workspace sees, set in the chart values
Skills
How an installed skill reaches the model and the sandbox
Bundles
The format a catalog
bundles item holds