Skip to main content
SimpleClaw’s Canvas feature provides an interactive web interface for building custom UIs and handling user actions from mobile apps.

Overview

Canvas Host is a local HTTP server that serves interactive HTML/JS/CSS from ~/.simpleclaw/state/canvas/ and provides:
  • Live reload during development
  • WebSocket-based action bridge (A2UI)
  • Cross-platform compatibility (iOS, Android, Web)
  • Sandboxed file serving

Architecture

The Canvas system has two main components:

1. Canvas Host Server

Serves user content from workspace directory (src/canvas-host/server.ts:399):
const server = await startCanvasHost({
  runtime,
  rootDir: "~/.simpleclaw/state/canvas",
  port: 0,  // Auto-assign
  listenHost: "127.0.0.1",
  liveReload: true
});
Endpoints:
  • /__simpleclaw__/canvas/ - User canvas root
  • /__simpleclaw__/a2ui/ - Bundled A2UI demo app
  • /__simpleclaw__/ws - WebSocket for live reload

2. A2UI (App-to-UI) Bridge

Enables mobile apps to send actions to the agent (src/canvas-host/a2ui.ts):
// iOS: window.webkit.messageHandlers.simpleClawCanvasA2UIAction.postMessage()
// Android: window.simpleClawCanvasA2UIAction.postMessage()

window.simpleClawSendUserAction({
  name: "photo",
  surfaceId: "main",
  sourceComponentId: "demo.photo",
  context: { timestamp: Date.now() }
});

Canvas Root Setup

Default canvas location: ~/.simpleclaw/state/canvas/ On first run, SimpleClaw creates a default index.html (src/canvas-host/server.ts:58):
<!doctype html>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>SimpleClaw Canvas</title>

<div class="wrap">
  <div class="card">
    <h1>SimpleClaw Canvas</h1>
    
    <button id="btn-hello">Hello</button>
    <button id="btn-photo">Photo</button>
    
    <div id="status"></div>
    <div id="log">Ready.</div>
  </div>
</div>

<script>
function send(name, sourceComponentId) {
  const ok = window.simpleClawSendUserAction({
    name,
    surfaceId: "main",
    sourceComponentId,
    context: { t: Date.now() }
  });
  console.log(ok ? "Sent: " + name : "Failed: " + name);
}

document.getElementById("btn-hello").onclick = () => 
  send("hello", "demo.hello");
document.getElementById("btn-photo").onclick = () => 
  send("photo", "demo.photo");
</script>

Live Reload

Canvas watches for file changes and auto-reloads browsers (src/canvas-host/server.ts:261):
const watcher = chokidar.watch(rootReal, {
  ignoreInitial: true,
  awaitWriteFinish: {
    stabilityThreshold: 75,
    pollInterval: 10
  },
  ignored: [
    /(^|[\\/])\../, // dotfiles
    /node_modules/
  ]
});

watcher.on("all", () => broadcastReload());
WebSocket connection injects into HTML (src/canvas-host/a2ui.ts:81):
const ws = new WebSocket(`ws://${location.host}/__simpleclaw__/ws`);
ws.onmessage = (ev) => {
  if (ev.data === "reload") location.reload();
};

Action Bridge API

The A2UI bridge provides JavaScript helpers for mobile/web clients.

Sending Actions

// Simple action
window.simpleClawSendUserAction({
  name: "search",
  surfaceId: "main",
  sourceComponentId: "search.button",
  context: { query: "weather" }
});

Action Status Events

window.addEventListener("simpleclaw:a2ui-action-status", (ev) => {
  const { id, ok, error } = ev.detail;
  console.log(`Action ${id}: ${ok ? "success" : error}`);
});

Platform Detection

const hasIOS = () => 
  !!(window.webkit?.messageHandlers?.simpleClawCanvasA2UIAction);

const hasAndroid = () => 
  !!(window.simpleClawCanvasA2UIAction?.postMessage);

const hasHelper = () => 
  typeof window.simpleClawSendUserAction === "function";

File Resolution & Security

