canvas-webapp — show a web app on the dashboard canvas
SkillMediaRender a React/Vite (or any bundled) web app on the pi-dashboard canvas, which loads loopback URLs in a sandboxed opaque-origin iframe. Use when a canvas(target:{kind:"url"|"server"}) target shows up blank white, an empty surface, or a /live/<id> 500 ECONNREFUSED. Covers why Vite dev servers and non-CORS static servers fail there, and the static-build + CORS-server recipe that works.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the canvas-webapp — show a web app on the dashboard canvas skill
What this skill tells your AI
The instructions your AI receives, as published by blackbelttechnology/pi-agent-dashboard in packages/extension/.pi/skills/canvas-webapp/SKILL.md and read by ahel’s review.
The dashboard opens a loopback canvas(kind:"url") target inside a
sandbox="allow-scripts" iframe with no allow-same-origin (opaque origin),
proxied under /live/<id>/ (LiveServerViewer.tsx → live-server-proxy). That
sandbox breaks two common serving strategies; this skill is the fix.
When to Use
Use when you must display a running web app / React / Vite / MUI mockup on the
pi-dashboard canvas via canvas(target:{kind:"url"|"server"}) and it shows up
blank white, an empty surface, or a /live/<id> 500 ECONNREFUSED.
Procedure
- Do NOT point the canvas at a Vite DEV server. The dashboard proxies
loopback targets under
/live/<id>/, but Vite dev emits ABSOLUTE asset paths (/main.tsx,/@vite/client, and runtime fetches like/__schema.json) that resolve against the dashboard root, not the proxy prefix → 404 → blank page. - Produce a STATIC production build with a RELATIVE base: a vite config with
base:'./', a dedicatedindex.htmlentry, and the runtime data (schema/props) IMPORTED statically (no fetch of an absolute path). Runnpx vite build --config <config>. - Verify the built
index.htmlreferences./assets/...(relative). Copy the entry html toindex.htmlso the proxy root (/live/<id>/) serves it. - Serve the dist with a tiny node static server that sets
Access-Control-Allow-Origin: *(andCross-Origin-Resource-Policy: cross-origin) on every response. This is REQUIRED: the opaque-origin iframe fetches<script type=module>in CORS mode withOrigin: null, so without ACAO:* the module is blocked → blank white even though the build is correct. A plainpython3 -m http.serverdoes NOT set CORS and renders blank. - Point the canvas:
canvas(target:{kind:'url', url:'http://127.0.0.1:<port>/'}, mode:'replace'). Loopback is required (SSRF gate);127.0.0.1is safest. - Self-verify by iframing your own harness before touching the canvas:
serve an HTML with
<iframe sandbox="allow-scripts allow-forms allow-popups" src="http://127.0.0.1:<port>/">on another port, open it in the browser tool, and screenshot — this reproduces the exact dashboard sandbox.
Minimal CORS static server
// canvas-serve.mjs — node canvas-serve.mjs <port> (serves ./canvas-dist/)
import { createServer } from "node:http";
import { readFile, stat } from "node:fs/promises";
import { extname, join, normalize } from "node:path";
const ROOT = new URL("./canvas-dist/", import.meta.url).pathname;
const PORT = Number(process.argv[2] ?? 5181);
const MIME = { ".html":"text/html;charset=utf-8", ".js":"text/javascript;charset=utf-8",
".css":"text/css;charset=utf-8", ".json":"application/json", ".woff":"font/woff",
".woff2":"font/woff2", ".svg":"image/svg+xml", ".png":"image/png", ".ico":"image/x-icon" };
createServer(async (req, res) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Cross-Origin-Resource-Policy", "cross-origin");
let p = decodeURIComponent((req.url ?? "/").split("?")[0]);
if (p.endsWith("/")) p += "index.html";
const full = normalize(join(ROOT, p));
if (!full.startsWith(ROOT)) { res.statusCode = 403; return res.end("forbidden"); }
const s = await stat(full).catch(() => null);
if (!s?.isFile()) { res.statusCode = 404; return res.end("not found"); }
res.setHeader("Content-Type", MIME[extname(full)] ?? "application/octet-stream");
res.end(await readFile(full));
}).listen(PORT, "127.0.0.1", () => console.log(`CORS static on http://127.0.0.1:${PORT}/`));
Pitfalls
- bash
kill %1job control does NOT carry across separate Bash tool calls — a dev server started in one call cannot be killed by%1in another. Kill stale servers by PID:lsof -tiTCP:<port> -sTCP:LISTEN | xargs kill -9. - Vite dev may bind IPv6
[::1]:<port>only, while the dashboard proxy dials IPv4127.0.0.1:<port>→ the/live/<id>route returns 500FST_REPLY_FROM_INTERNAL_SERVER_ERROR 'connect ECONNREFUSED 127.0.0.1:<port>'. Another reason to avoid dev servers and bind static servers explicitly to127.0.0.1. canvas(target:{kind:'server', port})produces a tap-to-open CHIP on desktop, not an auto-opened view;kind:'url'with a loopback URL auto-opens viaopenLiveTarget. Preferkind:'url'.- The build succeeds and renders fine in a NORMAL browser tab yet is blank in the dashboard — that difference is the sandbox/CORS issue, not a build bug. Don't chase the build.
- Non-loopback/remote URLs are refused by the dashboard SSRF gate
(
validateLiveTarget).
Verification
- The static server responds 200 with header
Access-Control-Allow-Origin: *(curl -D - -o /dev/null). - An
<iframe sandbox="allow-scripts">pointed at the server renders the app (browser-tool screenshot), matching the dashboard's opaque-origin sandbox. - After
canvas(kind:'url'), the user confirms the app is visible on the canvas (not blank, no 500).
Signals
- GitHub stars
- 283
- Forks
- 41
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
canvas-webapp- Source
- github.com/blackbelttechnology/pi-agent-dashboard