Robutler

App authoring

An app on Robutler is a small bundle of files: a manifest, an entry HTML page that loads the App SDK, and optional custom-function source. Apps are built as widgets, so the manifest is widget.json and the build tools are named widget_*. This page is the reference for what goes in the bundle and why.

If you just want to ship something, start with the quickstart, which drives this whole loop with a coding agent.

Project layout

A minimal bundle:

my-app/
  widget.json        manifest: name, version, tools, UI descriptor
  index.html         the entry: loads the SDK, renders your app
  tools/
    hello.js         a custom function (one file per declared tool)

You can add JS / CSS modules, images, fonts, and an AGENT.md. Text files are written through widget_put_files; binary assets (images, fonts) go through the dev-token upload path. Nested paths auto-create their parent folders.

widget.json

The manifest declares the app's identity, its tools, and its UI. widget_scaffold produces a working starting point:

{
  "name": "my-app",
  "version": "1.0.0",
  "description": "my-app widget",
  "tools": [
    {
      "name": "hello",
      "description": "Example tool",
      "_meta": {
        "robutler": {
          "file": "tools/hello.js",
          "runtime": "node",
          "expose": ["http"],
          "path": "/hello",
          "httpAuth": "public"
        }
      }
    }
  ],
  "_meta": {
    "ui": { "resourceUri": "./index.html", "mimeType": "text/html", "permissions": [] },
    "robutler": { "v": 1 }
  }
}

Key fields:

  • name, version, description: app identity. name becomes the app's display name and seeds the slug for its dedicated agent.
  • _meta.ui.resourceUri: the entry file, ./index.html by default. Publish reads it to find your entry HTML.
  • _meta.ui.permissions: browser features the app asks for (camera, microphone, and so on). This is the Permissions-Policy list that feeds the iframe's allow= attribute; it is not the CSP (Content Security Policy) and does not open any network or media access. Keep it minimal; see the security model.
  • _meta.ui.csp: the sandbox CSP allowlist. With no declaration an app can reach only its own origin and cannot play any audio or video, including a local file the user picked. The sandbox route reads this block from widget.json on every request, so a widget_put_files edit plus __reload is enough to change it. Every key is documented in Widget manifest.
  • tools[]: each declared tool maps to a custom-function file and how it is exposed (below).

The full manifest schema is documented in Widget manifest.

index.html and the App SDK

The entry HTML loads the App SDK and waits for the host before doing anything:

<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <meta
      name="robutler:widget"
      content='{"title":"My App","description":"My App","size":{"width":360,"height":320},"kind":"iframe"}'
    />
    <script src="/widgets/sdk.v2.js"></script>
  </head>
  <body>
    <main id="app">My App</main>
    <script>
      (async () => {
        await host.ready();
        // use host.kv / host.content / host.fn / host.discover here
      })();
    </script>
  </body>
</html>

The <meta name="robutler:widget"> tag carries render hints (title, default size, kind). The SDK exposes everything through the host.* global once host.ready() resolves. The surface includes:

  • host.kv: per-instance key-value storage.
  • host.content, host.documents: name-addressed content and documents.
  • host.collab: realtime multiplayer.
  • host.fn: call your app's custom functions.
  • host.discover: discover agents and intents.
  • host.commands: the agent command surface (below).
  • host.infer, host.python, host.shell, host.live, host.user, and more.

Each of these has its own page; start at the App SDK overview.

A note on the SDK script tag: first-party bundles reference the SDK at the portal-absolute path /widgets/sdk.v2.js. The local dev server serves that path too, so a bundle renders the same locally as on the portal.

Loading entry modules

If your entry HTML boots through an inline <script type="module"> that dynamically imports your app code, resolve the specifier against document.baseURI instead of writing it bare:

const app = await import(new URL('./app.js', document.baseURI).href);

Apps render inside a sandboxed srcdoc iframe with an injected <base href>. Chrome applies that base when resolving relative module specifiers, but Safari/WebKit does not, so a bare import('./app.js') loads in Chrome and fails in Safari with Module name, './app.js' does not resolve to a valid URL, and the app never boots. Only the first hop from the inline module script needs this: static imports inside app.js resolve against its own (absolute) URL, and a classic <script src="./app.js"> like the example above is unaffected because the browser applies <base> to src attributes.

Custom functions

Each entry in tools[] points at a source file (default tools/<name>.js) that exports a handler. The scaffold's example:

export default async function hello(ctx) {
  return {
    status: 200,
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ ok: true, ts: (ctx && ctx.request && ctx.request.body) || null }),
  };
}

The _meta.robutler block on the tool controls wiring:

  • file: source path. Defaults to tools/<name>.js.
  • runtime: node by default.
  • expose: where the function shows up. http adds an HTTP endpoint at path; tool adds an agent-callable tool. The default when omitted is both (["http", "tool"]).
  • path: the HTTP route, default /<name>.
  • httpAuth: public for an anonymous endpoint, or a session-gated mode. Choose carefully; see Widget content auth.

At publish, each declared tool's source must exist at its file path or the publish fails. Publish wires the functions onto the app's dedicated agent and exposes them per expose.

The 64 KB custom-function cap

A custom-function source larger than 64 KB is rejected at deploy (CODE_TOO_LARGE) and the function will 404 at call time (FN_NOT_FOUND). If you are embedding data inline, minify it or move it out of the function body. See Error codes.

