@package-system

App surfaces

Widgets, cards, and docks — the renderer and trust matrix for package UI.

Every UI contribution a package makes lives under neuralis.app.surfaces[] in the manifest. A surface declares kind: "widget" | "card" | "dock" plus a renderer, and validation enforces a strict renderer-by-trust matrix at load time — what a surface is allowed to execute follows from who shipped the package, never from what the manifest claims.

Renderer × trust matrix

RendererFirst-partyTrustedUntrustedNotes
directyesnonoCompiled React mounted via the host component registry — first-party builtins only, attached at runtime from the package's prebuilt UI module
iframeyesyesrelative-onlyEvery sandboxed render path. Absolute URLs require trusted/first-party; untrusted packages must point at a relative asset under their own app/
mcpimportedimportednoImported/hosted external surfaces (widgets only)

Two real render paths exist: direct and iframe (mcp is a marker for imported external surfaces, never a native authoring target). The former inline-html and wasm-ui renderer values — and the fallback field that accompanied wasm-ui — were removed from the contract: validation rejects them with a migration error (ship the HTML asset and declare renderer iframe with a relative url), and a leftover legacy fallback key is inert and warned about. A self-contained HTML page is the default shape and is fully supported: ship it under its canonical surface root and declare it as an iframe surface with a relative url. A multi-file build output works too — same declaration plus assetMode: "bundle", see asset modes.

Passing validation is not the same as being drawn

A widget renders when it is direct with a resolvable registry component, or iframe with a url; a direct import the registry cannot resolve falls through to a placeholder reading "This widget cannot be rendered by this host." Cards render through iframe (plus first-party direct) on the same rule. If you are shipping a project package, use iframe with a relative url under your own canonical surface root — it is the only path available to you. And "renders" depends on the entry's asset mode: with the default self-contained, a surface whose stylesheet or script is a separate file loads, and loads blank. Declare assetMode: "bundle" and those files are served.

Trust upgrades do not unlock direct for project packages — the React lane below is reserved for host-assigned first-party builtins; iframe is the project-package path. And a direct declaration is only half the wiring: the component is registered by the package's own install entry, which reaches the workspace through its runtime UI module. A direct surface the manifest declares and no module registered renders the placeholder ("Loading…" while a runtime module is still attaching).

The surface asset contract

An iframe surface with a relative url is asset-backed: the package ships an HTML entry and the host serves it. Two rules govern it, and both fail quietly if you ignore them.

1. The entry lives in its own canonical root

app/surfaces/{kind}/{surfaceId}/…

{kind} is the surface kind (widget or card) and {surfaceId} is the surface's exact declared identity — a widget's type, a card's id — used verbatim as a single path segment. An entry at the app/ root, in another surface's directory, in an ancestor directory, or reachable through a .. segment is a load-time validation error, and a contribution-validation error fails the whole package, not just the surface.

The optional app/shared/ directory is declared package-public: any visible surface of the package may read it, so nothing feature-private belongs there. A surface entry may never live under it.

2. The entry has an asset mode

The entry is navigated as a sandboxed document with an opaque origin (allow-scripts allow-forms, deliberately no allow-same-origin), so it cannot reach host DOM, cookies, or storage — and every subresource request it makes leaves that opaque origin carrying no session cookie. What follows from that is decided by one optional field on the render block, assetMode.

self-contained — the default

Inline your CSS and JavaScript; ship images and fonts as data: URIs. The platform does not serve the entry's subresource requests. Nothing blocks you and nothing warns at runtime: a linked stylesheet simply never arrives and the surface renders unstyled. app/shared/ is unreachable from such an entry for the same reason. This mode works on every deployment and in every trust tier.

bundle — one document plus its own files

"component": {
  "renderer": "iframe",
  "url": "./app/surfaces/widget/my_workspace/index.html",
  "assetMode": "bundle"
}

The platform injects a <base href> into the entry response, pointing at a short-lived session-free lane, and serves the entry's sibling files from there. A bundler's dist/ built with a relative base copies in and works: separate stylesheets, code-split chunks, dynamic import(), a runtime fetch('./data.json'), real font and image files, source maps. app/shared/ becomes reachable too.

