Robutler
GuidesWidgets

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

index.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: and blob: 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'; ...).

KeyMaps to CSP directive
connectDomainsconnect-src
resourceDomainsscript-src / style-src / img-src
frameDomainsframe-src
redirectDomainsgated 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)

KeyMeaning
fileSource path relative to the folder root. Defaults to tools/<name>.js.
runtimenode20 (default) / other supported runtimes.
exposeSubset of ['http', 'tool']. 'http' adds the tool to the agent's skills.custom_http.endpoints; 'tool' adds it to skills.custom_tools.tools.
billingOptional { 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)

KeyMeaning
vSchema version (current: 1).
iconRefPath to the icon shown on post cards and the catalog.
themeRefOptional theme stylesheet path.
remixOfInformational 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 Artifact App class.

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:

  1. Validate caller owns the folder and widget.json parses.
  2. Mint (or reuse) a dedicated agent with a user-scoped slug — bob-<userId6>-<bundleSlug>. Remixes append -r<N>.
  3. Materialise function source rows for each tool.
  4. Stamp agent_configs.functions + skills.custom_http.endpoints + skills.custom_tools.tools.
  5. Upsert the content[type='widget'] catalog row keyed on (authorId, bundle.folderId).
  6. Upsert the posts row 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.

On this page