Skip to content

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.

┌─ 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.

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.css

The 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.

Iterate on the bundle without a deploy round-trip:

Terminal window
$ 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.

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

Two options. Use @linkworld_ai/sdk-browser for typed tool calls, or raw postMessage for zero-dependency bundles.

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.py
const result = await bridge.callRoute('GET', '/outlets?wave=2')
if (result.ok) {
renderTimeline(result.body.outlets) // already parsed
}
// PATCH for a status update
await 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).

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.

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:

VariablePurpose
--lw-color-scheme'light' or 'dark'
--lw-bgPage background
--lw-surfaceCards, inputs
--lw-surface-raisedModals, popovers
--lw-textPrimary text
--lw-text-mutedSecondary text, captions
--lw-borderDefault border
--lw-accentPrimary action / link color
--lw-accent-textForeground 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.

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 via bridge.tools.call(...) and bridge.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 returns decision='scope_denied'. Add the tool and bump version.
  • Iframe nestingframe-src 'none'; your bundle is the leaf node, which avoids UI-redress attacks.

Three layers, each independently sufficient:

  1. Origin separation. Your bundle runs on apps-cdn.linkworld.ai, the tenant shell runs on app.linkworld.ai. The browser blocks cross-origin DOM, cookie, and storage access by default.
  2. 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.
  3. Server-side scope check. The platform verifies the tool call against manifest.tools and granted_scopes before 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”.