Calling your own function from the app

From inside the app, call a function with host.fn.invoke(name, args). host.fn is a namespace, not a function; host.fn('hello') throws.

const res = await host.fn.invoke('hello', { name: 'world' });

name is a bare endpoint name ([A-Za-z0-9_-], up to 64 characters), which the host resolves against your own bundle's dedicated agent. args is an optional plain object; the function receives it as ctx.request.body.args, with the server-stamped _itemId and _folderId merged in (any client-supplied values for those two keys are overwritten). The promise resolves with the function's JSON response body verbatim. A non-2xx response rejects with an error whose code is not_found, permission, rate_limited, timeout or internal, and whose message carries the function's own JSON error body when it returned one, so read the message before assuming a platform fault. The "<agent>/<endpoint>" form reaches the system robutler agent or an agent the app has been granted, and nothing else; see host.fn.

This is the supported route for an app's client code to reach its own server-side function, and it is the route that sidesteps the sandbox CSP and CORS entirely. The call travels over the host bridge to the portal, which POSTs /api/widgets/<itemId>/fn/<name> on the portal origin as the current viewer and bills the app-instance owner. The sandboxed iframe never opens a network connection of its own, so the baseline connect-src 'self' never sees it. Do not fetch() your function's /api/agents/... URL from inside the iframe instead: on the baseline policy that request is refused as a CSP violation, and even with a connectDomains carve-out it would run without the widget-scope claims (ctx.kv.at('instance'), ctx.kv.at('project')) that only the bridge route stamps.

Limits come from the bridge op registry (lib/widgets/widget-bridge-ops-registry.ts): fn.invoke is rated at 120 calls per minute per user and times out after 30 seconds. In detached mode (host.detached === true, no host bridge) the call rejects with unknown_op; fall back to bundled data there.

The agent command surface

To make your app drivable by agents (and by your coding agent over workspace_widgets_invoke), declare a command interface. The entry HTML can declare it inline in the robutler:widget meta, as commands (and optional events):

<meta
  name="robutler:widget"
  content='{"title":"My App","commands":{"addItem":{"description":"Add an item","args":{"text":"string"}}}}'
/>

A command declaration accepts exactly four keys, and the parser (parseCommandsObject in lib/workspaces/widget-meta.ts) copies only these:

  • description (string): what the command does. A plain string in place of the object is accepted as a description-only command ("clear": "Remove all items.").
  • args: the parameter shape. Free-form and descriptive ({"text":"string"} or a JSON-Schema-ish object); the host does not validate calls against it, agents read it to shape the call.
  • returns: the result shape, same rules as args.
  • streams: an array of event names a long-running command emits while it runs.

Any other key inside a command declaration is ignored, not rejected. input, params, parameters, schema, or a misspelling of args all parse without an error and are dropped, so the command reaches agents with its description and no parameter schema, and nothing anywhere tells you. When a command that takes parameters shows up in workspace_widgets_list with no args, this is the first thing to check. The platform's own starter (public/widgets/_starter/index.html) is a correct reference.

commands and events may sit at the top level of the JSON, as above, or nested under interface alongside a description (the WidgetSpec shape); both forms parse identically. Keep the JSON inside the single-quoted content attribute free of raw apostrophes, which end the attribute early and drop the whole declaration silently.

The interface is not read live. It is captured from the entry HTML and stamped on the app's stored manifest snapshot, which is what the canvas command bus and the workspace_widgets_* tools resolve, at three points: widget_scaffold reads your entry HTML when it registers the app; widget_remix copies the source app's stored interface as-is and does not read your copy's entry HTML; widget_publish re-reads the entry HTML every time. Editing the meta tag with widget_put_files and calling __reload refreshes the running page, not the stored interface, so a remixed app keeps the original's command list until you publish. Verify what was captured rather than trusting the tag: workspace_widgets_list should list every command with its args, and __describe over workspace_widgets_invoke should report your commands rather than "Builtin host commands only". First-party core apps declare their commands server-side in the registry instead; custom apps use the inline form above. Wire the handlers with host.commands.

The full model, including how commands are dispatched and how to handle them, is in Agent command interface.

If your app is collaborative

Realtime apps have one failure mode that outranks every other bug you can ship: a user typing into a document that is open but not connected, and losing the work on reload. It is invisible while it happens — the app looks fine, presence may even be live — so it will not show up in casual testing.

Three rules cover it. All three are the same idea from different angles:

  1. Do not mount an editable surface before the room has really synced. Never wait for sync with a timeout; a timeout turns "we could not connect" into "proceed as if we did". Render a connecting state instead.
  2. Respect the token's role. A reader token's writes are dropped by the collab service, which still delivers presence — so the session looks healthy while nothing the viewer does survives. Neutralise the write surface and say why; /widgets/shared/collab-readonly.js gives you both, and using its READER_NOTICE keeps the wording identical to every other app.
  3. If you add offline persistence, stage it. Never attach a local store straight to the live doc: a stale copy merges back and resurrects deleted content.

Each rule, with the code and the reasoning, is in host.collab. Remixing a first-party app inherits the correct boot order already; if you rewrite the boot, re-read that page first.

Next

On this page