Skip to content

SDK — Browser bridge

@linkworld_ai/sdk-browser is the runtime that pairs with frontend bundles served from apps-cdn.linkworld.ai. It wraps the postMessage protocol used to talk to the tenant shell and forwards platform tool calls through the parent window.

For the higher-level “how do I ship a frontend?” guide, see Building app UIs. This page is the API reference.

Terminal window
npm install @linkworld_ai/sdk-browser

The package is ESM-only, browser-targeted, ~3 KB minified, with no runtime dependencies.

Wires the postMessage listener and returns a bridge instance. One per page — constructing a second is allowed but unusual.

import { LinkworldBridge } from '@linkworld_ai/sdk-browser'
const bridge = new LinkworldBridge({
onInit: (init) => { /* … */ },
onThemeChange: (theme) => { /* … */ },
})
FieldTypeDefaultDescription
parentOriginstring'*'Target origin for outbound posts. The parent’s own origin gate is the load-bearing check; set this if you want defense-in-depth.
toolCallTimeoutMsnumber30000Per-call timeout. The Promise rejects with Error after this.
onInit(init: BridgeInit) => voidCalled once when the parent posts the init payload.
onThemeChange(theme: BridgeTheme) => voidCalled when the parent posts a theme change (init + light/dark toggles).
onLanguageChange(language: string) => voidCalled when the parent’s BCP-47 language tag changes. Fires after init too if a language was in the init payload.
onRouteChange(routeName: string) => voidCalled when the parent shell’s pathname changes (NOT when the bundle’s own route changes).
injectThemeStylebooleantrueAuto-write --lw-* CSS custom properties on document.documentElement. Set false if you want to manage CSS yourself via onThemeChange.
injectLanguagebooleantrueWrite the language tag to document.documentElement.lang so :lang(de) selectors and screen-reader pronunciation just work. Set false to manage <html lang> yourself.

bridge.tools.call(tool, args?) => Promise<...>

Section titled “bridge.tools.call(tool, args?) => Promise<...>”

Mirror of the server-side ctx.tools.call(...). Resolves with the tool result; rejects with ToolCallError on platform refusal or Error on timeout / network failure.

const result = await bridge.tools.call('email_send', {
subject: 'Hi',
body: 'Hello.',
})

Platform tool names + argument schemas are bundled with the SDK as a PlatformTools map (generated from the tool registry at SDK release time). bridge.tools.call is overloaded so:

  • Known platform tool name (e.g. 'email_send') → args are typed, required fields are enforced, autocomplete works on field names
  • Any other string (a partner-defined tool from your manifest’s tools: block) → args are Record<string, unknown>, result is unknown — the platform doesn’t know your custom tools at SDK build time, so types are open
// Typed — autocomplete + required-field check
await bridge.tools.call('calendar_create_event', {
subject: 'Standup',
start: '2026-04-28T09:00:00Z',
end: '2026-04-28T09:15:00Z',
})
// Compiler error — `to` is required for email_send
await bridge.tools.call('email_send', { subject: 'oops' })
// Typed args AND typed result — rag_query response is well-known
const corpus = await bridge.tools.call('rag_query', {
slot: 'launch_corpus',
tags: ['hook_c'],
top_k: 3,
})
// corpus.documents[0].body, corpus.documents[0].source_status — typed
// Partner-defined tool — typed as Record<string, unknown>
await bridge.tools.call('my_app_classify', { text: 'hello' })

Result types are typed when the tool definition declares an output_schema (e.g. all rag_* tools, kv_get, etc.) — autocomplete works on the response too. Tools without output_schema fall back to Record<string, unknown>; narrow at the call site if you need a structured return.

You can also import the catalog types directly:

import type { PlatformTools, PlatformToolName } from '@linkworld_ai/sdk-browser'

bridge.callRoute(method, path, body?) => Promise<{ok, status, body, headers}>

Section titled “bridge.callRoute(method, path, body?) => Promise<{ok, status, body, headers}>”

Calls one of your app’s own @app.http_route(...) handlers (see sdk-python / sdk-typescript). This is the equivalent of fetch() for the bundle — the iframe’s CSP is connect-src 'none' so direct fetch is blocked. callRoute forwards through the parent, which proxies to /api/apps/<your-slug>/route/<path> with the user’s session cookie attached.

Use this for partner-defined HTTP endpoints (CRUD, custom views, file serving). Use bridge.tools.call for platform tools (email_send, calendar_*, etc.) — different beast.

