github/create-canvas
Author, validate, and debug canvas extensions that the agent can open in the GitHub Copilot app's side panel. Use when creating, reviewing, or troubleshooting a canvas extension — including its actions, inputs, rendered content, and the surrounding extension wiring (tools, hooks, lifecycle).
npx skills add https://github.com/github/gh-aw --skill create-canvas
Use this skill when creating or debugging an extension canvas that integrates with the runtime canvas model.
The host app no longer participates in extension canvas registration. Extensions declare canvases directly to the runtime via the Copilot SDK on joinSession; the runtime routes provider callbacks (canvas.open, canvas.action.invoke, etc.) straight to the declaring connection; the host app just renders whatever URL the provider returns.
.github/extensions/<name>/) is committed to the repo and shared with the team. User ($COPILOT_HOME/extensions/<name>/) is local to the current user. Session ($COPILOT_HOME/session-state/<sessionId>/extensions/<name>/) is loaded only for the current session — ideal for a throwaway canvas for one conversation. Unless the request makes it obvious, ask before creating files — the choice changes who sees the extension, whether it's committed, and the resulting extensionId. extensions_manage({ operation: "scaffold", kind: "canvas", name: "<name>", location: "project" | "user" | "session" })
The canvas scaffold produces an extension.mjs with joinSession({ canvases: [createCanvas({...})] }), a working loopback HTTP server per instance, an example action, and onClose cleanup. Edit from there.
open, actions[], optional onClose, and (if needed) tools/hooks alongside.extensions_reload. Stale canvas instances flip to stale, then back to ready once the provider reconnects.extensions_manage({ operation: "list" }) and extensions_manage({ operation: "inspect", name: "<name>" }). If the extension is marked failed, inspect surfaces the log file path and a tail — that log is the primary debugging surface.list_canvas_capabilities, open_canvas, and invoke_canvas_action.A canvas extension is a Copilot CLI extension running as a forked Node process that speaks JSON-RPC over stdio to the CLI.
extension.mjs (ES modules only; TypeScript is not supported)..github/extensions/ (relative to git root), $COPILOT_HOME/extensions/, and $COPILOT_HOME/session-state/<sessionId>/extensions/.extensionId of ${source}:${name} (e.g. project:my-extension); session-scoped extensions embed the session id as session:<sessionId>:<name>.@github/copilot-sdk is resolved automatically by the CLI — do not add a package.json or node_modules for it.stdout is reserved for JSON-RPC. Never console.log. Use session.log(message, { level, ephemeral }).joinSession signatureimport { joinSession, createCanvas, CanvasError } from "@github/copilot-sdk/extension";
const session = await joinSession({
canvases: [createCanvas({ /* ... */ })],
tools: [/* optional */],
hooks: {/* optional */},
onPermissionRequest: async (request) => ({ kind: "approve-once" }), // optional
});
// session.sessionId, session.workspacePath (string | undefined),
// session.send, session.sendAndWait, session.log, session.on, session.rpc
canvas.d.ts)import { createCanvas, CanvasError, joinSession } from "@github/copilot-sdk/extension";
const canvas = createCanvas({
id: "main", // unique within this extension
displayName: "My canvas", // human-readable label shown in host chrome
description: "Short summary the agent sees in the system-prompt canvas catalog.",
inputSchema: { /* JSON Schema for open input (optional) */ },
actions: [
{
name: "do_thing", // unique within this canvas; MUST NOT start with `canvas.`
description: "...",
inputSchema: { /* JSON Schema (optional) */ },
handler: async (ctx) => {
// ctx: { sessionId, extensionId, canvasId, instanceId, actionName, input, host }
// Return raw result; throw CanvasError("code", "message") for errors.
return { /* ... */ };
},
},
],
open: async (ctx) => {
// ctx: { sessionId, extensionId, canvasId, instanceId, input, host }
// Idempotent: same instanceId may arrive again after provider reconnect.
return {
url: "http://127.0.0.1:<loopback-port>/", // optional for native canvases
title: "...", // optional, shown in host chrome
status: "...", // optional, shown in host chrome
};
},
onClose: async (ctx) => {
// Optional. Fire-and-forget; return value ignored.
},
});
Runtime rules:
canvas. are reserved and rejected at declaration time.actions[] entry must have a handler; missing handlers fall through to CanvasError.noHandler().inputSchema is validated before open; action-level inputSchema is validated before dispatch. Failure returns canvas_input_invalid with Ajv details.CanvasError("code", "message") for errors.ctx.canvasId (not ctx.id) is the canvas identifier in handler context.import { createServer } from "node:http";
import { createCanvas, joinSession } from "@github/copilot-sdk/extension";
const servers = new Map(); // instanceId → { server, url }
async function startServer(instanceId) {
const server = createServer((req, res) => {
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(renderHtml(instanceId));
});
await new Promise((r) => server.listen(0, "127.0.0.1", r));
const port = server.address().port;
return { server, url: `http://127.0.0.1:${port}/` };
}
await joinSession({
canvases: [
createCanvas({
id: "my-canvas",
displayName: "My canvas",
description: "...",
open: async (ctx) => {
let entry = servers.get(ctx.instanceId);
if (!entry) {
entry = await startServer(ctx.instanceId);
servers.set(ctx.instanceId, entry);
}
return { title: "My canvas", url: entry.url };
},
onClose: async (ctx) => {
const entry = servers.get(ctx.instanceId);
if (entry) {
servers.delete(ctx.instanceId);
await new Promise((r) => entry.server.close(() => r()));
}
},
}),
],
});
Bind embedded servers to loopback only (127.0.0.1). The host only embeds loopback URLs. Push state updates to the iframe with Server-Sent Events (/events).
canvasId — the canvas *type* declared by the extension. Accepted by list_canvas_capabilities and open_canvas; not by invoke_canvas_action.extensionId — auto-derived ${source}:${name} (e.g. project:triage-board). Only pass it to disambiguate when two extensions declare the same canvasId.instanceId — caller-invented handle for one running panel (slug or UUID). Two panels of the same canvas need two instanceIds. Validated against ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ — max 128 chars, alphanumeric first character, then only [A-Za-z0-9._-].Example: open_canvas({ canvasId: "triage-board", instanceId: "triage-1" }) then invoke_canvas_action({ instanceId: "triage-1", actionName: "refresh" }).
// Open a canvas panel
open_canvas({ canvasId: "my-canvas", instanceId: "my-canvas-1", input?: {...} })
// Returns { instanceId, url? } — no extensionId needed unless two providers share the canvasId.
// Discover a canvas type's actions and input schema
list_canvas_capabilities({ canvasId: "my-canvas" })
// Invoke an action on an open panel
invoke_canvas_action({ instanceId: "my-canvas-1", actionName: "do_thing", input?: {...} })
Re-opening the same instanceId focuses the existing panel (the host emits session.canvas.opened with reopen: true and reloads the iframe without invoking the provider again).
additionalContext from hooks (everyday path). onSessionStart → once-per-session guidance; onUserPromptSubmitted → per-turn nudges. The runtime appends it as a developer-role message.systemMessage on joinSession (system-prompt path):{ mode: "append", content } — append to the SDK foundation.{ mode: "customize", sections: {...} } — override individual sections (identity, tone, safety, custom_instructions, runtime_instructions, etc.) with replace / remove / append / prepend / transform-callback actions.{ mode: "replace", content } from an extension — it discards the entire SDK-managed prompt including safety guardrails.instanceId identifies the panel, not the data. Never key persistent state by instanceId alone — iframes reload, extensions restart, and a fresh instanceId may be opened for the same logical content.
Pick the right storage scope:
session.workspacePath (returned by joinSession). Lives alongside session artifacts, cleaned up with the session.$COPILOT_HOME/extensions/<extension-name>/artifacts/. Follows the user across sessions.documentId or file path passed via input.instanceId) — only ephemeral UI state that genuinely should not survive a reload.Resolve the owning ID in open() from input, load persisted state for that ID, and render. Two instanceIds for the same documentId should show the same content.
The runtime mirrors the canvas theme contract onto your canvas document:
data-color-mode, data-dark-theme, data-light-theme, data-theme-source, data-theme-tone, data-visual-mode, pointer-on-hover class.--background-color-default, --border-color-default, --text-color-default, --text-color-muted, --color-focus-outline.--font-sans, --font-mono, --font-weight-semibold, --text-body-medium, --leading-body-medium.--true-color-red, --true-color-red-muted, --true-color-blue, --true-color-blue-muted.body {
margin: 0;
background: var(--background-color-default, #ffffff);
color: var(--text-color-default, #1f2328);
font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif);
font-size: var(--text-body-medium, 14px);
line-height: var(--leading-body-medium, 20px);
}
Only depend on the attributes, classes, and variables documented above — others are app-internal and can change.
<canvases> section of the system prompt, then verify list_canvas_capabilities({ canvasId }) returns your actions[].open_canvas({ canvasId, input? }). Confirm you receive { instanceId, url? } and no error.invoke_canvas_action({ instanceId, actionName, input? }) and confirm correct response payload.open_canvas with input that fails inputSchema. Expect canvas_input_invalid.invoke_canvas_action(instanceId, "canvas.open", ...). Expect canvas_reserved_action_name.extensions_manage({ operation: "list" }) — is the extension loaded? Marked failed?extensions_manage({ operation: "inspect", name: "<name>" }) — surfaces log file path and tail. Primary debugging surface.extensions_reload before re-running RPC calls.open_canvas again with the same instanceId.extension.mjs and lives in an immediate subdirectory of .github/extensions/, $COPILOT_HOME/extensions/, or the session's extensions dir.Use share_extension / install_extension tools. Gist format:
\ in gist keys, decoded on install).copilot-extension.json manifest: { "name": "<extension-name>", "version": 1 }.node_modules/, dist/, build/, .git/, hidden dirs (except .github/) skipped during share.console.log corrupts JSON-RPC. Use session.log() for user-visible messaging.canvas..handler on every actions[] entry.open may be re-invoked on provider reconnect — treat it as idempotent and rehydrate from durable storage.CanvasError("code", "message") for errors.ctx.canvasId, not ctx.id.extension.mjs only — .ts is not supported. The @github/copilot-sdk import is auto-resolved; do not add a package.json for it.await session.log("message", { level?: "info"|"warning"|"error", ephemeral?: boolean });
await session.send({ prompt: "...", attachments?: [{ type: "file", path: "..." }] });
const response = await session.sendAndWait({ prompt: "..." });
const unsub = session.on("tool.execution_complete", (event) => { /* ... */ });
// session.workspacePath — path to session workspace (string | undefined)
// session.sessionId — current session ID
Key event types: assistant.message, tool.execution_start, tool.execution_complete, user.message, session.idle, session.error, permission.requested, session.shutdown.
Take github/create-canvas 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.