@aistrike-dev/ui-shell-sdk
v3.5.0
Published
Shell-to-MFE contract for the AiStrike UI shell: manifest, lifecycle, storage conventions, event bus, Vite plugin, conformance kit.
Keywords
Readme
@aistrike-dev/ui-shell-sdk
The SDK for building a micro front-end (MFE) that runs in the AiStrike UI shell: the types and helpers that implement the shell contract (manifest, storage keys, bus topics), the Vite plugin that builds an MFE and the conformance kit that checks one. Bundle it into your MFE; nothing is shared at runtime. Design: docs/superpowers/specs/2026-10-02-ui-shell-design.md in the ui-shell repository.
Build an MFE
// vite.config.ts
import react from '@vitejs/plugin-react';
import { mfe } from '@aistrike-dev/ui-shell-sdk/vite';
import { defineConfig } from 'vite';
export default defineConfig({ plugins: [react(), mfe({ id: 'detections', devPort: 5201 })] });devPort sets the dev server's port. In dev, an imported asset's URL resolves against the module that imports it, as in the build, so it points at your server on whichever host the browser used, not at the shell's. Styles must come from Emotion: CSS files imported by the entry are not injected, since there is no HTML in the build.
vite build writes dist/entry.js, dist/chunks/*, dist/assets/* and dist/manifest.json, all with relative URLs, so the folder runs from any origin. Deploy it to <origin>/ui/<version>/ and point <origin>/ui/latest/manifest.json at it with a temporary, uncached redirect.
The entry plus everything it imports statically must stay under 20 KB gzipped (entryBudgetBytes to change). Put the UI behind import() inside mount.
entityRenderers (published path → source, e.g. { 'renderers/alert.js': 'src/renderers/alert.ts' }) builds each entity renderer at its path, outside the budget, as settingsModules does for custom settings elements; a path may be published by only one of the two. In dev the path serves the source.
Page-tool descriptors live in src/tools.ts (tools to move it, false to skip the check), one exported ToolDescriptor constant per tool. vite build loads the file and fails on any export that is not a valid descriptor (a description over 1024 characters or a descriptor over 4096 characters of JSON included) and on a name exported twice. The file may import types only (import type { ToolDescriptor } from '@aistrike-dev/ui-shell-sdk'): the check loads it with Vite's runnerImport, which leaves packages to Node, so a value import may not load and is reported as src/tools.ts could not be loaded. Without the file, the domain has no page tools.
Write the entry
// src/entry.ts
import type { Activate } from '@aistrike-dev/ui-shell-sdk';
export const activate: Activate = (ctx) => {
let dispose: (() => void) | undefined;
let gen = 0; // bumps on every mount/unmount; a stale import bails out
return {
nav: [{ label: 'Rules', path: '/rules' }], // shown under your domain in the shell's navigation
settings: null, // or a settings descriptor
entities: [], // kinds of thing people can reference; see Entities
commands: [], // global commands; see The command menu
async mount(container) {
gen += 1;
const my = gen;
dispose?.();
dispose = undefined;
const { mountUi } = await import('./ui.js'); // React, your router, your design system
if (my !== gen) { return; } // unmounted or remounted during the import
dispose = mountUi(container, ctx); // use ctx.domain.route as the router basename
},
unmount() { gen += 1; dispose?.(); dispose = undefined; }, // leave the container empty
};
};The page-wide tab id lives in the global __aistrikeTabId, which every SDK version in a page must agree on.
An activation states every field: a list is [] for none and settings is null for none. The shell fails a domain whose activation lacks a field, with a reason naming each one, and ignores the fields earlier builds carried (assistant, pane, pageCommands) with one warning. The assistant domain also exports activateSession (an ActivateSession: (ctx) => SessionContribution), whose mount(container, host) mounts one view, a session or the sessions list, reports to the shell through its SessionHost and returns a SessionHandle (docs/superpowers/specs/2026-11-06-assistant-session-pane-design.md, 4.1). It replaces activatePane, which is removed: the shell no longer loads it, and the conformance kit names a leftover activatePane export as a problem.
Inside the MFE: create your own React root; wrap it in your own error boundary; create your own Emotion cache with key: ctx.domain.id; do not render CssBaseline (the shell does). Create the Emotion cache once per MFE module, not per mount: a new cache on every mount injects its styles again each time.
Read shared state
import { getAccessToken, getClaims, getTenant, getThemeMode, getDomain } from '@aistrike-dev/ui-shell-sdk';
const token = getAccessToken(); // read at request time; never cache
const tenant = getTenant(); // this tab's tenant
const other = getDomain('hunting'); // { apiBase, uiBase, version, state } for cross-domain callsPreferences
@aistrike-dev/ui-shell-sdk/preferences is the one client the shell, every MFE and the design system use. Preferences live in web storage, one JSON object per namespace and scope, under aistrike.prefs.<scope>.<namespace>; an MFE's namespace is its domain id.
import { createPreferences } from '@aistrike-dev/ui-shell-sdk/preferences';
const prefs = createPreferences({ namespace: ctx.domain.id });
prefs.set('density', 'compact'); // scope 'user' (default): synced by the shell
prefs.set('sidebar.collapsed', true, { scope: 'device' }); // this browser only
prefs.get('density'); // tab → device → user
prefs.resolve(['DataGrid[list].columns', 'DataGrid.columns']); // instance before type default, then scope
const stop = prefs.subscribe(({ name }) => { /* re-read */ });Every change is announced on the bus as preferences.<namespace>.changed (browser scope, payload { namespace, scope, name }, no value); copies of the client in other MFEs and other tabs re-read storage. Only the shell talks to the preferences API: it syncs the user buckets. React: @aistrike-dev/ui-shell-sdk/react exports PreferencesProvider, usePreferencesClient and usePreference.
Publish and subscribe
import { SHELL_TOPICS, createBus, type TenantChangedPayload } from '@aistrike-dev/ui-shell-sdk';
const bus = createBus({ source: 'detections' });
bus.subscribe<TenantChangedPayload>(SHELL_TOPICS.tenantChanged, () => refetch());
bus.publish('detections.rule.saved', { id }, { scope: 'browser' }); // topics start with your domain idscope is required on every publish; SHELL_TOPICS holds the shell's topic names, with a payload type for each.
Scopes: tab (this tab), browser (every tab), user (reserved for the websocket bridge). Payloads are hints to refetch, not data of record. Additive changes only; a breaking change is a new topic.
Entities
An entity type is a kind of thing people can reference: the assistant's @ mentions, its reference chips and the command menu's search all read the same declaration, at the activation's root.
// src/entry.ts
entities: [{ type: 'alert', label: 'Alert', icon: 'alert', renderer: 'renderers/alert.js', search: '/api/alerts/search' }],renderer: a module path relative to the domain'suiBase, built by the Vite plugin'sentityRenderers, ornull. The module exportsmount(el, { ref, details, presentation })and returns its cleanup; draw text withtextContent, neverinnerHTML. Presentations areinline,popoverandcard; one that does not know a presentation draws its card.loadEntityRenderer(url)loads a module, with a 5 s timeout.search: a path under/api/on the domain's origin that takes?query=and answers{ entities: EntityRef[] }, ornullwhen the type is not searchable. A path that could leave the domain's API (.., a host, a query, a fragment, a backslash, an encoded dot) is refused and the type dropped. The shell calls it with the person's token, not the page.
The command menu
The shell's command bar (⌘K / Ctrl+K, rebindable in Settings → Keyboard shortcuts) opens one menu for the whole page. A domain puts things in it two ways, and is searched from it a third.
// src/entry.ts: global commands, whatever page is open. The shell calls `run` in its own document.
export const activate: Activate = (ctx) => ({
commands: [{
id: 'go-cases', title: 'Go to Cases', keywords: ['investigations'], icon: null,
run: () => navigate('/investigations/cases'),
}],
...
});A global command states { id, title, keywords, icon, run }, with keywords: [] and icon: null for none. run may return a promise; if it throws, the shell shows <title>: <error message> in a notice. There is no href. An activation carries at most 100 commands, and a list with an invalid command is refused whole.
// Inside a mounted page: what can be done right now, offered by a component with its whole descriptor (see Page context).
// The handler runs in the page; the shell invokes it over the bus.
useCommand({ id: 'dismiss', title: 'Dismiss selected alerts', keywords: [], icon: null }, () => dismiss(selection));Search uses the entity types' search paths (see Entities). As the person types, the shell calls the path of every active domain's searchable types in parallel, with the person's token, and lists each answer as it arrives, at most 10 per type. An entity is listed when it has an href.
Page context
A routed page tells the assistant and the command menu what is on screen, and what it can do, through one
PageController, created when the page mounts. Nothing about page tools or page commands is declared at activation: a
component offers each with its whole descriptor while it is mounted. Page-tool descriptors are constants in
src/tools.ts, which vite build checks (see Build an MFE); a page command's descriptor is written where it is offered
and checked by the controller. Import the hooks from @aistrike-dev/ui-shell-sdk/react.
// src/tools.ts: types only.
import type { ToolDescriptor } from '@aistrike-dev/ui-shell-sdk';
export const selectAlerts: ToolDescriptor = {
name: 'select_alerts', description: 'Select alerts in the table on screen, by id', effect: 'read',
input: { type: 'object', properties: { ids: { type: 'array', items: { type: 'string' } } }, required: ['ids'] },
};
export const clearSelection: ToolDescriptor = {
name: 'clear_selection', description: 'Clear the alert selection', input: { type: 'object', properties: {} }, effect: 'read',
};function AlertsRoute({ ctx }: { ctx: ActivationContext }): ReactElement {
const controller = usePageController(() => new PageController({ domainId: ctx.domain.id }), []);
return <Page controller={controller}><Alerts /></Page>;
}
function Alerts(): ReactElement {
const ask = useAsk();
useTitle('Needs triage');
useContext({
summary: '96 alerts, sorted by agent score',
entities: rows,
selection,
suggestions: [
{ text: 'Select the cluster', expects: [{ tool: 'select_alerts' }] },
...(selection.length > 0 ? [
{ text: `Explain ${selection.map(md).join(', ')}`, expects: [{ tool: 'get_alert' }], details: detailsOf(selection) },
{ text: 'Clear the selection', expects: [{ tool: 'clear_selection' }] },
] : []),
],
});
useTool(selectAlerts, (input) => select(input));
useTool(clearSelection, () => clear(), { relevant: selection.length > 0 });
useCommand({ id: 'select-all', title: 'Select every alert shown', keywords: ['all'], icon: null }, () => selectAll());
// ...
}useContext(object | null, { key? }): this component's part of the page's context. The page merges every mounted part;nullor unmounting withdraws it.useTool(descriptor, handler, { relevant? }),useCommand(descriptor, handler, { relevant? }): offer a page tool or command with its descriptor whilerelevantis true, the default, and runhandlerwhen it is called. Handlers always run the latest render's function; a new descriptor object with the same content keeps the registration, a changed one registers again. A page command's descriptor states every field:{ id, title, keywords, icon }, withkeywords: []andicon: nullfor none, and nohref.- The controller refuses, with a console error, a descriptor that is not valid, and one unlike the descriptor already
offered under its name: the first offered wins until every handler that offered it is gone. Equal descriptors share
the name, and the most recently registered handler runs. A page offers at most 20 page tools and 20 page commands
(
MAX_PAGE_TOOLS,MAX_PAGE_COMMANDS); one past the cap is left out of the report, with a console error, until another goes. A handler registered withrelevant: falsedoes not count. useAsk(): asks the assistant about refs ({ prompt?, refs? }); does nothing outside aPage.useTitle(title),setTitle(title): the page's title,document.title, which the shell reads.navigate(path, { title })sets it for SDK navigations. When the person moves to another domain, the shell restores its default title after unmounting the old domain and before mounting yours, so set your title on mount if you want one.- Outside React, call
PageControllerdirectly:contribute(),handleTool(descriptor, …),handleCommand(descriptor, …),ask(),dispose().
Check your build
// src/conformance.test.ts, under Vitest's happy-dom environment
import { runConformance } from '@aistrike-dev/ui-shell-sdk/conformance';
const result = await runConformance({ distDir: 'dist', id: 'detections' });
expect(result.ok).toBe(true);Local development
Add shellDevHost() from @aistrike-dev/ui-shell-dev/vite after mfe(); vite then serves the shell at / and your MFE at /ui/latest/ from one origin with HMR.
