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
| Renderer | First-party | Trusted | Untrusted | Notes |
|---|---|---|---|---|
direct | yes | no | no | Compiled React mounted via the host component registry — first-party builtins only, attached at runtime from the package's prebuilt UI module |
iframe | yes | yes | relative-only | Every sandboxed render path. Absolute URLs require trusted/first-party; untrusted packages must point at a relative asset under their own app/ |
mcp | imported | imported | no | Imported/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
iframeentry that is an HTML document (.html/.htm). Adirectrenderer, 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 atsub/index.htmlneeds../../shared/…. A reference in the entry that resolves outside the surface root orapp/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
| Surface | Sandbox / origin | Data it receives |
|---|---|---|
| Widget (any trust) | allow-scripts allow-forms, serialized origin null | nothing — 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 navigation | nothing — the host shares no identity with it; the admin dashboard lists every such origin |
| Card, host-served or same-origin | same opaque sandbox | the bounded one-way card-data feed; the privileged bridge is refused fail-closed |
| Card, trusted absolute remote, no bridge opt-in | same opaque sandbox | the bounded one-way card-data feed |
| Card, trusted absolute remote, explicit bridge opt-in | allow-same-origin added, only for an HTTPS origin that differs from the host's | the 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 group | What it controls |
|---|---|
component | renderer plus url (iframe) or import (direct registry key) |
requires | features (visibility gate — see Features and access) and tools the widget depends on |
layout | Default and minimum dimensions |
chrome.mode | toolbar (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.transparent | Baseline 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.userTransparency | false locks the baseline and hides the user-facing transparency cycle; the host also force-locks it for untrusted packages |
chrome.sortKey | Workspace tile ordering |
defaultOpen | Opens the widget automatically once the agent runtime is ready |
open.singleton | At 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 tocompact) 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" }
}tabtargets existing builtin tabs only — a manifest can never create a new tab (custom tabs are a first-party direct-registration capability).badgeFieldnames a top-levelstructuredContentstring field used as the chip label;pathFieldnames 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:
- The entry announces itself with a
card-readymessage from an inline script. The host accepts it only from the exact frame window and the exact expected origin. - The host answers with one full snapshot per document generation, then monotonic-revision updates, and only when the payload actually changed.
- The payload carries the tool's
name,input,status,resultandstructuredContent— 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. - 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.
- 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.enabledon a package below trusted.iframewithout aurl.mcpas a card renderer, ormcpwidgets below trusted.- The removed renderers
inline-htmlandwasm-ui— rejected with a migration error: ship the HTML asset and declare rendereriframewith a relative url. A leftover legacyfallbackkey is a warning, not an error. - A relative entry outside its own
app/surfaces/{kind}/{surfaceId}/root: anapp/root entry, another surface's root, an ancestor directory, an entry underapp/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, dockid, or cardidwithin the same package. Uniqueness is not checked across packages: two packages declaring the same widgettypeload without complaint and one shadows the other — for a duplicate cardtypethe host logs a warning, but nothing in the manifest refuses the declaration — so namespace yourtypewith your package id. - An invalid card
match.state(anything other than the two values above). - An invalid card
composerblock: a non-object value, ataboutside the four builtin tabs, an empty or non-stringbadgeField/pathField, or an unknown key. Two surfaces declaring one cardtypewith differentcomposerblocks are a profile conflict, same as a renderer mismatch. - Missing required identity fields (
id, widgettype, dockid).
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".
Runtime stack and substitution tokens
What packages and skills can know about where they run, and the tokens resolved at activation.
Skill launcher
One reusable affordance any package drops into its UI to hand a skill to the agent — it prefills the chat composer exactly like a slash pick, never sends, and derives its own visibility so it can never advertise a skill the caller cannot run.