// GET — fetch your own data
const result = await bridge.callRoute('GET', '/outlets?wave=2')
if (result.ok) {
console.log(result.body) // already parsed JSON
}
// POST with a JSON body — the bridge serializes it
const draft = await bridge.callRoute('POST', '/outlets/abc/draft', {
language: 'de',
})
// PATCH for status updates
await bridge.callRoute('PATCH', '/outlets/abc/status', { status: 'pitched' })
ArgTypeNotes
method'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'Other methods rejected with TypeError
pathstringMust start with /, ≤ 1024 chars, no traversal/control chars. May carry a query string (?limit=200) but no fragment.
bodyunknownJSON-serialized before posting. Unserializable bodies (BigInt, circular ref) reject with TypeError.
{
ok: boolean // res.status in 200..299
status: number // HTTP status code from your handler
body: unknown // parsed JSON if Content-Type was JSON, else string
headers: Record<string, string>
}

The Promise resolves on any HTTP response (including 4xx/5xx) — check ok / status yourself. It rejects only on:

  • Timeout (toolCallTimeoutMs, default 30s)
  • Not running inside a parent frame (standalone-dev mode)
  • Invalid method or path (TypeError before posting)

The path you call must be declared in your manifest’s http_routes block, else the platform proxy returns 404 before reaching your handler:

http_routes:
- path: /outlets
method: GET
- path: /outlets/{outlet_id}/draft
method: POST

Path params with {name} braces work the same as in @app.http_route('/outlets/{outlet_id}/draft', methods=['POST']) — the handler receives outlet_id as a kwarg.

Asks the parent to set the iframe height. Useful for content-driven sizing. The parent clamps the value to [80, 8000] so a buggy bundle can’t consume the whole page.

A common pattern is to push on every layout change:

const post = () => bridge.resize(document.documentElement.scrollHeight)
window.addEventListener('load', post)
new ResizeObserver(post).observe(document.documentElement)

Asks the parent to perform a real Next.js navigation to path. The URL bar changes, browser back/forward works, and the new route survives a hard reload. Use this for cross-view navigation that the user would expect to look like normal page navigation.

// Inside a list-item click handler:
bridge.navigate('/apps/office-assistant/documents/abc-123')

Allowlist: the parent rejects any path that does not start with /apps/<this-app-slug>/. The bundle cannot escape its own namespace — attempts to navigate to admin pages, other apps, or the platform root are silently dropped (logged in dev; no error to the bundle to avoid an info-leak gate).

Defense-in-depth checks beyond the prefix: rejects .., // inside the prefix, control chars, ?, #, length > 256.

For in-bundle route changes that should NOT re-mount the iframe but should still survive reload, use bridge.replace instead.

