Building app UIs
Your app can ship a static frontend bundle (HTML/CSS/JS) that mounts
inside the tenant shell at https://app.linkworld.ai/apps/<your-slug>.
The bundle runs in a sandboxed iframe served from
apps-cdn.linkworld.ai and talks to the platform via a postMessage
bridge — same scope checks, same audit log, same tools as your
server-side handlers.
Mental model
Section titled “Mental model”┌─ app.linkworld.ai (tenant shell) ────────────────────────────┐│ ┌─ /apps/your-slug ────────────────────────────────────┐ ││ │ <iframe src="apps-cdn.linkworld.ai/your-slug/v…/"> │ ││ │ Your bundle: HTML + CSS + JS │ ││ │ </iframe> │ ││ └──────────────────────────────────────────────────────┘ ││ ↑ ↓ ││ theme / init / tools.result tools.call / resize / nav │└────────────│──────────────────────────────│──────────────────┘ │ │ │ ┌─ /api/mcp/iframe-tools ─────────┐ │ │ tenant cookie auth │ │ │ verify install + scopes │ │ │ route via SkillToolBridge │ │ └─────────────────────────────────┘ └─ result ─────────────┘The bundle never sees the user’s session cookie. The parent forwards tool calls using the cookie; the platform re-verifies the app is installed for the tenant before any tool runs.
Step 1 — scaffold
Section titled “Step 1 — scaffold”linkworld init creates a web/ directory next to your handlers:
your-app/├── linkworld.app.yaml├── main.py # or app.ts├── Dockerfile└── web/ ├── index.html ├── app.js └── styles.cssThe scaffolded index.html is a single-page bundle that demonstrates
the bridge — init listener, a tool call, resize. Tweak it to build
your real UI.
Step 2 — local dev loop
Section titled “Step 2 — local dev loop”Iterate on the bundle without a deploy round-trip:
$ linkworld dev
linkworld dev app_id: your-app bundle: /path/to/web mocks: /path/to/linkworld.dev.json (using defaults) open at: http://localhost:5174/ bundle on: http://localhost:5173/Two local servers start: the bundle on :5173 (static files +
hot-reload over SSE) and a fake tenant shell on :5174 that mounts
your bundle in an iframe and answers postMessage calls. Edit
anything under web/ and the iframe reloads automatically.
The fake parent has a Toggle theme button so you can verify the
theme.changed flow, and logs every bridge message in the chrome
bar.
To control what tool calls return, drop a linkworld.dev.json
next to your manifest — see the
CLI reference for the schema.
When you need real platform tools (real scopes, real audit), deploy to staging instead — see step 3.
Step 3 — deploy
Section titled “Step 3 — deploy”linkworld deploy auto-detects dist/ first, then web/:
$ linkworld deploy✓ Registered your-app v0.1.0 promoted to current_version
Uploading frontend bundle from /path/to/web…✓ Uploaded bundle (3 files, 6419 bytes) bundle: https://apps-cdn.linkworld.ai/your-app/0.1.0/Bundles are immutable per version. Re-deploying the same version
is rejected — bump the version (v0.1.1) for new uploads. Tenants
on v0.1.0 keep loading v0.1.0 until they explicitly upgrade.
Bundle size limits:
- 10 MB tarball (gzipped)
- 50 MB unpacked
- 2 MB per individual file
- 200 files max
- Allowed extensions:
.html,.js,.css,.svg,.png,.jpg,.jpeg,.webp,.woff2,.json,.txt,.map
Step 4 — use the bridge
Section titled “Step 4 — use the bridge”Two options. Use @linkworld_ai/sdk-browser for typed
tool calls, or raw postMessage for zero-dependency bundles.
With @linkworld_ai/sdk-browser
Section titled “With @linkworld_ai/sdk-browser”import { LinkworldBridge, ToolCallError } from '@linkworld_ai/sdk-browser'
const bridge = new LinkworldBridge({ onInit: (init) => { document.querySelector('#hello')!.textContent = `Hi ${init.userIdPrefix} on tenant ${init.tenantIdPrefix}` },})
document.querySelector('#send')!.addEventListener('click', async () => { try { const result = await bridge.tools.call('email_send', { subject: 'From the bundle', body: 'Sent via the bridge.', }) console.log('sent', result) } catch (err) { if (err instanceof ToolCallError && err.decision === 'scope_denied') { alert(`Missing scope: ${err.neededScopes?.join(', ')}`) } else { throw err } }})
bridge.resize(document.documentElement.scrollHeight)bridge.navigate('/settings')To call your own @app.http_route handlers from the bundle, use
bridge.callRoute(method, path, body?):
// GET your own handler — same as the @app.http_route declared in main.pyconst result = await bridge.callRoute('GET', '/outlets?wave=2')if (result.ok) { renderTimeline(result.body.outlets) // already parsed}
// PATCH for a status updateawait bridge.callRoute('PATCH', '/outlets/abc/status', { status: 'pitched' })The path must be declared in your manifest’s http_routes: block;
the platform proxy 404s anything else before it reaches your
container. See the SDK browser
reference
for the full response shape and error handling.
The SDK auto-injects the tenant theme as CSS custom properties, so
your stylesheet can just reference var(--lw-bg) etc. and look
native immediately. Pair this with the
lw-ui design system to skip
writing styles entirely:
<link rel="stylesheet" href="./vendor/sdk-browser/lw-ui.css" />…<main class="lw-app"> <header class="lw-app-header">…</header> <div class="lw-kpi-grid"> <div class="lw-kpi lw-kpi--success">…</div> </div> <button class="lw-btn lw-btn--primary">Save</button></main>The lw-* classes consume the same --lw-* tokens the bridge
injects, so light/dark/tenant-branded themes work automatically.
Apps that ship custom CSS with hardcoded #0a0e1a colors stay dark
forever, even when the tenant runs the light theme — that’s the
top reason bundles look “off-platform”. Use lw-ui for the
standard primitives (header, KPIs, buttons, cards, badges, modals)
and layer your own CSS only for app-specific bits (charts, custom
layouts).
Raw postMessage (no dependencies)
Section titled “Raw postMessage (no dependencies)”Useful for tiny bundles or when you don’t want to ship a build step.
window.parent.postMessage( { id: 't1', type: 'tools.call', tool: 'email_send', args: { /* … */ } }, '*',)
window.addEventListener('message', (e) => { if (e.data.id === 't1' && e.data.type === 'tools.result') { /* … */ }})
window.parent.postMessage({ type: 'resize', height: 800 }, '*')window.parent.postMessage({ type: 'navigate', path: '/settings' }, '*')The protocol is documented in the SDK browser reference.
Theme tokens
Section titled “Theme tokens”The parent forwards the tenant shell’s resolved color tokens via the
init message and any theme.changed follow-up (when the user
toggles light/dark mode). Available CSS custom properties on your
bundle’s :root after init:
| Variable | Purpose |
|---|---|
--lw-color-scheme | 'light' or 'dark' |
--lw-bg | Page background |
--lw-surface | Cards, inputs |
--lw-surface-raised | Modals, popovers |
--lw-text | Primary text |
--lw-text-muted | Secondary text, captions |
--lw-border | Default border |
--lw-accent | Primary action / link color |
--lw-accent-text | Foreground on accent backgrounds |
Provide fallbacks in your stylesheet so the bundle still looks
sensible when run standalone (e.g. opening web/index.html locally,
which never receives an init message):
:root { color-scheme: var(--lw-color-scheme, dark); --lw-bg: #0b0d12; --lw-text: #e6e8ee; --lw-accent: #6366f1; /* … */}The scaffolded web/styles.css already does this.
Sandbox model
Section titled “Sandbox model”The iframe is a sealed surface — all I/O routes through the bridge, which is what makes the audit trail complete.
- Outbound network — CSP
connect-src 'none'; reach the platform and your own HTTP routes viabridge.tools.call(...)andbridge.callRoute(...). - Tenant cookies / localStorage / DOM — origin-isolated on
apps-cdn.linkworld.ai; the browser enforces the boundary. - Tool calls — the bridge accepts only tools declared in
manifest.tools; anything else returnsdecision='scope_denied'. Add the tool and bump version. - Iframe nesting —
frame-src 'none'; your bundle is the leaf node, which avoids UI-redress attacks.
Trust model
Section titled “Trust model”Three layers, each independently sufficient:
- Origin separation. Your bundle runs on
apps-cdn.linkworld.ai, the tenant shell runs onapp.linkworld.ai. The browser blocks cross-origin DOM, cookie, and storage access by default. - CSP. The bundle vhost serves
connect-src 'none'; frame-src 'none'; frame-ancestors app.linkworld.ai. Even if bundle JS tried to fetch out, the browser refuses. - Server-side scope check. The platform verifies the tool call
against
manifest.toolsandgranted_scopesbefore running it. The bridge isn’t an escape hatch — it’s the same gate every tool call goes through.
The audit log records every iframe-driven tool call with the real
tenant user as user_id (server-side calls use a synthetic
app-principal:<slug> instead). Tenants and platform admins see who
clicked what, not just “the app did something”.