Skip to content

Views & surfaces

Plugins ship UI in three shapes, from least to most powerful: declarative view specs that render a directory as a table or board with no code, ESM code viewers that render run-tree rows and page types with a real React component, and surfaces that claim a whole special-page route. All three run inside the same host-pinned, CSP-locked sandbox, so a plugin’s UI has DOM access but no remote script and no cross-origin exfiltration channel, and any crash degrades gracefully instead of taking the app down. This page builds on views and connects to page types.

The simplest plugin view is pure data. A view-spec element renders a dir as a table or board over the pages in a knowledge-base directory (kb.dir.pages), with filters, grouping, and column selection expressed as JSON. There is no code to review and nothing to sandbox: the first-party renderer interprets the spec, so a declarative view carries no new trust at install. This is the lane the app builder and most CRM-style dashboards use.

{ "kind": "dir", "layout": "board", "dir": "books", "groupBy": "status",
"columns": ["title", "author", "rating"] }

When a view needs real logic, a plugin ships a React component as plain ESM under views/ and declares it in viewers: [{ id, src, meta? }]. The host exposes only /plugins/<name@version>/views/*.js to browsers (an immutable cache, since version directories never change); the manifest and pages/ never ship to the client.

  1. The async viewer registry resolves an id

    When the SPA meets a run-tree row whose viewer id it does not recognize, the async viewer registry resolves the id to a URL through plugins.viewers and dynamically imports the bundle.

  2. Host libraries are pinned by an import map

    react, react/jsx-runtime, react-dom, and @subspace/ui resolve to the host’s copies through an import map injected into the page, so a viewer shares the app’s React instance instead of bundling its own. In dev the map points at host source; a build emits stable /plugin-host/*.js entries.

  3. The component receives a row

    A viewer is called with { row: { kind, tool?, title, args?, output? } } and returns UI.

A viewer entry’s meta is data only, not code: { step, tool?, title (a template like "search → {{args.q}}"), redact (JSON paths) }. The first-party interpreter compiles it and appends it to the run tree’s row-description resolution. Crucially, templates run on the redacted input, so a title cannot leak what redact removed (for example args.secret renders as [redacted]), and a plugin’s meta entry can never shadow a first-party binding.

A surface is a plugin-owned special page. Declaring surfaces: [{ slug, src, title? }] claims the route /p/:slug, rendered by a plugin ESM component from views/*.js. Installing registers each surface as an element page (kind element, elementType plugin-surface, root-level, provenance-tagged with metadata.surface {src, title?}), so the server treats it like a core special page: it is routable at /p/:slug, listed in the sidebar’s special section, and carries page ACLs exactly like /inbox or /agents.

The SPA resolves the component through plugins.surfaces (enabled plugins only) and imports it via the same import-map machinery as viewers; the component receives { page: { id, title, slug } }.

The three shapes compose through page types. A page type can name a defaultView, and pages in that type’s namespace (its owning directory slug) auto-render the declared component between the page title and the outline, with zero SPA patching. listPageTypes resolves defaultView to a defaultViewUrl; the SPA takes the first namespace-matching declaration that has a view, imports it, and renders it with { page: { id, title, slug, metadata } } inside an error boundary, so a crash or a failed load simply renders nothing rather than breaking the page. This is how a plugin gives a whole directory of pages a consistent header card without touching core UI code.