A feature plugin can own its backend operations and its Control UI. It can add pages, navigation, session actions, panels, dashboard widgets, and header accessories, or provide replacements for the workspace, session list, composer, transcript, and tool results.

All plugin APIs are experimental, including the backend and browser contracts on this page. Pin and test your Apus host version.

Native UI runs trusted JavaScript in the Control UI origin. Install it only from authors you trust. Native modules share the signed-in operator's Gateway authority: host.request can call any method that connection's scopes allow, including administrative methods for administrators. Use the existing sandboxed dashboard widget or MCP App surfaces when the content should be isolated from the host application.

Open the Control UI served by the connected Gateway. Native plugin assets require that same origin; a separately hosted UI connected to another Gateway cannot load them and explains which Control UI to open.

Authenticated native UI also requires HTTPS or a browser-trusted loopback URL such as http://127.0.0.1:18789/. Plain HTTP on a LAN address can pair and use the dashboard, but cannot send the secure cookies used for native assets. Plugin pages explain how to switch to HTTPS/Tailscale Serve or localhost; backend operations remain available.

Enable custom plugin UI

In the Control UI, open Settings → Labs → Custom plugin UI. The setting defaults to off and controls native browser code from user-installed plugins, including local development plugins. The equivalent config is:

{
  gateway: {
    controlUi: {
      experimental: { customPlugins: true },
    },
  },
}

Changes apply without restarting the Gateway, and connected Control UI pages refresh their plugin views automatically. Disabling it prevents custom native UI from loading and removes its views. Reload browser tabs to clear plugin JavaScript that already ran. This does not uninstall plugins or disable their backend operations, tools, or services. Ordinary plugin APIs, sandboxed dashboard widgets, and MCP Apps are unaffected.

Native UI shipped with Apus remains available for enabled bundled plugins, including Workboard. Apus determines bundled status from the loaded plugin's origin, not its name or a manifest claim. A separately installed copy uses the custom-plugin setting.

Create a feature plugin

Enable the Custom plugin UI lab before opening the scaffold's browser views.

apus plugins init draft-review --name "Draft Review" --type feature
cd draft-review
npm install
npm run build
npm run validate
apus plugins install .

The scaffold includes a draft-analysis operation, an agent tool, a native page, and a composer replacement. Open Draft Review from the Control UI sidebar, or open Plugins → Advanced → Customize UI and choose Draft composer. Choose Built-in to restore a view. Replacement selection belongs to the current browser runtime; it is not a persistent configuration setting.

Customization controls are the first section in Plugins → Advanced. If a workspace replacement hides navigation, open /settings/plugins?tab=advanced under your Control UI base URL to choose Built-in; plugin settings always use the built-in workspace.

The project has three public SDK imports:

Import Purpose
apus/plugin-sdk/feature-contract Shared operation schemas, typed clients, and event subscriptions; browser safe.
apus/plugin-sdk/feature-plugin Register backend implementations as session actions, optional agent tools, and command adapters.
apus/plugin-sdk/control-ui Native browser activation, host capabilities, contribution types, and component mounts.

Browser code owns its DOM and bundles its framework dependencies. It does not import Control UI internals or return the host framework's templates.

Define operations once

defineFeatureContract declares named queries and actions with TypeBox input and output schemas. defineFeaturePlugin implements them through setup(api, events) and registers each operation on the existing plugin session action transport. Queries require operator.read; actions require operator.write.

An operation with a tool declaration also registers an agent tool. Optional command adapters parse the actual command context and format the result. The handler receives a discriminated invocation context: source is session-action, tool, or command, with the original context for that surface. Tool policy, authenticated command handling, and Gateway scope checks remain owned by their existing execution paths.

Use createFeatureClient(contract, context.host) inside a browser view:

const feature = createFeatureClient(contract, context.host);
const report = await feature.invoke("analyze", { text: "A draft to inspect" });
context.signal.throwIfAborted();
output.textContent = `${report.words} words`;

Backend inputs, outputs, and events are validated as bounded JSON. Define meaningful limits in the schemas as well. Use the plugin's existing runtime and storage APIs for durable state and services.

For changing data, declare events in the contract and emit them through the events argument after the plugin service has started. A client can subscribe with feature.on(...) or use feature.watch(query, input, options) to request a fresh snapshot initially, after named events, and after reconnect. watch requires onChange and onError, returns a disposer, and rejects stale results after a newer refresh or view disposal. It does not poll.

