A Web plugin’s manifest is the single source of truth: it is both the runtime data served by the backend and the type source for compile-time checking. Defined in web/src/plugin-api/manifest.ts.
export interface PluginManifest {
/** Globally unique plugin id (e.g. "xbot.git-info"). */
id: string
name: string
version: string
description?: string
/** Capability declarations — determine the ctx shape (type-as-contract §capability-as-type). */
permissions?: readonly Permission[]
/** Hard dependencies: plugin ids that must be activated BEFORE this plugin (topological activation). */
activationDependencies?: readonly string[]
/** Typed contribution declarations. */
contributes: readonly Contribution[]
/** Frontend entry module (ESM). Purely backend plugins have no such field. */
entry?: string
}
export type Permission = 'events' | 'commands' | 'rpc' | 'state' | 'ui' | 'plugins' | 'config'
Each permission unlocks one capability interface on PluginContext (see PluginContext API). The backend permission whitelist (plugin/permissions.go, allPermissions) must stay in sync with this list — adding a new frontend Permission value requires adding the matching constant there too.
Contribution is a discriminated union of eight members:
export type ViewContainer = 'right_sidebar' | 'panel' | 'bottom' | 'info_bar' | 'status_bar_right' | 'iteration' | 'main'
export interface ViewContribution {
kind: 'view'
/** Globally unique: <pluginId>.<viewId>. */
id: string
/** Render container. */
container: ViewContainer
title: string
icon?: string
/** ESM module path (relative to plugin package root). The default export is the view. */
entry?: string
/** L1 declarative view: type + props (no entry needed). */
component?: ComponentDecl
/** Alignment inside the container: 'start' (default) or 'end'. */
align?: 'start' | 'end'
/** Parameterized dynamic view: no static entry; opened only via ctx.ui.openViewTab. */
dynamic?: boolean
}
- A view’s panel tab appears automatically in both desktop and mobile sidebars (
usePluginViewPanels) — declare once, render everywhere. dynamic: trueviews are filtered out of sidebars and the layout registry; they are opened only viactx.ui.openViewTab({ viewId, params })(VSCode webviewPanel semantics — see Editor View API).container: 'main'maps to the desktop main editor area (desktop.mainslot) — the view renders as a full-width editor tab.
export interface CommandContribution {
kind: 'command'
id: string
title: string
keybinding?: string
/** Enable/disable condition expression (reserved for a future when-evaluator). */
when?: string
}
The handler is resolved at runtime: first the manifest’s optional handlers[id], then the module’s exported commandHandlers[id] (registry.ts mount).
Renders tool results / messages in the chat flow. Matching conditions refine the render parameter type. See Message Renderer.
export interface ToolbarContribution {
kind: 'toolbar'
id: string
title: string
icon?: string
/** Command id executed on click. */
command: string
}
export interface ContextMenuContribution {
kind: 'contextMenu'
id: string
title: string
/** Matches message/file type (reserved). */
when?: string
command: string
}
export interface SettingContribution {
kind: 'setting'
key: string
type: 'boolean' | 'string' | 'number' | 'select' | 'multiselect'
label: string
description?: string
default?: unknown
options?: Array<{ label: string; value: string }>
/** Group name: properties in the same section are grouped in the settings panel. */
section?: string
/** Sensitive value: masked input in the UI. */
secret?: boolean
placeholder?: string
required?: boolean
}
export interface EventHandlerContribution<E extends keyof EventMap = keyof EventMap> {
kind: 'eventHandler'
event: E
/** Handler module path. The module exports handler(payload: EventMap[E]). */
entry: string
/** Permission required for the subscription (defaults to plugin permissions). */
permission?: string
}
export interface ThemeContribution {
kind: 'theme'
cssVars: Record<string, string>
}
activate can dynamically register extra contribution points. Each registration returns a disposable, cleaned up automatically on unload:
export interface ContributionAPI {
register(contribution: Contribution): Disposable
registerAll(contributions: readonly Contribution[]): Disposable
}
/** Cleanup handle: calling it releases resources; idempotent. */
export type Disposable = () => void
/** Lifecycle metadata passed to activate. */
export interface PluginMeta {
id: string
version: string
}
The frontend ContributionRegistry.validate() is the only place that validates contribution semantics (web/src/plugin-runtime/registry.ts):
id/name/versionmust be present;contributesmust be an array.- Each contribution must have a known
kindand a non-emptyid. - Contribution ids must be unique across plugins — except
messageRenderer, where duplicates are legal (priority chain). - All
activationDependenciesmust already be active.
The backend performs only transport-level checks (non-empty entry, valid plugin ID, safe static path). Never add a second semantic-validation layer on the backend — two gates drift.
Plugin copy ships with the plugin — never in the host’s i18n files:
{
"web": {
"entry": "index.js",
"i18n": { "en": { "save": "Save" }, "zh-CN": { "save": "保存" }, "ja": { "save": "保存" } }
}
}
ctx.i18n.t(key, fallback) resolves against the host’s current locale with this chain:
exact locale → language prefix (zh-TW/zh-HK fall back to zh-CN) → en → first available
locale → your fallback argument → the key itself. ctx.i18n is available to every plugin
(no permission needed) and reads the locale at call time, so switching the host language updates
the plugin UI immediately. Either make the fallback argument the English string, or keep all three
tables complete — otherwise the plugin shows fallback text where the host shows another language.
⚠️ Manifests are read once at server startup. Editing
plugin.json(web.i18n/web.entry/permissions/version…) does not change anything until you reload plugins (config action=reload_plugins, logged asPlugin discovered plugin=<id>) or restart the server. Plugin web assets (/plugins/<id>/web/*.js) are served from disk per request, so they update immediately — which makes “the new bundle runs but the manifest is old” a real trap (symptom:ctx.i18nsilently falls back to your fallback strings).
right_sidebar · panel · bottom · info_bar · status_bar_right · iteration · main.
mainrenders as a full-width editor tab in the desktop main area.info_barrenders in the desktop bottom status bar — the same row as the app’s “check for updates” button, ideal for a compact status chip (e.g. the ssh-runner plugin shows the session’s bound runner there and lets you switch it with a click).