@finamx/host-bridge
v3.4.0
Published
Primitives for embedding FinamX extensions into host applications — web iframes, React apps, and MCP agents.
Readme
@finamx/host-bridge
Primitives for embedding FinamX extensions into host applications — web iframes, React apps, and MCP agents.
Install
npm install @finamx/host-bridge @finamx/bridge-protocolQuick Start — New Host Integration
The fastest way to embed a FinamX extension with platform capability proxying in ≤20 lines:
npm install @finamx/host-bridge @finamx/bridge-protocolimport {
fetchEmbedLaunch,
launchToBridgeSession,
resolvePlatformBase,
useExtensionEmbed,
} from '@finamx/host-bridge';
export function MyExtensionEmbed({ extensionId }: { extensionId: string }) {
const [state, setState] = React.useState<{
launchUrl: string;
session: ReturnType<typeof launchToBridgeSession>;
} | null>(null);
React.useEffect(() => {
fetchEmbedLaunch({ extensionId, platformBase: resolvePlatformBase() }).then((launch) => {
setState({ launchUrl: launch.launchUrl, session: launchToBridgeSession(launch) });
});
}, [extensionId]);
const bridge = useExtensionEmbed({
launchUrl: state?.launchUrl ?? '',
allowedOrigin: state?.session?.allowedOrigin ?? '',
session: state?.session ?? { sessionId: '', extensionId, allowedOrigin: '', grantedScopes: [] },
// Declarative proxy: both fields → createCapabilityRouterWithPlatformProxy auto-created
platformBase: resolvePlatformBase(),
sessionJwt: state?.session?.sessionJwt ?? '',
});
if (!state) return <p>Loading…</p>;
return (
<div ref={bridge.containerRef} style={{ height: 400 }}>
<iframe ref={bridge.iframeRef} {...bridge.frameProps} style={{ height: '100%' }} />
</div>
);
}Extension calling portfolio.summary receives real platform data because platformBase/sessionJwt
trigger createCapabilityRouterWithPlatformProxy automatically — no manual CapabilityRouter setup required.
Method remapping (custom methods + platform fallback)
When your host handles some methods itself and delegates the rest to the platform:
import { createCapabilityRouterWithPlatformProxy, resolvePlatformBase, CapabilityRouter } from '@finamx/host-bridge';
const platformRouter = createCapabilityRouterWithPlatformProxy({
platformBase: resolvePlatformBase(),
sessionJwt: session.sessionJwt ?? '',
});
const myRouter = new CapabilityRouter({
invokeHandler: async (req) => {
if (req.method === 'my.custom.method') {
return { ok: true, data: await myBackend.get() };
}
return platformRouter.invoke(req); // fallback to platform
},
});
// Then pass myRouter explicitly:
useExtensionEmbed({ ..., capabilityRouter: myRouter });Quick Start — Native-like embed (React)
import {
ExtensionEmbedFrame,
fetchEmbedLaunch,
launchToBridgeSession,
} from '@finamx/host-bridge';
const launch = await fetchEmbedLaunch({
extensionId: 'com.example.my-extension',
platformBase: process.env.NEXT_PUBLIC_CMS_URL!,
userId: 'poc-user-1',
});
<ExtensionEmbedFrame
session={launchToBridgeSession(launch)}
launchUrl={launch.launchUrl}
/>;ExtensionEmbedFrame applies server-driven embedLayout (height bounds, scroll policy, gesture mode), handles ui:resize, ui:scroll-lock, and ui:gesture-claim. See docs/architecture/native-embed-sdk.md.
Quick Start — Web Host
import { createWebHostBridge, CapabilityRouter, ExtensionSessionConfig } from '@finamx/host-bridge';
const config: ExtensionSessionConfig = {
sessionId: crypto.randomUUID(),
extensionId: 'com.example.my-extension',
allowedOrigin: 'https://my-extension.example.com',
grantedScopes: ['market.read', 'ui.toast'],
};
const bridge = createWebHostBridge({
iframe: document.getElementById('extension-frame') as HTMLIFrameElement,
allowedOrigin: config.allowedOrigin,
session: config,
});
// Tear down (removes window message listener):
bridge.destroy();Quick Start — Fill-container layout (D-04)
For host UI where the embed should fill a card or modal rather than using a fixed height:
import { ExtensionEmbedFrame } from '@finamx/host-bridge';
// The container div controls the available height; ExtensionEmbedFrame
// fills it and sends host:layout with measured px dimensions via ResizeObserver
// so the extension can respond with appropriate layout (not minHeight: 100vh).
<div style={{ height: 320 }}>
<ExtensionEmbedFrame
session={session}
launchUrl={launch.launchUrl}
fillContainer // iframe height = 100% of container; ResizeObserver pushes dimensions
/>
</div>With useExtensionEmbed, mount containerRef on the wrapper div:
const bridge = useExtensionEmbed({ ..., fillContainer: true });
<div ref={bridge.containerRef} style={{ height: '100%' }}>
<iframe ref={bridge.iframeRef} {...bridge.frameProps} />
</div>Size preference (SIZE-02)
ui:sizePreference is not ui:resize. When a preference arrives and fillContainer is
false, the container height becomes the clamped resolveSizePreference result. When
fillContainer is true and no preference is set, height stays 100%. When both are set,
the container uses CSS aspect-ratio (W / H from parsed integers) with height: auto
so a Conduit card can honor the preference without ui:resize.
Wall overlay (WALL-02)
Hosts can pass renderWall to replace DefaultWallOverlay. If omitted, the default
presentational chrome still works. Consent overlay remains a separate host-app component.
reason: payment is copy only (Требуется оплата) — it does not imply a billing
provider or checkout.
<ExtensionEmbedFrame
session={session}
launchUrl={launch.launchUrl}
renderWall={({ visible, reason }) =>
visible ? <MyWall reason={reason} /> : null
}
/>Hook users get wall, setWall (delegates to the controller), and wallOverlay:
const bridge = useExtensionEmbed({ launchUrl, allowedOrigin, session });
<div ref={bridge.containerRef} {...bridge.containerProps}>
<iframe ref={bridge.iframeRef} {...bridge.frameProps} />
{bridge.wallOverlay}
</div>Quick Start — Design tokens + debug tooling
Pass real design tokens so embedded extensions inherit your host's theme instead of the generic shadcn defaults, and opt into bridge traffic / token inspection panels.
Unified debug kit (D-05, D-06)
BridgeHostDebugKit composes the log panel, token preview, and scope revoke controls
into a single opt-in component. Always gate it behind a debug flag — never render in
production embeds (T-48-02):
import {
useExtensionEmbed,
BridgeHostDebugKit,
CapabilityRouter,
} from '@finamx/host-bridge';
const [debug, setDebug] = useState(false);
const [grantedScopes, setGrantedScopes] = useState(session.grantedScopes);
const bridge = useExtensionEmbed({ ..., debug });
// Render conditionally — never default on in production
{debug && (
<BridgeHostDebugKit
entries={bridge.bridgeLog}
onClear={bridge.clearBridgeLog}
tokens={uiTokens}
grantedScopes={grantedScopes}
onScopesChange={setGrantedScopes} // simulate scope revoke (dev-only)
uiTokensEnabled={tokensEnabled}
onUiTokensEnabledChange={setTokensEnabled}
/>
)}Individual panels (manual composition)
import {
useExtensionEmbed,
BridgeDebugLogPanel,
BridgeTokenPreviewPanel,
CapabilityRouter,
} from '@finamx/host-bridge';
import { mergeUITokens, type UITokens } from '@finamx/bridge-protocol';
function readHostUITokens(): UITokens {
const style = getComputedStyle(document.documentElement);
const colors = { ...mergeUITokens({}).colors };
for (const key of Object.keys(colors) as (keyof UITokens['colors'])[]) {
const v = style.getPropertyValue(`--${key}`).trim();
if (v) colors[key] = v;
}
return mergeUITokens({ colors });
}
const capabilityRouter = useMemo(() => new CapabilityRouter(), []);
const uiTokens = useMemo(() => readHostUITokens(), []);
const [debugEnabled, setDebugEnabled] = useState(false);
const bridge = useExtensionEmbed({
launchUrl: launch.launchUrl,
allowedOrigin: session.allowedOrigin,
session,
capabilityRouter,
uiTokens,
debug: debugEnabled,
});
// ...render the iframe via bridge.iframeRef / bridge.frameProps as usual, then:
{debugEnabled && (
<BridgeDebugLogPanel entries={bridge.bridgeLog} onClear={bridge.clearBridgeLog} />
)}
<BridgeTokenPreviewPanel tokens={uiTokens} />bridgeLog/clearBridgeLog only populate while debug: true — leave it off by default and
gate the toggle behind an internal/dev flag, not exposed to end users.
Quick Start — MCP Agent
import { createMcpWidgetTools } from '@finamx/host-bridge/mcp';
const tools = createMcpWidgetTools({
widgetCatalog: [
{
extensionId: 'com.example.widgets',
name: 'Portfolio',
description: 'Portfolio summary widget',
routes: ['/widgets/portfolio-summary'],
},
],
launchWidget: async (extensionId, route, params) => {
// Return { launchUrl, sessionId } from your launch service
return {
launchUrl: `https://app.example.com/embed?ext=${extensionId}&route=${route}`,
sessionId: crypto.randomUUID(),
};
},
});
// Register with your MCP server:
// tools.forEach(t => server.tool(t.name, t.description, t.inputSchema, t.handler));Exports
Main package (@finamx/host-bridge)
| Export | Description |
|--------|-------------|
| createWebHostBridge(options) | Browser iframe bridge with automatic window message listener management |
| createHostBridge(options) | Low-level factory — caller manages window events |
| createCapabilityRouter(options?) | Factory helper for CapabilityRouter |
| CapabilityRouter | Validates scopes, dispatches platform:invoke to handler |
| ExtensionEmbedFrame | Drop-in React iframe with embed layout + scroll-lock side effects |
| DefaultWallOverlay | Presentational wall chrome (auth / payment copy). Override via renderWall |
| resolveEmbedLayout() | Merge session embedLayout with defaults and overrides |
| getEmbedFrameStyles() | CSS for iframe from layout + scroll/gesture state |
| runScrollLockSideEffects() | Apply parent scroll lock when extension sends ui:scroll-lock |
| sendHostLayoutEvent() / sendHostScrollStateEvent() | Dispatch platform:event to embed |
| useExtensionEmbed(options) | React hook — iframe ref, frameProps, layout, lock/claim state, bridgeLog, bridgeReady |
| fetchEmbedLaunch(options) | Calls POST /api/v1/embed/launch, returns LaunchResponse |
| launchToBridgeSession(launch, userId?) | Converts LaunchResponse → BridgeSessionConfig |
| ExtensionSessionConfig | Session config with optional uiTokens and embedLayout |
| UITokens | Design token shape (re-exported from @finamx/bridge-protocol) |
| BridgeLogEntry | Shape of one bridge traffic log entry (useExtensionEmbed({ debug: true })) |
| BridgeDebugLogPanel | Opt-in component — live view of bridge send/recv traffic, latency, ok/error |
| BridgeTokenPreviewPanel | Opt-in component — swatch preview of the UITokens actually sent to the extension |
| BridgeHostDebugKit | Unified dev-only debug UI: log + token preview + scope revoke + token toggle (D-05/D-06) |
Widgets sub-package (@finamx/host-bridge/widgets)
DivKit + custom-id widget host helpers for routing CMS-authored widgets (D-07).
Requires optional peer @divkitframework/react for DivKitWidgetHost rendering.
DivKit custom components — shadow DOM workaround
DivKit renders type: "custom" elements by inserting a <template shadowrootmode="open"> into the DOM via JavaScript (insertBefore). Browsers only process declarative shadow DOM during HTML parsing — dynamic JS insertion is silently ignored. The SVG/HTML inside the template ends up in an inert #document-fragment and never renders.
DivKitWidgetHost and WidgetBySlugEmbed handle this automatically: after DivKit mounts, they scan for <template shadowrootmode> elements, call host.attachShadow() imperatively, and move the template content into the real shadow root. A MutationObserver repeats this for any subsequent DivKit DOM updates.
Known limitation — reactive custom_props
The workaround removes the <template> from the DOM after processing (tmpl.remove()). DivKit still holds a reference to that detached element. If DivKit reactively updates custom_props (e.g. the CMS pushes new chartPoints via a live binding), it writes new SVG content to r.content.innerHTML on the detached template — the shadow root is not updated and shows stale content.
Current mitigations:
- All existing custom components (
finamSparkLine,finamNewsCard,webEmbed) receive staticcustom_propsfrom the CMS — no live bindings, so the limitation is not triggered.
Required fix when reactive custom components are added:
Instead of removing the template, keep it in the DOM and observe it with a second MutationObserver targeting the template's content. On each change, replace the shadow root's content:
// pseudo-code — implement in activateShadowRoots() in each DivKit host component
const shadow = host.attachShadow({ mode });
const sync = () => {
shadow.replaceChildren(document.importNode(tmpl.content, true));
};
sync();
// Do NOT remove the template — keep it for DivKit to update
const contentObserver = new MutationObserver(sync);
contentObserver.observe(tmpl.content, { subtree: true, childList: true, characterData: true });This change is required before shipping any DivKit widget whose custom_props change at runtime.
import {
resolveWidgetRenderMode,
fetchDivKitWidget,
DivKitWidgetHost,
CustomIdEmbedHost,
} from '@finamx/host-bridge/widgets';
// Route a board card to the correct renderer
const mode = resolveWidgetRenderMode(card.meta);
// → 'divkit' | 'extension-embed' | 'external-embed' | 'custom-id'
// Fetch DivKit JSON from CMS (host passes its own auth headers — T-48-05)
const widget = await fetchDivKitWidget('my-slug', {
platformBase: 'http://localhost:3001',
headers: { 'X-User-Id': 'poc-user-1' },
});
// All-in-one routing component (render props per path)
<CustomIdEmbedHost
meta={card.meta}
renderDivKit={(slug) => (
<DivKitWidgetHost
slug={slug}
fetchOptions={{ platformBase: 'http://localhost:3001', headers: { 'X-User-Id': userId } }}
/>
)}
renderExtension={(extensionId) => <ExtensionEmbed extensionId={extensionId} />}
renderExternalEmbed={(slug) => <ExternalEmbedContent slug={slug} />}
/>| Export | Description |
|--------|-------------|
| resolveWidgetRenderMode(meta) | Returns 'divkit' | 'extension-embed' | 'external-embed' | 'custom-id' from card metadata |
| fetchDivKitWidget(slug, options) | GET /api/v1/divkit/widgets/:slug — host passes platformBase + auth headers |
| DivKitWidgetResponse | Response type for fetchDivKitWidget |
| DivKitWidgetHost | React component — fetches + renders DivKit JSON; dynamic import of @divkitframework/react |
| CustomIdEmbedHost | Routes card metadata to render props for divkit/extension/external paths |
MCP sub-package (@finamx/host-bridge/mcp)
| Export | Description |
|--------|-------------|
| createMcpWidgetTools(options) | Creates plain-object MCP tool definitions for widget rendering |
| renderWidgetForAgUi(options) | Builds an ag-ui widget_render event payload |
| createWidgetAdapter(options) | Generic launch→render adapter for non-MCP systems |
| McpToolDefinition | Plain-object MCP tool type (no @modelcontextprotocol/sdk hard dep) |
| AgUiWidgetEvent | ag-ui widget render event type |
Extension-owned topics (D-874-23)
LaunchResponse.extensionTopics ({ publishes: [{ topicType, schema?, legacy? }], subscribes: [] }) travels
through launchToBridgeSession into BridgeSessionConfig and PubSubBroker.register, so hosts using
useExtensionEmbed need no code change. A topic an extension declares in publishes passes between
extensions without a host grant and is validated against its schema; external subscribers must declare it in
subscribes. A name that is in the host domain always follows host rules.
Platform catalog client (any host)
import {
createPlatformClient,
createHostCapabilityRouter,
fetchEmbedLaunch,
launchToBridgeSession,
useExtensionEmbed,
} from '@finamx/host-bridge';
const client = createPlatformClient({
platformBase: 'http://127.0.0.1:3001',
userId: 'user-1',
});
const catalog = await client.fetchExtensionCatalog();
const installed = await client.fetchInstalledExtensions();
// Launch + embed + local invoke overrides:
const launch = await fetchEmbedLaunch({ extensionId: 'com.example.widget', userId: 'user-1' });
const session = launchToBridgeSession(launch);
const router = createHostCapabilityRouter({
platformBase: 'http://127.0.0.1:3001',
sessionJwt: session.sessionJwt ?? '',
localHandler: async (req) => {
if (req.method === 'my.host.method') return { ok: true, data: {} };
return null; // fall through to platform proxy
},
});DivKit widgets: fetchDivKitWidget from @finamx/host-bridge/widgets.
Host-signed identity (3.0.0)
Signing is server-only: signHostIdentity is exported from @finamx/host-bridge/server and signs platform.identity.v1 (ES256, iss = host id, sub = host-local user id, aud = extension id, kid, TTL at most 300 s) with a private JWK from the host secret store. The browser bundle does not sign and never holds the key. Call it only when the extension declares requiresIdentityContext, then hand the result to the iframe as identityToken in widget.identity.grant. The CMS stores only the public JWK; there is no HMAC fallback. sessionJwt and Core/Gateway tokens must not be placed in the iframe or in the identity grant.
import { signHostIdentity } from '@finamx/host-bridge/server';
const identityToken = await signHostIdentity({ hostId, userId, extensionId, kid, privateKey });Build
npm run build # tsc → dist/ (includes dist/mcp/)
npm test # build + run capability-router testsOutput is ESM-only ("type": "module"). Part of the FinamX Mobile monorepo.
Bootstrapping a brand-new host app (agent skill)
Everything in this README is also packaged as the plugin skill
openplatform-host-init (plugin skill /openplatform:openplatform-host-init, https://github.com/NikitaEfimenko/openplatform-plugin) — so a new project
doesn't need a human to manually replay these steps. It covers: installing
@finamx/bridge-protocol + @finamx/host-bridge from npm, fetching a launch, rendering via
the hook or ExtensionEmbedFrame, wiring real design tokens from the host's own theme,
opt-in debug tooling, capability proxying, host domain, and grants. Templates live in the
skills/openplatform-host-init/resources/ folder of the
openplatform plugin.
To run it, install the plugin in Claude Code, Codex or Cursor and run:
/openplatform:openplatform-host-initor describe the goal in plain language (e.g. "wire up extension hosting in this app") —
the skill matches automatically. The skill's first step always re-checks
npm view @finamx/host-bridge version before doing anything, since this package's publish
status can change between sessions.
If you're not using an agent, follow the "Quick Start" sections above in order: Install → launch → render (hook or component) → design tokens → debug tooling → capability proxying — that's the same sequence the skill automates.
For a full walkthrough that also covers starting the platform locally, picking a seeded test
extension, and a concrete verification checklist, see
docs/guides/application-layer/connect-a-new-host.md.
Agent-generated compose embeds (Phase 58, AGW-04)
MCP compose and Canvas workspace.launchWidget({ divkitJson }) return a server-built
embedUrl pointing at CMS /widget/inline?json=…&trust=agent-generated. Hosts must not
construct this URL client-side from raw DivKit JSON — the CMS inline route re-validates via
validateAgentGeneratedDivKit.
When mounting the compose iframe, pass a bridge session with trustClass: 'agent-generated':
import {
buildAgentGeneratedEmbedSession,
useExtensionEmbed,
} from '@finamx/host-bridge';
const session = buildAgentGeneratedEmbedSession({
sessionId: bridgeSessionId,
embedUrl, // from MCP compose structuredContent or LAUNCH_WIDGET SSE payload
extensionId: 'agent://compose/<widgetId>', // optional; defaults to agent://compose/{sessionId}
});
const bridge = useExtensionEmbed({
launchUrl: embedUrl,
allowedOrigin: session.allowedOrigin,
session, // trustClass: 'agent-generated' gates pubsub / MCP-UI sensitivity (Phase 54)
});Audit: compose (MCP), inline render (CMS), and graph instance launch (platform MCP graph.addInstance) each POST to
/api/v1/embed/audit with method: agent-generated.v1, extensionId, and sessionId.
Host implementers should correlate their own session/user IDs with these audit fields.
web-chat: wiring deferred until the submodule is restored; use buildAgentGeneratedEmbedSession
in any React host that mounts compose iframes.