Contribute and replace views

Export defineControlUiPlugin({ id, activate(host) }) from the browser entry. Its id must match the plugin manifest. Register contributions through host.ui; each registration returns a disposer.

Registration Host placement
registerPage and registerNavigation Plugin-owned routes and sidebar destinations.
registerAction Composer, header, or session menu actions. An optional resolve function supplies the current label, hidden state, and disabled state.
registerPanel Session panels.
registerAccessory Session header content.
registerSidebarSection A section of session rows above the sidebar's Sessions list.
registerWidget Native dashboard widget views.
registerReplacement workspace, session-list, composer, transcript, or tool-result.
registerThemedFrame Keeps an iframe your page owns on the operator's theme (apus:widget-theme on load and on every theme or accent change).

A native work page can opt into retainOnNavigate: true to preserve its in-memory editor across Settings and other routes. The shell retains at most one plugin work-page target, separately from the conversation. The target includes all page parameters; another target replaces it. Hidden views receive presented: false and must suspend navigation and presentation work while allowing already-started writes to settle. Connection/auth scope changes and plugin activation disposal still retire the view. Settings pages and external frame descriptors do not opt in. Retention is not durable draft storage or additional authority.

Set a navigation item's parent to another navigation ID in the same plugin to show it nested while the parent or a child destination is active; children only appear as top-level entries when pinned. Set defaultVisible: false to offer an item in Customize, and call host.ui.pinNavigation(id) after registering it to append an ordinary saved sidebar pin. Pinning is a no-op for an unknown or already pinned ID; call it for a user action such as creation, not on every catalog refresh, so a later manual removal stays removed.

A sidebar section's resolve function returns its rows, the sessions it claims, optional header actions, and an optional Filter & sort view, or null to hide the section. The host draws rows with its own session rows, leaves every claimed session (and the children of claimed row sessions) out of Sessions, removes the saved pin of each claimed row session, and keeps a claimed session group out of Sessions unless another session belongs to it. Claim the sessions of rows your filter currently hides too. The host reads resolve again after host.ui.invalidate() or a registration change, not on every render, so keep it pure and synchronous; a resolve that throws is reported and hidden until it is registered again. Registrations end with the activation, so a section never outlives its Gateway connection.

Navigation items can supply actions with an id, label, optional icon and destructive flag, and a run callback. The sidebar opens these actions on right-click, Shift+F10, or the context-menu key on the focused link, including nested and pinned entries. Selecting an action closes the menu; Escape or an outside click dismisses it. Use host.ui.isNavigationPinned(id) to read a saved pin and host.ui.unpinNavigation(id) to remove it idempotently. Plugins choose which actions to offer and own confirmation for destructive actions.

For a dashboard widget, also register a backend api.session.controls.registerControlUiDescriptor with surface: "widget", the same widget id, and its requiredScopes. The Gateway advertises widget kinds for the current connection's scopes; a native view renders only when its matching backend descriptor is advertised.

Use host.ui.openPanel("editor", { sessionKey, agentId }) to open one of your registered panels beside a session. Omitting the session uses the currently selected session. The host owns navigation and sidebar presentation, including opening from a plugin page before the session pane has mounted. Only the same plugin's registered panels can be opened; retained handles expire with their view or activation.

For a document link, use host.navigation.pageHref(...) to build a link to a registered plugin page. That page can resolve its document and call openPanel with the target session. This does not intercept ordinary file links or change the Files plugin's ownership.

Use host.ui.invalidate() when plugin-owned state changes the presentation of an action or another contribution. Namespace custom elements and CSS with the plugin id so independently bundled plugins can coexist.

A view mounts into an HTMLElement and receives context.host, props, signal, presented, and mountDefault. Return an object with update, focus, and dispose as needed. Surface props and presentation changes arrive through update; use context.host.subscribe(...) to observe host snapshot changes. A view that remains mounted retains its host lifetime when presented is false; use it to pause visual work while preserving state. Removing or disposing the view aborts its signal and retires its host handles. Check the signal after asynchronous work and release plugin resources in dispose.

Session-bound views and actions receive sessionKey and agentId. Carry both when making a session request: the application's selected agent can differ from the view's agent. Changing either part of that identity retires the old view's handles, including retained composer operations. Header and composer actions also retire when their pane stops being presented. A later return to that pane allows new actions; it does not revive handles from an earlier invocation.

