Skip to content

The plugin system

LoomWeaver is a plugin platform with no domain logic of its own. That means “how plugins are loaded, trusted and controlled” is the platform. This page describes it from the distribution’s point of view. It covers three things: the three ways a plugin can reach a running app, what the capability broker does in each case, and what the user can turn off.

For the plugin author’s view — what ctx offers and how to build a weaver — see authoring a weaver.

Three rungs, one contract

Every plugin implements the same Plugin interface and receives the same ctx. What differs is isolation and how it arrives:

Trusted Sandboxed Community-installed
Arrives via providePlugins() provideSandboxPlugins() the user, from a catalog
Runs in the app itself <iframe sandbox="allow-scripts"> <iframe sandbox="allow-scripts">
ctx is a direct object a Penpal RPC proxy a Penpal RPC proxy
Written in Angular anything anything
Grant comes from your composition root your composition root the install dialog
Decided at build time build time run time, by the user

The isolation ladder is deliberate: the transport changes, the broker does not. A capability check runs at exactly the same place for all three.

Trusted — composed at build time

// src/app/app.config.ts — in the providers array
provideCapabilityGrants({ notes: ['contributions', 'ui', 'navigation'] }),
...providePlugins(notesPlugin),

The plugin is a normal dependency of your distribution. It can register Angular components as surfaces, and it runs in your app’s JavaScript context — which is also the honest limit: a trusted plugin is not sandboxed. Compose only code you would ship yourself.

Sharing the context also means sharing globals the platform does not broker. The custom element registry is one of them. A trusted plugin can define its own element. Nothing can take that tag back for the lifetime of the document — not disabling the plugin, not uninstalling it. That is a documented escape hatch rather than a supported path. It is also one more reason the trusted rung is a review decision, not a default.

Sandboxed — an iframe over RPC

// src/app/app.config.ts — in the providers array
...provideSandboxPlugins({
id: 'charts',
entryUrl: '/charts/plugin.html',
capabilities: ['contributions', 'ui'],
}),

The entry document is loaded into a hidden <iframe sandbox="allow-scripts">. The sandbox has no allow-same-origin, so the plugin gets an opaque origin. It cannot touch your DOM, cookies or storage. It talks to the host over Penpal RPC. The host’s end of that channel is the very same broker object the trusted runtime uses. On the plugin’s side the whole activation is the handshake:

// inside /charts/plugin.html (or a script it loads):
const messenger = new Penpal.WindowMessenger({
remoteWindow: globalThis.parent,
allowedOrigins: ['*'], // opaque origin; isolation comes from the sandbox attribute
});
Penpal.connect({ messenger }).promise.then((ctx) =>
ctx.registerSurface({
id: 'charts.view',
title: 'Charts',
iframe: '/charts/view.html',
routable: { path: 'charts' },
}),
);

The complete worked example — both documents, the flat RPC ctx surface, receiving pushed state — is in authoring a weaver → the sandbox bootstrap; the scaffold_sandbox_plugin generator emits this exact layout (scaffolding).

entryUrl must be same-origin: you serve the plugin’s files yourself. That is what makes review a meaningful control. The plugin’s visible UI is a second iframe, called the surface. The host paints the design tokens into it, so a sandboxed plugin looks native without importing anything from you — see the sandbox UI kit.

Only data crosses an RPC boundary, so a sandboxed plugin reaches a subset of ctx:

Reaches the host Trusted only
registerSurface ({ iframe } or { container }, routable or docked) registerCommand, registerBarItem, registerRailItem
registerMenuItem, registerSettingsSection¹ contributeIcons, contributeTheme
navigateContent, openContentTab, keep/pin/unpin/closeContentTab, revealSurface ui beyond toast — dialogs, prompts, openMenu, openSettings
ui.toast ctx.host, ctx.activeContent, ctx.session²

¹ as data — the control kinds carry values, not callbacks, and the host owns the storage. ² the session is pushed into the plugin’s surface instead, if it was granted session.

The pattern behind the split is simple. Anything whose contract is a function cannot be serialised — run, onClose, a notification action. A sandboxed plugin therefore does that work itself, for instance drawing its own <lw-menu> at the cursor rather than asking the host to.

A sandboxed surface is not confined to a content tab. It may declare docks and appear as a sidebar view, or declare a container and host a nested tree of child surfaces; a docked surface has no address, so its channel’s navigate is a no-op with a development warning and its pushed tab is always empty. What the host pushes tells it where it is: instanceId (the pane or named instance) and params (route params, or the container’s :id for a container child). access is the one field the seam still rejects — a sandboxed surface gates itself from the pushed session state.

The retention protocol follows the same pattern. A surface that declares retain: 'always' is hidden in place rather than destroyed: no reload, no new handshake per tab switch. A collapsed sidebar and a closed pane are safe too, wherever the browser can move a node without detaching it (Chromium and Firefox today; WebKit rebuilds instead). A split, a drag into another pane and a minimise rebuild it everywhere, because moving an <iframe> the ordinary way reloads it. That is the one place where a sandboxed surface is weaker than a trusted one, and it is worth weighing before you choose the rung: a surface a user is likely to want beside something else pays for it. Nothing is lost that the surface has written to ctx.state. And instead of the trusted DirtySurface interface, the surface channel carries setDirty(true|false) plus an optional beforeClose() veto — see recipe 8.

