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.
Required platform features
A plugin whose code cannot work while a platform feature is off names that feature inrequires, by its kind — the name its GET /api/system/kinds row and the
OFF table use:
tai-plugin.yml
studio_plugins name, and the config provider
TAI_CONFIG_MODE selects. The check runs before any manifest-named module imports
(the config provider is already loaded by then, since the configuration is read
through it). A required feature that is off refuses the start with one
error naming the plugin, the feature, and the variable that turns it on; a name
that is no platform feature refuses it and lists every known feature. A refused
reload leaves the running configuration serving. requires needs a code item: a
descriptor-only plugin loads no module, so there is nothing to check.
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. An in-process plugin installs into the plugin layer: one environment shared by every installed in-process plugin, built underTAI_PLUGINS_DIRagainst the image’s own constraints and added after the image on the server’s import path. It never replaces a package the image provides. A data plugin may still ship a package: anmcp-serverwhose command is a console script its wheel declares, for example, is data-payload and package-delivered. Such a server package installs into its own virtual environment atTAI_PLUGINS_DIR/servers/<package>/current, never into the layer, and its server launches from there with no path of the server’s own added. The item’scommandmust name a console script its own package declares, and no other installed server package may already provide that name; the install refuses either with a409. 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 route module’s own registration declares the rest of a route’s reach. A login or
recovery route passes pre_auth=True (it must be public): a stale credential the
browser still presents is then ignored there instead of refused. An identity-introspection
route passes any_authenticated=True (it must be authed), and a caller self-service
route under /api/auth passes self_service=True so the seeded roles carve it in. See
routes declare their reach.
The registration also declares what the platform reads about a route rather than
guesses:
- Flood control — a public route’s
DeclaredRouteMetadatamay name itsrate_limit_familyand the family’srate_limit_budget(RateLimitBudget(limit=…, burst=…)). A declared family holds whatever base the install remaps. Every public route of one family declares the same budget; a plugin whose routes disagree fails the boot, or the reload that installs it, loudly. See budgets. - Success media types —
success_media_types={"GET": ("text/html",)}publishes the body each method really answers in the OpenAPI document; a method left out publishesapplication/json. - Reloads — a route declaring
reload_gated=Trueanswers the retriable reload503while a configuration reload runs; the platform enforces it around the handler, so the route carries no check of its own. A route that hands a turn to the conversation bridge needs no flag: the bridge refuses the turn during a reload and the platform answers the same503.
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, each plugin in the image, the plugin layer, or a server
package’s own environment that declares one, whether or not the manifest loads it,
and each plugin the manifest loads — and applies its pending files in order. A plugin author owns the files; the
runner owns the mechanism.
The framework also checks the chain: the boot gate
refuses to start a server, or to apply a reload, while a chain of a plugin the manifest
loads is pending or rewritten, so a plugin needs no schema check of its own.
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, the component is off, a valid state rather than a
failure. tai db migrate, tai db migrate --plan and tai db status skip its chain
and name it with this line; an off component does not change the exit code:
--json, the component is listed under skipped_chains as {"component": ...}.
tai doctor names it in its schema check without failing the check, and the boot gate
skips the chain and logs the same line as a warning. So the store stays off until an
operator opts it in by declaring its binding.
Editable installs. Discovery reads the installed metadata and never imports a
plugin. A path-based editable install — setuptools, and the default editable mode of
hatchling, flit, pdm-backend and uv_build — is found as soon as it is installed. An
editable install served by an import hook — hatchling dev-mode-exact, pdm-backend
editables, meson-python — records its packages in no installed file, so its chain is
found once a manifest loads one of its modules. Until then tai db migrate,
tai db status and tai doctor name it with this line, and the exit code does not
change:
--json, it is listed under unlocatable as {"distribution": ..., "reason": ...},
beside skipped_chains.
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, builds the verified package into a fresh plugin layer (or, for a server package, its own environment) and switches to it, 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. An update keeps each MCP server’s tool
selection (its entry’s include / exclude).
Because the install writes the plugins directory and the manifest, a self-hosted
deployment keeps both on persistent storage — the TAI_PLUGINS_DIR directory and
the manifest file — so a container recreation does not lose the plugin. A packaged
install refuses until TAI_PLUGINS_DIR is set; a descriptor-only install needs no
directory. 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.

