Skip to main content
A Studio plugin extends the browser shell itself — it contributes full pages, rich tool-run panels, settings tabs, sidebar nav entries, and conversation route panels to a running Studio. It is distinct from a server code plugin, which runs in the Python process and adds HTTP surface, while a Studio plugin is a browser ESM bundle that the shell loads at runtime. A deployment turns a Studio plugin on by naming its package in the server manifest’s studio_plugins list. Each named package ships its plugin dist alongside a studio-manifest.json; the skeleton serves the assets under /api/plugins/{name}/studio/ and injects them into the shell.

The registration contract

A plugin bundle exports a single register(context) entry. The host imports the bundle, calls register with a PluginContext, and awaits it; every contribution flows through that context — there are no free registration functions.
The five methods are:
  • registerPage — a full page mounted under /plugins/{pluginId}/{path}.
  • registerToolPanel — a rich run panel for a tool by name, replacing the auto-generated form.
  • registerSettingsTab — a tab in the settings area.
  • registerNavEntry — a sidebar link to one of this plugin’s pages.
  • registerRoutePanel — a panel on the drill-in of every conversation route on one channel.
The context is sealed once register settles: a registration made afterward (from a deferred timer or a post-resolve microtask) throws instead of silently dropping, and a register that throws commits nothing. The host stamps each contribution with the plugin’s identity, so a plugin never supplies its own id and two plugins may register the same path without colliding.

Deep-linkable pages

A contributed page is mounted at /plugins/{pluginId}/{path}. By default it matches that path exactly and receives only its pluginId. To make a page deep-linkable — bookmarkable, shareable, back-button-correct — declare a params schema (a PluginPageParamsSchema) on the PageContribution:
When a page declares a schema it becomes addressable under its path as a prefix. The shell resolves a URL to a page by longest registered prefix, then hands the sub-path remainder after that prefix to parseParams and the raw search object to parseSearch. Each parser validates and shapes its input and raises on anything it rejects — the shell renders that throw as a loud error card, never a blank or partial view. The validated results arrive on the page as the optional params and search props of PluginPageProps. Both parsers are optional and independent. Omit the whole schema and the page keeps its exact-path-only behavior and receives neither prop; omit just parseParams and the page still matches only its exact path; omit just parseSearch and search is not forwarded. Because every part is additive, deep-linkable pages are a non-breaking addition — the plugin API version does not move. Navigate between a plugin’s own pages with the usePluginNavigation hook, whose navigatePlugin drives a client-side transition and resolvePluginPath yields the href. These target a runtime plugin path — /plugins/{pluginId}/{pagePath} plus an optional validated sub-path and search — not a shell route token: the compile-time routes.ts never learns plugin paths, and the token-typed shell navigation (useAppNavigate) knows nothing of them. This is how a page derives its whole view state from the URL: it reads params/search to render, and calls navigatePlugin to move, so the browser’s history, bookmarks, and back button all work. registerNavEntry adds a sidebar link, and its path must match a page the same plugin also registers — a nav entry with no backing page is a dead link and is rejected loudly. The optional icon is a component rendering a square inline SVG that fills its box and draws with currentColor; the host constrains it to a fixed 1em box, aria-hidden (the title is the accessible name). The icon’s bytes live inside the bundle, so there is no external fetch and no CSP change. Omit it for a text-only entry, exactly like the core nav. The optional section names the sidebar group the entry renders in — one of the built-in sections (Capabilities, Connections, Doors, Activity, Administration). A named built-in section places the entry inside that group, after its core rows; omit it (or name an unrecognised section) and the entry renders in a single shared Plugins section placed after the core sections. Every such entry — from every contributing plugin — lands in that one group, each keeping its own title and its own host-applied provenance badge. The same set is declared statically in the plugin’s studio-manifest.json under contributions.nav_entries, so the server-side catalog and read surfaces can report a nav-contributing plugin without executing its bundle. That manifest field is the declarative parity of the runtime registerNavEntry for the page path only — its schema is a list[str] of page paths and cannot carry section, which is a runtime-API field alone. It carries the same backing rule: every nav_entries entry must appear in the plugin’s pages, or manifest load fails loudly. When a rename, a delete or a detach is refused because something still uses the thing, the Studio lists each holder the server returns, and links each one to the screen that fixes it. A holder of a core kind links to its core screen. A plugin that declares its own holder kind registers the page that shows it with registerHolderLink({ kind, resolve }): resolve receives the holder entry (kind, tag, name, locator, summary) and returns a { pagePath, params?, search? } target on one of the plugin’s own pages. A count holder — the holders of one kind and tag the caller’s role cannot read, sent as one entry whose name is the tag and whose locator is only { count } (see Holders a role cannot read) — names no holder, so the list shows its summary as plain text and calls no resolver for it. One resolver serves each kind across the deployment: registering a kind twice, by any plugin, or registering a core kind throws. A plugin’s own dialogs read a refusal’s holders with useApi().holdersFromError(error), and the stored preset a presets read failed on with useApi().storedPresetFaultFromError(error); both are members of the API client, because a plugin bundle holds no copy of the client’s error classes to test a caught error against.

