A metadata index
The registry stores metadata, never packages. A listing points at the plugin’s real distribution channel — usually a pip package and a source repository, or, for a descriptor-only plugin, thetai-plugin.yml itself — and
installing resolves that pointer and installs by source: a
GitHub-sourced release is downloaded and its SHA-256 verified against the
digest the registry recorded at ingest, while a PyPI-sourced release pins the
exact version and relies on PyPI’s file immutability. A third source, spec,
backs a descriptor-only listing that ships no
package at all — its artifact is the tai-plugin.yml itself, pinned at its tag
and digest. The registry’s catalog is curated: it lists the ecosystem’s
first-party plugins, and every listing carries an official trust tier.
Listings and items
One installable plugin carries many capabilities: a single package can ship tools, extensions, and agents together. The registry indexes each contained capability as its own item row with a kind, categories, and tags — because an item is what you search for. You search at item level and install at plugin level: finding thegenerate_uuid tool leads you to install the listing that
carries it.
An item’s kind decides how it wires into a server, and it also decides
whether the item is code or data. Most kinds are code: the item names a
Python module the server imports. Two kinds are data — mcp-server and
connector — where the item carries a declarative block (an MCP transport entry,
or a connector’s provider descriptor) instead of a module,
so nothing has to be imported to register it. That data-versus-code split is
independent of how the plugin is delivered, below.
Versions
Each listing publishes versions. A version records the exact release, its artifact, and the tai42-contract compatibility range it declares, so a server can tell whether a release fits its contract before installing. The registry validates every release’s artifact against its listing before publishing it — a release that fails validation is never served.Advisories and the kill switch
An advisory flags affected versions of a listing with a severity and a summary. Separately, a version can be killed — a registry-side kill switch that refuses to resolve that version at all, so it can never be installed. The two are distinct: an advisory is a published warning that can later be withdrawn, while a killed version is pinned out of resolution. See Advisories for how a server checks.The plugin descriptor: tai-plugin.yml
Every plugin describes itself with atai-plugin.yml — the plugin descriptor —
at its repository root and, when it ships one, inside its built wheel. It names
the listing, the version, the contract range, the provides list of every item
the plugin carries, and — for a plugin that owns database tables — the
migrations folder that holds its migration chain.
It also names the package, the pip distribution that backs the listing — but
package is optional: a plugin whose every item is data can omit it and
ship no code at all (see Delivery).
The registry indexes a plugin from this file and validates the artifact against
it; the installer uses the provides list to patch the server
manifest per provided item. It is a different file from the
server manifest: tai-plugin.yml describes what a plugin offers, the server
manifest declares what a server loads.
Delivery: package or descriptor
A listing carries two independent axes. Payload is what each item is — code or data, decided by its kind (above). Delivery is how the plugin arrives, and it is set by one field:package.
- Package delivery (
packagepresent) — the listing points at a pip distribution. Installing pip-installs it into the server’s environment, exactly as every code plugin does. A data plugin may still ship a package: anmcp-serverwhose command is a console script the wheel installs, for example, is data-payload and package-delivered. Delivery is independent of payload. - Descriptor delivery (
packageabsent) — a descriptor-only plugin. Every item is data, so there is nothing to import and nothing to pip-install. The registry lists it withsource: spec: the artifact is thetai-plugin.ymlitself, pinned at its tag and digest, with no wheel and no PyPI lookup. Today a connector ships this way — a whole OAuth integration as one descriptor, no code.
delivery is derived, never declared: it reads descriptor when package is
absent and package when it is present, and every surface — the CLI, the Studio,
and the marketplace detail — shows that one word so you always know whether an
install will touch the environment.
Declared HTTP routes
A plugin that mounts HTTP routes — arouter item, or a channel item with an
inbound webhook — declares them in its descriptor rather than wiring absolute
paths in code. A router item must carry a routes block; a channel item may;
no other kind does. The block is two parts:
base— the item’s default mount prefix, relative and template-free (one or more[a-z0-9-]segments joined by/). The resolved absolute path of every route is/api/+base+ the route’s ownpath. The/api/root is fixed platform-wide; onlybasemoves, and the operator can remap it at install — always, and as the collision remedy.paths— the route rows. Each row is apathrelative tobase, a non-emptymethodslist, and a requiredpublicflag. Apathis/-prefixed with at least one segment; a segment is a literal or a{name}template.publichas no default — the declaration always states whether the resolved route answers unauthenticated.
tai-plugin.yml
/api/alerts/relay/inbound (public, so it answers
without a credential) and the status read to
/api/alerts/relay/status/{event_id} (authed). The item’s route module registers
each handler against the relative path; the declaration decides the absolute
mount and the public-ness, so the code never hardcodes either.
Each declared route has one owner. Two routes collide when their resolved path
shapes overlap — same segment count, and at every position both are the same
literal or one is a template — and they share a method (GET /api/x/{id} overlaps
GET /api/x/foo; a different method or a different segment count never collides).
The server refuses to register a route that collides with one a different plugin or
the core already owns, and install
surfaces the clash up front with remap as the remedy. How a declared public route
is served without a credential is the declared-public
tier.
The migrations contract
A plugin that owns tables ships its schema as a migration chain, not a one-shot DDL apply. The framework runs the chain:tai db migrate discovers every
component — the core plus each installed plugin that declares one — and applies
its pending files in order. A plugin author owns the files; the runner owns the
mechanism.
Declare the folder. Point the descriptor’s migrations field at a directory
packaged inside the wheel:
tai-plugin.yml
NNNN_short_name.sql — one or more leading
digits, an underscore, a name, the .sql suffix — applied in ascending numeric
order. A .sql file that does not match the pattern, or two files claiming the
same number, is a loud discovery error, never a silent skip.
tai_schema_history table under the plugin’s distribution name
(package) — for the accounts plugin, tai42-accounts-postgres — and that
default identity never changes across releases, so the runner always reads the
exact rows earlier versions wrote.
A plugin whose feature store is a separate database component rather than the
distribution itself may instead declare an explicit migrations_component naming
that component (declaring it requires migrations; a component with no chain
migrates nothing). A component-scoped chain runs only while that component’s
database binding is explicitly
declared: with the binding set, the chain migrates that component’s store under the
declared name; with it unset, tai db migrate visibly skips the chain — a
distinct, logged skip naming which chain did not run — rather than migrating it
into the default database. So the store stays off until an operator opts it in by
declaring its binding.
Checksum immutability — never edit a shipped file. The runner records a
SHA-256 of every applied file. On the next migrate or boot gate
check it re-verifies each recorded version against the file that produced it; a
changed checksum reads as a rewritten chain and is a hard error, never a
silent re-run. A shipped migration is immutable. A correction — even a typo — is a
new numbered file appended to the chain, never an edit to the old one. Chains
roll forward only; there are no down-migrations.
Breaking changes: expand then contract. Because there is no down-migration and
older replicas may still be running during a rollout, split a breaking change
across releases rather than rewriting a column in place:
- Expand — add the new shape additively (a new nullable column, a new table) in one migration. Old and new code both run against it.
- Backfill and cut over — ship code that writes both shapes, or backfills the new one, until every replica is on the new release.
- Contract — in a later release, once nothing reads the old shape, add a migration that drops it.
Installing into a server
Install is a server-side operation: the server resolves the listing, installs the verified package into its own environment, patches its manifest per provided item, and reloads. The server records the marketplace ref and installed version locally, soinstalled, update checks, and advisories answer from
local truth. The whole flow is one CLI command or one Studio click — see
Install a plugin.
Because the install writes into the server’s own environment and manifest, a
self-hosted deployment keeps both on persistent storage — the plugin-prefix
directory and the manifest file — so a container recreation does not lose the
plugin. See the persistent mounts.
Installing a descriptor-only plugin
A descriptor-only listing installs with no pip run: the server resolves the frozentai-plugin.yml, patches the manifest — a
connector adds one entry to the manifest connectors list — and reloads. Nothing
lands in the environment except the config it needs.
Because there is no package to carry secrets, a descriptor states the environment
it needs and the server refuses to write it half-configured. The install
preview computes the required_env for the listing — each variable as
{name, secret} — and the missing_env still absent from the server. The Studio
install dialog reads those and prompts for the missing values, seeding the secret
toggles from required_env; the CLI takes them as flags:
--env KEY=VALUE supplies every value; --secret KEY is a bare mark that keeps a
value masked and out of logs (a connector’s client secret is auto-marked too, and
--secret stays available for any key you want hidden). An install that would
leave a required variable unresolved is refused up front, naming the variables,
before anything is written.
Installing a descriptor mutates the running server the same as any install, so it
sits behind the same admin-only trust boundary: scope the
/api/marketplace/* routes with access control, and
treat a descriptor’s official tier the way you treat a code plugin’s — a
first-party connector is a curated, admin-installed integration, not an
open door.
See also
- Browse and search — find plugins and items.
- Install a plugin — install, update, uninstall.
- The manifest — the server-side file the installer patches.
- Plugins — every listed plugin, read from the marketplace.

