Widgets are small apps that live on your workspace canvas — calculators, shells, charts, games, even Python notebooks. You drag them in, drop them next to an agent, and let the agent drive them while you watch. Each widget keeps its own state, so it picks up exactly where you left it after a reload.
Add a widget
Open a workspace, tap +, choose Widget, then pick one from the catalog. Highlights:
- Local shell — terminal attached to the Robutler daemon on your machine
- SSH — credential-backed remote shell
- Python — a SciPy-grade Pyodide kernel that runs in your browser
- Browser embed — any URL as a sandboxed iframe
- Calculator, Timer, Form, Chart — the staples
- Snake, Falling Blocks, Space Invaders — clean-room arcade
You can also ask an agent to drop one in: "add a chart and plot last week's sales."
Let an agent drive a widget
Every widget — Python kernels, shells, calculators, third-party iframes — has an Allow agents to control setting in its header. The default is conservative for risky widgets and permissive for sandboxed ones:
| Widget kind | Default | Why |
|---|---|---|
| Python | everyone | Sandboxed in your browser tab |
| Daemon | nobody | Local shell — can touch your files |
| SSH | nobody | Remote shell — highest blast radius |
| Iframe | everyone | Lives in an iframe sandbox |
Behind the scenes the setting is a TrustRules list — the same shape your agent's acceptFrom uses — so you can be coarse (everyone / nobody) or precise (@my-trusted-agent). When an agent calls a widget that doesn't list it, the call is rejected with a permission error and the agent has to ask you to flip the rule on. Every invocation, allow or deny, lands in the widget audit log so you can see what ran.
Build your own
A widget is any HTML page. Drop it in public/widgets/<id>/index.html (or host it anywhere on the web), declare it with a meta tag, and the workspace picks it up.
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Counter</title>
<!-- One tag is all you need. Title, default size, persisted keys. -->
<meta name="robutler:widget" content='{
"title": "Counter",
"size": { "width": 240, "height": 160 },
"kv": ["count"]
}' />
<script src="/widgets/sdk.v1.js"></script>
</head>
<body>
<button id="b">+1</button>
<span id="n">0</span>
<script>
(async () => {
await host.ready();
const n = document.getElementById('n');
n.textContent = (await host.kv.get('count')) ?? 0;
document.getElementById('b').addEventListener('click', async () => {
const next = Number(n.textContent) + 1;
n.textContent = next;
await host.kv.set('count', next);
});
host.kv.subscribe('count', (v) => (n.textContent = v));
})();
</script>
</body>
</html>That's the whole API for 90% of widgets:
host.ready()— resolves once the workspace acknowledges youhost.kv.{get,set,delete,list,subscribe}— persistent key/value that survives reloads and syncs across tabshost.emit(payload)— broadcast a one-shot event on the workspace bus
Peer-widget control (driving a sibling Python kernel, shell, or any other widget on the canvas) doesn't live on host.* anymore — both your widget and any agent on the workspace drive peers through the same surface, workspace_widgets_invoke(itemId, name, args), gated by each peer's own agentControl rule. See Talking to widgets below.
When a widget is opened outside Robutler (a developer double-clicks the HTML, or someone embeds it on their own site), the SDK transparently falls back to localStorage for state. host.detached === true tells you you're running standalone — handy for hiding "share with workspace" buttons.
The declarative meta tag
Any HTML page becomes a Robutler widget by adding a robutler:widget meta tag. Use the JSON canonical form for everything:
<meta name="robutler:widget" content='{
"title": "Chart",
"description": "Live chart fed by host.kv",
"size": { "width": 520, "height": 360 },
"kv": ["series"],
"version": "1.0.0",
"author": "@robutler"
}' />Or mix-and-match per-field tags when you only need a couple:
<meta name="robutler:widget:title" content="Chart" />
<meta name="robutler:widget:size" content="520x360" />
<meta name="robutler:widget:kv" content="series" />The platform reads the tags as the source of truth for the catalog entry: default canvas size, declared KV keys, icon, version. If you publish a widget on your own domain, Robutler discovers and installs it from those tags alone — no separate registration step.
Talking to widgets
Widgets advertise what agents can do with them through two declarations in the meta tag: commands (what an agent can call) and events (what the widget fires onto the workspace bus). Both are free-form descriptions — agents read them and decide whether to call.
<meta name="robutler:widget" content='{
"title": "Calculator",
"commands": {
"press": { "description": "Tap a key", "args": { "key": "0-9, +, -, =" } },
"clear": { "description": "Reset the display" },
"recall": { "description": "Load a tape entry", "args": { "index": "number" } }
},
"events": {
"compute": { "description": "Fires when = is pressed" }
}
}' />Implement them with host.commands.handle and host.emit:
host.commands.handle('press', ({ key }) => {
press(key);
return { display: current };
});
document.addEventListener('keydown', (e) => {
if (e.key === '=') host.emit({ kind: 'compute', expression, result });
});Every widget gets two commands for free without declaring them:
__describe— returns the widget's declaredcommands+events. Useful when discovery order matters:list→__describe→invoke.__getState— returns the workspace item DTO snapshot (type, position, state). Read-only.
Inside the workspace, an agent invokes commands through four tools:
workspace_widgets_list()— every widget on the canvas with its declared commands + eventsworkspace_widgets_describe(itemId)— the full interface for one widgetworkspace_widgets_invoke(itemId, name, args)— call a command, await the resultworkspace_widgets_subscribe(itemId, eventName, cb)— listen for an event
Native widgets (daemon, ssh, python, agent) advertise the same shape so an agent can drive a Python REPL, an SSH session, and a third-party calculator with one API. Long-running commands (Python run, shell run, agent chat) declare their streaming events in the streams field of the command spec; agents that want progress in real time open a workspace_widgets_subscribe for each event BEFORE calling invoke. The four tools are only visible to agents running inside the workspace tab — voice / Discord / SDK transports don't see them because they can't reach the in-tab command bus.
Why some sites refuse to be embedded
LinkedIn, X, Facebook, and most major sites send X-Frame-Options: DENY (or Content-Security-Policy: frame-ancestors 'none') to prevent clickjacking. The browser refuses to render them in any iframe — there's no JS hook to bypass it. The Browser Embed widget detects this (the iframe never fires load within 3 seconds), shows a friendly card, and emits a loadBlocked event for any agent listening.
Three real workarounds, none free:
- Sites you control — drop the header.
- Robutler browser extension — coming as a separate widget. The extension has full DOM permissions so it can drive any site.
- Server-side screenshot widget — a cloud-browser session (Browserbase, Browser Use, self-hosted Playwright) that streams screenshots and accepts click/type commands. These are published as agent-authored widgets — see Publishing from your own domain below.
Publish to the marketplace
Once your widget works, publish it as a post. The post links back to a content row with contentType: 'widget', the workspace's Discover panel picks it up, and other users can install it with one tap.
Publish HTML you already authored in Robutler
If you drafted your widget as a .html document inside Robutler (drag-and-dropped a file, asked an agent to write one, or built it in the editor), publishing is a single click:
- Open the HTML document — either on the canvas (it renders as an HTML preview) or on its dedicated
/content/<id>page. - Click Publish as widget in the actions toolbar.
- The platform reads the file, infers the widget metadata, and creates a marketplace post.
Metadata is resolved in this order:
- Anything you set directly in the publish request (title, description, size — the canvas node passes its current width/height as the widget's default size).
- Any
<meta name="robutler:widget*">tags already in the HTML. - The content row's display name, then a safe default (400×300, "Untitled widget").
Once published, the new post lives at /p/<postId> and appears in:
- Search-everything's Widgets filter (FAB → Widget, or the type chip).
- The Discover hub's Widgets chip.
/api/posts?content_type=widgetfor any agent or integration browsing the marketplace.
Each call creates a NEW post — there is no implicit republish-in-place. Versioning lives in your source HTML; iterate, then publish again when you want a new marketplace entry.
Find and install widgets
Open + → Widget anywhere on the canvas to land in Search-everything with the widget filter applied. The empty state shows trending widgets — first-party (Calculator, Snake, Python notebook, …), system shells (Local shell, SSH, Python notebook), and anything the community has published. Type to search by title or description; hit the trailing Add button on a row to drop it onto the canvas.
The widget catalog is one list: system-seeded widgets and user-published widgets are both posts rows paired with content widget rows, served by the same /api/posts?content_type=widget endpoint. There is no separate "official" shelf — visibility comes from sort=hot (recency + likes) and, eventually, install count.
Where do the system widgets come from?
System widgets (Python notebook / pyodide, Local shell / daemon, SSH, Calculator, Snake, …) are published the same way as user widgets: as posts rows paired with content rows of contentType = 'widget', authored by @robutler. They're seeded by scripts/seed-system-widgets.ts, which is wired into pnpm db:seed:all.
If the Widgets filter looks empty on a fresh database, re-seed with:
pnpm db:seed:pinned # ensures @robutler exists
pnpm db:seed:widgets # publishes the system widget postsA quick sanity check: SELECT count(*) FROM content WHERE content_type='widget' should return at least 15. The Widgets filter in both Search-everything and the Discover hub queries GET /api/posts?content_type=widget directly — system + user widgets land in the same feed (see ADR-0018), so seeding them is all you need to get the marketplace populated.
Other paths to publish
- Use your domain — host the HTML anywhere on the web, paste the URL into Discover. Robutler fetches the page, reads the meta tags, registers the widget, and creates the post for you.
- Ship from a factory chat —
+ → Widget → Create widgetopens a chat with@robutler.factoryprefilled with "Create a new widget for…". Describe what you want; the factory builds the HTML, opens it on the canvas, and the Publish as widget button is right there.
Publishing widgets from your own domain
Your widget doesn't have to live on Robutler. Host it on any HTTPS URL, declare its meta tag, and the workspace can register and run it dynamically — no platform PR, no waiting on a release. This is how marketplace widgets and agent-authored integrations (browser-control, BaaS sessions, custom dashboards) ship.
The contract:
- Host the HTML at a stable HTTPS URL. The page MUST start with
<script src="https://robutler.ai/widgets/sdk.v1.js"></script>sohost.*is available. - Declare the meta block — same
robutler:widgettag as before, including yourcommandsandevents. Robutler fetches the page once at publish time, parses the meta, and persists it on the widget's content row. - Optional REST surface — if your widget needs server-side work (cloud-browser commands, OAuth-protected APIs), back it with an agent skill that exposes
custom_httpendpoints. The widget calls them throughfetchlike any web app; BYOK secrets are resolved via the agent'sctx.secrets.
Once the marketplace post lands, adding the widget to a workspace is one tap for any user. The platform renders it as an external iframe with the same sandbox and SDK surface as bundled widgets — your widget only sees the contract, never the implementation. There is no special "marketplace" code path; agent-authored, third-party, and first-party widgets all flow through the same registry.
That's the whole platform: HTML pages with one meta tag, a tiny JS bridge, and an open registry.
What agents can show you
Your agents can drop things directly onto your canvas as they work:
- Artifacts — images, code snippets, sticky notes, charts, URL embeds — appear next to the agent that produced them. Artifacts (other than URL embeds and widget seeds) land in your content library at
/content/<id>so you can find them again, attach them to other chats, or share them. - Other agents — when an agent searches for, creates, or otherwise references another agent, it can surface that agent on the canvas as a peer widget. Mention an
@usernamein chat and click the mention to open the same widget — humans and agents both work. - Delegation pucks — while an agent is talking to another agent, you'll see a small collapsed avatar to the right with an animated line. When the sub-agent finishes the line settles. Click a historical
delegaterow in any chat to replay that chain on the canvas.
Tips:
- Shift-drag the canvas to select multiple widgets. Hit Delete (or use the toolbar) to remove them in one go; Fit to selection zooms the camera to the group.
- Mention an agent in chat with
@usernameto open it on the canvas — works for humans too.