Route panels

A channel plugin decorates the conversations drill-in of its own channel’s routes with registerRoutePanel. The slot is keyed by the channel name — the route row’s channel — and the host names no channel: it looks the drilled route up in the route catalogue and renders the panel registered for that row’s channel, if any, above the thread list.
  • The panel receives { pluginId, route }, where route is the channel-door row as GET /api/conversations lists it.
  • One panel per channel deployment-wide: a second registration for the same channel, by this or another plugin, throws and that plugin loads nothing.
  • The panel renders inside a region named by its title; a panel that throws is contained in an error card labelled with that title, and the rest of the drill-in keeps working.
  • requiredCapabilities gates it like every other contribution — absent means a full projection only.
  • The manifest lists the same channels under contributions.route_panels. A bundle whose registered route panels differ from its manifest fails to load loudly.

Reaching the plugin’s own routes

A plugin’s route items are mounted under bases a deployment may remap, so a bundle never hard-codes its paths:
  • context.routeBase(itemName) returns the resolved absolute base of one of this plugin’s route items (for example /api/channels/web), or null when the item has no mount on this deployment — an optional router not listed, a channel not loaded. It never throws, so a missing optional router never takes the plugin’s other contributions off the Studio. Call it during register and keep the value; each feature that needs the base shows its own unavailable state for null.
  • useApi().request(path, schema, options) is the host’s authenticated request for those routes: the same auth header, error classes and { data } unwrap as every core call. The plugin passes its own zod schema; an answer that misses it throws the api-client’s ApiSchemaError. A 401 throws the api-client’s ApiUnauthorizedError — the host’s own query cache does not see a plugin’s calls, so the plugin routes it to sign-in with the SDK’s useOnUnauthorized() handler (in its own query cache’s onError, or where it awaits the call). The api-client’s errors are recognised by error.name, since a plugin bundle does not import the api-client’s classes. Core routes use the client’s named methods.
The bases come from the authed plugin listing (GET /api/plugins), whose entries carry mounts: the plugin’s route items and the base each is served under, read off the routes the server is serving now.

The built-in jq editor

