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 iframe —
srcdocconstructed by the outer with a CSP built from yourwidget.json's_meta.ui.csp.*after allowlist validation. Yourindex.htmlruns 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 form | Why 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 literal | bypasses DNS-based domain controls |
anything with :// or :port | use 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 key | CSP directive |
|---|---|
connectDomains | connect-src |
resourceDomains | script-src / style-src / img-src |
frameDomains | frame-src |
redirectDomains | allowlist 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:
| Capability | Gated on |
|---|---|
user | unconditional once Plan 1 ships (an authenticated currentUser is threaded) |
collab | isCollabV3Enabled(userId) from Plan 1's feature flag |
live | LIVE_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:3000Then load your widget against the sandbox host:
https://sandbox.localhost:3000/widget-sandbox/<folderId>/index.htmlThe 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.