/**
* The Studio-plugin authoring contract. A platform plugin ships an ESM bundle
* that exports a `register(context)` entry ({@link PluginEntry}); the host calls
* it with a {@link PluginContext} through which the plugin contributes tool
* panels, pages, settings tabs, and sidebar nav entries.
*
* Two styling paths are sanctioned, and only these two:
* 1. SDK components plus inline styles that read the design-system tokens
* (`var(--tai-*)`). Raw Tailwind utilities are host-internal, not part of
* this contract.
* 2. A plugin-shipped SCOPED stylesheet, listed in the bundle's manifest
* `integrity` map as a `.css` asset and HOST-injected as an SRI'd
* `` before the bundle's JS is imported. It is bound
* by three hard rules: (a) no global resets/preflight — never style `html`,
* `body`, `:root`, `*`, or bare element selectors at top level; (b) every
* selector is scoped under a plugin root class the plugin renders itself,
* prefixed with the plugin's own package name; (c) theme values in
* PLUGIN-AUTHORED rules come from the SDK custom properties (`var(--tai-*)`),
* never hardcoded colors, so the stylesheet themes itself with no logic of
* its own. Bundled third-party base CSS is exempt from rule (c) but rules
* (a) and (b) still bind it.
*/
import type { ComponentType } from 'react';
/**
* A capability requirement a contribution may declare. `routes` is a list of
* route-path prefixes (anyOf semantics, the same evaluator the shell nav uses):
* the contribution renders iff the caller's capability projection covers at least
* one of them. ABSENT (the field is optional) means the contribution renders only
* for a FULL projection — safe-by-default for every existing plugin, which never
* declared a requirement. The server remains the authority; this only shapes what
* the UI advertises.
*/
export interface RequiredCapabilities {
readonly routes: readonly string[];
}
/** Props a contributed tool-run panel receives (replaces the auto-form). */
export interface ToolPanelProps {
/** The tool name this panel targets. */
readonly toolName: string;
/** The tool's JSON schema (Pydantic-emitted), for panels that introspect it. */
readonly schema: Record;
/** Run the tool with arguments; resolves with the typed result or throws. */
readonly run: (args: Record) => Promise;
}
/**
* Props a contributed full page receives. A page is deep-linkable: the shell mounts
* it at `/plugins/{pluginId}/{path}` and, when the contribution declares a
* {@link PluginPageParamsSchema}, forwards the VALIDATED sub-path remainder as
* `params` and the VALIDATED search object as `search`. Both are OPTIONAL — a page
* that declares no schema receives neither (and accepts no sub-path), so this stays
* a non-breaking addition to every existing contribution and the plugin API version
* does not move. Navigate between plugin pages with `usePluginNavigation`.
*/
export interface PluginPageProps {
readonly pluginId: string;
/** The validated sub-path remainder (present only when the page declared a schema). */
readonly params?: Record;
/** The validated search object (present only when the page declared a schema). */
readonly search?: Record;
/**
* The per-history-entry state slot the host stored for THIS plugin on the current
* history entry (written via `navigatePluginWithOptions` / `updatePluginEntryState`
* on the SDK navigation surface), or `undefined` when none was set. It survives
* back/forward traversal and a hard reload, because the host round-trips it through
* `history.state`. OPTIONAL — a page that never uses the entry-state channel receives
* `undefined`, so this stays a non-breaking addition and the plugin API version does
* not move.
*
* FAILURE-DIVERGENCE — why there is no host-side schema for it, unlike `params` /
* `search`: those two come from the URL, a shareable and forgeable surface, so a bad
* one is a broken LINK — the host VALIDATES them against the page's
* {@link PluginPageParamsSchema} and renders a LOUD error card on rejection, never a
* half-populated view. `entryState` is different in kind: it is opaque, author-written
* data the host only round-trips through `history.state`, never parses and never shows
* a human, and the page that wrote it owns its shape. So the host delivers it RAW and
* the page MUST degrade GRACEFULLY on anything unexpected — treat a malformed or absent
* value as "no checkpoint" and fall back to defaults, never throw. That asymmetry —
* an error card at the host for URL surfaces, graceful raw delivery for entry state —
* is exactly why entry state carries no host-side schema slot.
*/
readonly entryState?: unknown;
}
/**
* A page's optional deep-link schema. The shell resolves a URL to a page by
* LONGEST registered `path` prefix; the remainder of the URL after the matched
* prefix is handed to `parseParams`, and the raw search object to `parseSearch`.
* Each parser VALIDATES and shapes its input, RAISING on anything it rejects — the
* shell renders that throw as a loud error card, never a blank or partial view. A
* contribution that omits the schema entirely accepts no sub-path (the pre-deep-link
* behavior, unchanged). Omitting just one parser leaves that half unvalidated:
* absent `parseParams` means the page still matches only its exact `path`; absent
* `parseSearch` means `search` is not forwarded.
*/
export interface PluginPageParamsSchema {
/** Validate + shape the sub-path remainder after this page's prefix; throws to reject. */
readonly parseParams?: (remainder: string) => Record;
/** Validate + shape the raw search object; throws to reject. */
readonly parseSearch?: (raw: Record) => Record;
}
/** Props a contributed settings tab receives. */
export interface SettingsTabProps {
readonly pluginId: string;
}
export interface ToolPanelContribution {
readonly toolName: string;
readonly component: ComponentType;
}
export interface PageContribution {
/** URL segment under `/plugins/{pluginId}/`. */
readonly path: string;
readonly title: string;
readonly component: ComponentType;
/** Capability gate (see {@link RequiredCapabilities}); absent ⇒ full-only. */
readonly requiredCapabilities?: RequiredCapabilities;
/**
* Deep-link schema (see {@link PluginPageParamsSchema}). When present the page is
* addressable under its `path` PREFIX, and the shell validates the sub-path
* remainder + search before rendering. Absent ⇒ the page matches only its exact
* `path` and receives no `params`/`search` (unchanged behavior).
*/
readonly params?: PluginPageParamsSchema;
}
/**
* A page as stored in the registry: the plugin's {@link PageContribution} plus
* the id of the plugin that registered it. The `pluginId` is stamped by the
* registry from the identity the host passed to {@link PluginEntry} — a plugin
* never supplies its own id — so a page resolves only under its owner's
* `/plugins/{pluginId}/` prefix and two plugins may register the same `path`
* without colliding.
*/
export interface RegisteredPage extends PageContribution {
readonly pluginId: string;
}
/**
* The sidebar section a nav entry renders in. A named core section groups the
* entry there, after that section's core rows. An absent field or any value that
* is not a live core section renders the entry in the shared "Plugins" section that
* follows the core sections (alongside every other undeclared plugin entry),
* placed after the core sections. The shell tolerates an unknown value at runtime
* (a bundle newer than this host), falling back to that shared section, so the
* field is a placement hint, never a hard contract.
*/
export type NavEntrySection =
'Capabilities' | 'Connections' | 'Triggers' | 'Activity' | 'Administration';
export interface NavEntryContribution {
/**
* Path of a page THIS plugin registers. The nav entry links to
* `/plugins/{pluginId}/{path}`, so it must match a {@link PageContribution}
* `path` of the same plugin — the registry rejects a nav entry with no page.
*/
readonly path: string;
readonly title: string;
/**
* Optional target sidebar section (see {@link NavEntrySection}); absent ⇒ the
* shared "Plugins" section. Additive — an older bundle omits it and stays
* on the same plugin API version.
*/
readonly section?: NavEntrySection;
/**
* Optional sort weight WITHIN the entry's section (whether a core section or the
* shared "Plugins" one): entries render in ascending `order`, and every
* entry that omits it sorts AFTER the ordered ones, in registration order. Two
* entries with the same `order` keep registration order (a stable sort). Additive —
* an older bundle omits it and stays on the same plugin API version.
*/
readonly order?: number;
/**
* Optional icon rendered before the title. It must be a square inline SVG that
* fills its box and draws with `currentColor`; the host constrains the slot,
* rendering it inside a fixed 1em box, `aria-hidden` (the accessible name is
* `title`), with the color inherited from the link. Its bytes live inside the
* plugin bundle, so no external fetch and no CSP change. Absent icon renders a
* text-only entry, exactly like the core nav.
*/
readonly icon?: ComponentType;
/** Capability gate (see {@link RequiredCapabilities}); absent ⇒ full-only. */
readonly requiredCapabilities?: RequiredCapabilities;
}
/**
* A nav entry as stored in the registry: the plugin's
* {@link NavEntryContribution} plus the id of the plugin that registered it. The
* `pluginId` is stamped by the registry from the identity the host passed to
* {@link PluginEntry}, so the entry links only under its owner's
* `/plugins/{pluginId}/` prefix and two plugins may register the same `path`
* without colliding.
*/
export interface RegisteredNavEntry extends NavEntryContribution {
readonly pluginId: string;
}
export interface SettingsTabContribution {
readonly id: string;
readonly title: string;
readonly component: ComponentType;
/** Capability gate (see {@link RequiredCapabilities}); absent ⇒ full-only. */
readonly requiredCapabilities?: RequiredCapabilities;
}
/**
* A settings tab as stored in the registry: the plugin's
* {@link SettingsTabContribution} plus the id of the plugin that registered it.
* The `pluginId` is stamped by the registry from the identity the host passed to
* {@link PluginEntry} — a plugin never supplies its own id — so a tab is always
* attributable to its owner and two plugins may register the same tab `id`
* without colliding.
*/
export interface RegisteredSettingsTab extends SettingsTabContribution {
readonly pluginId: string;
}
/** Everything a single plugin bundle has registered at import time. */
export interface PluginContributions {
readonly toolPanels: ReadonlyMap;
readonly pages: readonly RegisteredPage[];
readonly settingsTabs: readonly RegisteredSettingsTab[];
readonly navEntries: readonly RegisteredNavEntry[];
}
/**
* The registration surface a plugin receives in its `register(context)` entry.
* Each method binds the contribution to the plugin the host is loading — the
* plugin's identity is fixed by the host, not read from any ambient state — so a
* contribution can never be misattributed to another plugin. Contributions are
* staged and committed together once `register` settles; a `register` that throws
* commits nothing. The context is SEALED once `register` settles: a call made
* after that (a deferred timer, a post-resolve microtask) throws instead of
* silently dropping, so every registration must happen during `register`.
*/
export interface PluginContext {
/** Register a full page mounted under `/plugins/{pluginId}/{path}`. */
registerPage(contribution: PageContribution): void;
/** Register a rich run panel for a tool by name (overrides the auto-form). */
registerToolPanel(contribution: ToolPanelContribution): void;
/** Register a settings tab. */
registerSettingsTab(contribution: SettingsTabContribution): void;
/**
* Register a sidebar nav entry linking to one of this plugin's pages at
* `/plugins/{pluginId}/{path}`. The `path` must match a page the plugin also
* registers (a nav entry with no page is a dead link and is rejected loudly).
*/
registerNavEntry(contribution: NavEntryContribution): void;
}
/**
* A plugin bundle's entry: the default plugin API. The host imports the bundle,
* reads its `register` export, and calls it with a {@link PluginContext}. All
* contributions flow through that context — there are no free registration
* functions. The entry may be synchronous or `async`; the host awaits it before
* committing, so an async entry must complete every registration before it
* resolves (see the seal on {@link PluginContext}).
*/
export type PluginEntry = (context: PluginContext) => void | Promise;