@rise-x/apps-sdk
v0.11.0
Published
SDK for building federated apps on top of the Rise-X Diana shell. Includes a CLI for scaffolding new apps.
Readme
@rise-x/apps-sdk
SDK for building federated apps on top of the Rise-X Diana shell.
- Runtime hooks/accessors —
useShellUser,useShellEnvironment,useShellNavigate,getShellApi,getShellApiV4,getShellAi. - Connectors —
@rise-x/apps-sdk/connectorsexposes typed, higher-level API wrappers (flows,work,assets,agents) so apps don't hand-roll multi-step API calls. - Lifecycle hooks —
onInstall,onUpdate,onUninstallthat apps export from a federated./lifecyclemodule; the shell invokes them best-effort. - Standalone-dev mock —
createMockShellfor running an app outside the shell. - Shell-side installer —
@rise-x/apps-sdk/shellexposesinstallShellBridgefor the host to wire the bridge. - Scaffolder CLI —
npx @rise-x/apps-sdk init <name>to create a new app in the current directory.
Quick start (scaffold)
npx @rise-x/apps-sdk init my-thing
cd my-thing
pnpm startCLI flags
| Flag | Default | Meaning |
| --- | --- | --- |
| --pm=<npm\|yarn\|pnpm> | auto-detected | package manager used for install |
| --port=<n> | 5101 | dev-server port in the generated app |
| --skip-install | off | skip <pm> install after scaffolding |
| --json | off | emit { path, slug, scope, pkgName, port, pm } on stdout |
Design assets (static HTML mocks)
The package ships what an agent needs to build static single-file HTML design mocks without repo access:
build/ui/styles.css— the compiled design-system stylesheet, Inter font embedded, Tailwind preflight included — inline it into the mock. The preflight matters: a standalone HTML file has no host to establishbox-sizing: border-box, and without it everyw-fullcontrol with horizontal padding renders wider than its container.build/ui/demo.html— the Shell design-system demo page, pre-rendered to static HTML: ground-truth markup for every component, generated at build time fromShell/src/components/__demo__. Fully self-contained — it inlinesstyles.cssabove, the same file you inline into your mock, so markup copied out of the demo renders in your mock exactly as it renders there. Utilities you invent for the mock's own scaffolding are a different matter: only classes used by the design system or by the demo page have rules, so write mock layout as plain CSS. Specimens styled by CSS outside the design system (the map's leaflet sheet, the Ask Diana animations) render unstyled in the demo and in mocks.build/ui/classes.json— the exact class string for every variant of every variant-bearing component, generated from the components' own cva functions and merged the way the component merges them. Use it for a combination the demo doesn't happen to render (adefaultbutton at sizesm) instead of splicing fragments from two specimens. Each entry names itsdimensions, and keys join their values in that order:button.classes["default|sm"]. Entries are named after the cva function, not the file it lives in —trendandconfidence, notstatisticandai.template/src/App.tsx— the canonical app layout (left-rail chrome + screens); scaffolded apps start from it, and design mocks follow the same composition.
Navigating demo.html (for agents)
The file is pretty-printed and starts with a navigation guide plus a JSON
table of contents (<script type="application/json" id="demo-toc">) listing
every section, screen, and the data-slot components each section
demonstrates. Read the first ~80 lines for the TOC, then grep:
| To find | Grep for |
| --- | --- |
| a section (component family / pattern) | data-section="<id>" |
| a full screen (app layout, AI chat, dashboard, ...) | data-screen-panel="<id>" |
| one component's real markup | data-slot="<name>" |
Component props and variants live in the shipped types —
build/ui/components/<name>.d.ts, named to match the data-slot prefixes
(in the rise-x-app monorepo, the sources are packages/ui/src/components/).
Use in app code
import {
useShellUser,
useShellEnvironment,
useShellNavigate,
getShellApiV4,
} from '@rise-x/apps-sdk';
const user = useShellUser();
const env = useShellEnvironment();
const navigate = useShellNavigate();
const apps = getShellApiV4('apps');
const work = getShellApiV4('work');UI components (@rise-x/apps-sdk/ui)
The Rise-X design system (shadcn components on the shell's Tailwind theme) is re-exported for apps:
import { Button, Dialog, DialogContent, Input, cn } from '@rise-x/apps-sdk/ui';App layout (AppFrame / AppRail / AppContent)
The root layout contract, scaffolded into every new app:
<AppFrame mobileNav="tabs">
<AppRail>{/* Nav / NavItem rail */}</AppRail>
<AppContent>{/* screens; the app's ONE scroll container */}</AppContent>
</AppFrame>The next-gen shell hands the app a full-height region that extends under its
floating top bar and publishes the bar's height as --topbar-h on the
document. AppRail stays fixed on the left and starts below the bar;
AppContent owns the scroll and slides content behind the bar's blur.
Both pad with var(--topbar-h, 0px), so in hosts without a floating bar
(the classic shell, standalone dev) the frame degrades to a plain
rail-plus-content layout. Never hardcode a bar height and never add a second
page-level scroller.
The frame is responsive by default, and it measures itself, not the window:
AppFrame is a CSS container, so the layout answers to the region the app was
given (the shell's chrome takes space the app never gets). Below the frame's
2xl container width (672px) it stacks and NavSection headers hide.
Being a container also makes the frame the containing block for position:
fixed descendants, in every mode: a fixed action bar or FAB you render inside
AppFrame pins to the app's region, not to the browser window. That is
usually what you want. For something that genuinely belongs to the window,
portal it to document.body the way the kit's Dialog, Sheet and Popover
already do.
mobileNav picks what the rail becomes at that width:
| Value | Narrow rail |
| --- | --- |
| "strip" (default) | A horizontal, scrollable nav strip under the top bar. |
| "tabs" | A bottom tab bar, the native phone pattern — icon over label, one equal share each, safe-area padding. The scaffold sets this. |
In tabs mode an app with more than five nav items shows the first four and
a More tab that opens the rest in a bottom sheet; the wide rail still lists
everything. Pass moreLabel on AppRail to localise the label. Don't hand-roll
a tab bar, and don't write your own breakpoints around the frame; compose inside
it.
Rendering an AppFrame anywhere in the app is the signal that the app owns
its layout: the next-gen shell detects it and hands over the full-bleed
region. Apps without one are compensated — the host pads and scrolls the
region itself — so pre-frame bundles keep working, minus the fixed rail.
How it works: the SDK ships the typings; the runtime is the shell's own
copy of @rise-x/ui, delivered through the Module Federation share scope —
exactly how apps already consume React. That is deliberate: the components are
styled by the shell's compiled Tailwind CSS, so only the shell's copy renders
correctly, and there is never a second React/component instance.
Requirements in the app:
It must be a Module Federation share. The preset does this for you:
'@rise-x/ui': { singleton: true, requiredVersion: false, import: false },Federated, components render from the shell's copy. The preset's standalone branch aliases
@rise-x/uito the SDK's own compiled bundle (./ui/standalone) instead — a self-contained snapshot with its CSS self-injected, so components render for real with no shell. It's a snapshot, not the source of truth: verify in a host (or deployed) before release.
MapView
The one component with a real library behind it. It renders a Leaflet basemap and nothing else — markers, routes and heat layers are yours to add. Leaflet is fetched in its own chunk on first mount, so apps that never show a map pay nothing for it.
onReady hands back the map and the Leaflet module it was built from. Build
layers off that L: the app then needs no leaflet dependency of its own, and
a second copy of the library never meets the first one at map.addLayer.
Leaflet's own chrome is restyled to the kit — the zoom cluster is a grouped
ghost control, tooltips match TooltipContent, popups match PopoverContent,
and markers are the design system's ringed pin rather than Leaflet's blue PNG.
All of it reads from the theme tokens, so it follows dark mode and an ecosystem
rebrand without any work in the app. Tiles default to tiles="auto", which
switches between the light and dark basemap live with the shell's theme.
import { MapView, mapPin } from '@rise-x/apps-sdk/ui';
<MapView
center={[51.92, 4.48]}
zoom={9}
className="h-[320px] rounded-lg border border-border"
onReady={(map, L) => {
// Already the branded pin — no icon needed.
L.marker([51.92, 4.48]).addTo(map).bindTooltip('Rotterdam');
// tone: 'brand' | 'success' | 'warning' | 'error' | 'info'
L.marker([52.01, 4.36], { icon: L.divIcon(mapPin({ tone: 'error' })) }).addTo(map);
}}
/>;tiles also takes a preset ('light', 'dark', 'streets', 'satellite'), a
custom tile layer, or null for no basemap at all.
center/zoom/bounds set the initial view only — pan and zoom afterwards
through the instance (map.flyTo(...)), so a re-render never yanks the map away
from someone who is using it. The map fills its parent and re-fits itself
whenever the box changes size, so it can sit in a panel, a split or a tab
without the usual grey gutters. interactive={false} gives a static preview for
cards and list rows, and children render over the map as overlays.
Lifecycle hooks
Expose a ./lifecycle module — the preset exposes it by default — and export any subset of the three hooks:
// src/lifecycle.ts
import type { InstallHook, UninstallHook, UpdateHook } from '@rise-x/apps-sdk';
import localforage from 'localforage';
export const onInstall: InstallHook = async ({ manifest, user, environment }) => {
// first time this device sees the app — pre-warm caches, seed defaults, etc.
};
export const onUpdate: UpdateHook = async (ctx, { from, to }) => {
// version bumped in the registry — run migrations if needed.
};
export const onUninstall: UninstallHook = async ({ manifest }) => {
// drop whatever the app persisted
await localforage.dropInstance({ name: `diana-app-${manifest.id}` });
};./App and ./lifecycle are exposed by defineAppConfig() already; pass
exposes only to add more:
// rsbuild.config.mts
defineAppConfig({ pkg, port: 5101, exposes: { './Widget': './src/Widget' } });The shell invokes hooks best-effort: errors are logged, 10s timeout per hook, missing hooks skip silently. Hooks run inside the shell's page — getShellApi() / getShellApiV4() work from inside them.
Storage
Bring your own — install localforage (or IndexedDB, Cache API, etc.) as a direct dep. Put cleanup in onUninstall.
Connectors
@rise-x/apps-sdk/connectors wraps the raw clients with typed, framework-agnostic
helpers. Prefer them over hand-rolled getShellApi* calls; fall back to the raw
clients only for endpoints the connectors don't cover.
import { flows, work, assets, agents, ConnectorError } from '@rise-x/apps-sdk/connectors';Flows — discovery
// Prefer ids over names: displayName is user-editable and name changes when a
// flow is rebuilt, so hardcoded name strings silently break later. Bake the
// flowOriginId into app config instead:
const FLOW_ORIGIN_ID = '7732039e-6c5e-4bca-9a23-3bb67b1a1b22';
const flow = await flows.get(FLOW_ORIGIN_ID); // flow + full step tree (merged)
// get/getConfig accept a concrete flow id or a flowOriginId — either
// resolves to the latest published version (steps and config included).
const config = await flows.getConfig(flow.id); // v4 step/task/layout grouping + nameMap
const layout = await flows.getLayout(config.steps[0].tasks[0].layoutId!);
const fields = flows.flattenLayoutFields(layout); // leaf form fields with data paths
// findTask/list's name-matching is for dev-time exploration or a user-facing
// search box, not for a shipped id — displayName isn't a stable reference:
const summaries = await flows.list({ search: 'risk' }); // statuses default ['Active']
const hit = await flows.findTask({ flow: 'Risk Management', task: 'Submit Risk' });
if (hit) console.log(hit.task.id, hit.step.displayName);flows.list() lists WORK flows only. Asset-type (Entity) flows are not in
it; those come from assets.types(), which also carries the entityType, the
item count and the image the asset APIs need. The two listings are disjoint,
so neither is a superset of the other and neither enumerates "all flows". Only
flows.get() / findTask() resolve a flow of either kind by id.
This matters when you need a flowOriginId to pin a search: take a work origin
id from flows.list(), and an asset origin id from assets.types(). Crossing
them gives a valid guid of the wrong flow family, which matches nothing and
returns an empty page with no error.
Work — items (read + write)
// Create a work item by starting a flow (flow.id or a bare flowOriginId —
// the platform resolves either to the latest published version).
const created = await work.start({ flowId: flow.id, data: { title: 'New risk' } });
// Connect to it.
const detail = await work.get(created.id);
const data = await work.getData(created.id, { path: '$.identifyPotentialRisk' });
// Update work data (operations: Set | Push | Pull | Unset | AddToSet | Merge).
await work.patchData(created.id, {
originId: detail.flowOriginId!,
path: '$.identifyPotentialRisk.riskDescription',
value: 'Updated from an app',
});
// Complete a task action. Patch data first — submit does not merge data.
const action = detail.actions[0];
const actionName = action?.eventName ?? action?.name;
if (!actionName) throw new Error('work item has no available action');
await work.submit({ workId: created.id, actionName });
// Remove a work item (no server-side undo).
await work.delete(created.id);
// Paginated listing — `properties` shapes each row's `data` subtree.
// Unlike start()/get()/getConfig(), listing filters STRICTLY by flowOriginId —
// a concrete flow id here silently returns an empty page, not an error.
for await (const row of work.iterate({
flow, // object from flows.list()/get(), or a bare flowOriginId string
properties: { riskLevel: '$.assessRiskByRiskCustodian.governingRiskLevel' },
maxItems: 200,
})) {
console.log(row.displayName, row.data?.riskLevel);
}
const audit = await work.getAudit(created.id);work.search() is the v4 index behind the same domain. Prefer it over
work.list()/iterate() for listings that need the platform's own status,
state, timestamps or assignees: those arrive on the row, so no per-item
fetch is needed, and a filter tree replaces client-side narrowing.
Every search must pin flowOriginId with an equals or in condition, at
the top level or inside and groups — a pin inside an or branch doesn't
narrow the search and is rejected the same way.
Environment-wide searches are what made the slow query paths possible, so the
server now rejects an unpinned search with a 400 whatever else the filter says. Both
work.search() and assets.search() therefore require filter in their types.
const page = await work.search({
filter: {
and: [
// Required, always. `in` with several origin ids works too.
{ field: 'flowOriginId', operator: 'equals', values: [flowOriginId] },
// `status` values are Open/Closed/Completed/Deleted/Ok. Step-level values
// like InProgress belong to `flowState` — a different field.
{ field: 'status', operator: 'in', values: ['Open'] },
],
},
// Projection: `data.*` paths resolve against the pinned flow's schema and
// arrive under `row.data` with the `data.` prefix stripped. `statusDisplay`
// carries the label and roleName.
fields: ['status', 'assignedUsers', 'statusDisplay', 'data.pricing.rate'],
// Sorting and the createdBy/lastModifiedBy filters need a pinned search; on
// an older API build they may fail, so pin first before assuming they're
// broken.
sort: [{ field: 'created', direction: 'desc' }],
pageSize: 50,
});
for (const row of page.items) {
console.log(row.workCode, row.status, row.created, row.assignedUsers, row.data?.pricing);
}
// page.hasMore drives the next page; pass includeTotalCount for an exact count.
// Caveat: `row.created`/`row.lastModified` are ISO strings, but date values
// inside `row.data` pass through as `{date, ticks, offset}` objects — don't
// feed them to `new Date()` directly; use the `date` property.Assets — typed records
Assets ("entities"/"things" in platform parlance) are typed records defined by an entity flow — the flow's origin id is the asset-type reference:
Breaking in 0.7.0: what 0.6.0 called
assets.search()— the v3 free-text search — is nowassets.quickSearch().assets.search()/useAssetSearchare the paged v4 index search below:filteris required and the hook is an infinite query.Also: asset search now asks the host for the
'asset'v4 client rather than'config'. Inside a Rise-X shell this is transparent. If you pass your ownapiV4handler tocreateMockShelland switch on the key strictly, add an'asset'case, or it will stop matching.
const types = await assets.types(); // asset types in the current ecosystem
const supplier = types.find((t) => t.entityType === 'Supplier')!;
// v3 free-text search within a type — fuzzy, but slow on large types.
const hits = await assets.quickSearch({ type: 'Supplier', search: 'acme', dataPaths: ['$.name'] });
// Full detail: data document, relationships, edit draft, permissions flags.
const detail = await assets.get(hits[0].id);
// Paginated listing — same `properties` data-shaping as work.iterate.
for await (const row of assets.iterate({ type: supplier, properties: { rating: '$.supplier.rating' } })) {
console.log(row.displayName, row.data?.rating);
}
// Follow flow-configured relationships (targets default to assets; pass
// targetDataType: ASSET_TARGET_DATA_TYPES.work for related work items).
const related = await assets.listRelated({ assetId: detail.id, relationships: ['contracts'] });
// Create/edit go through a draft work item (the platform's edit model):
// fill it with work.patchData(), save it with work.submit().
const draft = await assets.create({ type: supplier });
await work.patchData(draft.id, { originId: draft.flowOriginId!, path: '$.supplier.name', value: 'Acme' });
const submitAction = draft.actions[0];
await work.submit({ workId: draft.id, actionName: submitAction.eventName ?? submitAction.name! });
const editDraft = await assets.startEdit({ assetId: detail.id, flowOriginId: detail.flowOriginId! });
await assets.clone(detail.id);
await assets.delete(detail.id);assets.search() is the v4 index behind the same domain, and takes the same
filter grammar as work.search(). Prefer it over quickSearch()/list() for
listings: it filters and sorts on any whitelisted field or data.* path, the
platform's own status and timestamps arrive on the row, and it is the
indexed replacement for the timeout-prone v3 field search.
The same mandatory flowOriginId pin applies — see work.search() above.
Source it from assets.types() (type.flow.flowOriginId), never from
flows.list(): that lists work flows only, so its ids match nothing here.
const page = await assets.search({
filter: {
and: [
// Required, always. `in` with several origin ids works too.
{ field: 'flowOriginId', operator: 'equals', values: [supplier.flow!.flowOriginId] },
// Asset `status` values are Open/Closed/Deleted — a narrower vocabulary
// than work's, which also has Completed/Ok.
{ field: 'status', operator: 'equals', values: ['Open'] },
{ field: 'displayName', operator: 'contains', values: ['acme'] },
],
},
// Projection. A `data.*` path must exist in the pinned flow's data schema or
// the request 400s naming it; values arrive under `row.data` with the
// `data.` prefix stripped (`data.supplier.rating` → `row.data.supplier.rating`).
// Bare `data` skips the schema check and returns the whole document.
fields: ['status', 'code', 'statusDisplay', 'data.supplier.rating'],
sort: [{ field: 'created', direction: 'desc' }],
pageSize: 50,
});
for (const row of page.items) {
console.log(row.code, row.status, row.created, row.data?.supplier);
}
// Same page envelope as work.search — hasMore drives the next page. `id` and
// `status` come back on every row even when `fields` omits them; the other
// scalars are present by default but drop out once `fields` is set.Agents — configurable AI
CRUD over the agent config registry (/api/v4/config/agent, rise-x-api) plus
streaming runs through the rise-x-ai gateway:
const { items } = await agents.list({ search: 'support' });
const agent = await agents.create({
name: 'Platform Helper',
model: 'gpt-5.6-luna',
systemPrompt: 'You help operators work with Rise-X flows.',
mcpServers: [
{
name: 'rise-x',
url: 'https://mcp.rise-x.io/mcp',
transport: 'Http',
authType: 'CallerToken', // runtime forwards the signed-in user's JWT
},
],
});
// Streaming a reply — the easy path. streamAgentReply() runs the loop AND the
// delta accumulation for you and yields a ready-to-render snapshot each time
// the text grows, an error arrives, or the stream ends. Wrap either agents.run
// or chat.send.
import { streamAgentReply, collectAgentReply } from '@rise-x/apps-sdk/connectors';
try {
for await (const s of streamAgentReply(
agents.run({ agentId: agent.id, message: 'Open risks?', useHistory: false }),
)) {
setReply(s.text); // already accumulated — just render it
// Report once, when the stream ends: the agent's error message if there was
// one, otherwise a non-COMPLETED terminal reason.
if (s.done) {
if (s.error) showError(s.error);
else if (s.reason !== 'COMPLETED') showError(`run ended: ${s.reason}`);
}
}
} catch (err) {
// Transport / HTTP / abort failures (e.g. the AI gateway being down) THROW —
// they are not delivered as s.error. Catch them here.
showError(String(err)); // ConnectorError, e.g. code 'AI_UNAVAILABLE'
}
// Not streaming the UI? Await the whole answer in one call. Same rule: mid-stream
// agent errors come back as `error`; transport/abort failures throw — so catch.
try {
const { text, reason, error } = await collectAgentReply(
agents.run({ agentId: agent.id, message: 'Open risks?', useHistory: false }),
);
} catch (err) {
showError(String(err));
}
// Multi-turn chat — memory is server-persisted in Mongo (ZDR-safe, no OpenAI
// retention cliff). createChat() captures the chat_id from the first turn's
// end_of_stream and passes it back on every subsequent send().
const chat = agents.createChat({ agentId: agent.id });
for await (const s of streamAgentReply(chat.send('Summarize the open risks'))) setReply(s.text);
for await (const s of streamAgentReply(chat.send('Now only the high-severity ones'))) setReply(s.text); // remembers
const chatId = chat.chatId; // stable server id — persist it to reopen laterChats are listable, reopenable, renamable, and deletable — the transcript (including tool-call items) is owned by the platform and read back for replay:
// List the signed-in user's chats (newest first), optionally per agent.
const { items } = await agents.listChats({ agentId: agent.id });
const one = await agents.getChat(items[0].id);
// Reopen a stored chat and continue it — memory is carried server-side.
const resumed = agents.createChat({ agentId: agent.id, chatId: one.id });
for await (const s of streamAgentReply(resumed.send('and the medium ones?'))) setReply(s.text);
// Render the full transcript (paged by seq; tool calls included).
const page = await agents.getChatMessages(one.id, { pageSize: 50 });
for (const m of page.items) renderMessage(m); // m.itemType: 'Message' | 'ToolCall' | 'ToolResult' | 'Reasoning'
// page.nextSeq → pass as fromSeq to page forward.
await agents.renameChat(one.id, 'Q3 risk review');
await agents.deleteChat(one.id); // removes the chat and its messagesNotes:
- Streaming contract.
agents.run/chat.sendyield rawAgentRunEvents where assistant text arrives asagent_eventframes (event_type: 'MESSAGE') whosetextis a per-token delta, never the cumulative string. PreferstreamAgentReply()(yields accumulatedAgentReplyStatesnapshots) orcollectAgentReply()(awaits the final state) so you never hand-roll that accumulation. For finer control, the frame readersagentMessageText/agentEndReason/agentErrorTextdecode a single event, and the typed views (AgentEventData,AgentEndData,AgentErrorData) are exported for reasoning / tool-call / handoff frames. agents.run/createChatneed shell bridge v3 (getAi) and a deployment withDIANA_AI_API_ENDPOINTconfigured — otherwise they throwConnectorError('SHELL_TOO_OLD' | 'AI_UNAVAILABLE').- Memory contract: clients only ever handle
chatId.useHistorydefaults totrue;useHistory:falseruns one-shot and persists nothing. Passing achatIdtogether withuseHistory:falseis illegal →INVALID_ARG(mirrors the gateway's 400) and makes no network call. listChats/getChat/renameChat/deleteChat/getChatMessageshit the server chat store via the sameconfigv4 client (/api/v4/ai/agent-chat).- Stored MCP
apiKeys are redacted to"***"on read;agents.update()strips the sentinel so read-modify-write can't clobber the stored secret.
Errors
Every connector failure is normalized to ConnectorError with a code:
| Code | Meaning |
| --- | --- |
| SHELL_UNAVAILABLE | not running inside the shell (or the standalone mock has no api/apiV4 handler) |
| SHELL_TOO_OLD | host bridge predates a required capability (e.g. getAi) |
| AI_UNAVAILABLE | shell present but no AI gateway configured |
| HTTP_ERROR / NOT_FOUND | non-2xx response (status populated) |
| NETWORK_ERROR | request never got a response |
| ABORTED | AbortSignal fired |
| PARSE_ERROR | malformed SSE/JSON payload |
| INVALID_ARG | caller-supplied argument can't be used, no request was made (e.g. an AssetType without a defining flow) |
Standalone dev
The default mock shell throws on API access. Point it at a backend to use connectors outside the shell:
import axios from 'axios';
import { createMockShell } from '@rise-x/apps-sdk';
const devAxios = axios.create({
baseURL: 'http://localhost:50061',
headers: { Authorization: token, Environment: 'dev' },
});
window.__DIANA_SHELL__ = createMockShell({
api: () => devAxios,
apiV4: (_name) => devAxios, // one dev backend serves every V4ApiKey
ai: { baseUrl: 'http://localhost:50062', getHeaders: () => ({ Authorization: token, Ecosystem: 'dev' }) },
});Without a backend, seed fixtures instead so data-backed screens render their
real content paths under pnpm start — otherwise standalone dev only ever
exercises the empty/loading/error states, and a rendering bug in the content
path is invisible until the app is deployed into a host:
window.__DIANA_SHELL__ = createMockShell({
fixtures: {
// Keyed by flowOriginId; '*' serves any flow. Paged with the request's
// skip/limit, so pagination and work.iterate() work as they would live.
workRows: { '*': [{ id: 'w1', displayName: 'Monthly Price Update', workCode: 'WPL-2026-884' }] },
// Both search fixtures are served only when the request pins flowOriginId,
// exactly as the live endpoints behave — an unpinned search throws here too.
workSearch: [{ id: 'w1', workCode: 'WPL-2026-884', status: 'Open', created: '2026-08-01T00:00:00Z' }],
workDetail: { w1: { id: 'w1', displayName: 'Monthly Price Update', actions: [] } },
workData: { w1: { pricing: { rate: 1.42 } } },
assetTypes: [{ id: 't1', entityType: 'Pump', flow: { flowOriginId: 'origin-a' } }],
assetRows: { '*': [{ id: 'a1', displayName: 'Pump 1' }] },
assetSearch: [{ id: 'a1', displayName: 'Pump 1', code: 'PMP-001', status: 'Open' }],
assetDetail: { a1: { id: 'a1', displayName: 'Pump 1' } },
},
});Only reads are served, and only the ones seeded: an unseeded call throws
SHELL_UNAVAILABLE naming the fixture key that would answer it, and writes
always throw. Both are deliberate — a fixture that returned empty data or
pretended to persist would make a broken app look like a working one.
Seed real-world-shaped data, not Item 1/foo. Fixtures are only worth
the effort if they exercise what live data will: real field names from the
flow's schema, plausible lengths (a supplier name that wraps, a code that
doesn't), the enum values the server actually returns (status: 'Open', not
'active'), ISO timestamps, and enough rows to page. Placeholder values hide
exactly the bugs standalone dev exists to catch — truncation, overflow, empty
optional fields, date formatting. Copy a few rows from the real ecosystem
(via the search endpoints or the Rise-X MCP) when you can.
Query layer (react-query)
@rise-x/apps-sdk/query wraps the connectors in a
@tanstack/react-query v5 layer: request dedupe,
caching, background refetch, and cancellation for free. react-query is an
optional peer — apps that never import /query don't need it installed.
import { useFlows, useWorkRows, useSubmitWork, dedupeRows } from '@rise-x/apps-sdk/query';
function MyPanel({ flowOriginId }: { flowOriginId: string }) {
const { data: flows, error, isFetching } = useFlows();
const rows = useWorkRows({ flow: flowOriginId, pageSize: 50 });
const submit = useSubmitWork(); // invalidates the right caches on success
const items = dedupeRows(rows.data); // flatten infinite pages, drop repeats
// …
}Zero config — do not mount a provider. The hooks resolve their
QueryClient from React context: federated apps get a per-app client the
shell mounts around them (isolated from the shell's own persisted cache and
from other apps), and everywhere else — standalone dev, older shells — the
hooks fall back to a per-bundle client automatically. The one requirement:
react-query must be a share so the shell's context can cross the federation
boundary — the preset does this for you:
shared: {
// keep the bundled fallback (no `import: false`) — see the scaffold comment
'@tanstack/react-query': { singleton: true, requiredVersion: '^5.0.0' },
}Using react-query directly? Wrap the app in AppQueryProvider. The SDK
hooks pass their client explicitly, so they never touch context — but plain
react-query APIs do, and standalone dev has no provider for them to find:
useQuery(flowQueries.list(args)), useQueryClient() and the devtools all
throw "No QueryClient set". AppQueryProvider publishes the resolved
client, so it is safe in both worlds — inside a host it re-publishes the very
client the shell mounted (nothing shadowed, cache still shell-managed), and
standalone it publishes the per-bundle fallback. Mounting a client of your own
is the thing that breaks shell-managed caching:
import { AppQueryProvider } from '@rise-x/apps-sdk/query';
export default function App() {
return (
<AppQueryProvider>
<MyScreens />
</AppQueryProvider>
);
}Read hooks (all take an optional trailing SdkQueryOptions — enabled,
staleTime, retry, …):
| Domain | Hooks |
| --- | --- |
| flows | useFlows, useFlow, useFlowConfig, useFlowLayout, useFlowTask |
| work | useWork, useWorkData, useWorkRows (infinite), useWorkSearch (infinite), useRelatedWork, useWorkAudit |
| assets | useAssetTypes, useAsset, useAssetSearch (infinite), useAssetQuickSearch, useAssetRows (infinite), useRelatedAssets |
| agents | useAgents, useAgent, useAgentChats, useAgentChat, useAgentChatMessages |
Mutation hooks compose your onSuccess with built-in invalidation (e.g.
useSubmitWork refreshes the work detail/data/audit, row listings, and asset
scope): useStartWork, usePatchWorkData, useSubmitWork, useDeleteWork,
useCreateAsset, useStartEditAsset, useCloneAsset, useDeleteAsset,
useCreateAgent, useUpdateAgent, useDeleteAgent, useRenameAgentChat,
useDeleteAgentChat.
Details worth knowing:
Keys are environment-scoped. Every key is
['rise-apps-sdk', envId, …]— switching ecosystem refetches automatically and the old environment's entries age out.queryKeysis exported for manual invalidation.Its prefix shapes take only
envId(queryKeys.work.searchAll(envId),allRows,relatedAll, …) and are the reliable way to invalidate a whole listing. For a single entry, don't rebuild the args by hand: every args-carrying key normalises its input first, and each domain does it differently — search keys off the request body viasearchCacheShape, whilework.rowsandassets.rowsresolve a flow ref and an asset-type ref through helpers that aren't part of the public API. Read the key off the factory instead, which is always the one the hook used:queryClient.invalidateQueries({ queryKey: workQueries.search(args).queryKey });Defaults:
staleTime30 s,gcTime5 min, no refetch-on-focus, up to 2 retries that skip terminalConnectorErrorcodes (4xx,NOT_FOUND,ABORTED, …). Override per call or viacreateAppQueryClient(overrides).Errors are
ConnectorError—error.codenarrows exactly like the connector table above.Full react-query power (select, suspense, prefetch): use the queryOptions factories directly —
useQuery(flowQueries.list(args)),queryClient.prefetchQuery(workQueries.detail(id)).useAppQueryClient()returns the same client the hooks use.Raw connector writes bypass the invalidation map — after one, call
invalidateAppSdkQueries(useAppQueryClient()).Out of scope: the async iterators (
work.iterate/assets.iterate— use the infinite hooks instead) and the SSE streams (agents.run, chatsend) stay on the connectors.
Shell-side (host) wiring
import { axios } from '@diana/core';
import { installShellBridge } from '@rise-x/apps-sdk/shell';
installShellBridge({
getApi: () => axios.api(),
getApiV4: (name) => axios.apiV4(name),
});getApiV4 takes a V4ApiKey ('apps' | 'work' | 'config' | 'attachment' | 'asset') —
guests choose which named v4 instance they want and the host forwards the key to
@diana/core's axios.apiV4(key). The host registers each key it exposes via
setupApiV4(key, { baseURL }) before installing the bridge.
'asset' is new in 0.7.0 and is the one key a host may leave unregistered: hosts
built before it either return nothing or throw for that key, so assets.search()
falls back to the 'config' client, which is where the endpoint was routed
previously. Register it (pointing at DIANA_API_V4_ASSET, defaulting to the v4
endpoint) to route asset search independently.
ShellBridgeKeeper (React component in the shell) pushes the current user, environment,
and router navigate into the bridge via getMutableShell — also exported from
@rise-x/apps-sdk/shell.
The shell's RemoteAppLoader additionally wraps every mounted app in a
per-app QueryClient (built with createAppQueryClient from
@rise-x/apps-sdk/query), which is what the SDK's query hooks pick up
through context in federated apps.
Build config (@rise-x/apps-sdk/rsbuild)
Apps build with Rsbuild, and the Module Federation contract with the shell lives in this preset rather than in each app's config.
// rsbuild.config.mts
import { defineConfig } from '@rsbuild/core';
import { defineAppConfig } from '@rise-x/apps-sdk/rsbuild';
import pkg from './package.json';
export default defineConfig(({ envMode }) =>
defineAppConfig({ pkg, port: 5102, standalone: envMode === 'standalone' }),
);pnpm rsbuild build # federated bundle -> dist/
pnpm rsbuild dev # federated, served for a host to load
pnpm rsbuild dev --env-mode standalone # standalone, no shell -> dist-local/Requires all three of @rsbuild/core, @rsbuild/plugin-react and
@rsbuild/plugin-type-check in the app — the preset imports each at module
level, so a missing one fails at config load with MODULE_NOT_FOUND. They are
optional peers here, so hosts that never import this subpath get no unmet-peer
noise; the scaffolder puts all three in a new app's devDependencies.
| Option | Required | Purpose |
|---|---|---|
| pkg | yes | the app's package.json. name derives the MF scope (@rise-x-apps/vendor-hub → app_vendor_hub); version is baked as __APP_VERSION__ |
| port | yes | dev-server port. Must be unique across apps |
| standalone | no | no shell, no MF, React bundled in |
| exposes | no | extra MF exposes, merged over ./App + ./lifecycle |
| define | no | extra source.define entries, merged into the preset's own |
__APP_VERSION__ is a define, so TypeScript needs to be told it exists. Add
declare const __APP_VERSION__: string; to the app if you read it.
The converse is the trap define exists for: a declare const in the app's
global.d.ts makes TypeScript accept the identifier whether or not the build
substitutes it. Declare one without passing it here and the app compiles clean,
then throws ReferenceError on load with nothing in the build output pointing
at the cause.
Values are raw code, not data — the same contract as webpack's DefinePlugin —
so a string arrives quoted. And because the option takes a fixed object, a value
that depends on the build has to be derived in the defineConfig callback:
export default defineConfig(({ envMode }) =>
defineAppConfig({
pkg,
port: 5101,
standalone: envMode === 'standalone',
define: {
__USE_SAMPLE_DATA__: String(envMode !== 'production'),
__API_LABEL__: JSON.stringify(envMode ?? 'development'),
},
}),
);Passing __APP_VERSION__ throws: it is derived from pkg.version so it always
matches the deployed manifest.
What the federated branch sets, and why it matters:
ModuleFederationPluginV1, never the enhanced plugin — V1 emits the container shape (window[scope].init/get) that both hosts' runtime loaders speak.output.uniqueName = scopeviatools.rspack. Rsbuild has nooutput.uniqueNameof its own, and it names the chunk-loading global — left at the default it collides with the host's.- React, its JSX runtimes and
@rise-x/uiare consume-only (import: false): the host provides the single copy.@tanstack/react-querydeliberately keeps its bundled fallback, so an app still works on a host that does not share it. - React is shared as
^18.0.0 || ^19.0.0, because the two hosts are on different majors (ClientApp 18, Shell 19) and one bundle must load on both. That is a build-level permission, not a licence to use React 19 APIs — write app code against the React 18-compatible API surface, since ClientApp is still the production host. - Flat
dist/—remoteEntry.jsbeside its chunks and every emitted asset, becausetools/apps-dev-serverprobesdist/remoteEntry.jsand the deploy zips the contents ofdist/. Rsbuild would otherwise route assets intostatic/<kind>/; the preset flattens all of them, which is also what the webpack configs it replaces did. assetPrefix: 'auto'on bothoutputanddevso one bundle works on the dev port and when the shell loadsremoteEntry.jsfrom the CDN. Rsbuild splits what webpack had as a singleoutput.publicPath, and the dev half defaults to/— a remote served byrsbuild devwould otherwise resolve its async chunks against the host's origin.- Production source maps, which Rsbuild otherwise defaults off.
The preset's own standalone specs resolve @rise-x/apps-sdk/ui/standalone,
so run pnpm build in this package before pnpm test on a clean checkout (CI
already builds first).
Set APP_SCOPE to override the derived scope. That is only for registering two
builds of one app side by side (an old and a redesigned version, to compare
them) — two registrations may not share a scope, because the shell caches
loaded remotes by URL and the second remoteEntry.js would overwrite the
first's global. Leave it unset for normal releases.
Local development against the SDK
In-repo consumers (ClientApp, apps/*) declare "@rise-x/apps-sdk": "workspace:*",
so pnpm links them straight to packages/apps-sdk — no publish or link step.
Live loop (rebuilds build/ on save; consumers pick it up on their next compile):
cd packages/apps-sdk && pnpm start:packageFor an app developed outside this repo, install the published SDK package:
pnpm add @rise-x/apps-sdkPublishing
Publishes to the public npm registry (registry.npmjs.org) under the rise-x org, per publishConfig in package.json. Releases are manual: run the Publish @rise-x/apps-sdk workflow (.github/workflows/publish-apps-sdk.yml) via workflow dispatch, from main or a release/* branch — it publishes the version in package.json, and fails if that version is already on the registry.
To publish manually you need an npmjs account with publish rights in the rise-x org (npm login), then:
pnpm publish --no-git-checksThe prepack hook builds @rise-x/ui and the package automatically — no need to build first. The registry and public access come from publishConfig, not CLI flags.
template/ is included via the files array so npx @rise-x/apps-sdk init works from the registry version.
