12.5. Plugin Authoring Guide¶
This guide is for developers building an App Plugin: a JavaScript module that renders inside Backend.AI GO's own UI. It covers the manifest schema, the four UI slots, the permission model, the dependency-resolution contract that lets a plugin share the host's React instance, the CSS pipeline, and the Plugin SDK.
If you are looking for how to install, enable, or configure a plugin as an end user, see Plugin Management instead. This page is the developer-facing companion to that guide.
Preview feature
The App Plugin system is in preview. The runtime described here (module loading, lifecycle, error isolation, the SDK) is implemented and covered by tests. Plugin distribution through a public registry is not yet available; plugins are installed from a .zip archive or a local directory.
What an App Plugin Is (and Isn't)¶
An App Plugin is:
- A
plugin.jsonmanifest plus a bundled JavaScript entry file (and, optionally, a CSS file) that the host dynamically imports and renders inside its own React tree. - Permission-gated: a plugin can only call the SDK methods covered by the permissions it declares. The permission list gates the SDK surface; see the trust model note below for what it does not guarantee.
- Installed either as a built-in plugin bundled with the application, or by a user from a
.ziparchive or a local directory through the App Plugins page.
An App Plugin is not:
- A Claude Code plugin bundle (
.claude-plugin). Those are imported and unpacked on the separate Extensions page, which installs their skills, subagents, and MCP servers into the agent runtime. They share the word "plugin" but are a completely different system with a different manifest format, a different installation path, and no relationship to the module loader or SDK described here. The naming split exists specifically to avoid this collision: the system in this guide is always called "App Plugins" in the product UI.
Trust model¶
A plugin bundle runs as ordinary JavaScript inside the host WebView, in the same realm as the application itself: it can touch the DOM, globals, and fetch like any other host code. The permission list is a least-privilege and consent surface for the SDK API, not a sandbox that can contain hostile code (a malicious bundle can simply bypass the SDK). What the permission system does guarantee is that a plugin using the SDK cannot accidentally exceed what it declared, and that users see an honest capability list before enabling it. Install plugins only from sources you trust, the same way you would treat a desktop application extension.
Anatomy of a Plugin¶
A plugin is a directory containing at minimum a manifest and an entry bundle:
my-plugin/
├── plugin.json # required: the manifest
├── dist/
│ ├── index.js # required: the bundled entry, referenced by "entry"
│ └── index.css # optional: extracted styles, referenced by "styles"
└── icon.png # optional: referenced by "icon"
Everything under dist/ is produced by a build step (see Building a Plugin); you do not hand-write a bundled index.js. The src/ directory holding your React/TypeScript source is a build-time concern only and is not part of the installed plugin's runtime layout.
The Manifest (plugin.json)¶
Every plugin directory must contain a plugin.json file that deserializes into the PluginManifest schema defined in src-tauri/src/plugins/manifest.rs. The host validates every manifest against this schema before installing or (for built-ins) registering a plugin; an invalid manifest is rejected with a list of every failing field, not just the first one.
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | yes | Kebab-case, lowercase letters/digits/hyphens only, no leading or trailing hyphen, no double hyphens, max 64 characters. This is the directory name under plugins/installed/ and the key used everywhere else (memory namespace prefix, storage scoping, asset URLs). |
name | string | yes | Human-readable display name shown on the App Plugins page. |
version | string | yes | Must parse as SemVer (e.g. "1.0.0"). |
description | string | yes | Short description shown on the plugin card. |
author | string | yes | Author or organization name. |
entry | string | yes | Relative path to the bundled JS entry (e.g. "dist/index.js"). Must end in .js and must not contain path traversal (..), an absolute path, or a backslash. |
styles | string[] | no | Relative paths to stylesheets the host injects on activate and removes on deactivate. Each entry must end in .css and pass the same traversal checks as entry. Omitted (or empty) for plugins with no stylesheets; see The CSS Pipeline. |
homepage | string | no | URL of the plugin's homepage or repository. |
license | string | no | SPDX license identifier (e.g. "MIT"). |
icon | string | no | Relative path to an icon file. Same traversal checks as entry. |
minAppVersion | string | no | Minimum Backend.AI GO version required, if present must be valid SemVer. |
category | enum | yes | One of model, ui, data, integration, utility, other. Purely informational; used for filtering on the App Plugins page. |
slots | string[] | no | Which UI slots the plugin renders into. See UI Slots. Defaults to an empty list. |
permissions | string[] | no | Which SDK capabilities the plugin requests. Every value must be one of the 13 known permission ids. See Permissions. Defaults to an empty list. |
tools | string[] | no | Allowlist of host tool names the plugin may advertise to the model during a chat.tools completion. Each name must be lowercase [a-z0-9_]+, non-empty, and unique. Omitted (or empty) for plugins that do not call tools. See Tool Calling. Defaults to an empty list. |
settings | object[] | no | User-configurable settings schema. See Settings Schema. Defaults to an empty list. |
A minimal valid manifest:
{
"id": "my-plugin",
"name": "My Plugin",
"version": "0.1.0",
"description": "Does one small thing well.",
"author": "Your Name",
"entry": "dist/index.js",
"category": "utility"
}
The Rust validator and the frontend SDK registry (src/types/plugins.ts) share the same permission vocabulary by construction: a test in manifest.rs reads src/types/plugins.ts directly and fails the build if the two lists ever drift apart. Add a new permission in both files together, never one alone.
UI Slots¶
A plugin declares which slots it renders into via the slots array. Each active plugin registered for a slot is mounted by the host's layout components:
| Slot | Purpose | Mounted in |
|---|---|---|
overlay | Full-screen floating layer, for widgets that need to sit above all page content (e.g. a floating companion avatar). | MainLayout.tsx |
statusbar | The application's bottom status bar. | MainLayout.tsx |
toolbar | The application's top toolbar. | MainLayout.tsx |
sidebar | The main navigation sidebar, below the nav items. Only rendered while the sidebar is expanded. | Sidebar.tsx |
Each slot is a <PluginSlot name="..."> container (src/components/Plugins/PluginSlot.tsx) that queries active plugins for that slot and renders each one through PluginRenderer, which wraps the plugin in an error boundary (see Lifecycle and Error Isolation). A slot with no active plugins renders nothing, so declaring a slot has no layout cost when no plugin uses it.
A plugin can declare more than one slot and render different content (or nothing) depending on which slot instance is currently mounting it, by inspecting manifest.slots inside its own component if it needs slot-aware behavior. Most plugins declare exactly one slot.
Permissions¶
A plugin can only call SDK methods gated by permissions it has declared in its manifest. Calling a gated method without the permission throws PluginPermissionError at the call site, not at install or load time, so a plugin can safely request a permission for a feature it does not use yet.
| Permission | Description |
|---|---|
chat.inference | Send messages to and receive responses from the inference server. |
chat.models | List and read information about available models. |
chat.tools | Advertise and execute host tools during a plugin chat completion, limited to the manifest tools allowlist. Required by chat.streamMessageWithTools. See Tool Calling. |
storage.scoped | Read and write files in the plugin's own data directory. |
storage.conversations | Create, read, update, and delete plugin-scoped conversations. |
memory.read | Read entries from the plugin's own Memory Bank namespaces. |
memory.write | Create, update, and delete entries in the plugin's own Memory Bank namespaces. |
memory.extract | Trigger LLM-based memory extraction into the plugin's namespaces. |
ui.overlay | Render UI components in the overlay layer. |
ui.sidebar | Render UI components in the sidebar. |
ui.statusbar | Render UI components in the status bar. |
ui.toolbar | Render UI components in the toolbar. |
ui.notifications | Display notifications to the user. |
Request the narrowest set your plugin actually needs. The permission list is shown to the user before they enable a plugin, so an over-broad request is both a trust signal and a maintenance liability.
Settings Schema¶
A plugin can declare user-configurable settings so the host renders the right control automatically, without the plugin building its own settings UI. Each entry in the settings array is a PluginSettingSchema:
| Field | Type | Notes |
|---|---|---|
key | string | Unique within the plugin. Duplicate keys fail validation. |
label | string | Displayed label. |
description | string, optional | Help text shown under the control. |
type | enum | One of string, number, boolean, select, textarea. |
defaultValue | any, optional | Applied when the user has not set a value. |
options | array, optional | Required (and must be non-empty) when type is "select". Each option is { "label": string, "value": any }. |
required | boolean, optional | Whether the user must provide a value. |
rows | number, optional | Visible text rows; only meaningful when type is "textarea". |
presetMap | object, optional | Only meaningful on a select setting. Maps each option's value to a record of other setting keys and the values to auto-fill when that option is chosen. The sentinel value "__SKIP__" leaves the target key unchanged, letting one option (typically "Custom") opt out of auto-fill while the others apply presets. |
Read settings values at runtime through api.settings.get(key) / api.settings.getAll(), and react to changes with api.settings.onChange(callback); see The Plugin SDK.
The Dependency-Resolution Contract¶
This is the mechanism that makes a plugin's UI actually render. Getting it wrong is the single most common way a plugin silently fails: the module imports successfully at build time, fails to resolve at runtime, and the plugin sits at status "error" forever with no visible UI.
The problem¶
A plugin's component renders inside the host's own React tree (PluginSlot → PluginRenderer → your component), not in an isolated iframe or a separate React root. React's hooks depend on a single, shared dispatcher instance; if a plugin bundle ships its own copy of react, calling useState or useEffect from that copy while mounted under the host's React tree breaks hooks (an "Invalid hook call" class of failure) or, more insidiously, works in some cases and silently misbehaves in others. The same problem applies to i18next/react-i18next: a second, separately-initialized i18next instance never receives the host's loaded translation resources, so useTranslation() renders the raw key or an English-only fallback that never responds to a user's language change.
The naive fix, treating react as an external dependency in the plugin's own bundler config, does not solve this. It just leaves the bare specifier import ... from "react" untouched in the emitted bundle. When the host loads the plugin's dist/index.js with a native import(), there is no Node-style module resolution and no import map configured, so the bare specifier cannot resolve, import() rejects, and the plugin's load promise fails.
Two alternatives were considered and rejected:
- Import maps (
<script type="importmap">): rejected because the host's own React/i18next chunk URLs are hashed by Vite at build time, which would couple the import map to a specific build and break on every rebuild; WebKitGTK's import map support also varies across the Linux distributions the app supports. - Bundling React into the plugin: rejected outright, it reintroduces the dual-React-instance hook-breakage problem described above.
The solution: the host module registry¶
The host publishes its own already-initialized module instances on a well-known global, and a shared esbuild plugin rewrites the plugin bundle's imports to read from it, so the plugin never ships its own copy and never has an unresolvable bare specifier.
The exact contract:
- The host module list. Exactly five bare specifiers are covered:
react,react-dom,react/jsx-runtime,i18next,react-i18next. This list isHOST_MODULE_IDS, defined in two places that must be kept in sync:src/lib/plugins/hostModules.ts(the TypeScript source of truth, used by the host at bootstrap) andscripts/esbuild-host-modules-plugin.mjs(a plain-Node duplicate, because the build script runs outside TypeScript path resolution). - Publishing, at host bootstrap.
src/lib/plugins/hostModules.tsstatically imports all five modules (so Vite bundles the host's real instances into the main app chunk) and exposesinstallHostModules(), which freezes and assigns them toglobalThis.__AIGO_HOST_MODULES__. The call is idempotent: a second invocation (hot reload, multiple entry points, tests) is a no-op and never clobbers an already-installed registry. -
Rewriting, at plugin build time.
hostModulesPlugin()inscripts/esbuild-host-modules-plugin.mjsintercepts esbuild'sonResolvefor exactly those five specifiers and redirects each into a virtualaigo-host-modulenamespace. ItsonLoadhook emits a tiny CommonJS shim for each one:const registry = globalThis.__AIGO_HOST_MODULES__; const hostModule = registry ? registry["react"] : undefined; if (!hostModule) { throw new Error("AIGO host module registry missing: \"react\""); } module.exports = hostModule;CommonJS is deliberate here: esbuild's CJS-to-ESM interop makes both a default import (
import React from "react") and named imports (import { useState } from "react") resolve correctly against the same runtime module object, regardless of which style the plugin's source code uses. -
The net effect. The plugin's emitted
dist/index.jscontains zero bare-specifier imports for these five packages. Every reference resolves, at runtime, to the exact same object the host application is already running. There is one React instance, one i18next instance, shared between host and plugin.
Every other dependency is bundled normally; only these five specifiers are intercepted. If your plugin needs a charting library, a date utility, or any other package, bundle it the ordinary way with bundle: true.
The rule¶
Never add react, react-dom, react/jsx-runtime, i18next, or react-i18next to your plugin's own package.json dependencies, and never configure your bundler to bundle or externalize them yourself. Always build through hostModulesPlugin(). This is the single rule that keeps a plugin's UI from silently failing to render.
Copy-paste esbuild.config.mjs template¶
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import * as esbuild from "esbuild";
import { hostModulesPlugin } from "../../scripts/esbuild-host-modules-plugin.mjs";
import { pluginOwnCssPlugin } from "../../scripts/esbuild-plugin-own-css.mjs";
// Absolute path to this plugin's directory, independent of the process cwd,
// so pluginOwnCssPlugin can tell the plugin's own stylesheets from foreign CSS.
const pluginRoot = dirname(fileURLToPath(import.meta.url));
await esbuild.build({
entryPoints: ["src/index.tsx"],
bundle: true,
format: "esm",
outfile: "dist/index.js",
target: "es2020",
minify: true,
sourcemap: false,
treeShaking: true,
plugins: [hostModulesPlugin(), pluginOwnCssPlugin(pluginRoot)],
logLevel: "info",
});
The two relative import paths (../../scripts/...) assume your plugin directory sits two levels below the repository root, which matches examples/plugin-template/. A built-in plugin under src/plugins/<id>/ is three levels down and uses ../../../scripts/... instead, exactly as src/plugins/companion-chat/esbuild.config.mjs does. Adjust the depth to however many levels separate your plugin directory from the repository root; the plugin exports and build options themselves do not change.
The CSS Pipeline¶
A plugin bundle does not go through the host's Vite build, so it cannot rely on Vite's CSS handling. Instead:
- Your plugin's own source imports its stylesheets normally, e.g.
import "./styles/widget.css"inside a component file. pluginOwnCssPlugin()(scripts/esbuild-plugin-own-css.mjs) tells your plugin's own CSS apart from CSS pulled in transitively through a host component you import (for example, the sharedMarkdownContentcomponent drags in KaTeX's stylesheet) or through anode_modulespackage. Your own CSS, a relative import that resolves inside your plugin's directory, is kept and extracted by esbuild's built-in CSS loader into a siblingdist/index.css. Everything else is replaced with an empty module and dropped, because the host application already ships that CSS globally through its own Vite build; re-bundling it would duplicate styles and, worse, pull in font assets (.woff/.woff2/.ttf) that this lightweight build configures no loader for, which would fail the build outright.- Declare the emitted file in your manifest's
stylesarray:"styles": ["dist/index.css"]. - At runtime, the loader injects and removes your stylesheet in lockstep with the plugin's own activation lifecycle (
src/lib/plugins/pluginStyles.ts):- Desktop (Tauri): fetches the CSS text over the
asset:protocol and inlines it as a<style data-plugin-id="...">node indocument.head. The CSP'sstyle-src 'unsafe-inline'permits this; a<link>pointed atasset:directly would additionally requirestyle-srcto allowasset:, which it does not. - Headless WebUI: references the same-origin authenticated asset route directly with a
<link rel="stylesheet" data-plugin-id="...">.style-src 'self'permits this. - On deactivate, every
<style>/<link>node tagged with the plugin's id is removed, so a disabled plugin leaves no visual footprint behind. - A stylesheet injection failure is collected as a warning and surfaced in the plugin's runtime status, but never blocks an otherwise-loadable plugin from activating.
- Desktop (Tauri): fetches the CSS text over the
If your plugin has no stylesheets, omit styles entirely; the manifest wire shape for a styleless plugin is unchanged from before this field existed.
The Plugin SDK¶
Every activated plugin receives a permission-scoped PluginAPI instance (src/lib/pluginSDK.ts, createPluginAPI()), passed as the api prop to the default-exported component and also to onActivate. Calling a method the manifest has not requested a permission for throws PluginPermissionError immediately, with a message naming the missing permission.
| Domain | Key methods | Required permission(s) |
|---|---|---|
chat | sendMessage(messages, options), streamMessage(messages, options, callbacks), streamMessageWithTools(messages, options, callbacks), cancelStream(), getActiveModel(), listModels() | chat.inference (send/stream/cancel), chat.tools (tool-enabled streaming, see Tool Calling), chat.models (active model/list) |
storage | get(key), set(key, value), delete(key); conversations.list(), conversations.load(id), conversations.save(conv), conversations.delete(id) | storage.scoped (key-value), storage.conversations (conversation CRUD) |
memory | createNamespace, listNamespaces, toggleNamespace, deleteNamespace, createEntry, listEntries, updateEntry, deleteEntry, extractFromConversation, buildContext, consolidate | memory.read (list/read), memory.write (create/update/delete/consolidate/toggle), memory.extract (LLM extraction) |
settings | getAll(), get(key), onChange(callback) | none |
ui | showNotification(message, type), getTheme(), onThemeChange(callback) | ui.notifications (notifications only; theme reads require no permission) |
Chat inference options (temperature, maxTokens, topP, topK) are validated and clamped to safe ranges before the request is issued, so a plugin cannot pass an out-of-range value through to the inference server.
Memory namespace isolation¶
Every namespace a plugin creates through memory.createNamespace(name, ...) is automatically prefixed with plugin:<pluginId>:. A plugin with id my-plugin calling memory.createNamespace("notes") creates a namespace literally named plugin:my-plugin:notes. Every read or write path additionally asserts ownership: an attempt to touch a namespace that does not start with the calling plugin's own prefix, whether it belongs to the host application or to a different plugin, throws PluginPermissionError before any data is read or written. This isolation is enforced in the SDK itself, not just by convention, so it holds even for a plugin whose author did not deliberately try to respect it.
memory.buildContext() is scoped the same way: the returned context is assembled only from the calling plugin's own enabled namespaces, never from the host application's memory bank or another plugin's namespaces.
Settings reactivity¶
api.settings.getAll() reads the plugin's current settings values (as configured by the user on the App Plugins page, merged with the manifest's declared defaults). api.settings.onChange(callback) subscribes to future changes and returns an unsubscribe function; use it instead of polling if your component needs to react live to a settings change, for example, if the user edits a system prompt setting while your chat widget is open.
Tool Calling¶
A plugin can let the model call host tools: functions that observe or operate the application (list models, report inference status, load a model, create a squad, navigate to a page). The full catalog of application-control tools, one row per tool with its purpose, approval flag, and desktop-only note, is the App Control Tool Reference. This section covers how a plugin opts in and renders the loop.
The design principle is that the host owns the entire tool loop and the plugin only renders it. There is no api.tools.execute() primitive for a plugin to build its own loop. A plugin calls one method, api.chat.streamMessageWithTools(...), and the SDK runs capability gating, allowlist enforcement, streamed delta accumulation, the approval gate, execution through the host, the loop cap, and the HTTP-400 fallback, reusing the exact helpers the main chat surface uses. The plugin receives lifecycle callbacks to draw status and prompt for approval, nothing more.
Opting in: the chat.tools permission and the tools allowlist¶
Tool calling requires two additive declarations in the manifest, on top of the chat.inference permission that plain chat already needs:
- The
chat.toolspermission. CallingstreamMessageWithToolswithout it throwsPluginPermissionError, exactly like any other gated method. - A
toolsallowlist: the explicit list of host tool names the plugin may advertise to the model. Exposing the full host catalog to plugins by default is deliberately not supported; a plugin names only what it needs.
{
"id": "my-assistant",
"name": "My Assistant",
"version": "0.1.0",
"description": "A helper that can look up and load models.",
"author": "Your Name",
"entry": "dist/index.js",
"category": "ui",
"slots": ["overlay"],
"permissions": ["chat.inference", "chat.tools", "ui.overlay"],
"tools": ["list_models", "get_inference_status", "load_model", "navigate_to_page"]
}
The set of tools actually advertised to the model on any given turn is the intersection of three filters:
- the manifest
toolsallowlist, - the live host catalog (only built-in host tools are addressable; MCP
server:toolnames never match the[a-z0-9_]+naming and are never advertised), and - the enterprise tool policy, enforced backend-side on every execute path, so a tool an administrator has denied is never advertised and could not execute even if the model named it.
Because the intersection is recomputed per turn, an air-gapped deployment that denies the network-requiring tools (for example search_huggingface_models and download_model) leaves the plugin advertising only the local tools; no plugin code change is needed.
Running the loop with streamMessageWithTools¶
streamMessageWithTools(messages, options, callbacks) mirrors streamMessage and adds tool-lifecycle callbacks. All callbacks are render-only; every one is optional. A complete minimal integration:
const result = await api.chat.streamMessageWithTools(
[{ role: "user", content: userInput }],
{ maxToolLoops: 5, temperature: 0.7 },
{
// Plain-chat callbacks (identical to streamMessage):
onContent: (chunk) => bubble.appendText(chunk),
onError: (error) => bubble.showError(error.message),
onComplete: (final) => bubble.markDone(final.tokenCount),
// Tool-lifecycle callbacks:
onToolCallStart: (call) => bubble.addToolChip(call.id, call.name),
onToolResult: (res) =>
bubble.updateToolChip(res.toolCallId, res.success, res.result),
// Approval gate. Return a Promise<boolean>: true approves this one call,
// false denies it. Resolve false on dismiss (see the security note below).
onApprovalRequired: (call, toolDescription) =>
new Promise<boolean>((resolve) => {
bubble.showApprovalPrompt(call.name, toolDescription, resolve);
}),
// Fired once when the turn silently degraded to plain chat.
onToolsUnavailable: (reason) => bubble.showHint(reason),
},
);
// result.toolCalls is every call the host processed, in execution order.
for (const call of result.toolCalls) {
console.info(call.name, call.success, call.executionTimeMs);
}
The callback surface, all defined in src/types/pluginSDK.ts:
| Callback | Signature | Fires when |
|---|---|---|
onContent | (chunk: string) => void | Each incremental content chunk (same as plain chat). |
onError | (error: Error) => void | The completion fails. |
onComplete | (result: PluginStreamResult) => void | The turn finishes successfully. |
onToolCallStart | (call: PluginToolCallInfo) => void | Once per tool call, immediately before the host executes it. call carries id, name, and the raw JSON arguments. |
onToolResult | (result: PluginToolResultInfo) => void | Once per tool call with its outcome. result carries toolCallId, name, success, the result payload, an optional error, and executionTimeMs. |
onApprovalRequired | (call, toolDescription) => Promise<boolean> | An approval-required tool is about to run. Resolve true to approve the single call, false to deny it. |
onToolsUnavailable | (reason: string) => void | Tools were requested but the turn degraded to plain chat (see Graceful degradation). |
options extends the plain PluginChatOptions (model, temperature, maxTokens, topP, topK) with maxToolLoops, the cap on model-to-tool round trips before the SDK forces a final tools-free completion so the user always ends with prose. It is clamped to 1..10 and defaults to 5. cancelStream() aborts an in-flight loop, including any tool turn.
Deny-by-default approval¶
Every mutating host tool (loading, unloading, downloading a model, creating a squad) carries requires_approval: true, and the SDK never runs one without an explicit user decision. The gate is deny-by-default in two ways:
- If the plugin supplies no
onApprovalRequiredcallback, an approval-required call is denied outright and never executes. - If the callback is supplied, the call runs only when it resolves
true. A callback that throws is treated as a denial. Your UI should resolve the promisefalseon any dismissal (Escape, closing or minimizing the widget, unmount), so a pending approval never resolves as an accidental approval.
This is a security boundary, not just UX. A companion that injects Memory Bank content into its system prompt is injecting attacker-influencable text, so a mutating action must never auto-execute on the model's say-so. State this posture in a code comment wherever you wire the approval callback. The bundled Companion Chat plugin's ToolApprovalPrompt and its dismiss-resolves-deny handling are a working reference.
Read-only tools (list_models, get_inference_status, and the other observation tools) carry requires_approval: false and run without a prompt; onToolCallStart / onToolResult still fire so the activity is visibly attributed. The Companion Chat plugin's ToolActivityChip is a working reference for that per-call status line.
Graceful degradation¶
A tool request never breaks a turn. In three cases the SDK silently completes the turn as plain chat, returns an empty toolCalls, and fires onToolsUnavailable(reason) exactly once:
- the resolved model does not support tool calling (the capability gate reuses the main chat's
resolveToolCallingSupport, so a parser-less self-hosted server never receives tools and never triggers an HTTP 400), - the allowlist intersects empty (nothing to advertise after the three filters above), or
- a tools-bearing request is rejected with an HTTP 400 and is retried once without tools.
Your plugin should treat onToolsUnavailable as informational: you still get a normal prose answer through onContent / onComplete. A plugin with an empty tools allowlist gets plain-chat behavior with no error; a plugin without the chat.tools permission gets a PluginPermissionError from streamMessageWithTools, and plain streamMessage is entirely unaffected.
Pre-flight: canUseTools¶
If your system prompt tells the model which tools it can call, gate that advertisement on await api.chat.canUseTools(model?). It resolves true only when the next streamMessageWithTools call would actually attach tools: the resolved model passes the capability gate and the manifest allowlist intersects the host catalog (or a permitted frontend tool) non-empty. Advertising tools that then silently fail to attach makes the model emit tool-call syntax and fabricated results as plain prose.
Unlike the action methods, canUseTools never throws on a missing permission: a plugin without chat.inference plus chat.tools simply resolves false. The method is optional on the interface so bundles stay loadable against hosts predating it; guard with typeof api.chat.canUseTools === "function" and fall back to your setting-keyed behavior. The bundled Companion Chat plugin's send path is a working reference.
Frontend tools and navigation¶
Some actions cannot run in the host backend because their effect lives in the WebView. A frontend tool is advertised to the model exactly like a backend catalog tool but is dispatched in the SDK layer and never reaches the backend execute path. The one frontend tool shipped today is navigate_to_page, which steers the host router.
navigate_to_page reuses the existing api.ui.navigate() surface and therefore the ui.overlay permission; no new permission id is introduced. To let the model navigate, a plugin declares ui.overlay in permissions and navigate_to_page in tools; both gates must pass. The tool's path argument is constrained to a fixed allowlist of top-level in-app routes both by the JSON-schema enum sent to the model and by a re-check at execution time, so a hallucinated path returns a structured error and does not navigate. Frontend tools are never approval-gated, but onToolCallStart / onToolResult still fire.
For the full list of application-control tools and their exact semantics, see the App Control Tool Reference.
Lifecycle and Error Isolation¶
A plugin's entry module can export:
default(required): a React component receiving{ api, pluginId, manifest }as props.onActivate(api)(optional): called once, after a successful dynamic import and before the plugin's status becomes"active". If it throws, the plugin's status becomes"error"and the component is never rendered.onDeactivate()(optional): called before the module is unloaded (the user disables the plugin, or the app shuts down). A thrown error here is logged but does not block unloading.
Runtime status (LoadedPluginStatus) is one of "loading", "active", "error", or "unloaded", and is surfaced on the App Plugins page so a failure is visible instead of a silently blank slot. A plugin that activates successfully but has one or more stylesheets fail to inject stays "active" with a non-fatal styleWarning recorded, rather than being treated as a load failure.
Separately from lifecycle-hook errors, every mounted plugin component is wrapped in a React error boundary by PluginRenderer. A rendering-time crash (an exception thrown while your component renders, not during onActivate/onDeactivate) is caught there, shows a fallback panel with a Retry button scoped to that plugin, and never brings down the rest of the application.
Internationalization¶
Because i18next and react-i18next resolve through the host module registry (see The Dependency-Resolution Contract), calling useTranslation() inside a plugin component binds to the exact same, already-initialized i18next instance the host application uses. Your plugin never initializes its own i18next instance and never ships its own locale files as a runtime resource.
This has a real consequence for where translation keys live. A plugin does not carry its own translation resource bundle; whatever keys you call t("...") with must already exist in the host's own locale files. In practice this means:
- For a plugin built inside a clone of this repository (a built-in, or a plugin you are developing against the monorepo before packaging it): add your keys to every locale file under
src/locales/<lang>/translation.json, under a namespace of your choosing (the built-in Companion Chat plugin uses a top-levelcompanion.*namespace, for examplecompanion.widget.ariaLabel). English (en) is thefallbackLngand the canonical source; a key present in other locales but missing fromenrenders as a raw key for every user, not just English speakers, so add it toenfirst and mirror it to every other locale. - For a third-party plugin installed from a
.zipor a directory, with no access to the host's source tree, there is currently no SDK method to register a resource bundle at runtime. Either reuse an existing host key that already says what you need, or skipuseTranslation()and hardcode your UI strings. Treat this as a known limitation of the current SDK surface, not a workaround to route around; a future SDK version may add a way for a plugin to register its own translation resources.
Building a Plugin¶
Two ways to invoke esbuild, depending on where your plugin lives:
- Plugins under
src/plugins/<id>/(built-ins and anything you want the aggregate script to pick up): runpnpm build:pluginsfrom the repository root.scripts/build-plugins.mjsscans every immediate subdirectory ofsrc/plugins/for anesbuild.config.mjsand runsnode esbuild.config.mjsin each one it finds. This also runs automatically beforepnpm tauri devandpnpm tauri build(wired intobeforeDevCommand/beforeBuildCommandinsrc-tauri/tauri.conf.json), so a built-in plugin'sdist/is always fresh for local development and packaging. - Plugins anywhere else (including
examples/plugin-template/, deliberately outsidesrc/plugins/so the aggregate script and the dev-mode builtin fallback never pick it up automatically): runnode esbuild.config.mjsdirectly inside the plugin's own directory. Node's module resolution walks up parent directories to findesbuildand the two shared plugin scripts in the repository's rootnode_modules/scripts, so this works without a localpackage.jsonornode_modules.
Either way, the output is the same: a dist/index.js (and, if you have stylesheets, a dist/index.css) that matches what your manifest's entry/styles fields point at.
Packaging and Installation¶
On-disk layout¶
A user-installed plugin lives under the application data directory:
{app_data_dir}/plugins/
├── registry.json
└── installed/
└── {plugin-id}/
├── plugin.json
├── index.js # (or wherever "entry" points, e.g. dist/index.js)
├── assets/
└── data/
├── settings.json
├── state.json
└── conversations/
├── index.json
└── {conv-id}.json
data/ is the plugin's own scoped storage (storage.scoped, storage.conversations), created automatically on install and never shared with any other plugin.
Installing from a .zip¶
The zip installer (src-tauri/src/plugins/installer.rs) enforces, in order: an archive size cap of 50 MB, a maximum of 10,000 entries, a plugin.json located at the archive root or exactly one level deep, manifest schema validation, the declared entry file actually existing in the archive, and rejection of any symlink or path-traversal entry. Only after every check passes are the files copied into plugins/installed/{plugin-id}/ and the plugin registered. A plugin can also be installed directly from an already-unpacked local directory (the same validation applies, minus the archive-specific checks).
Built-in bundling¶
A plugin shipped with the application itself (like Companion Chat) is registered as a Tauri bundle resource in src-tauri/tauri.conf.json, with each file listed explicitly:
"resources": [
{ "../src/plugins/companion-chat/plugin.json": "plugins/builtin/companion-chat/plugin.json" },
{ "../src/plugins/companion-chat/dist/index.js": "plugins/builtin/companion-chat/dist/index.js" },
{ "../src/plugins/companion-chat/dist/index.css": "plugins/builtin/companion-chat/dist/index.css" }
]
At startup, register_builtin_plugins_from_resource_dir (src-tauri/src/plugins/builtin.rs) reads every immediate subdirectory of the bundled plugins/builtin/ resource directory (falling back to the dev source tree src/plugins/ when running from a source checkout rather than a packaged build), parses and validates each plugin.json, and for each plugin:
- Not yet installed: copies its files into
plugins/installed/{plugin-id}/and registers it withbuiltIn: true. - Installed, at an older version: updates the files, bumps the recorded version, and preserves the user's existing enabled/disabled preference.
- Installed, at the same or a newer version: skipped, no action taken.
A built-in manifest that fails schema validation is skipped entirely (logged as an error) rather than aborting startup or being installed unvalidated; the remaining built-ins still register normally.
Headless seeding¶
The headless server has no Tauri bundle resource directory, so it resolves its built-in plugin source at runtime, in priority order:
- The
AIGO_BUILTIN_PLUGINS_DIRenvironment variable, if it points at an existing directory. - An executable-adjacent
plugins/builtin/directory (the packaged headless server layout). - The dev source tree
src/plugins/(only present, and thus only ever selected, on the machine that built the binary from a source checkout).
Whichever directory is found is fed through the exact same register_builtin_plugins core used by the desktop path, so both platforms seed built-ins through identical logic.
Uninstalling and updating¶
A built-in plugin can be disabled but never uninstalled (the uninstall action is hidden for it); a user-installed plugin can be freely uninstalled, which removes its entire plugins/installed/{plugin-id}/ directory including its scoped storage.
The Example Template¶
examples/plugin-template/ is a minimal, working plugin you can build and install as-is, or copy as a starting point. It contains a plugin.json requesting the statusbar slot, an esbuild.config.mjs using the shared host-modules and own-CSS plugins exactly as described above, a small statusbar component that calls api.ui.showNotification through the SDK, one CSS file, and a README with build and install instructions.
It lives under examples/, not src/plugins/, on purpose: the dev-mode builtin fallback described in Headless seeding only scans src/plugins/, so this template is never auto-installed just by existing in the repository; you build and install it explicitly, exactly as an end user would install any third-party plugin.
See Also¶
- App Control Tool Reference: the full catalog of host tools a
chat.toolsplugin can call, one row per tool. - Plugin Management: the end-user guide to installing, configuring, and troubleshooting plugins from the App Plugins page.
docs/DEVELOP.md: the "App Plugin System" section cross-references this guide from the broader internal architecture documentation.src-tauri/src/plugins/manifest.rs: the authoritative manifest schema and validation rules.src/types/plugins.tsandsrc/types/pluginSDK.ts: the TypeScript types mirroring the manifest and SDK.