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. A sandboxed surface’s declaration carries no actions: the seam keeps id, title, icon, badge,
order, instanceable, retain, saveOn, closable, padded, the address and the docking fields
and drops the rest, so ctx.updateSurfaceAction has nothing there to
replace. Entries registered to the slot <surface id>/actions, by any plugin and by the sandboxed
plugin itself through registerMenuItem, are still drawn in its header. 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. Its surface endpoints are registerSurface · retitleSurface · updateSurfaceBadge ·
setChildShown · registerMenuItem · registerToolbar · registerSettingsSection. Its content endpoints are
navigateContent · openContentTab / keepContentTab / pinContentTab / unpinContentTab /
closeContentTab / updateContentTab · revealSurface. The rest are invokeCommand / invocableCommands · toast,
and stateWatch / stateSet / stateClear / stateUnwatch for plugin state.
Every call runs through the same default-deny
capability broker as a trusted plugin. An ungranted capability rejects, so .catch and degrade.
A sandboxed plugin’s toasts are bounded. A toast always leaves by itself, after 15 seconds at the
latest: timeoutMs: 0, a longer lifetime and an error without one are all shortened to that. The
plugin holds at most three toasts at once, shown and waiting together, and a further toast call
rejects until one of them has left. A toast raised again and counted on one that is still there is
not a further one, and neither is a toast that replaces one of the plugin’s own by id. Neither
keeps it longer: the count and the content follow, and the toast leaves when it would have left
anyway. While a toast of a sandboxed plugin is still waiting for a place, a toast of the application
or of a composed plugin is shown before it. An action is a function and does not cross; an icon crosses as its name. A
sandboxed plugin has no host dialog, so a message that has to last belongs in the plugin’s own
surface, or as a badge on its tab through updateSurfaceBadge.
(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 with the built-in icon set seeded, from the same element list and behaviour source the host runs. The family islw-icon·lw-tooltip·lw-select/lw-option·lw-nav-tree/lw-nav-group/lw-nav-item·lw-menu/lw-menu-item·lw-markdown·lw-button·lw-progress-ring·lw-spinner·lw-toolbar. 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,connectToolbarsand thetoolbarsit feeds,capture, andsurfaceMethods, which adds the platform’s own methods to yours.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.LwSlotHost,LwSlotView,LwSlotEntryandLwSlotHeaderare the same things for the toolbars a surface places, described in a toolbar inside the surface. The first is the host methodsconnectToolbarstakes, the second what the workbench pushes for a watched slot or an opened menu, the third one entry of it, the fourth an opened menu’s heading.LwSurfaceCapture/LwSurfaceCaptureRequestare what the workbench asks for and what you answer with when it draws a picture of itself, described next.
A toolbar inside the surface
A surface may place <lw-toolbar> in its own markup, exactly as a plugin in the page does, and
other plugins fill it from the page. The element is in the kit; what it needs from the workbench
comes over the surface channel, as data:
<lw-toolbar menu="my-plugin.view/toolbar" context='{"record":"frame"}' size="sm"></lw-toolbar>// plugin.js — the entry document owns the slot, so it is declared and others may fill itctx.registerToolbar({ slot: 'my-plugin.view/toolbar', title: 'my-plugin.toolbar' });
// view.js — once the surface's connection resolves, hand it to the kit; surfaceMethods already// carries the slotChanged push the workbench answers withconnection.promise.then((host) => { LwFrame.connectState(host); LwFrame.connectToolbars(host);});From then on every <lw-toolbar> in the surface is watched on the workbench. The workbench
resolves the slot against the element’s context, narrows it to what the session may run, words
it for the language in effect and pushes the result. The surface redraws when entries, commands,
the session or the words change, without asking again. Activating an entry is reported back and
runs in the workbench with the context, through the same place every trigger runs through, so a
refusal or a failure is handled as it is for any other trigger.
The toolbar is announced by the title the slot was registered with, worded the same way. A label
the surface sets on the element is kept as its name instead; inside the frame it is taken as
written, so word it in the surface’s own language.
An entry that opens a menu says so with opensMenu. Activating it asks the workbench for that
entry’s menu, and the answer is the menu the page would open for the same entry: matched against
the entry’s context, with the heading the entry declares. The kit draws it inside the surface,
beside the entry, with the same drawing the page uses.
An entry that names a variant is pushed with it, already narrowed: primary arrives only where
the slot’s owner asked for it, so the surface draws what it is told and cannot promote an entry
another plugin contributed. The kit draws each variant as the same toolbar in the page draws it.
What does not come across: a cell another plugin registered with registerToolbarCell. It is code,
and code does not cross the boundary; the surface gets the declarative entries alone. The context
you give the element must be named values only (strings, numbers, booleans); anything else is
refused rather than matched.
A picture of the workbench, and your part in it
A product can ask the workbench to draw a picture of what the user is seeing, usually for a fault report. The browser will not photograph a tab without asking the user’s permission and making them pick a target every time, so the picture is rendered from the document instead. That stops at your surface: it is isolated, so nothing outside it may read what it drew. The workbench therefore asks your surface to draw itself.
Expose the platform’s own methods beside yours and this happens for you:
Penpal.connect({ messenger, methods: LwFrame.surfaceMethods({ render(state) { LwFrame.applySurfaceState(state); }, }),});surfaceMethods returns your methods plus the ones the workbench may call on any surface. Write it
this way and requests added to the platform later arrive without you editing the plugin again. The
platform’s own names win over yours, so you cannot shadow one by accident. A surface that does not
expose them is not broken. It simply appears on the picture as an area saying its content is not
included.
The renderer is fetched from beside the kit the first time a picture is asked for, so a surface that is never captured never pays for it.
Keeping something off the picture
Mark any element and its content stays off every picture; the area says so instead:
<p data-lw-withhold>Account 8812 3345 9901</p>It is read at the moment a picture is made, so setting or clearing it takes effect at once, and your surface is never told that it is being pictured. There is no moment at which it could behave differently because it is being observed, which is the whole point of a fault report.
You cannot refuse to be pictured, and marking your root does nothing. The user is already looking at those pixels and can photograph the screen by other means. A refusal would withhold nothing from them, while making the feature useless for the case it exists for: reporting a fault in a plugin. What you can do is keep particular content out, which is what the marking is for.
What is not on the picture at all: content scrolled out of sight inside a region, and any surface the user opened in its own window. The picture is what the user could see. And because it is drawn rather than photographed, it is a faithful depiction rather than an exact one, and a surface that paints by means that cannot be re-rendered may differ from the screen.
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.