Five things to know before choosing it:

  • The declaration is a request, not a switch. It is honoured only for a relative iframe entry that is an HTML document (.html/.htm). A direct renderer, an absolute url, a non-HTML entry or an unrecognised value is served self-contained instead, with a warning from the validator and the pre-flight rather than an error.
  • References resolve against the entry's own directory, in URL space. app/shared/ is reachable as ../shared/… only when the entry sits at the first level of its surface root; an entry at sub/index.html needs ../../shared/…. A reference in the entry that resolves outside the surface root or app/shared/ is a pre-flight error, and the lane enforces the same bound at request time on the real path — for every request, including the ones a stylesheet or a script makes, which the pre-flight never sees: a symlink inside the surface root whose target lives outside it is refused, not followed. Package your surface with real files.
  • One document, not one site. The lane never serves HTML, so <a href="page2.html"> does not work. Multi-file yes, multi-page no.
  • Budgets: 64 files and 8 MiB per surface. The lane is no-store, so every render refetches the whole set; the pre-flight warns past 1 MiB. The per-file 2 MiB cap applies in both modes.
  • Still no remote origins. The CSP names none, so a CDN script or a hosted font is blocked before the request leaves the browser.

Load-time validation sees the declaration, never the files: the validator receives the manifest object with no package root and no filesystem, so it can warn that an assetMode will be ignored but can never inspect what an entry references. Whether a document actually obeys the mode it declared is checked by the packaging pre-flight shipped with the package-creation skill — and in bundle mode, enforced again by the lane at request time.

The served URL carries no identity

The host mints a short-lived, refcounted asset scope and navigates an opaque URL containing no package id, no project id, no agent id, no token and no query string. location.href, the referrer and the network path are all identity-free — there is no projectId for the frame to read back. Every request for the entry re-checks the caller's full, live visibility, so a role, feature, trust, disable or uninstall change revokes the scope immediately. A bundle surface's subresource lane has no caller to re-check — it carries no session by design — so it re-verifies the package on every request instead (an update, an uninstall or a load failure cuts it off at once). A caller-side loss is caught by the entry lane on its next request, or by the frame's own 60-second heartbeat — either one revokes the whole scope, and both lanes stop together. It is not left to expire on idle.

