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.