Robutler
GuidesWidgets

Running in the widget sandbox (third-party authors)

Folder-bundle widgets run inside a double iframe on a dedicated sandbox subdomain (widgets.${PUBLIC_DOMAIN}) — a hard origin boundary that prevents widget code from reading apex session state. This guide covers the contract widget code sees: what's around the inner iframe, how to declare CSP, which host capabilities are available, and how to test locally.

The double iframe

The apex page embeds:

  • Outer iframe — served from widgets.${PUBLIC_DOMAIN}, sandbox='allow-scripts allow-same-origin'. Holds no secrets; acts as a message relay between apex and the widget.
  • Inner iframesrcdoc constructed by the outer with a CSP built from your widget.json's _meta.ui.csp.* after allowlist validation. Your index.html runs here.

Your widget code talks to the host via window.postMessage. The SDK (/widgets/sdk.v2.js) is injected automatically — you do not need to add a script tag for it.

import { bridge, App } from '/widgets/sdk.v2.js';

// MCP Apps style
await bridge.initialize();
const result = await bridge.callTool('daily', { date: '2026-05-26' });

// Or the Claude App class
const app = new App();
await app.connect();

// Or the ChatGPT App SDK shim
const r = await window.openai.callTool('daily', { date: '2026-05-26' });

All three resolve to the same transport.

CSP allowlist syntax

widget.json declares four domain lists under _meta.ui.csp:

{
  "_meta": {
    "ui": {
      "csp": {
        "connectDomains": ["api.example.com", "telemetry.example.com"],
        "resourceDomains": ["cdn.example.com"],
        "frameDomains": [],
        "redirectDomains": ["example.com", "docs.example.com"]
      }
    }
  }
}

Each entry is a bare domain — no scheme, no port, no path. The validator rejects:

Token formWhy rejected
*wildcard defeats the allowlist
'unsafe-inline', 'unsafe-eval', etc.CSP escape vectors
data:, blob:URL-handle smuggling
127.0.0.1, ::1, any IPv4 / IPv6 literalbypasses DNS-based domain controls
anything with :// or :portuse bare domain

If any token in any list fails validation, the bundle falls back to a restrictive default: default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self'. Test your manifest at publish time — the failure is silent at first user load otherwise.

Mapping:

Manifest keyCSP directive
connectDomainsconnect-src
resourceDomainsscript-src / style-src / img-src
frameDomainsframe-src
redirectDomainsallowlist consulted by bridge.openLink

Available host capabilities

The host advertises its capability set in the ui/initialize response under hostCapabilities.experimental.robutler.capabilities. Feature-detect — don't hard-code:

const init = await bridge.initialize();
const caps = init.hostCapabilities?.experimental?.robutler?.capabilities ?? [];
if (caps.includes('user')) {
  const me = await host.user.get();
}

Always-present capabilities: kv, emit, commands, screenshot, workspace.

Conditionally present:

CapabilityGated on
userunconditional once Plan 1 ships (an authenticated currentUser is threaded)
collabisCollabV3Enabled(userId) from Plan 1's feature flag
liveLIVE_FEATURES_ENABLED env (Plan 2 / 3 flip on at their seed)

The capabilities array is runtime-computed by the server — a widget cannot self-grant capabilities by editing its own manifest.

_meta.ui.visibility

Per-tool, default ['app']. 'app' means only the widget UI may invoke; the model cannot. To let an agent invoke your tool, include 'model'. To surface it in human-facing tool palettes, include 'user'.

The default ['app'] is deliberate: model-driven invocation requires explicit opt-in. Tools without 'app' reject calls from the widget's own UI; tools without 'model' reject MCP / agent calls.

Local testing

sandbox.localhost resolves to 127.0.0.1 in every modern browser (Chrome, Firefox, Safari) without any /etc/hosts edit. Run your dev server:

pnpm dev   # binds 0.0.0.0:3000

Then load your widget against the sandbox host:

https://sandbox.localhost:3000/widget-sandbox/<folderId>/index.html

The dev proxy.ts rewrites sandbox.localhost:3000/* to /widget-sandbox/* so the route layout mirrors production. The isolation boundary (cookie scope, CORS pair, CSP construction) is identical to staging / production — there is no "works locally, breaks in prod" gap on these controls.

If you're testing fetches from the sandbox back to the portal, remember the CORS contract: the folder resolver sets Access-Control-Allow-Origin: https://sandbox.localhost:3000 but omits Access-Control-Allow-Credentials. Sandbox-side fetches MUST use credentials: 'omit' — the SDK does this by default; if you fetch directly, do the same.

See also: authoring.md for widget.json schema, mcp-app-publishing.md for the outbound MCP App contract, ADR-v3-18 for the sandbox isolation model.

On this page