Authoring a folder-bundle widget
A Robutler widget is a folder containing an HTML entry, a
widget.json sidecar manifest, optional tool sources, and any static
assets. The folder is the unit of identity — it survives copy-paste,
forking and remixing.
If you only have a single HTML file, the publish flow auto-wraps it into a one-child folder; you can keep authoring single-file widgets indefinitely. The folder layout below is the first-class shape for anything richer than that.
File layout
my-widget/
index.html # default entry; declares the manifest
widget.json # SEP-1865 sidecar; source of truth
tools/
daily.js # one file per declared tool (optional)
assets/
logo.svg
style.cssindex.html should declare the manifest so external hosts can find it:
<!doctype html>
<html>
<head>
<link rel="widget-manifest" href="./widget.json" />
<title>My widget</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./assets/style.css"></script>
</body>
</html>The SDK (/widgets/sdk.v2.js) is injected automatically by the
folder resolver — you don't need to add a <script> tag for it.
widget.json schema
widget.json is shaped as an MCP Apps (SEP-1865) manifest with a
_meta.robutler extension. The full schema lives next to the type
definitions in lib/widgets/bundle-types.ts (look for the JSDoc
reference example). The minimum shape:
{
"name": "my-widget",
"version": "1.0.0",
"description": "A short summary shown in the catalog.",
"_meta": {
"ui": {
"resourceUri": "./index.html",
"mimeType": "text/html",
"csp": {
"connectDomains": ["api.example.com"],
"resourceDomains": ["cdn.example.com"],
"frameDomains": [],
"redirectDomains": ["example.com"]
},
"permissions": ["clipboard-write"]
},
"robutler": {
"v": 1,
"iconRef": "./assets/logo.svg"
}
},
"tools": [
{
"name": "daily",
"description": "Generate today's report",
"inputSchema": { "type": "object", "properties": {} },
"_meta": {
"ui": { "visibility": ["app"] },
"robutler": {
"file": "tools/daily.js",
"runtime": "node20",
"expose": ["http", "tool"],
"billing": {
"currency": "USD",
"amountNanocents": 100000
}
}
}
}
]
}_meta.ui.csp.*
The four CSP domain lists are merged with a restrictive default at sandbox render time. Each is an array of bare domains — no scheme, no port, no path. The validator rejects:
*and other wildcards.'unsafe-*'keywords (e.g.'unsafe-eval').data:andblob:schemes.- IP literals (v4 / v6).
If any token is rejected the bundle falls back to the restrictive
default (default-src 'none'; script-src 'self' 'unsafe-inline'; ...).
| Key | Maps to CSP directive |
|---|---|
connectDomains | connect-src |
resourceDomains | script-src / style-src / img-src |
frameDomains | frame-src |
redirectDomains | gated allowlist for bridge.openLink |
_meta.ui.visibility
Per-tool, default ['app'] — only the widget UI may invoke. Add
'model' to let an agent invoke programmatically, 'user' to surface
it in human-facing tool palettes.
The default of ['app'] means the model cannot invoke your tool
unless you explicitly opt in — this is the safe default for widgets
that are not designed to be agent-driven.
_meta.robutler.* (per tool)
| Key | Meaning |
|---|---|
file | Source path relative to the folder root. Defaults to tools/<name>.js. |
runtime | node20 (default) / other supported runtimes. |
expose | Subset of ['http', 'tool']. 'http' adds the tool to the agent's skills.custom_http.endpoints; 'tool' adds it to skills.custom_tools.tools. |
billing | Optional { currency, amountNanocents }. Presence is the billable flag: tools without billing are free (attribution-only user_interactions rows); tools with billing write payment_charges via the platform's existing payment-token settlement. |
_meta.robutler.* (bundle-level)
| Key | Meaning |
|---|---|
v | Schema version (current: 1). |
iconRef | Path to the icon shown on post cards and the catalog. |
themeRef | Optional theme stylesheet path. |
remixOf | Informational only. The authoritative lineage lives in content.remix_of_* columns (see ADR-v3-15) — this field is for human readers of the file. |
MCP Apps interop
Every published widget is automatically exposed as an MCP App at
/api/widgets/[postId]/mcp, discoverable in the catalog at
/.well-known/mcp/widgets.json. External hosts (Claude, ChatGPT,
MCP-UI) can consume your widget without a Robutler-specific client.
The SDK provides three compatible surfaces in the iframe:
bridge.callTool(name, args)— native MCP Apps JSON-RPC.window.openai.callTool(name, args)— ChatGPT App SDK shim.new App(); await app.connect();— Claude ArtifactAppclass.
All three resolve to the same underlying transport. Author against whichever feels natural; the host bridge dispatches identically.
Publishing
POST /api/folders/[folderId]/publish-as-widget runs the publish
pipeline:
- Validate caller owns the folder and
widget.jsonparses. - Mint (or reuse) a dedicated agent with a user-scoped slug —
bob-<userId6>-<bundleSlug>. Remixes append-r<N>. - Materialise function source rows for each tool.
- Stamp
agent_configs.functions+skills.custom_http.endpoints+skills.custom_tools.tools. - Upsert the
content[type='widget']catalog row keyed on(authorId, bundle.folderId). - Upsert the
postsrow keyed on(authorId, metadata->>'bundleFolderId').
Re-running the publish on an unchanged folder updates the manifest snapshot and refreshes the catalog row in place; you do not get a duplicate marketplace listing.
See also: mcp-app-publishing.md for the
external consumption story, remixing.md for what
happens when someone forks your widget, and
sandbox-mcp-app.md for the sandbox runtime
contract.