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.
Install
Section titled “Install”npm install @linkworld_ai/sdk-browserThe package is ESM-only, browser-targeted, ~3 KB minified, with no runtime dependencies.
new LinkworldBridge(options?)
Section titled “new LinkworldBridge(options?)”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) => { /* … */ },})BridgeOptions
Section titled “BridgeOptions”| Field | Type | Default | Description |
|---|---|---|---|
parentOrigin | string | '*' | Target origin for outbound posts. The parent’s own origin gate is the load-bearing check; set this if you want defense-in-depth. |
toolCallTimeoutMs | number | 30000 | Per-call timeout. The Promise rejects with Error after this. |
onInit | (init: BridgeInit) => void | — | Called once when the parent posts the init payload. |
onThemeChange | (theme: BridgeTheme) => void | — | Called when the parent posts a theme change (init + light/dark toggles). |
onLanguageChange | (language: string) => void | — | Called when the parent’s BCP-47 language tag changes. Fires after init too if a language was in the init payload. |
onRouteChange | (routeName: string) => void | — | Called when the parent shell’s pathname changes (NOT when the bundle’s own route changes). |
injectThemeStyle | boolean | true | Auto-write --lw-* CSS custom properties on document.documentElement. Set false if you want to manage CSS yourself via onThemeChange. |
injectLanguage | boolean | true | Write 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.',})Typed platform tools
Section titled “Typed platform tools”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 areRecord<string, unknown>, result isunknown— the platform doesn’t know your custom tools at SDK build time, so types are open
// Typed — autocomplete + required-field checkawait 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_sendawait bridge.tools.call('email_send', { subject: 'oops' })
// Typed args AND typed result — rag_query response is well-knownconst 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 dataconst 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 itconst draft = await bridge.callRoute('POST', '/outlets/abc/draft', { language: 'de',})
// PATCH for status updatesawait bridge.callRoute('PATCH', '/outlets/abc/status', { status: 'pitched' })Parameters
Section titled “Parameters”| Arg | Type | Notes |
|---|---|---|
method | 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | Other methods rejected with TypeError |
path | string | Must start with /, ≤ 1024 chars, no traversal/control chars. May carry a query string (?limit=200) but no fragment. |
body | unknown | JSON-serialized before posting. Unserializable bodies (BigInt, circular ref) reject with TypeError. |
Response shape
Section titled “Response shape”{ 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)
Manifest declaration
Section titled “Manifest declaration”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: POSTPath 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.
bridge.resize(height: number) => void
Section titled “bridge.resize(height: number) => void”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)bridge.navigate(path: string) => void
Section titled “bridge.navigate(path: string) => void”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.
bridge.replace(path: string) => void
Section titled “bridge.replace(path: string) => void”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.
bridge.subPath: string | null
Section titled “bridge.subPath: string | null”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.
bridge.kv — per-(tenant, app) KV store
Section titled “bridge.kv — per-(tenant, app) KV store”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.
bridge.toast(options) => void
Section titled “bridge.toast(options) => void”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.
bridge.init: BridgeInit | null
Section titled “bridge.init: BridgeInit | null”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.
bridge.language: string | null
Section titled “bridge.language: string | null”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.
bridge.routeName: string | null
Section titled “bridge.routeName: string | null”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.
bridge.theme: BridgeTheme | null
Section titled “bridge.theme: BridgeTheme | null”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; /* … */}class ToolCallError extends Error
Section titled “class ToolCallError extends Error”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:
decision | Meaning |
|---|---|
scope_denied | The app didn’t request the scope at install. Update manifest.tools and bump version. |
install_missing | Should be unreachable — parent gates on install state. |
| (unset) | Tool ran but returned an error; check message. |
bridge.destroy() => void
Section titled “bridge.destroy() => void”Removes the message listener and rejects pending tool calls. For tests; production code shouldn’t need it.
Wire format
Section titled “Wire format”If you want to skip the SDK and use raw window.parent.postMessage,
the wire format is:
Bundle → parent
Section titled “Bundle → parent”{ 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 }Parent → bundle
Section titled “Parent → bundle”{ 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.
Trust boundary
Section titled “Trust boundary”The SDK does not enforce any security on the bundle side. The real gates:
- The parent verifies origin —
event.originmust matchapps-cdn.linkworld.ai(or an allow-listed dev origin). - The parent verifies source —
event.sourcemust equal the iframe’s owncontentWindow. - 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 checkslinkworld_tenant_appsand rejects withdecision='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.