@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-protocol
Quick 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-protocol
import {
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' |
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 tests
Output 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-init
or 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.
Flow notifications and human decisions (server only)
createHostS2sEndpoint checks the caller's registered scope and validates method body schemas
loaded through createCachedDomainLoader. Declare requestBody / responseBody as
{ "$ref": "#/topics/<topicType>" } and provide those topic schemas in the same host domain.
Parameters are checked before invoking the handler, and results after unwrapping hostResult.
Missing or malformed references refuse the call before invoking the handler. Legacy inline
paramsSchema / resultSchema remain descriptive, as on the browser path.
METHOD_PARAMS_INVALID and METHOD_SCHEMA_UNRESOLVED mean the handler was not called.
METHOD_RESULT_INVALID means its result was withheld; the handler may already have performed
an effect. Effect methods still need durable idempotency and reconciliation in the host.
The server subpath exports createSelfNotificationSender, flowNotificationKey,
createFlowDecisionStore and createFlowDecisionService. The notification sender
completes at a validated Outreach HTTP 202 acceptance, with a deterministic key
per host/extension/rpcId. The caller must resolve the recipient and message from
an authenticated host context; the graph cannot choose a recipient.
Decision storage requires a host-owned atomic persistence adapter (a Mongo
collection matches DecisionCollection). Pending requests have no expiry. The
answer and completion outbox are written atomically; callbacks are retried by the
host worker. Host session/CSRF checks, current consent/entitlement, artifact access,
outbox draining and host UI remain integration responsibilities. These helpers do
not install host topics/methods, grant background access, or authenticate a raw
request body. They must never be called from browser or Temporal workflow code.
CMS signing trust (3.9.1)
Deploy /op/jwks and /host/jwks in CMS before upgrading. Standard CMS token
verifiers select this dedicated source when configured with the legacy aggregate
JWKS URL, without fallback. Custom JWKS sources and injected getKey/cmsGetKey
resolvers must contain only CMS signing keys. Host identity keeps its separate host
key source (/host/jwks) and CMS-signed install binding. Current/overlap host keys
remain available at the legacy /.well-known/jwks.json endpoint for compatibility;
aggregate membership never grants CMS authority.
AG-UI chat transport
Use @finamx/host-bridge/ag-ui in the browser and @finamx/host-bridge/server for the Node stream, scoped receipts and durable ACK/cancel lifecycle. Both ESM and CommonJS server consumers are supported. Host auth, endpoint routing and chat/board persistence are supplied as ports. See the host integration guide for the lifecycle and the Trading Diary entry-point seam. Preview patches are inert; a stream commit never grants an action capability.