A tool-result replacement can provide a pure synchronous matches(props) predicate to opt into individual tool calls. With Automatic selected, the host uses the first matching registration ordered by plugin ID and contribution ID; unmatched calls keep the built-in view. Predicates receive the current tool name, call ID, input, output, and session identity. Keep them cheap and free of side effects. A throwing predicate is diagnosed and skipped until that contribution is registered again. An explicit replacement selection takes precedence over matching; selecting Built-in view disables automatic replacements for that surface. These choices belong to the current browser window and reset on reconnect. Unregistering a matching contribution retires its mounted views and restores the next matching or built-in view. Use host.ui.invalidate() if plugin-owned state changes matching, and context.mountDefault(container) to retain raw tool output.

Replacements can compose the built-in view by calling context.mountDefault(container). A workspace replacement should use this when it wants to retain the built-in chat state and session owners. The SDK does not expose a separate headless chat service for a completely independent workspace.

A composer replacement receives the current draft, admission state, disabled reason, and canonical setDraft, send, and optional abort operations. Use these operations instead of issuing a raw chat RPC. send() resolves true when admitted, false when rejected, or undefined for a local command or no submission. Show rejected submissions rather than clearing the draft. Composer operations retire when the view stops being presented, even while its DOM and host lifetime survive. Use the fresh operations supplied by update when the view is presented again; previously captured operations remain retired.

Host capabilities

Use the host for shared application behavior:

Capability Purpose
agents Agent snapshots, selection, scope, and refresh.
components Host-owned dialogs, pickers, and session dashboards.
connection Current connection and operator capabilities.
dock (optional) Open a conversation beside the current page with openSession, close the dock, and observe openSessionKey.
navigation Open plugin pages and build their URLs.
request and onEvent Authenticated Gateway requests and event subscriptions.
sessions Session snapshots, independent queries, navigation, creation, and updates.
subscribe Observe host snapshot changes, including the docked session key.
ui Register, select, and invalidate plugin contributions.

Session and agent refresh() operations fetch new snapshots and reject on failure, so a plugin can display an error and offer Retry. host.sessions.rows is the current filtered, paginated session list. host.sessions.refresh() preserves that list's filters. Use host.sessions.observe(query, onChange) to maintain an independent session query without replacing it. The query accepts agentId, search, archived (true, false, or "all"), limit, configuredAgentsOnly, includeGlobal, includeUnknown, excludeDock, includeDerivedTitles, and includeLastMessage. The callback receives { result, loading, error }, starting with the current snapshot; result is null until data is available. Results contain sessions and the Gateway's hasMore, nextOffset, and totalCount pagination metadata.

Create a conversation with host.sessions.create({ agentId, displayName?, label?, surface? }). displayName is a reusable display title; label is a unique session label. For a conversation owned by a plugin page's dock, pass surface: "plugin-dock". This immutable creation-surface marker hides the conversation from ordinary session lists without changing its human creator, access, sharing, or sandbox rules. Rows expose isDock and createdSurface; createdVia retains its ordinary operator provenance. Independent host list queries exclude dock conversations by default; pass excludeDock: false when deliberately including them. The session key remains usable with host.dock.openSession and direct reads.

The host fetches the query and keeps it current through session events, observer recovery, and its normal deletion handling. observe returns { refresh, dispose }: call refresh() for an explicit retry and dispose() when the query is no longer needed. Errors appear in the snapshot, and an explicit refresh rejects on failure. View disposal also releases its queries; retained handles and unfinished refreshes reject after that lifetime ends.

A missing row in a filtered or incomplete list does not prove that a session was deleted. Pending deletions can also hide rows before pagination metadata changes. Pass { sessionKey: row.key, agentId: row.agentId } to host.sessions.open, host.sessions.patch, host.sessions.runAction, and a dashboard's session prop. host.sessions.runAction(session, action) runs a session menu action as the Control UI's own session menu does: "split-right" opens the session beside the chat on screen, "copy-link" copies its shareable link, and "open-window" opens it in a new window. The host tells the person the outcome, such as a blocked popup. It resolves false without telling anyone when the view cannot split, so the plugin can open the session another way. Carry the identity from the returned row: a raw global key belongs to its queried agent, not the current selection or a card's assigned agent. Treat an unresolved or ambiguous search as unknown. Session action presentation updates when the roster changes, and actions check the current row again when invoked. Actions can inspect session.hasActiveRun; an absent value means activity is not yet known. host.components mounts host-owned dialogs, agent pickers, searchable select pickers, and session dashboards from plain props and DOM content. Each component returns update and dispose methods; the host retains permission checks, focus handling, and dashboard provider ownership. Use mountSelectPicker for a list of { value, label, description? } options, a selected value, an accessibleLabel, and an onSelect callback. With searchable: true, lists longer than eight options show a search field. The picker matches option labels, values, and descriptions.

