widget.json manifest
Apps are built as widgets, and a widget's manifest is the single declaration the platform reads to render it, sandbox it, and expose it to agents. The shape is the WidgetSpec. First-party apps declare it in the registry; community and bundle apps declare it in widget.json (or, for a single HTML file, in a <meta name="robutler:widget"> tag, see the meta form).
WidgetSpec fields
interface WidgetSpec {
kind: 'native' | 'iframe' | 'iframe-external' | 'projection-only';
itemType: WorkspaceItemType; // workspace_items.type to insert (iframe/projection-only use 'content')
size: { width: number; height: number };
entry?: string; // iframe path or external URL
csp?: { frameSrc?: string[]; connectSrc?: string[] };
allow?: string; // iframe allow= (Permissions-Policy); first-party only, derived not hand-coded
interface?: WidgetInterface; // agent-facing command + event surface
collab?: boolean; // declares use of host.collab realtime rooms
preferBundle?: boolean; // serve from the self-contained DB bundle
}kind
The render strategy:
| Kind | How it renders |
|---|---|
native | A dedicated React component owns the UI (terminal, agent, daemon, ssh, python, files). No iframe. |
iframe | A sandboxed iframe loading entry from /widgets/... on the portal origin. Talks to the host over the postMessage bridge. This is the common case for App SDK apps. |
iframe-external | A sandboxed iframe loading a fully-qualified external URL. Stricter sandbox (no allow-same-origin); the host injects no CSP because the external origin owns its own. |
projection-only | No iframe; the canvas renders the app's declarative DSL projection directly. For purely visual surfaces (Weather, agent-status) with no interactive chrome. |
itemType
The workspace_items.type to insert when the app is added to the canvas. Native apps use their dedicated type (terminal, agent, daemon, ssh, python, files); iframe and projection-only apps all use content, and the content row's metadata.widgetType carries the actual identity.
size
The default canvas footprint { width, height } on first spawn.
entry
For kind: 'iframe', a path under public/widgets/ (for example /widgets/snake/index.html). For kind: 'iframe-external', a fully-qualified URL. Ignored for native and projection-only apps.
csp
A per-app CSP carve-out for first-party apps served from /widgets/*, merged into the permissive static baseline. Two keys:
frameSrc: origins the app may embed in child frames. Needed only by embed-style apps (for examplebrowser-embedsetsframeSrc: ['https:']).connectSrc: extra connect origins. The static baseline already allowshttps:andwss:, so most first-party apps need nothing here.
{
"csp": {
"connectSrc": [
"https://esm.sh",
"https://cdn.jsdelivr.net",
"wss://*.robutler.ai"
]
}
}This field does nothing for a bundle app. Its only consumer is composeWidgetCspHeaders in lib/workspaces/widget-csp.ts, which builds static response-header rules for /widgets/<dir>/* paths from the hard-coded WIDGET_REGISTRY at server start. An app you scaffold, remix or publish is served through the sandbox route instead, and its policy comes from _meta.ui.csp in widget.json (below). The csp and csp.frame-src / csp.connect-src keys of the <meta name="robutler:widget"> tag are parsed onto the meta record and then read by nothing on that path: publish does not store them, and no request handler consults them. Declaring them there is a silent no-op.
Keep carve-outs minimal either way: the sandbox, not the CSP, is the security boundary (see the security model), but a tight policy keeps the app's network surface honest.
allow
The iframe allow= (Permissions-Policy) attribute, for first-party apps only. It is not hand-coded per app: the platform derives it from the app's declared permissions and the delegation rules. A community app can never self-escalate trust through allow; its sensitive features come only from per-app user grants. See Permissions-Policy delegation.
interface
The agent-facing declaration: a description plus the commands an agent can invoke and the events the app emits. Optional. An app without an interface still renders, but it will not appear in workspace_widgets_list with anything for agents to drive. See the agent command interface for the full shape.
Each command accepts description, args, returns and streams, and nothing else: an unrecognised key (input, params, schema, a typo) is dropped without an error, leaving the command with a description and no parameter shape. The key for parameters is args. The authoring guide spells out each key.
{
"interface": {
"description": "A pitch deck: fullscreen, navigable slides.",
"commands": {
"next": { "description": "Advance one slide." },
"goTo": { "description": "Jump to a slide.", "args": { "index": "number" } }
},
"events": {
"deck.slide_view": { "description": "Fires when a slide becomes active." }
}
}
}collab
true declares that the app uses host.collab realtime rooms. This is the gate the room-scope collab token mint checks: only collab-enabled apps may open code-based lobby rooms under their content id, so a room token cannot be turned into access to arbitrary item or workspace rooms. Set it whenever your app calls host.collab.room(...).join(...).
preferBundle
true serves the app from its self-contained DB bundle (the seeded *-bundle folder, via the sandbox route) instead of the static public/ entry, whenever the content row carries a folder bundle. This makes a db seed widgets enough to push an app update to a cloud environment, no CI redeploy of public/ required. It falls back to the static entry when no bundle is present. The trade-off: the mount loads through the sandbox double-iframe plus a DB read instead of a static asset.
Sandbox CSP for bundle apps
Every bundle app (scaffolded, remixed or published) renders through the sandbox route, /widget-sandbox/<folderId>/<entry>, which injects a Content-Security-Policy into the inner document. That policy is composed on every request by composeSandboxCsp (lib/widgets/sandbox-csp.ts) from the _meta.ui.csp block of the folder's widget.json. The route reads the widget.json file child of the folder first and falls back to the manifest snapshot on the app's content row only when that file is missing, unparseable, or declares neither csp nor permissions, so editing widget.json with widget_put_files and calling __reload is enough to change the policy; no republish is involved. Script files served out of the folder carry the same policy, cached per folder for one minute (lib/widgets/folder-csp.ts). The block is validated whenever the manifest is loaded for registration, remix or publish (manifest_invalid_csp on a bad entry); the sandbox route itself parses without validating, and widget_put_files does not validate either, so a malformed entry written mid-edit surfaces at the next publish, not at reload. This block is the only CSP lever an author of a bundle app has. The type lives in lib/widgets/bundle-types.ts as WidgetCsp.
The baseline with no declaration
An app whose manifest has no _meta.ui.csp and none of the opt-ins below gets SANDBOX_BASELINE_CSP, verbatim:
default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'Two consequences follow, and both fail silently from the app's point of view (a CSP violation is a console line in the browser, never an error the app can catch):
- No outbound calls to any host but the app's own origin.
connect-src 'self'coversfetch,XMLHttpRequest,WebSocketandEventSource. A call to a third-party API, a CDN, awss://endpoint or the portal apex from inside the iframe is refused. Your own bundle files and the SDK are same-origin and load normally, and host.fn.invoke goes over the host bridge rather than from the iframe, so it is unaffected. - No media playback at all. There is no
media-srcdirective, sodefault-src 'none'governs<audio>and<video>. Nothing plays: not anhttps:URL, not adata:URL, and not ablob:object URL created withURL.createObjectURL()from a file the user just picked in an<input type="file">. Decoding throughAudioContext.decodeAudioDatais unaffected because it never fetches; it is the media element that is blocked.media: true(below) is the switch.
The baseline also has no frame-src (child frames are refused), no 'wasm-unsafe-eval' (WebAssembly.instantiate is refused), and no worker-src, which falls back to script-src and therefore admits a same-origin worker script but not a blob: worker.
Domain allowlists
Four arrays of bare hostnames: no scheme, no port, no path, no IP literals, no bare *, and none of the CSP tokens *, 'unsafe-eval', 'unsafe-inline', data: or blob:. A leading wildcard label is allowed (*.example.com). Each entry is emitted as https://<host> and nothing else.
| Key | Directive(s) it feeds | Notes |
|---|---|---|
connectDomains | connect-src | Emits https://<host> only. A wss:// socket to that same host is still refused; sockets need realtime or webrtc. |
resourceDomains | script-src, style-src, img-src, font-src (and connect-src when realtime is set) | The key for a CDN ES module: a dynamic import() from esm.sh or jsdelivr is a script-src fetch, so the host goes here, not in connectDomains. |
frameDomains | frame-src | Default is frame-src 'none'. |
redirectDomains | none | Validated with the same rules; it is the target allowlist for ui/open-link, not a CSP directive. |
The opt-in booleans
Each one widens the policy by a fixed set of tokens, and nothing else. Fail-closed: an absent flag changes nothing.
realtime: true, the collaboration opt-in.connect-srcgainswss:(any host), the portal origin overhttps:, everyresourceDomainshost, andblob:;script-srcgainsblob:(esm.sh resolves some dynamic imports through blob URLs);img-srcgainsblob:. It also marks the appcollab-enabled on its resolved spec, which is what lets it mint aroom-scope token for host.collab lobbies.media: true, the audio and video opt-in. Addsmedia-src 'self' blob: data: https:andworker-src 'self' blob:, and addsblob:toconnect-srcandimg-src. This is the flag that makes local playback work: an<audio>or<video>element fed ablob:URL from a user-picked file plays, streamedhttps:media plays,data:posters render, and ablob:Worker (a WebCodecs encode loop, for example) can start. It adds no host toconnect-src; an app that plays a local file and also calls an API still needsconnectDomainsfor the API.webrtc: true, the peer-to-peer opt-in.connect-srcgainsstun:,turn:,turns:andwss:. Nothing else changes. Camera and microphone access is a separate matter, handled bypermissionsand a user grant.wasm: true, the WebAssembly opt-in.script-srcgains'wasm-unsafe-eval', soWebAssembly.instantiateworks, including inside anAudioWorkletGlobalScope, which enforces the document's CSP. Nothing else changes: no worker widening, no CDN widening.
One entry in _meta.ui.permissions also changes the CSP: webgpu is the compute escalation. It adds 'wasm-unsafe-eval' to script-src, worker-src 'self' blob:, blob: and data: to connect-src, and blob: to img-src. It is the only permission with a CSP effect.
Whenever any key or opt-in is present the composed policy also carries frame-src 'none' (or your frameDomains), base-uri 'self' and form-action 'self'.
A worked example
An app that plays a user-picked audio file, calls one analysis API over HTTPS, and imports a library from a CDN:
{
"name": "voice-timeline",
"version": "1.0.0",
"_meta": {
"ui": {
"resourceUri": "./index.html",
"mimeType": "text/html",
"permissions": ["microphone"],
"csp": {
"media": true,
"connectDomains": ["api.example.com"],
"resourceDomains": ["esm.sh"]
}
},
"robutler": { "v": 1 }
}
}That yields media-src 'self' blob: data: https:, connect-src 'self' https://api.example.com blob:, and script-src 'self' 'unsafe-inline' https://esm.sh. The microphone entry is what lets getUserMedia run once the user grants it; it plays no part in the policy above.
_meta.ui.permissions is a different rail
_meta.ui.permissions is the Permissions-Policy list: the browser features the app wants delegated into its iframe through the allow= attribute. It accepts camera, microphone, display-capture, geolocation, clipboard-write, fullscreen, webgpu, xr-spatial-tracking and files (a bridge-gated platform capability that is never emitted into a Permissions-Policy), and rejects anything else with manifest_invalid_shape. It has no effect on the CSP, with the single webgpu exception noted above.
The two rails are easy to conflate because both look like "what the app is allowed to do", and because a granted feature is visible in the workspace item: a community app cannot self-grant a sensitive feature, so microphone reaches the outer iframe's allow= only after the user approves a per-instance grant, and that grant is stored on the item's state. Seeing microphone there means exactly one thing: getUserMedia({ audio: true }) is permitted in this instance. It says nothing about whether the app may fetch a host, open a socket, or play a file; those are CSP questions, answered only by _meta.ui.csp. An app that records from the microphone and then cannot play the recording back is missing csp.media, not a permission. See Permissions-Policy delegation for how grants flow.
Declaring via a meta tag
A single HTML file can be an app by dropping a <meta name="robutler:widget"> tag. Two forms, which can be mixed (per-field tags win over the JSON form for keys they cover):
<!-- JSON form -->
<meta name="robutler:widget" content='{"title":"Snake","size":{"width":360,"height":420}}' />
<!-- per-field form -->
<meta name="robutler:widget:title" content="Snake" />
<meta name="robutler:widget:size" content="360x420" />
<meta name="robutler:widget:description" content="A classic snake game." />
<meta name="robutler:widget:permissions" content="microphone,webgpu" />Recognized fields: name, title, description, size, minSize, maxSize, mobile, icon, kv, author, version, permissions, kind, fileHandlers, events, commands (top-level or nested under interface), and csp.frame-src / csp.connect-src. The kv field is a private-storage disclosure (surfaced in the install confirmation), not an enforced allowlist.
In the JSON form each command is an object with description, args, returns and streams; other keys are ignored silently (see interface). The per-field robutler:widget:commands and robutler:widget:events tags take only name:description pairs, so a command with parameters has to use the JSON form.
This declaration is not read live. When widget_scaffold registers the app and on every widget_publish, the platform reads it from the entry HTML and stores the resulting interface, size, minSize, maxSize, mobile and fileHandlers on the app's content row, and folds the interface into the bundle's manifest snapshot as _meta.ui.interface, which is what the canvas and workspace_widgets_list resolve. widget_remix copies the source app's snapshot unchanged, so a remixed app carries the original's interface until it is published. The csp keys are not stored and not enforced on this path. The sandbox policy for a bundle app comes only from _meta.ui.csp in widget.json, described above.
Related
- Agent command interface: the
interface.commandssurface in depth - Security model: CSP, Permissions-Policy, and resource grants
- App SDK overview: the runtime the manifest describes
host.rpc, host.get, host.list, host.emit, host.onMessage
The low-level escape hatch for direct bridge dispatch, permission-gated resource access, and host to app messaging.
Agent command interface
How an app exposes an agent-driveable command surface: the manifest interface.commands declaration, host.commands.handle at runtime, the built-in commands, and how agents invoke them.