Asks the parent to remember an in-bundle sub-path as a URL fragment (e.g. /apps/<slug>/#/documents/123). Survives reload, but the parent does NOT re-mount the iframe — your bundle keeps its in-memory state.

// When the user navigates inside the bundle to the detail view:
bridge.replace('/documents/abc-123')
// On the next mount, read it back via bridge.subPath to bootstrap
// to the right view (see below).

Allowlist: path must start with /, contain only [A-Za-z0-9/_.\-], max 200 chars, no .., no //, no control chars.

Use bridge.navigate instead when you want the parent’s URL pathname to actually change.

The URL fragment the parent had at iframe-mount time — populated from init.subPath. Use to bootstrap your bundle to the right view after a reload.

const bridge = new LinkworldBridge({
onInit: (init) => {
if (init.subPath?.startsWith('/documents/')) {
const id = init.subPath.slice('/documents/'.length)
openDetail(id)
return
}
openList()
},
})

Same shape rules as bridge.replace — null when the parent had no fragment.

bridge.confirm(options) => Promise<boolean>

Section titled “bridge.confirm(options) => Promise<boolean>”

Show a platform-rendered confirmation dialog. The platform’s own ConfirmDialog component renders full tenant-window-sized, NOT centered on the iframe — use this for destructive actions where a cramped iframe-bound dialog would feel wrong.

const ok = await bridge.confirm({
title: 'Brief löschen?',
message: 'Dieser Vorgang kann nicht rückgängig gemacht werden.',
confirmText: 'Löschen',
confirmVariant: 'danger', // 'default' | 'danger' | 'warning'
cancelText: 'Abbrechen',
})
if (ok) await deleteIt()

Plain text only — title and message are escaped before render. HTML in any field is shown as text, never executed.

title is required; everything else has sensible defaults ('Confirm' / 'Cancel' / 'default'). Rejects with Error on 60s timeout or if not running inside a parent frame. Only one dialog at a time — concurrent calls resolve false to prevent hangs.

Persistent JSONB key-value store for settings, activity feeds, cursors. See the dedicated KV reference for full semantics, limits, and storage details.

const settings = await bridge.kv.get('settings') ?? {}
await bridge.kv.set('settings', { cadence_minutes: 10 })
const items = await bridge.kv.list({ prefix: 'activity:', limit: 50 })
await bridge.kv.delete('activity:msg-abc')

Methods: get(key), set(key, value, { ttlSeconds? }), list({ prefix?, limit?, includeValues? }), delete(key). The browser bridge routes through the same tools.call postMessage pipeline, so origin verification and scope checks still apply.

Show a platform-rendered toast (success / error / warning / info). Fire-and-forget — no Promise to await.

bridge.toast({ type: 'success', message: 'Brief gespeichert' })
bridge.toast({ message: 'Gespeichert' }) // type defaults to 'info'

Rate-limited: at most 5 toasts/sec across all bridges in a page. Excess calls are silently dropped to prevent a buggy bundle from spamming the user. Both the SDK and AppFrameHost enforce the cap independently — bypassing the bridge can’t exceed it.

Last received init payload. Null until the parent posts the first init message.

interface BridgeInit {
appId: string // your app slug
tenantIdPrefix: string // 8-char display string only
userIdPrefix: string // 8-char display string only
theme: BridgeTheme | null // see below
language: string | null // BCP-47 tag, e.g. 'en', 'de-DE'
routeName: string | null // parent shell pathname
}

The prefixes are NOT auth tokens — they’re 8-character display strings for “Hi {{user}}” UX. Don’t use them for any kind of identity check; the platform side handles auth via the parent’s session cookie.

Latest BCP-47 language tag the parent shell pushed (e.g. 'en', 'de-DE'). null until the parent has sent one. The bridge writes this to document.documentElement.lang automatically unless injectLanguage: false.

Validation: the bridge only accepts shapes matching ^[a-z]{2,3}(-[A-Z]{2,4})?$. The parent runs the same regex before posting, so you should never see malformed values; any that slip through are silently dropped.

Latest parent-shell pathname (no scheme, no host, no query, no hash). Useful for bundles that want to react to the user navigating to a different panel — e.g. dim the bundle when the user moves elsewhere in the tenant shell.

new LinkworldBridge({
onRouteChange: (route) => {
document.body.dataset.parentRoute = route
if (!route.startsWith('/apps/my-app')) {
document.body.classList.add('background-mode')
} else {
document.body.classList.remove('background-mode')
}
},
})

NOT auth-scoped data — any tenant user reading this iframe sees their own pathname.

Latest theme snapshot. Set after init and on every theme.changed.

interface BridgeTheme {
colorScheme: 'light' | 'dark'
bg: string
surface: string
surfaceRaised: string
text: string
textMuted: string
border: string
accent: string
accentText: string
// Non-color tokens. All optional for back-compat with older
// parent shells. Partner CSS uses
// `var(--lw-radius-md, 8px)` fallbacks so absent tokens are safe.
spacing?: { xs?: string; sm?: string; md?: string; lg?: string; xl?: string; '2xl'?: string }
radii?: { xs?: string; sm?: string; md?: string; lg?: string; xl?: string; full?: string }
fonts?: { sans?: string; mono?: string }
shadows?: { xs?: string; sm?: string; md?: string; lg?: string; xl?: string }
}

Color values are plain CSS color strings (whatever the tenant shell’s design tokens resolved to). When injectThemeStyle: true (default) the bridge writes them as CSS custom properties:

:root {
color-scheme: <theme.colorScheme>;
--lw-color-scheme: <theme.colorScheme>;
--lw-bg: <theme.bg>;
--lw-surface: <theme.surface>;
--lw-surface-raised: <theme.surfaceRaised>;
--lw-text: <theme.text>;
--lw-text-muted: <theme.textMuted>;
--lw-border: <theme.border>;
--lw-accent: <theme.accent>;
--lw-accent-text: <theme.accentText>;
/* Non-color tokens, written when present in the theme. */
--lw-space-xs: <theme.spacing.xs>;
--lw-space-sm: <theme.spacing.sm>;
--lw-space-md: <theme.spacing.md>;
--lw-space-lg: <theme.spacing.lg>;
--lw-space-xl: <theme.spacing.xl>;
--lw-space-2xl: <theme.spacing['2xl']>;
--lw-radius-xs: <theme.radii.xs>;
--lw-radius-sm: <theme.radii.sm>;
--lw-radius-md: <theme.radii.md>;
--lw-radius-lg: <theme.radii.lg>;
--lw-radius-xl: <theme.radii.xl>;
--lw-radius-full: <theme.radii.full>;
--lw-shadow-xs: <theme.shadows.xs>;
--lw-shadow-sm: <theme.shadows.sm>;
--lw-shadow-md: <theme.shadows.md>;
--lw-shadow-lg: <theme.shadows.lg>;
--lw-shadow-xl: <theme.shadows.xl>;
--lw-font-sans: <theme.fonts.sans>;
--lw-font-mono: <theme.fonts.mono>;
}

Each non-color token value is rejected if it contains newlines, NUL, exceeds 200 chars, or includes </style — defense in depth against CSS injection via a misbehaving parent. Bundles should use fallbacks in their CSS so a parent that ships only colors still produces a usable layout:

.card {
border-radius: var(--lw-radius-md, 8px);
padding: var(--lw-space-lg, 16px);
box-shadow: var(--lw-shadow-sm, 0 1px 3px rgba(0,0,0,0.3));
font-family: var(--lw-font-sans, system-ui, sans-serif);
}

Provide fallbacks in your stylesheet for standalone runs (no parent):

:root {
color-scheme: var(--lw-color-scheme, dark);
--lw-bg: #0b0d12;
--lw-text: #e6e8ee;
--lw-accent: #6366f1;
/* … */
}

Thrown by tools.call when the platform refused.

import { ToolCallError } from '@linkworld_ai/sdk-browser'
try {
await bridge.tools.call('email_send', {...})
} catch (err) {
if (err instanceof ToolCallError) {
err.decision // e.g. 'scope_denied' | 'install_missing'
err.neededScopes // string[] | undefined
err.message // human-readable
}
}

Common decisions:

decisionMeaning
scope_deniedThe app didn’t request the scope at install. Update manifest.tools and bump version.
install_missingShould be unreachable — parent gates on install state.
(unset)Tool ran but returned an error; check message.

Removes the message listener and rejects pending tool calls. For tests; production code shouldn’t need it.

If you want to skip the SDK and use raw window.parent.postMessage, the wire format is:

{ id: string, type: 'tools.call', tool: string, args: object }
{ type: 'resize', height: number }
{ type: 'navigate.push', path: string } // real router.push
{ type: 'navigate.replace', path: string } // fragment update only
{ id: string, type: 'overlay.confirm', title, message, confirmText?,
confirmVariant?, cancelText? }
{ type: 'overlay.toast', toastType, message }
{ type: 'init', appId, tenantIdPrefix, userIdPrefix, theme, language, routeName, subPath }
{ type: 'theme.changed', theme }
{ type: 'language.changed', language }
{ type: 'route.changed', routeName }
{ id: string, type: 'tools.result', result: unknown }
{ id: string, type: 'tools.error', error: { decision?, message, neededScopes? } }
{ id: string, type: 'overlay.confirm.result', confirmed: boolean }

language, routeName, and subPath may be null in the init message when the parent couldn’t resolve them. Older parents send init without those fields — the bridge handles absent → null cleanly.

The legacy {type: 'navigate', path} message is kept as a no-op on the parent so old SDK bundles posting it don’t blow up, but new code should use navigate.push / navigate.replace.

id is whatever the bundle picked — the parent echoes it back so the bundle can correlate calls and responses.

The SDK does not enforce any security on the bundle side. The real gates:

  1. The parent verifies originevent.origin must match apps-cdn.linkworld.ai (or an allow-listed dev origin).
  2. The parent verifies sourceevent.source must equal the iframe’s own contentWindow.
  3. The platform re-verifies install + scopes before running any tool. The cookie auth tells it which user; the request body carries the app_id; the platform checks linkworld_tenant_apps and rejects with decision='scope_denied' if the tool isn’t in the app’s manifest.

A bundle that posts well-formed messages without being an actual installed app gets nothing.