Canvas uses sandboxed file serving (src/canvas-host/file-resolver.ts):
export async function resolveFileWithinRoot(
  rootReal: string,
  urlPath: string
): Promise<{ handle: FileHandle; realPath: string } | null> {
  // Normalize path and prevent directory traversal
  const normalized = normalizeUrlPath(urlPath);
  const resolved = path.join(rootReal, normalized);
  
  // Verify file is within root
  const real = await fs.realpath(resolved);
  if (!real.startsWith(rootReal)) {
    return null;  // Path escape attempt
  }
  
  return { handle: await fs.open(real), realPath: real };
}
Security features:
  • Path traversal prevention (../ blocked)
  • Root boundary enforcement
  • No directory listings
  • Cache-Control: no-store on all responses

Configuration

Canvas Root Directory

# Custom canvas location
canvasHost:
  rootDir: "~/my-canvas"
  liveReload: true

Disable Canvas

export SIMPLECLAW_SKIP_CANVAS_HOST=1
Or set canvasHost.enabled: false in config.

Development Workflow

  1. Edit canvas files:
    cd ~/.simpleclaw/state/canvas
    vim index.html
    
  2. Open in browser:
    http://localhost:<port>/__simpleclaw__/canvas/
    
  3. Save changes - browser auto-reloads
  4. Check logs:
    tail -f ~/.simpleclaw/logs/gateway.log | grep canvas
    

A2UI Demo App

The bundled A2UI demo is served from src/canvas-host/a2ui/ at:
http://localhost:<port>/__simpleclaw__/a2ui/
Includes interactive examples:
  • Hello action
  • Time query
  • Photo request
  • Custom Dalek action
View source at src/canvas-host/a2ui/index.html

Mobile Integration

iOS (Swift)

import WebKit

class CanvasViewController: UIViewController, WKScriptMessageHandler {
  func userContentController(
    _ userContentController: WKUserContentController,
    didReceive message: WKScriptMessage
  ) {
    guard message.name == "simpleClawCanvasA2UIAction" else { return }
    let action = parseUserAction(message.body)
    sendToAgent(action)
  }
}

Android (Kotlin)

class CanvasWebView(context: Context) : WebView(context) {
  init {
    addJavascriptInterface(
      CanvasA2UIBridge(this),
      "simpleClawCanvasA2UIAction"
    )
  }
}

class CanvasA2UIBridge(val webView: WebView) {
  @JavascriptInterface
  fun postMessage(payload: String) {
    val action = parseUserAction(payload)
    sendToAgent(action)
  }
}

Advanced Features

Custom MIME Types

Canvas auto-detects MIME types using src/media/mime.ts:
const mime = 
  lower.endsWith(".html") ? "text/html" :
  await detectMime({ filePath: realPath });

Live Reload Injection

HTML responses get auto-injected bridge code (src/canvas-host/a2ui.ts:81):
// Injected before </body>
const ws = new WebSocket(...);
globalThis.SimpleClaw = { postMessage, sendUserAction };
globalThis.simpleClawSendUserAction = sendUserAction;

Nested Skills Root Detection

Canvas can detect nested skills/ directories:
~/.simpleclaw/state/canvas/
  skills/              # Detected as real root
    github/SKILL.md
    slack/SKILL.md

Troubleshooting

Canvas not starting? Check if disabled:
echo $SIMPLECLAW_SKIP_CANVAS_HOST
Live reload not working? Verify WebSocket connection in browser console:
// Should see: WebSocket connected to ws://localhost:XXXX/__simpleclaw__/ws
Actions not received? Check mobile bridge is injected:
console.log(typeof window.simpleClawSendUserAction);
// Should be: "function"
File not found? Verify path is under canvas root:
ls ~/.simpleclaw/state/canvas/index.html

API Reference

Key functions from src/canvas-host/:
  • startCanvasHost() - Start canvas server (server.ts:399)
  • createCanvasHostHandler() - Create HTTP handler (server.ts:205)
  • handleA2uiHttpRequest() - Serve A2UI bundle (a2ui.ts:142)
  • injectCanvasLiveReload() - Inject WS client (a2ui.ts:81)
  • resolveFileWithinRoot() - Safe file resolution (file-resolver.ts)

Build docs developers (and LLMs) love