Sandboxed surfaces
This is a guide, not the contract. What the platform guarantees is specified under
openspec/specs/. For this page:plugin-sandbox·surfaces. Where this page and a specification disagree, the specification is right, and that is a defect in this page: change the behaviour there, then explain it here.
A sandboxed surface is an iframe the host mounts in isolation, which is how a plugin written in any
technology contributes a view. This page declares one, routable or docked, shows how a sandboxed
plugin gets its ctx, and introduces the frame UI kit its surfaces paint with. It closes with what a
plugin needs once a store distributes it: an honest capability declaration, a monotonic version and
data-only settings.
Presentation: component or iframe
A surface renders either from an Angular component (the trusted, in-process form, see
The content area) or from an iframe URL. That choice is independent of whether a
URL points at the surface:
ctx.registerSurface({ id: 'report', title: 'report.title', iframe: '/my-plugin/report.html', routable: { path: 'report/:id' } });The iframe form is how a sandboxed, non-Angular plugin contributes a content view: the host mounts
the URL in an isolated <iframe sandbox> (own JS context, no host access). A plain string, it serialises
across the ctx-RPC boundary, unlike an Angular class. For a sandboxed plugin the URL must be
same-origin (served by the distribution, like the plugin itself). A foreign origin, javascript: or
data: URL is rejected at the RPC seam, so an untrusted plugin cannot point the host chrome wherever it
likes. A sandboxed surface may be docked (docks) as well as routable, and it may declare a
container. The seam rejects access instead of silently dropping it, because a sandboxed surface gates
itself from the session state the host pushes. A trusted plugin may use the same iframe form to
embed a foreign origin on purpose (a dashboard, a docs site, a video). There the distribution decides
what may be framed through its CSP frame-src, which the browser enforces. The tab strip works
identically, since it only sees the path. This is the first sandbox rung; see
building a distribution for wiring a sandboxed
plugin, and Capabilities and trust for the isolation model.
Trusted in-process weavers keep using component.
An iframe surface is a first-class content view: the host gives it a small two-way channel (Penpal). The
host pushes the active UI language, the active sub-route segment, the preview state, and the
resolved light/dark theme. A sandboxed iframe has none of the host’s --lw-* tokens, so the host
also pushes the full resolved --lw-* token values plus the root font size. Apply them with
LwFrame.applySurfaceState (see The frame UI kit). With this the surface
can localise, reflect its own level-2 sub-tabs and match the theme without reloading;
the surface can call back navigate('<path>') to drive the router, so its sub-tabs are shareable and
browser-navigable. Surface navigation is confined to the route’s own tab root (its sub-routes).
The surface channel carries no capability grant, so anything beyond the plugin’s own view goes through
the plugin (logic) channel’s ctx.navigateContent (the navigation capability). The channel is
opt-in: a static iframe that never connects just renders. (A worked example ships in the testbed’s
sandbox-rpc plugin.)
A docked iframe surface
The same iframe form works at a dock, so a surface that is not routable can still be an iframe:
ctx.registerSurface({ id: 'notes.frame', title: 'notes.frame.title', docks: ['right-panel'], iframe: '/my-plugin/panel.html' });It receives the same pushed state, with two differences that follow from having no address. Its
tab is always empty, because there is no tab root and no sub-route to reflect. And navigate is a
no-op with a development warning rather than an error: the channel is only safe because it is
confined to the surface’s own tab root, and a docked surface has none. To move the user somewhere, go
through the plugin channel’s ctx.navigateContent (the navigation grant). The pushed state also
carries an instanceId: the pane or named instance this mount belongs to, so two mounts of the same
surface can keep their own per-instance data apart. It carries params as well: the route params for a
routable surface, and the container’s :id for a container child, which is how an iframe child
learns which container it is inside. A component child injects the same values off its route.
The sandbox bootstrap — how a sandboxed plugin gets ctx
A sandboxed plugin is two documents, and knowing which is which is half the model:
- the entry (logic) document is the
entryUrlthe distribution composes or the catalogue lists. The host loads it in a hidden sandboxed iframe; it never renders. Its whole job is the Penpal handshake: connect to the parent, receivectx, make your registrations. - the view (surface) document(s) are the
iframe:URL(s) yourregisterSurfacecalls point at. These are the visible surfaces; they load the frame UI kit below and receive pushed state (render) instead of holding actx.
A complete, working entry document (this is the in-repo sandbox-rpc plugin, trimmed):
<!-- plugin.html — the entryUrl document; loads the transport, then your logic --><!doctype html><meta charset="utf-8" /><script src="/frame-kit/penpal.global.js"></script><script src="/my-plugin/plugin.js"></script>// plugin.js — handshake with the host, then register through the RPC ctxconst messenger = new Penpal.WindowMessenger({ remoteWindow: globalThis.parent, allowedOrigins: ['*'], // the sandboxed iframe has an opaque origin; isolation comes from the sandbox attribute});
Penpal.connect({ messenger }) .promise.then((ctx) => Promise.all([ ctx.toast({ message: 'Hello from the sandbox', kind: 'success', timeoutMs: 4000 }), ctx.registerSurface({ id: 'my-plugin.view', title: 'My view', iframe: '/my-plugin/view.html', // same-origin — the visible surface document routable: { path: 'my-plugin' }, }), ]), ) .catch((error) => console.error('[my-plugin] activation failed', error));The RPC ctx is flat: unlike the in-process ctx the other how-to pages use, there is no ctx.ui
facade. The endpoints are registerSurface · registerMenuItem · registerSettingsSection ·
navigateContent · openContentTab / keepContentTab / pinContentTab / unpinContentTab /
closeContentTab · revealSurface · toast. Every call runs through the same default-deny
capability broker as a trusted plugin. An ungranted capability rejects, so .catch and degrade.
(Generate this whole layout with nx g @loomweaver/devkit:frame-plugin or the MCP
scaffold_frame_plugin, described in scaffolding.)
The frame UI kit
A sandboxed surface (the view document) does not import @loomweaver/shell. Instead the
distribution serves the frame UI kit (@loomweaver/frame-kit) same-origin under the well-known path
/frame-kit/, and your surface references it:
<link rel="stylesheet" href="/frame-kit/lw-frame.css" /><script src="/frame-kit/penpal.global.js"></script><script src="/frame-kit/lw-elements.global.js"></script>lw-elements.global.jsdefines the whole<lw-*>element family (lw-tooltip·lw-select/lw-option·lw-menu/lw-menu-item·lw-button·lw-markdown·lw-icon·lw-progress-ring) with the built-in icon set seeded, the same behaviour source the host runs. It also exposesglobalThis.LwFrame:setIcon(name, svg)/removeIcon/hasIconfor plugin-own icons (sanitised), andapplySurfaceState(state). Call that one from yourrenderhandler and the pushed tokens, root font size and light/dark theme are applied for you.lw-frame.cssis the host’s.lw-*class contract compiled to plain CSS onvar(--lw-*)(with light/dark fallbacks for the blink before the first push), so there is no hand-kept CSS mirror.penpal.global.jsis the RPC transport (globalThis.Penpal).
The kit is versioned with the distribution’s shell: you reference it, you do not vendor it, so
your paint always matches the host the plugin actually runs in. For development outside a
distribution, copy the files from the @loomweaver/frame-kit npm package.
Writing the surface in TypeScript
The package ships dist/lw-frame.d.ts, a description of the global the script installs. It is an
ambient declaration rather than a module, because you load the kit with a <script> tag and never
import it. So you reference it once and LwFrame is typed everywhere:
{ "compilerOptions": { "types": ["@loomweaver/frame-kit"] } }A wrong method name or a wrong argument is then reported while you write it, instead of failing as
undefined is not a function inside a frame you cannot easily inspect. Nothing about the surface
changes: plain HTML with a script tag stays exactly as valid, and the declaration is emitted from the
same source the bundle is built from, so the two cannot disagree. What it describes:
LwFrameApiis the shape ofglobalThis.LwFrameitself: the icon methods,applySurfaceState,connectStateand thestatestore.LwSurfaceRenderStateis what the host pushes to yourrenderhandler: theme, design tokens, root font size and the product’s replacement glyphs. Hand it toapplySurfaceStateunchanged.LwStateApiis the surface half ofctx.state:watch(key)for a handle, andapply(...)to feed the host’sstateChangedpush in from yourmethods.LwStateHandleis one key’s handle:value·loaded·set·clear·dispose, plusonChangeso you can re-render. It mirrors what a trusted plugin holds, so the store reads the same on both rungs of the isolation ladder.LwStateHostis the set of host methods your Penpal connection exposes for the store. You pass the resolved connection toconnectState; you do not call these yourself.
Distributing through a plugin store
A sandboxed plugin needs nothing extra to be store-installable: a distribution lists it in its
plugin catalogue (id, entry URL, display metadata) and users install
it at runtime. Two things matter to you as the author. First, declare your capabilities honestly.
The install dialog shows exactly the declared set to the user, and accepting grants exactly that. An
undeclared capability is never granted; a declared one the user can still revoke later. Second, expect
your files to be copied into the product’s own origin. The store is same-origin by design, so
getting listed means passing the operator’s review, not hosting anything yourself. Ship a
README.md with your plugin: the operator copies it into the store next to your files and the
store’s detail pane renders it in-app. It is your plugin’s storefront page. (The testbed’s
store-full plugin is the worked example.)
Shipping a new version
Updates ride on the catalogue’s version field: the operator raises it together with your files, and
every installed user is offered an update that swaps the entry and respawns your plugin live. Two
consequences for you. Keep the version monotonic: segments are compared numerically, 1.10.0
beats 1.9.0, and only a strictly newer version is offered. And a version which declares
capabilities the user never consented to asks for consent again, listing exactly the added ones, so
growing your declaration is safe but never silent.
Settings: declare data, the host renders and stores
A sandboxed plugin can contribute a settings section over RPC, but in a data-only form. Each row
declares a control kind and its default value instead of value()/set() callbacks, which cannot
cross the wire. The host renders the controls. It also owns the storage (user-local
through the distribution’s settings store). It pushes the current values back by calling the
settingsChanged(sectionId, values) method you expose on your RPC channel. That call comes once after
registration with the restored state, then on every change, including a change made in another
browser window. Plugin settings ride the shell’s cross-tab sync, so every window’s copy stays
current; there is nothing to wire. Labels may be plain literals (you cannot contribute
translations). The host decides where your section appears: an installed plugin’s section lands
under the “Community plugins” nav group, a composed frame plugin’s under “App plugins”. The
group is never your choice, so nothing can masquerade as the app.
ctx.registerSettingsSection({ id: 'prefs', title: 'My plugin', rows: [ { id: 'greeting', label: 'Greeting', control: { kind: 'text', value: 'Hello' } }, { id: 'loud', label: 'Shout', control: { kind: 'toggle', value: false } }, // also: { kind: 'select', value, options: [{ value, label }] } · { kind: 'slider', value, min?, max?, step? } ],});The host calls the settingsChanged method you expose on your side of the
bootstrap handshake:
Penpal.connect({ messenger, methods: { settingsChanged(sectionId, values) { // called once with the restored state after registration, then on every change }, },});Where next
- Frame plugins: how a distribution composes a sandboxed plugin and serves the kit.
- Plugin store: the catalogue, runtime install and updates on the operator’s side.
- Your plugin’s own store: the store both documents of a sandboxed plugin share.