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 singleregister(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.
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.
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:
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.
Nav entries
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, Triggers, 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 undeclared entry — from every contributing plugin — lands in that one group, each keeping its own title and its own host-applied provenance badge. The field is additive — an older bundle without it simply lands in the shared Plugins section, so the plugin API version does not move.
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.
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-studioJqField 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 flow field.
SchemaForm gets the editor with no code
at all: a string 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 astudio-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 itssha384-<base64>subresource-integrity hash. The host serves only integrity-listed files and injects each with its SRI hash.contributions— the declaredtool_panels,pages,settings_tabs, andnav_entries.
The api_version gate
The plugin API carries a single monotonic integer,STUDIO_PLUGIN_API_VERSION (currently 1), bumped only on a breaking change to the plugin API or to the public prop surface of a design-system component the SDK exports. 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:- 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. - A plugin-shipped scoped stylesheet, listed as a
.cssasset in the manifestintegritymap and host-injected as an SRI’d<link rel="stylesheet">before the bundle’s JS is imported.
- 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, thePluginPageParamsSchema, and the version gate, generated from the SDK. - Navigation reference —
usePluginNavigation,navigatePlugin, andresolvePluginPath. - The manifest — the
studio_pluginsfield 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.