Community-installed — the user decides

A distribution can offer a curated catalog; the user installs from it at runtime:

// src/app/app.config.ts — in the providers array
...providePluginCatalog('/plugins/catalog.json', { title: 'acme.store.title' }),

Installation is user-local and persisted through the settings store, so it follows the user the same way their other state does. Everything else is identical to the sandboxed rung: same iframe, same broker, same same-origin rule. The catalog lives on your origin, and you copy approved plugins into it. Operator review plus same-origin is the integrity boundary. That is why plugin signatures are not part of the model today.

The install dialog lists the capabilities the plugin declares, and agreeing is the grant — there is no separate grant map for installed plugins. Consequently an update that widens the declaration asks again, listing only what was added; an update that does not, applies silently.

See plugin store for the catalog schema.

Capabilities: default-deny

There are six coarse capabilities. They are exported as CAPABILITIES in canonical order, so a product building its own permissions UI iterates the list instead of hard-coding it. A plugin declares what it needs. The distribution grants. The effective set is the intersection. A declaration alone grants nothing, and a grant for something undeclared does nothing.

Capability Unlocks
contributions registerSurface / Command / BarItem / RailItem / SettingsSection / MenuItem, contributeIcons
ui ctx.ui.* — dialogs, toasts, settings, context menus
host ctx.host — version and update state
navigation driving and reading the content area, incl. ctx.activeContent
session ctx.session — login state and roles, for self-gating
theme ctx.contributeTheme — re-colouring the whole app

Using a surface you were not granted raises a CapabilityError rather than failing quietly, and the shell turns that into a toast offering to open the permissions settings. A missing grant is a misconfiguration, and misconfigurations should be loud.

Granularity is coarse on purpose. Splitting a capability later is compatible; merging two is not.

What the user controls

Three independent switches, all persisted and all reversible:

  • Revoke a capability — the plugin stays loaded, but that ctx surface starts refusing. It takes effect at the next call, so contributions already registered stay. Revocation works forward. Only capabilities that were granted can be revoked; a grant is never widened past the distribution. contributions is not revocable at runtime. It is checked at registration time, so turning it off after activation would change nothing.
  • Disable a plugin — the whole plugin is unloaded and its contributions disappear; re-enabling spawns it again. Live, without a reload.
  • Uninstall — only for community-installed plugins. Its settings are deliberately kept, so a reinstall picks up where the user left off.

The built-in Permissions and Plugin store settings sections expose all three. Your own front-end can drive the same state through CapabilityGrantService, PluginEnablementService and PluginInstallService — see host services.

You can also remove those sections entirely (provideShell({ omit: ['setting:shell.permissions'] })) if your product decides these are not the user’s call.

Lifecycle

Two runtimes implement the rungs behind the same abstraction: PluginRuntime for composed plugins and SandboxPluginRuntime for iframe ones. That is why a plugin’s lifecycle reads the same either way. Neither is something a distribution wires up — providePlugins and provideSandboxPlugins do that. The services in host services are the supported way to intervene.

activate(ctx) runs once when the plugin loads; whatever it registers returns a Disposable, and the runtime disposes all of them on deactivation — so disabling, uninstalling or updating a plugin leaves no orphaned chrome behind. A plugin that starts something of its own implements deactivate():

src/lib/plugin/charts.plugin.ts
import { Plugin } from '@loom/plugin-sdk';
export const chartsPlugin: Plugin = {
manifest: { id: 'charts', name: 'Charts', capabilities: ['contributions'] },
activate(ctx) {
ctx.registerRailItem({ id: 'charts.rail', rail: 'activity', icon: 'document',
title: 'charts.title', command: 'charts.open' }); // tracked — undone for you on deactivation
startPolling(); // your own resource: undo it yourself
},
deactivate() {
stopPolling();
},
};

Activation is resilient: a plugin that throws during activate is rolled back and logged, and the others still come up. One broken plugin cannot take the app with it.

A sandboxed plugin is re-spawned when its signature changes. The signature is the entry URL, the declared capabilities, the granted capabilities and the version. The version matters for updates at the same URL. Without it in the signature, replacing the files would leave the running iframe on the old code while the UI claimed it had updated. The browser re-fetches the entry document on respawn. So serve plugin files with revalidating cache headers, or the “update” hands the user a cached old build.

Contribution ids and collisions

Contributions are addressed by id, and registering an existing id replaces it. That is the mechanism behind distribution-level recomposition (override a default by re-registering it) and it applies to plugins too: a later contribution wins.

Plugin ids themselves are guarded: an installed plugin cannot claim the id of a composed one. Individual contribution ids are not guarded. An installed plugin can therefore replace a menu entry or a content route that something else registered. That is a deliberate consequence of the same-origin, operator-review boundary — what you copy into your catalog is code you have reviewed. If that trade does not fit your product, do not enable the runtime store.


Next: Backend integration — wiring your own backend behind the three seams. See also: authoring a weaver — the other side of this contract · host services — the services behind the management UI