Canvas is the piece of OpenClaw that makes agents feel less like chatbots and more like full-blown applications. Fire it up and you get a zero-install visual workspace on localhost:18793 where agents push dynamic HTML, stream data visualizations, and expose clickable controls—all without writing a single line of front-end code. This page unpacks exactly how that happens: the A2UI protocol, the WebSocket lifecycle, the rendering model, and patterns the community is already using in production.
Canvas in the OpenClaw architecture
Everything starts with two long-running processes:
- gateway — the UI you log into (port 3000 by default).
- daemon — the brain that schedules tasks, keeps agents alive, and now launches Canvas.
When you start the daemon (Node 22+), it forks an additional server listening on TCP 18793. The name “Canvas” is literal—agents paint onto it by sending A2UI messages. The browser just renders whatever shows up; no React bundle, no build step.
Quick start
Assuming you already have an agent named finbot running:
# 1. Install if you haven’t
npm install -g @openclaw/cli@0.34.2
# 2. Start the daemon (this also spawns Canvas)
claw daemon --workspace ~/claw-home
# 3. Open Canvas in your browser
open http://localhost:18793
You should see an empty gray grid. That grid is a virtual desktop; each widget the agent sends is positioned absolutely, using a 12-column CSS grid under the hood.
A2UI protocol: agent-to-UI in 4 message types
A2UI is a thin JSON envelope the agent process writes to process.stdout. The daemon pipes those messages into the Canvas WebSocket. Four verbs cover 95 % of use cases:
- add — push a new widget.
- update — mutate an existing widget by
id. - event — user did something (click, input, drag).
- remove — destroy a widget.
Full spec lives in docs/A2UI.md, but the payloads are intentionally boring. Example:
{
"verb": "add",
"id": "chart-btc",
"kind": "html",
"x": 0,
"y": 0,
"w": 6,
"h": 4,
"body": "",
"script": "new Chart(document.getElementById('btc'), {...});"
}
Notice two extra fields not related to placement:
- body — raw HTML string inserted into an isolated iframe.
- script — optional JavaScript evaluated after insertion (same sandbox).
Because the iframe lives on the same origin (localhost:18793), you get full access to browser APIs without CORS headaches. You don’t get access to window.top, preventing escapes.
WebSocket lifecycle: what actually flows over port 18793
Canvas serves a single /ws endpoint. The client (your browser) opens the socket immediately after loading the empty grid. From there, the daemon streams A2UI envelopes verbatim.
Frame cadence
- Heartbeat: ping/pong every 12 s keeps proxies happy.
- State sync: on reconnect, the daemon replays the last N messages to guarantee idempotent state.
- Delta updates: only diffs are sent; if you update a chart’s value, Canvas receives a 62-byte JSON delta, not the full widget.
Round-trip latency on a local laptop is <2 ms. Across the Internet (ClawCloud’s Sydney region to my Berlin browser) I see ~135 ms. Good enough for interactive sliders.
Security note
The socket is unauthenticated when bound to 127.0.0.1. The moment you bind to 0.0.0.0 (or deploy to ClawCloud) the daemon injects a JWT requirement. You can supply your own auth middleware via canvas.auth.js—documented but poorly tested. Contributors welcome.
Rendering model: how Canvas turns JSON into pixels
Canvas follows three rules:
- Everything is a widget (internally a
<section>). - Widgets live in a CSS grid (12 columns, 12 rem baseline).
- HTML and CSS are opaque blobs; Canvas never rewrites them.
HTML widgets
The kind: "html" you saw earlier is the most common. Body goes straight into the iframe. If you need external libraries, bundle them inside a <script src> tag—as long as the URL is absolute or data:, it works offline.
Community trick: use Esm.sh to pull D3 at runtime:
body: "",
script: "import('https://esm.sh/d3@7').then(d3 => {/* draw */})"
Markdown widgets
Set kind: "md" and pass body containing GitHub-flavored markdown. Canvas converts it to HTML via marked@12.0.1 on the daemon side. Useful for instructions or status boards.
Form widgets
kind: "form" wraps <form> elements and automatically wires submit to an event message back to the agent. Makes manual parsing unnecessary.
Putting it together: building a live crypto dashboard
I’ll walk through a pared-down version of what lives in my ClawCloud account. Goal: three widgets—BTC price chart, recent tweets mentioning “$BTC”, and a manual “Refresh” button.
Agent skeleton
import {Agent} from '@openclaw/sdk'
import fetch from 'node-fetch'
import {WebSocket} from 'ws'
const ws = new WebSocket('ws://127.0.0.1:18793/ws')
const agent = new Agent('finbot', {memory: true})
ws.on('open', () => initDashboard())
async function initDashboard() {
await addChart()
await addTweets()
await addRefreshButton()
}
Widget helpers
function send(msg) { ws.send(JSON.stringify(msg)) }
async function addChart() {
send({
verb: 'add',
id: 'chart',
kind: 'html',
x: 0, y: 0, w: 6, h: 4,
body: '',
script: `import('https://esm.sh/chart.js').then(({Chart}) => {
window.chart = new Chart(document.getElementById('c').getContext('2d'), {
type: 'line', data: {labels: [], datasets:[{label:'BTC',data:[]}]}
})
})`
})
await updatePrice()
setInterval(updatePrice, 60000)
}
async function updatePrice() {
const {price} = await fetch('https://api.coinbase.com/v2/prices/BTC-USD/spot').then(r=>r.json()).then(r=>({price: +r.data.amount}))
send({verb:'event',id:'chart',payload:{price,t:Date.now()}})
}
The important line is verb:'event'. When Canvas sees an event targeting an existing widget, it re-emits that event into the widget’s iframe as window.dispatchEvent(new CustomEvent('a2ui', ...)). The JS we injected in script listens and adds the new data point.
Interactive elements: wiring buttons, sliders, and drag-and-drop
Because the iframe is sandboxed yet same-origin, UIs can reach back to the agent in two ways:
- postMessage to
window.top(Canvas catches and translates toevent). - Emit DOM events Canvas already listens for (
click,input,change).
Example “Refresh” button:
verb: 'add',
kind: 'html',
id: 'refresh', x:0, y:5, w:2, h:1,
body: '',
script: 'document.getElementById("r").onclick = () => parent.postMessage({id:"refresh",action:"click"}, "*")'
On the agent side:
ws.on('message', raw => {
const {verb, id, payload} = JSON.parse(raw)
if (verb==='event' && id==='refresh' && payload.action==='click') {
updatePrice()
reloadTweets()
}
})
Zero boilerplate, no Redux in sight.
Common use cases the community is shipping
A survey of #canvas on Discord (as of v0.34.2):
- Ops dashboards: Grafana-lite boards pulling Prometheus metrics every minute. No extra ingress.
- Data labeling tools: embed an image, draw bounding boxes, send back coordinates. 40 lines of agent code.
- Marketing A/B testers: form widgets capturing copy variants, writing directly to Airtable via Composio.
- ETL monitors: stream tail-f logs from Snowflake COPY jobs, color-code failures.
- Whiteboard brainstorming: Markdown + Mermaid diagrams + WebRTC screenshare—experimental but works.
Performance and trade-offs
Canvas is intentionally dumb: it stores every message in memory, replays on reconnect, and never garbage-collects widgets unless removed. On my M2 MacBook Pro:
- ~6 MiB baseline after launch.
- +50 KiB per widget average (HTML + script).
- Replaying 1 000 widgets takes 41 ms.
If you push 10 000 widgets you will feel jank. For real-time tables, prefer a single widget with incremental innerHTML updates.
Concurrency limits
One daemon—one Canvas instance. Multiple browsers can connect; each gets its own iframe sandbox but shares the same widget tree. There’s no diff compression per client yet. Feature request #842.
Security gotchas
- Agents can inject arbitrary HTML. If you let untrusted users author agent code you already lost.
- The sandbox blocks
window.top.locationchanges but can still exfiltrate data via XHR. Run agents with separate API keys. - WebSocket JWT secret defaults to a random 32-byte string on boot. Persist
$CANVAS_JWT_SECRETif you want stable URLs.
Deploying Canvas on ClawCloud
ClawCloud automatically exposes Canvas at https://<agent-slug>.canvas.claw.run. Steps:
- Commit your agent to GitHub.
- Link repository in the ClawCloud dashboard.
- Toggle “Canvas” checkbox and hit “Deploy”.
Buildpacks install Node 22, run claw daemon, and proxy the Canvas port through a TLS terminator with JWT enforced. Handoff latency via Cloudflare is ~25 ms vs 2 ms local, totally fine for dashboards.
Debugging checklist
- Nothing shows up — confirm the agent writes JSON to
stdout;claw logs -fis your friend. - Widgets stack on each other — you probably forgot
wandh; default is 1×1. - Cross-origin errors — check if you used
//cdnlinks; prependhttps:. - WebSocket 403 on ClawCloud — your JWT secret rotated, redeploy or set
$CANVAS_JWT_SECRET.
Next step: ship your own workspace
Canvas turns OpenClaw from a chat interface into a desktop—one that lives in the browser, version-controlled alongside your agent code, and deploys in 60 seconds. Clone the snippets above, tweak them, and post a screenshot in #canvas-showcase; the maintainer team routinely merges community widgets back into the boilerplate repo. If something hurts, open an issue—half the features above exist because users complained loudly enough.