Building plugins

Feature plugins

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 OpenClaw 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:

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

Restart the Gateway and reload connected browser tabs after changing this setting. Disabling it prevents custom native UI from loading; it 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 OpenClaw remains available for enabled bundled plugins, including Workboard. OpenClaw 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.

bash
openclaw plugins init draft-review --name "Draft Review" --type featurecd draft-reviewnpm installnpm run buildnpm run validateopenclaw plugins install .openclaw gateway restart

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 choose Draft composer in the UI customization controls. Choose Built-in to restore a view. Replacement selection belongs to the current browser runtime; it is not a persistent configuration setting.

The project has three public SDK imports:

Import Purpose
openclaw/plugin-sdk/feature-contract Shared operation schemas, typed clients, and event subscriptions; browser safe.
openclaw/plugin-sdk/feature-plugin Register backend implementations as session actions, optional agent tools, and command adapters.
openclaw/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

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:

typescript
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.
registerWidget Native dashboard widget views.
registerReplacement workspace, session-list, composer, transcript, or tool-result.

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.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.

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.

The host also exposes session and agent snapshots and operations, plugin page navigation, authenticated requests, and subscriptions. 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, 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.

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, and a dashboard's session prop. 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, 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.

Build and reload

package.json names the browser source:

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

openclaw 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 openclaw.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 one self-contained JavaScript entry and optional CSS. Embed other static assets in the bundle; arbitrary files and split lazy chunks 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 limit for the whole plugin browser build.

Plugins with prebuilt browser bundles can omit package.json.openclaw.controlUi and declare the built entry and styles in openclaw.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 use 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 still use the normal plugin update and Gateway restart. 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:

bash
openclaw 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 openclaw 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, OpenClaw 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. OpenClaw 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 openclaw 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 OpenClaw's active inference route. Stop OpenClaw and install that artifact from a trusted shell.

After the Gateway restarts, 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.

Was this useful?
On this page

On this page