Dock a conversation

Check host.dock before offering a dock action. From a mounted view, use context.host.dock.openSession({ sessionKey, agentId, label, context }) to open the named conversation alongside your plugin page. label supplies the dock tab title; the optional context is { page, detail? }, where page can be your plugin page id and detail contains string reference fields.

The host reuses the Home dock's placement controls, chat pane, drafts, and attachments. Page navigation keeps the conversation dock open; ending the plugin activation closes a dock that activation still owns. Use host.subscribe(...) to refresh your action when host.dock.openSessionKey changes. See the dock contract for replacement, visibility, access, and context limits.

Build and reload

package.json names the browser source:

{
  "apus": {
    "extensions": ["./dist/index.js"],
    "controlUi": "./src/control-ui.ts"
  }
}

apus plugins build bundles that source and its browser dependencies with the plugin's esbuild dev dependency. It writes immutable JavaScript and CSS under dist/control-ui/<content-hash>/, then publishes their paths in apus.plugin.json.controlUi. A failed build leaves the previous manifest and assets usable. plugins validate and plugins build --check detect stale source, assets, or generated metadata.

The build emits a JavaScript entry, optional CSS, and JavaScript chunks for lazy imports. The content hash covers the complete generation, including its chunks. CSS remains attached to the entry; loading a JavaScript chunk does not attach stylesheets. Embed other static assets in the bundle; arbitrary files are outside this build contract. Imports must be analyzable by esbuild: literal paths and supported glob imports work; unresolved dynamic imports, indirect require calls, and require.resolve are rejected. Each asset is limited to 4 MiB, with an 8 MiB and 128-asset limit for the whole plugin browser build.

Plugins with prebuilt browser bundles can omit package.json.apus.controlUi and declare the built entry and styles in apus.plugin.json.controlUi. Packing and serving include JavaScript and CSS dependencies under that entry's directory, including nested chunks and imported stylesheets, with the same byte limits. TypeScript sources, source maps, and hidden files are excluded. Keep all browser dependencies inside that directory; traversal is limited to eight nested directory levels and 128 entries, counting both files and directories.

After browser-only edits, rebuild the installed plugin and open Plugins → Advanced → Customize UI → Reload plugin UI as an administrator. The Gateway captures a fresh asset revision and notifies connected browsers. Asset loading or activation failures are reported in the UI customization controls; the previous working activation is retained when possible. Retry after correcting the plugin, or select Built-in to recover a replaced view.

Advertised asset revisions remain available for the lifetime of their backend plugin, including imports needed by an older tab or a retained working view. The shared cache holds at most 256 revisions and 64 MiB across all native plugins. When it fills, reload refuses the new build and preserves advertised assets. Restart the Gateway, then retry the reload.

On first activation, plugin pages and dashboard widgets show a loading state while the catalog, asset authorization, and initializer complete. Each plugin becomes available independently of other plugins that are still loading. A disconnected view waits for the Gateway connection before checking availability. Widgets show initialization failures with a Retry action.

Registrations and replacement selections made during activation publish together after initialization succeeds. Disposing a registration before that point also cancels its pending selection. Invalid selections reject the new activation without retiring the preceding working UI.

Selected replacements survive a successful revision when the contribution still exists for that surface. Unregistering the contribution, removing its plugin, or reconnecting clears the choice; reusing an ID does not restore it.

Custom element definitions belong to the browser document. If a plugin changes an existing custom element class, reload the browser tab as well, or use a new versioned tag name.

Backend changes use plugin update or Reload to replace the running plugin without restarting the Gateway. Browser reload does not replace backend services or change an already running agent's tool catalog.

Approve an agent-built artifact

After building and validating, produce an import archive:

apus plugins pack --root . --out ./draft-review.tgz --json