Entry responses are Cache-Control: private, no-store, Referrer-Policy: no-referrer, capped at 2 MiB apiece, restricted to a static-asset extension allowlist (.html, .css, .js, images, fonts, …; TypeScript/JSX source is never served), and carry a strict CSP: default-src 'none', script-src/style-src 'self' 'unsafe-inline' (an entry's own inline script and style are the point), img-src 'self' data: blob:, font-src 'self' data:, connect-src 'self', form-action 'none', base-uri 'self', frame-ancestors 'self' and sandbox allow-scripts allow-forms. That last directive costs a surface nothing — it mirrors the sandbox attribute the frame already carries — but it means the document is forced into an opaque origin even if it is ever opened outside the workspace frame, so a stray link to it can never run script with the app's own origin.

The subresource lane a bundle surface loads from applies the same extension allowlist minus HTML documents and the same per-file cap, is also no-store, and carries a CSP of its own that denies script to anything navigated to it directly — it exists to feed one already-authorized frame, not to be browsed. .svg is still served, and an SVG is a scriptable document; that CSP is exactly what disarms it.

What reaches the frame

SurfaceSandbox / originData it receives
Widget (any trust)allow-scripts allow-forms, serialized origin nullnothing — there is no host→widget message channel
Widget, first-party, absolute https: URL on a different origin than the host's (no credentials in the URL)allow-same-origin (plus popups and downloads) added, so the external application runs on its own origin; never top navigationnothing — the host shares no identity with it; the admin dashboard lists every such origin
Card, host-served or same-originsame opaque sandboxthe bounded one-way card-data feed; the privileged bridge is refused fail-closed
Card, trusted absolute remote, no bridge opt-insame opaque sandboxthe bounded one-way card-data feed
Card, trusted absolute remote, explicit bridge opt-inallow-same-origin added, only for an HTTPS origin that differs from the host'sthe feed plus the minimal window.neuralis bridge

A widget that blocks on an incoming message renders forever-empty. The host's theme variables do not cascade into any frame either — ship your own light/dark styling inside the document.

The privileged window.neuralis bridge is opt-in via bridge.enabled, restricted to trusted/first-party packages, re-validated at runtime against the exact frame origin, and intentionally minimal: submitApproval and requestData only.

Direct renderer binding (builtin-class)

A direct surface's React code reaches the workspace through ONE lane, reserved for host-assigned first-party builtins: a runtime UI module. The package exports an install…HostComponents({ registry, port }) function that registers each component.import string in the host component registry (one module may register several surfaces).

Runtime module. The package declares app.module and ships one prebuilt browser module, built with neuralis-build ui from app/host.tsx into dist/app/. The host serves it from a content-hashed, same-origin URL to signed-in members, imports it when the workspace loads and calls its install exports — no host rebuild, no host code change. React and the platform client library are read from the host's own instances (a build that would bundle a copy fails), so the module stays small and the workspace never runs two Reacts. A package can also publish modules of its own to other first-party UI modules (app.module.provides), so two packages share one instance instead of each bundling a copy. Utility classes come from ONE stylesheet the host compiles across every first-party package's app/ sources, so a package's utility can never out-cascade the host's own responsive variants — keep all UI sources (and any className-bearing code) under app/. A module loads once per page; a new build is picked up after the package is reloaded, on the next page load. The host refuses a module built for a different host API version or React major (or a React-reading build that recorded none) and the surface shows the placeholder, so rebuild the module after a platform upgrade.

The port argument (WorkspaceHostPort) carries shared workspace state plus host-owned component slots (port.components — e.g. UserAvatar), so install entries never need extra host-specific parameters. It also carries an optional onClientStateReset subscription: a package that keeps its own client-side store subscribes once at install time, and the dock's clean (broom) control — at widget, agent or project level — tells it to drop that scope's state without the host ever naming the package.

Two refresh verbs sit on it and they are not interchangeable. reload() rebuilds the whole workspace and discards every project's arranged widget layouts; reloadAgents() re-reads only the active project's agent list and keeps those layouts. A package that created or removed an agent wants the second one.

Widgets

{
  "kind": "widget",
  "id": "my-pkg.widget",
  "type": "my_workspace",
  "title": "My Workspace",
  "icon": "layout-dashboard",
  "component": {
    "renderer": "iframe",
    "url": "./app/surfaces/widget/my_workspace/index.html"
  },
  "requires": { "features": ["my-pkg.read"] },
  "layout": { "defaultWidth": 480, "defaultHeight": 640, "minWidth": 320, "minHeight": 240 },
  "chrome": { "mode": "frameless" },
  "open": { "singleton": true, "defaultTitle": "My Workspace" }
}
Field groupWhat it controls
componentrenderer plus url (iframe) or import (direct registry key)
requiresfeatures (visibility gate — see Features and access) and tools the widget depends on
layoutDefault and minimum dimensions
chrome.modetoolbar (host title bar), frameless (edge-to-edge, hover-revealed controls — recommended for iframe widgets that draw their own chrome), floating (always-on overlay controls)
chrome.transparentBaseline transparency the widget mounts at. When a widget does not set this, it mounts at the user's workspace default widget transparency (a Layout-settings preference). The user can cycle it, and the level changes the panel's CSS containment — so a position: fixed overlay inside a widget must createPortal(…, document.body) to anchor and clip the same way at every level (position: absolute overlays need no portal)
chrome.userTransparencyfalse locks the baseline and hides the user-facing transparency cycle; the host also force-locks it for untrusted packages
chrome.sortKeyWorkspace tile ordering
defaultOpenOpens the widget automatically once the agent runtime is ready
open.singletonAt most one instance

All three chrome modes render the same compact control strip — modes differ only in placement and visibility, never in bespoke per-mode buttons.

The strip's close (X) button minimizes the widget into its dock item, it does not destroy it: the widget's instance, layout slot and component state are preserved (and survive a page refresh), and clicking the dock item restores it. One documented exception: a widget rendered as an <iframe> (component.renderer: "iframe") is detached from the document while minimized, so the browser discards the frame's document — on restore it reloads from the same URL and any state held INSIDE the frame is lost. The same applies when the user switches stage mode. If iframe-widget state must survive minimize, persist it server-side or in the frame's own storage rather than in memory. Permanently closing a widget is done from the dock item's clean (broom) control, shown whenever the widget has open instances — it resets the widget's client-side state, including a package's own client store when the package subscribed to the port's onClientStateReset (the Files widget's tree, tabs and caches, for example); data your widget persisted server-side is untouched. Widget authors should not assume the close button discards their widget's state.

Dock entries

Dock icons come from separate kind: "dock" surfaces (a widget's own dock.* block is metadata only):

{
  "kind": "dock",
  "id": "my-pkg.open",
  "label": "My Pkg",
  "icon": "layout-dashboard",
  "position": 30,
  "section": "top",
  "action": { "type": "open-widget", "widget": "my_workspace", "title": "My Workspace" }
}

Actions are either open-widget or a named action. Lower position sorts higher; requires.features gates visibility the same way as widgets.

Every icon — on a widget, a dock entry or a card, a team member's appearance.icon, a workflow template — names an entry of ONE platform icon library: about 270 curated lucide icons in categories, exported as ICON_LIBRARY from @neuralis/package-system/icons. Write the PascalCase name (LayoutDashboard) or its lucide-kebab form (layout-dashboard). An unknown name still loads, with a load warning, and renders a fallback glyph.

Agent icons resolve through the same library. First-party agent surfaces use resolveAgentIcon and resolveAgentColor from @neuralis/package-system/client: configured config.appearance values win, an icon outside the library falls back to Bot, and missing colors use one deterministic palette by agent index. Human profile images/icons are owned by UserAvatar and are not interpreted as agent appearance.

Cards

Cards render inline in chat, matched to tool results via match.tools[] (paired with the tool's x-neuralis.ui.cardType — see Tools). Cards reject the mcp renderer and add a match.state discriminator for user-blocking situations:

  • interaction-required — the model is waiting on a user answer. The card body may render its own input controls, but the final submission routes through the host's persisted interaction-response API.
  • approval-required — a permission-style approval. The card body is preview-only: the host wraps it with approve/deny chrome, the runtime bridge filters embedded approve/deny buttons, and the validator warns when an approval card declares its own rendered URL.
{
  "kind": "card",
  "id": "my-result.card",
  "type": "my_result",
  "match": { "tools": ["my_query"] },
  "render": {
    "renderer": "iframe",
    "url": "./app/surfaces/card/my-result.card/index.html"
  }
}

While the tool call is still streaming, its input reaches a first-party direct card as a partial JSON string, not the parsed object. Never re-parse that growing buffer on every delta — the chat package exports streaming helpers for exactly this: previewInput (a bounded input preview), extractStreamingField (stream one named string field progressively, from the settled object or the partial buffer alike), and parseSettledObject (a constant-time completeness check before the one real parse). Sandboxed iframe cards need none of this — the one-way card-data channel below already delivers the bounded streaming input.

Row height — what the timeline reserves before your card renders

The chat timeline is virtualized: it needs an initial height for a row it has never measured, and reserving the wrong amount is what makes a list jump when a card appears. That initial height used to come from a host-side table that knew only the first-party cards; every other card shared one generic constant, which was measured wrong by as much as 579px.

A card can now declare its own. A first-party card registered from your package's host entrypoint passes an estimateHeight model alongside its renderer and mount policy: given the tool payload, its status, the row's remembered expand state and how wide the panel currently is, it returns the row height it will render at, and the timeline uses that instead of a guess. These rules make it safe:

  • return the card's own height, without the surrounding row gap;
  • keep it a module-level function, not a fresh closure per call — the registry compares it by identity to decide whether a re-registration changed anything;
  • keep it cheap and pure. It runs inside the virtualizer's measurement arithmetic, for every unmeasured row, on every offset rebuild. If it parses the payload, memoize by the tool reference together with every input the result depends on — the status it was computed at and the wrap width. The tool object is updated in place as a pending call settles, so a reference-only memo keeps serving the pending-era height after the tool completes; a memo that forgets the width keeps serving the previous panel size after a resize. A model that throws, returns nonsense, or falls outside a sane range is discarded in favour of the default rather than being trusted;
  • round up when unsure, and account for the expanded state. Each row sits directly below the previous one's reserved height, so guessing too high leaves a gap that disappears on the first real measurement, while guessing too low draws the next row on top of yours. If your card can be expanded by the user, model that height too — reporting the collapsed size for an expanded row is the one mistake that reliably produces visible overlap;
  • scale your characters-per-line constants by the width factor the model receives, because whether you rounded up at all depends on how wide the panel is. A chat panel is not one width — it is split, resized, docked and widened — and a constant calibrated at one width falls into the under-reserve half of the previous rule at every narrower one. Measured on a real row, the same content rendered 1251px in a 350px column and 614px at 977px against a fixed 792px reservation: a harmless gap at the wide end, a 459px shortfall at the narrow one, from the same constants. The factor is a ratio against the width the platform's own estimates were authored at, so multiply your constant by it through the exported helper rather than deriving your own formula from the number. It is optional and defaults to no scaling, so a card that ignores it behaves exactly as it did before the input existed.

Declaring nothing is a valid choice, so a card imported from elsewhere needs no changes: a registered card without a model keeps the generic default constant. A tool whose card type has no registration at all does even better — the timeline renders it with the built-in fallback card and automatically estimates it from the same input/result content that card will actually show. A card with a genuinely unpredictable height — one whose content sizes itself at runtime — is usually better off saying nothing than committing to a constant.

An iframe card declared purely in the manifest cannot supply a function — it declares the numbers instead, with a height block on the card surface:

{
  "kind": "card",
  "id": "example_result.card",
  "type": "example_result",
  "render": { "renderer": "iframe", "url": "./app/surfaces/card/example_result.card/index.html" },
  "height": { "compact": 192, "expanded": 432 }
}
  • compact (required) is the row height in layout px at first paint / collapsed — first view is the branch the estimate exists for.
  • expanded (optional, defaults to compact) is the height when the user has expanded the row.

The platform compiles the block into the same kind of model a first-party card registers, so all the height rules above apply unchanged — with one structural exception: two constants cannot react to the panel width, so this lane is width-blind by construction and never receives the width factor. That is a property of declaring numbers, not a gap to be closed, and it is the thing to weigh when choosing a lane: declare shell numbers (chrome plus a fixed frame), which do not wrap. A card whose height tracks its own text is the case that wants a registered model. The block otherwise carries the same postures as the composer block: a structurally invalid height is a manifest load error that sinks the package; a height on a direct renderer is ignored with a warning (a direct card passes estimateHeight in its own host registration); and every estimate — declared or coded — is clamped to the platform's sane range, so the manifest carries no bounds of its own. Declare the real shell numbers (measure your rendered card) or declare nothing: an undeclared card keeps the generic default, exactly as before the field existed.

Composer aggregation (Changes & Actions)

Above the chat composer sits a collapsible Changes & Actions panel — the per-conversation aggregation of tool activity: a Changes tab (one row per changed file, with +N/−M line stats and click-to-open), a Todos tab, an Actions tab, and a Delegates tab, plus a post-stream summary chip listing the files a turn touched. Which tab a card's activity lands on — and how the Changes tab and summary read its results — is declared by the card itself, through the same registration that carries its renderer and height model. The aggregation surfaces iterate the card registry; nothing in the host hardcodes a card type (the built-in filesystem card declares its own contract from its own package, the same way yours does).

First-party direct cards pass a composer contract alongside estimateHeight in their host-entrypoint registration:

  • tab — a builtin tab id (changes / todos / actions / delegates) or a custom tab object { id, label, icon?, color? }. A custom tab appears after the builtins and lists your card's activity chips; a custom id that collides with a builtin is coerced to that builtin.
  • badge?(tool) — optional override over the generic chip derivation (label / tooltip / open-path). The generic derivation remains the base, so declaring nothing is a valid choice — a contract-less card lands on the Actions tab with a generic chip, exactly as before.
  • changes?(tool) — the changes-lane extractor: the changed paths (plus an optional real line diff) a settled call produced. One extractor feeds both the Changes tab and the post-stream summary. Extractor calls are fail-soft: a throwing extractor degrades to the generic chip and never breaks the turn.

Like the height model, the contract must be a module-level stable reference — the registry compares it by identity on re-registration. There is no per-card status field: settled-status derivation stays host-owned.

Manifest-declared iframe cards — packages that ship no JS — get a declarative subset on the card surface:

{
  "kind": "card",
  "id": "my-result.card",
  "type": "my_result",
  "match": { "tools": ["my_query"] },
  "render": { "renderer": "iframe", "url": "./app/surfaces/card/my-result.card/index.html" },
  "composer": { "tab": "changes", "badgeField": "label", "pathField": "uri" }
}
  • tab targets existing builtin tabs only — a manifest can never create a new tab (custom tabs are a first-party direct-registration capability).
  • badgeField names a top-level structuredContent string field used as the chip label; pathField names a top-level field (string or string array) carrying the changed path(s). The host compiles these into the same generic extractors the direct lane writes by hand. No dot-paths and no diff field — the declarative surface is deliberately minimal.

An invalid composer block is a validation error at package load (the package fails, like any other invalid contribution). A composer declared on a direct-renderer card is a warning and is ignored: the direct lane's contract has one owner — the install-file registration.

The one-way card-data channel

A sandboxed iframe card — and only a card — receives its tool's data over a strictly one-way host→frame message:

  1. The entry announces itself with a card-ready message from an inline script. The host accepts it only from the exact frame window and the exact expected origin.
  2. The host answers with one full snapshot per document generation, then monotonic-revision updates, and only when the payload actually changed.
  3. The payload carries the tool's name, input, status, result and structuredContent — and nothing else. No conversation, user, project, agent or tool-call id; no pending interaction, token, credential, or action capability. Because the frame's origin is opaque the host must broadcast, which is exactly why the payload carries no identity.
  4. It is bounded: 131,072 bytes serialized in total and 16,384 characters per string, with an explicit truncation marker wherever a cap cut real data. Cards render a summary; the full result stays where the user opens it deliberately.
  5. The frame cannot answer with data and cannot request a host operation on this path.

The status the frame receives — and the status the card header paints — is derived, not the raw runtime flag: a finished call whose payload carries no explicit status still settles, while a payload that declares its own pending: true or status: "pending" keeps the card running. Report unfinished work with one of those two keys; any other bespoke shape ({ "done": false }) reads as completed.

The channel is owner-gated: only a card bound to its own package's tool receives the real input and result. A card type matching a foreign package's tool, or one no owner claims, receives a safe generic placeholder instead — this is not a cross-package data channel.

Stream pacing, retention and the host's single multiplexed realtime hub are host-owned. A card should not open its own event stream or run its own scheduler.

What validation rejects

validateContribution runs at package load; a failing surface fails the package. The common rejections:

  • renderer: "direct" on a non-first-party package.
  • An absolute iframe URL on an untrusted package.
  • bridge.enabled on a package below trusted.
  • iframe without a url.
  • mcp as a card renderer, or mcp widgets below trusted.
  • The removed renderers inline-html and wasm-ui — rejected with a migration error: ship the HTML asset and declare renderer iframe with a relative url. A leftover legacy fallback key is a warning, not an error.
  • A relative entry outside its own app/surfaces/{kind}/{surfaceId}/ root: an app/ root entry, another surface's root, an ancestor directory, an entry under app/shared/, a ../%-escaped path, a surface id that is not a safe path segment, or two roots that collide after case-folding.
  • Duplicate widget type, dock id, or card id within the same package. Uniqueness is not checked across packages: two packages declaring the same widget type load without complaint and one shadows the other — for a duplicate card type the host logs a warning, but nothing in the manifest refuses the declaration — so namespace your type with your package id.
  • An invalid card match.state (anything other than the two values above).
  • An invalid card composer block: a non-object value, a tab outside the four builtin tabs, an empty or non-string badgeField/pathField, or an unknown key. Two surfaces declaring one card type with different composer blocks are a profile conflict, same as a renderer mismatch.
  • Missing required identity fields (id, widget type, dock id).

A package that omits access.trust is treated as untrusted, with a warning. Validation behavior for the rest of the manifest is covered in Testing and validation; the complete worked example of all three kinds lives in the contract examples — example-builtin carries all three, including the second widget that demonstrates assetMode: "bundle".

On this page