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:

  1. add  — push a new widget.
  2. update  — mutate an existing widget by id.
  3. event  — user did something (click, input, drag).
  4. 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:

  1. Everything is a widget (internally a <section>).
  2. Widgets live in a CSS grid (12 columns, 12 rem baseline).
  3. 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 to event).
  • 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.location changes 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_SECRET if you want stable URLs.

Deploying Canvas on ClawCloud

ClawCloud automatically exposes Canvas at https://<agent-slug>.canvas.claw.run. Steps:

  1. Commit your agent to GitHub.
  2. Link repository in the ClawCloud dashboard.
  3. 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 -f is your friend.
  • Widgets stack on each other — you probably forgot w and h; default is 1×1.
  • Cross-origin errors — check if you used //cdn links; prepend https:.
  • 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.