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 arrayprovideCapabilityGrants({ 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
ctxsurface 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.contributionsis 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():
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