>- Render 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 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.
npx skills add https://github.com/BlackBeltTechnology/pi-agent-dashboard --skill canvas-webapp
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.
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.
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.
base:'./', a dedicated index.html entry, and the runtime data
(schema/props) IMPORTED statically (no fetch of an absolute path). Run
npx vite build --config <config>.
index.html references ./assets/... (relative). Copythe entry html to index.html so the proxy root (/live/<id>/) serves it.
Access-Control-Allow-Origin: *** (and `Cross-Origin-Resource-Policy:
cross-origin`) on every response. This is REQUIRED: the opaque-origin iframe
fetches <script type=module> in CORS mode with Origin: null, so without
ACAO:* the module is blocked → blank white even though the build is correct. A
plain python3 -m http.server does NOT set CORS and renders blank.
url:'http://127.0.0.1:<port>/'}, mode:'replace')`. Loopback is required (SSRF
gate); 127.0.0.1 is safest.
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.
// 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}/`));
kill %1 job control does NOT carry across separate Bash tool calls — adev server started in one call cannot be killed by %1 in another. Kill stale
servers by PID: lsof -tiTCP:<port> -sTCP:LISTEN | xargs kill -9.
[::1]:<port> only, while the dashboard proxy dialsIPv4 127.0.0.1:<port> → the /live/<id> route returns 500
FST_REPLY_FROM_INTERNAL_SERVER_ERROR 'connect ECONNREFUSED 127.0.0.1:<port>'.
Another reason to avoid dev servers and bind static servers explicitly to
127.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 via
openLiveTarget. Prefer kind:'url'.
dashboard — that difference is the sandbox/CORS issue, not a build bug. Don't
chase the build.
(validateLiveTarget).
Access-Control-Allow-Origin: *(curl -D - -o /dev/null).
<iframe sandbox="allow-scripts"> pointed at the server renders the app(browser-tool screenshot), matching the dashboard's opaque-origin sandbox.
canvas(kind:'url'), the user confirms the app is visible on the canvas(not blank, no 500).
Take blackbelttechnology/canvas-webapp from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.
The instructions reference npx.
Without those the skill loads but fails at the first command.