The receipt contains the absolute archive path, SHA-256 digest, plugin id, and plugin_activate_artifact request. Packing bundles backend dependencies, keeps the host apus imports external, and includes the manifest and compiled UI. The archive contains no install scripts or runtime package dependencies. It must have one backend entry; features that require separate runtime files need the normal reviewed package-install flow. Packing rejects backend references to import.meta, __dirname, __filename, and require.resolve, including those in bundled dependencies, because their original files and locations do not travel with the artifact. Import JSON or embed other resources in the backend build instead.

Backend imports must also be analyzable by esbuild. Direct CommonJS imports work, including Node builtins and the host SDK. Unresolved dynamic imports, indirect require calls, and prebundles with opaque runtime loaders are rejected. Provide an entry compiled without those loaders so packing can bundle its dependencies, or use the normal package-install flow.

The system agent can propose activation with that path and digest. Before approval, Apus verifies and retains the exact archive and inspects its declared capabilities and native UI presence without executing the plugin. Approved application uses those retained bytes through the managed plugin installer. Changing the source file while approval is pending cannot change what is installed. Existing install policy and capability checks still apply.

Artifact approval does not enable the Custom plugin UI lab. The installed backend can run with that setting off; its native browser UI remains gated.

Pending imports expire after one hour. Apus keeps at most eight pending archives of up to 32 MiB each and prunes expired or oldest imports when another proposal is prepared. An expired or evicted review requires a fresh proposal. Approved archives are retained separately as the install source, including when an installer error leaves the final installation outcome uncertain.

Artifact activation currently requires plugin configuration in the root config file without a root-level $include. For a plugins section containing only $include: "plugins.json5" (a single file under the config directory with no nested includes), use apus plugins install <archive> from a trusted shell. The regular installer also rejects root-level, nested, and external include layouts; adjust those layouts before installation.

Artifact activation also refuses to replace the plugin backing Apus's active inference route. Stop Apus and install that artifact from a trusted shell.

Gateway-hosted artifact activation waits for backend runtime application. Terminal or other hosts without a live Gateway lifecycle callback save the install and report the writer's follow-up; that result is not a runtime application receipt. Once the Gateway has applied the plugin, inspect plugins.controlUi.status to see activation reports from currently connected Control UI clients. A report names the plugin revision and either activated or failed; it is a browser activation receipt, not proof that every feature operation has been exercised. No connected browser means no browser activation receipt yet.

For the underlying manifest fields, see Plugin manifest. For install, update, and removal, see Manage plugins.

Shared Settings pages

Native pages can declare settings: { order: 30 } when registered with host.ui.registerPage. The host lists the page in its Settings sidebar and host.navigation.openPage / pageHref use the shared /settings/plugin?plugin=<owner>&id=<page> route, respecting the Control UI base path. The page uses the existing page capability and activation lifetime; removing the registration removes its Settings entry. Use a separate registered page for the workspace and for its settings.

A Settings page can declare settings.contextParams, an allowlist of work-page parameter names such as ["projectId", "conversationId"]. Settings sidebar links copy only those p.* values from the shell's last workspace location, and only when that location belongs to the same plugin. The link URL and click action use the same target. This keeps a work context available while navigating between plugin settings and shared settings; it does not add a context store or infer a session from the currently selected Chat. Omit the allowlist for app-wide plugin settings. These URL parameters must be non-secret references, never credentials or proof of access; operations still validate their target at the owning boundary.

Plugins must also carry their work parameters on explicit openPage transitions between work and Settings pages. Settings-only options such as section should not become work context. Without a prior workspace location, sidebar links open unbound Settings. A direct Settings deep link is not itself a remembered work location. Gate iframe-driven navigation on context.presented and the view's lifetime so a retained hidden frame cannot redirect the foreground page; let already initiated work settle under its existing execution owner.

host.navigation.openSettings(destination) opens the authoritative host page for profile, model-providers, appearance, or notifications. These destinations are closed, checked at the navigation boundary, and unavailable after activation ends. A plugin should link to these owners instead of copying app-wide preferences. Plugin-specific execution or media settings can remain on its own Settings page; sharing navigation does not copy credentials or imply a shared provider store.

The Apus Ami Design adapter projects the shared app locale onto Design's supported languages, falling back to English for unsupported languages. Design currently has no Vietnamese translation; adding that translation is future work. The shared language preference does not imply that all Design content is localized.