A plugin field that holds a jq expression renders the platform’s built-in visual jq editor — no registration, no extension point, no gating. The Studio SDK re-exports the jq-studio JqField component and its declaration types; the Studio host injects the design system through a PrimitivesProvider and installs the editor’s evaluation worker once, at the shell. A plugin simply imports JqField and renders it — the visual canvas is present by construction, exactly as it is on every host field and every other plugin’s field.
A jq field declares what the expression’s input document is, seeds a sample for the editor’s Test panel, and optionally supplies a server-validate hook — the declaration threads onto the field so its context chip, Test seeding, and Validate action read from one object. See the jq editor for the component and jq-studio’s repository for its full API. A plugin rendering a server schema through SchemaForm gets the editor with no code at all: a string or templated-text property carrying the x-tai42-expression annotation renders JqField automatically, with the annotation’s own label, blurb, and sample. The import is lazy, so a form with no annotated field never bundles the editor.
Migrating from the expression-editor extension point. SDK 8 removed the optional registerExpressionEditor context method, the ExpressionField and ExpressionEditorsProvider components, the expression-editor contributions, and the separate editor-props contract version. A plugin that consumed those symbols now imports JqField (and its declaration types) from the SDK’s jq re-export and renders it directly — there is no editor to contribute, resolve, feature-detect, or version. The editor is built in, so a jq field is always available; the “no editor registered” degradation the old extension point admitted no longer exists.

The plugin manifest

Every plugin ships a studio-manifest.json next to its dist. It is parsed strictly — an unknown field is a loud rejection — and carries:
  • name, version — the plugin’s package identity.
  • api_version — the Studio-plugin API version the bundle targets (see below).
  • entry — the ESM bundle filename the host imports.
  • integrity — a map of every emitted asset filename (the entry bundle and every lazy chunk, plus any stylesheet) to its sha384-<base64> subresource-integrity hash. The host serves only integrity-listed files and injects each with its SRI hash.
  • contributions — the declared tool_panels, pages, settings_tabs, nav_entries, and route_panels (channel names; defaults to none).

The api_version gate

The plugin API carries a single monotonic integer, STUDIO_PLUGIN_API_VERSION (currently 4), bumped only on a breaking change to the plugin API, to the public prop surface of a design-system component the SDK exports, or to the design-token names (TOKEN_NAMES), the only names a plugin styles through (a rename or a removal is a break; a new name is not). The host accepts a plugin iff the plugin’s targeted api_version exactly equals the host’s; anything else is rejected with a “must be rebuilt” error card, and the plugin commits nothing. Equality is the correct gate because additive changes never bump the version — a host that gains a new optional capability keeps the same version, so every existing plugin keeps loading. The one asymmetry the equality gate leaves is a plugin that uses a newer capability on an older host that lacks it; that fails loudly by construction (calling a context method the host does not define throws during register, caught as that plugin’s error card), never through silent negotiation.

Styling: two sanctioned paths

A plugin styles itself in exactly one of two sanctioned ways, and no other:
  1. SDK components plus inline styles that read the design-system tokens (var(--tai-*)). Raw Tailwind utilities are host-internal and are not part of this contract.
  2. A plugin-shipped scoped stylesheet, listed as a .css asset in the manifest integrity map and host-injected as an SRI’d <link rel="stylesheet"> before the bundle’s JS is imported.
A scoped stylesheet is bound by three hard rules:
  • No global resets or preflight — never style html, body, :root, *, or bare element selectors at the top level.
  • Scope every selector under a plugin root class the plugin renders itself, prefixed with the plugin’s own package name.
  • Theme through the tokens — colors in plugin-authored rules come from the SDK custom properties (var(--tai-*)), never hardcoded, so the stylesheet themes itself with no logic of its own. Bundled third-party base CSS is exempt from this rule, but the no-global and scoping rules still bind it.
Stylesheet injection is a host capability. A plugin shipping a .css asset to a host that does not inject stylesheets is served but never injected — the page renders with SDK-component styling only. This is the single quiet degradation the mechanism admits, so pair a Studio host with plugins built for it.

See also

  • The jq editor — jq-studio, the built-in visual jq editor a plugin’s jq fields render.
  • Plugin API reference — the PluginContext, contribution types, the PluginPageParamsSchema, and the version gate, generated from the SDK.
  • Navigation reference — usePluginNavigation, navigatePlugin, and resolvePluginPath.
  • The manifest — the studio_plugins field that turns a plugin on.
  • The screens — the shell surfaces a plugin’s pages and nav entries join.
  • Studio SDK reference — the hooks and shared components a plugin builds from.