Create Canvas App
A batteries-included way to build Copilot App canvas extensions. It exists
because hand-rolled canvases keep hitting the same walls: an innerHTML repaint
loop that eats keystrokes, state that lives only in one panel, ad-hoc styling
that ignores the host theme, and inconsistent icons. The kit fixes all four.
What a canvas is (and when to build one)
A canvas is an interactive surface the agent opens in a side panel via
open_canvas. Both the agent and the user act on the same state
through the same action handlers. Reach for a canvas when chat text or a diff
isn't enough: live dashboards, editors, spreadsheets, trackers, kanban/board
views, document previews, tool-specific workflows.
If the user just needs a non-visual agent tool, build a normal extension tool
instead — not a canvas.
Rendering tier — decide first
| Tier |
Use when |
How |
| Static HTML string |
Read-only or near-static content; no inputs to protect |
The runtime's vanilla scaffold (extensions_manage scaffold kind:canvas). |
| Preact + htm + this kit |
Anything interactive: inputs, live updates, lists, forms, shared state |
This kit. Default choice for real canvases. |
The single most important reason to use the kit: Preact diffs the DOM, so a
live state push from the agent does not clobber focus, caret position, or
half-typed text in an input. The innerHTML = ... pattern most early canvases
use repaints the whole tree and loses keystrokes on every push. Don't do that.
The model (read this before coding)
extension.mjs ── the ONLY file that imports the Copilot SDK (thin adapter; also wires host AI)
canvas.mjs ── your canvas: id, schema, state load/save, action handlers (SDK-free)
canvas-kit/ ── the kit (copied in verbatim; do not edit)
web/index.html ── shell: loads /kit/theme.css and ./app.mjs
web/app.mjs ── your Preact view
- State is shared and durable. It's keyed by a domain id resolved from the
open input (
resolveDomainId), not by instanceId — open the same domain
in two panels and they show the same data. Persistence goes through
userStore(extName, file) → $COPILOT_HOME/extensions/<name>/artifacts/<domain>.json.
Two more tiers exist in kit/storage.mjs for non-durable/scoped state:
sessionStore(sessionId, extName, file) (per-session scratch, discarded with
the session) and workspaceStore(workspacePath, file) (rooted at the session
workspace). All three write atomically (temp + rename) and serialize
concurrent saves to the same file, so a racing agent + UI save can't corrupt
or EPERM the durable file.
- Shared, multiplayer state (optional). For a board multiple people edit,
swap the local tier for
githubStore({ owner, repo, path }) from
kit/github-store.mjs: it persists the same JSON to a file in a (private) repo
via the GitHub Contents API, so every collaborator with push access edits ONE
document — GitHub is both the store and the access control. Wire it as
loadState/saveState, and add syncState: () => store.poll() +
syncIntervalMs so the runtime polls for other people's edits (cheap ETag 304
when unchanged) and adopts them live — but only while a panel is being viewed.
Writes are optimistic-locked by blob SHA (a conflicting commit re-reads +
retries; last-writer-wins by default, or pass a merge(remote, mine)). Token
comes from GH_TOKEN/GITHUB_TOKEN or gh auth token and is only ever sent as
an Authorization header.
- Agent and UI share handlers. An action invoked by the agent and the same
action invoked from a button run the identical handler and produce the identical
state mutation. Write the logic once, in
canvas.mjs.
- Live updates are automatic. The kit serves
GET /state, GET /events
(SSE), and POST /action. mountCanvas wires them up; every state change fans
out to all open panels.
server.mjs is SDK-free so the whole runtime is testable with plain Node
HTTP (see test/http.test.mjs). Keep SDK calls in extension.mjs only.
Two kinds of state in the view — keep them separate
- Shared domain state arrives via SSE and is re-rendered on every push. Treat
it as read-only in the view; change it only by calling
invoke(action, input).
- Local UI state (a draft input value, the active tab/filter) lives in
useState and must never be pushed to the server until the user commits it.
Action results & errors — how failures surface
Both callers (agent and UI) hit the same handler, and the runtime wraps every
invocation in the same envelope, so model failures deliberately:
- Return a small plain object for success (
{ id, status }, { count }).
POST /action answers { ok: true, result }.
- Throw for a hard failure (bad input, missing id). The runtime answers
{ ok: false, code, message } — HTTP 400 for a known kit error (e.g. an
unknown action) and 500 for a handler throw — and mountCanvas's
invoke rejects with a CanvasActionError carrying that code/message,
so a button handler can try/catch it. On the agent side, extension.mjs maps
the same error to a CanvasError. A throw is surfaced, never a crash.
- For an expected, transient failure (a flaky upstream fetch on a background
poll), don't throw — capture it into
state.error and return so the error
card shows while the poll stays quiet (see the data template's refresh).
Input & state are schema-validated at the boundary
An action's inputSchema is enforced by the runtime, not just declared: a
payload that violates it (wrong type, missing required field, out-of-enum value,
or an unexpected property under additionalProperties: false) is rejected with
an invalid_input error (HTTP 400) before your handler runs — from the
agent side too. So inputSchema is your typed contract; write it precisely and
your handler only sees well-shaped input. Business rules still live in the
handler (a schema-valid but empty " " title reaches the handler, which
throws → 500). Open input is validated against config.inputSchema the same way.
Optionally set config.stateSchema (same JSON-Schema subset, kit/validate.mjs)
to guard the durable shape: if a handler produces state that violates it, the
mutation is rolled back and the action fails loud (500) instead of persisting
corrupt state. It's opt-in — omit it and any state shape is allowed.
Icons — official GitHub Lucide, always
The kit vendors the exact Lucide set github-app ships (lucide-react@1.23.0,
byte-identical). Use it for every icon; do not hand-write SVGs or pull a CDN.
import { Icon } from "/kit/client.mjs";
html`<${Icon} name="circle-check" size=${16} />`
Contract: names are kebab-case (circle-check, trash-2, list-todo).
viewBox is fixed 0 0 24 24, stroke-width is 2 — never pass
strokeWidth. Use sizes 12 · 14 · 16 · 20 · 24 · 32. Match github-app's own
glyph choices when there's an obvious mapping (e.g. open=circle-dot,
done=circle-check, bookmark=bookmark). iconNames() / hasIcon(name) are
available if you need to validate.
Theme — Primer tokens, with fallbacks
Link /kit/theme.css and build on its ck-* classes (ck-card, ck-btn,
ck-btn-primary, ck-input, ck-badge, ck-tabs, ck-status, ck-empty, …).
They map to the Primer tokens the runtime injects onto the canvas document, each
with a hardcoded GitHub-dark fallback, so a canvas looks right even if a token is
missing. Add canvas-specific CSS on top; don't restyle the primitives.
Badges are generic. Use the semantic variants ck-badge-success,
ck-badge-accent, ck-badge-muted, ck-badge-danger, ck-badge-attention and
map your statuses to them in the view (the reference does
open → success, decided → accent, parked → muted). Don't add app-specific
status class names to the shared theme.
Loading & error primitives (don't hand-roll these):
ck-spinner — spin animation; pair with a Lucide loader, e.g.
html\<${Icon} name="loader-circle" class="ck-spinner" />``.
ck-skeleton — shimmering placeholder block for content that's still loading.
ck-callout / ck-callout ck-error — an inline notice / error surface for a
failed fetch, e.g. html\<${Icon} name="circle-x" size=${16} />${state.error}``.
Motion safety is global. theme.css already ships a
@media (prefers-reduced-motion: reduce) guard that neutralizes all animations
and transitions (kit spinners/skeletons and any you add). You do not repeat
it per canvas — but do keep motion decorative, never the only signal.
View API — hooks, helpers, fragments
/kit/client.mjs re-exports the full vendored Preact hook set, not just
useState: useEffect, useRef, useMemo, useCallback, useReducer,
useContext, useLayoutEffect, useImperativeHandle. Call hooks
unconditionally at the top of a component — before any early return
(e.g. the if (!state) return … loading branch comes after your hooks).
It also exports formatting + id helpers so you stop reinventing them:
import { nid, relativeTime, compactNumber, percent } from "/kit/client.mjs";
nid(); // "lq3k9f2a" — short unique id
relativeTime(iso); // "5m ago" / "3h ago" / "2d ago" / a date
compactNumber(2_300_000); // "2.3M"
percent(1.25); // "+1.25%"
nid is also importable server-side in canvas.mjs via
import { nid } from "./canvas-kit/format.mjs" (the generator does this).
The formatters are locale-aware — relativeTime ends in a
toLocaleDateString(), and compactNumber/percent go through
Number.prototype.toLocaleString, so their output depends on the host locale
("1.5K" vs "1,5 K", 2025-01-31 vs 31/01/2025). Keep the raw values in
durable state (an ISO string, a Number) and format in the view. Never store
a formatted string and never parse one back — formatting is a presentation
concern, and a value formatted under one locale can't be reliably re-read under
another.
Fragment is NOT exported. To return sibling nodes without a wrapper, use
htm's built-in empty-tag syntax — html`<><${A} /><${B} /></>` — or just
return an array of vnodes. Don't import { Fragment }; it isn't there.
External-data canvases — fetch + auto-refresh
Most real canvases aren't user-entered CRUD lists; they pull from the network and
refresh. The shape:
fetch() lives in the action handler (or a helper it calls), never in the
view. Use the kit's safeFetch — it SSRF-guards the URL and applies a
hard timeout, so a caller-influenced source can't reach the internal network
and a slow upstream can't hang the panel:
import { safeFetch } from "./canvas-kit/net.mjs";
async function fetchItems(url) {
const res = await safeFetch(url, { headers: { Accept: "application/json" } });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return mapItems(await res.json());
}
safeFetch runs server-side on the loopback runtime, so it enforces an SSRF
guard (kit/net.mjs): it allows only http/https to a public host and
rejects loopback, link-local (incl. cloud metadata), and private ranges —
including every address the hostname resolves to — then fetches with an
AbortSignal.timeout. assertPublicUrl(url) / isBlockedAddress(ip) are also
exported if you need the check without the fetch. Anything you feed back to the
agent (e.g. via list_items) is still attacker-influenced text, so keep the
source one you choose and treat fetched content as untrusted (render it as
text, never innerHTML).
Expose a refresh action that fetches and writes the result into shared
state. Capture failures into state.error and return (don't throw) so a
background poll tick stays quiet while the error card shows. Capture the input
before the await, and write back with a functional set so a
concurrent action (another poll, a set_source) isn't clobbered:
refresh: {
inputSchema: { type: "object", properties: {}, additionalProperties: false },
handler: async ({ state, set }) => {
const sourceUrl = state.sourceUrl; // capture before await
try {
const items = await fetchItems(sourceUrl);
set((cur) => cur.sourceUrl !== sourceUrl // drop a stale response
? cur
: { ...cur, items, error: null, lastRefresh: new Date().toISOString() });
return { count: items.length };
} catch (err) {
set((cur) => ({ ...cur, error: `Couldn't load: ${err.message}`, lastRefresh: new Date().toISOString() }));
return { ok: false, error: String(err.message) };
}
},
},
Poll with the kit, not a raw setInterval. pollWhileVisible(tick, seconds) runs tick on an interval only while the panel is visible (so a
backgrounded canvas stops hitting the network) and returns a cleanup function —
a drop-in useEffect return:
import { pollWhileVisible } from "/kit/client.mjs";
// interval bound to durable state; rebuilds when it changes:
useEffect(() => pollWhileVisible(() => invoke("refresh"), state.autoRefreshSec || 0),
[state.autoRefreshSec]);
For a fixed interval you can skip the effect entirely:
mountCanvas({ view, poll: { action: "refresh", seconds: 60, immediate: true } }).
Stamp this whole shape with node scripts/new-canvas.mjs <name> --template data.
Build a canvas — fastest path
- Stamp it (one working canvas, kit already nested, a smoke test included):
node scripts/new-canvas.mjs <name> --dir <target> --title "Display Name"
Target .github/extensions/<name> for an in-repo extension (committed, shared
with the team), $COPILOT_HOME/extensions/<name> for a personal one (local to
you), or $COPILOT_HOME/session-state/<sessionId>/extensions/<name> for a
throwaway canvas scoped to just the current session — it's discovered like the
others (its extensionId is session:<sessionId>:<name>) and disappears when
the session does. Add --template data for an external-data canvas (fetch +
refresh + visibility-gated polling), or --template ai for a host-AI canvas
(ctx.ai silent generation + ctx.askAgent handoff), instead of the default
user-entered list.
- Reload + open. Run
extensions_reload, then open_canvas with
canvasId: "<name>". The list canvas works immediately.
- Shape your data. In
canvas.mjs, edit createInitialState, the action
handlers (rename/replace add_item/toggle_item/remove_item/list_items),
and statusLine. Keep each handler pure: read state, call set(next),
return a small result.
- Shape the view. In
web/app.mjs, render the shared state and call
invoke("<action>", input) from controls. Keep drafts in useState.
- Add an action both sides use. Give the agent a verb (e.g.
add_item) and
wire the same verb to a button — one handler, two callers.
If you prefer to hand-assemble instead of using the generator: copy kit/ into
your extension as canvas-kit/, then copy reference/decision-log/extension.mjs
verbatim and adapt canvas.mjs + web/ from the reference.
Keeping your vendored kit in sync
A shipped extension vendors the kit verbatim as canvas-kit/ — there's no npm
package or build step, so nothing tells you whether the copy you shipped matches
the current kit/. The kit is version-stamped to close that gap: kit/version.mjs
exports KIT_VERSION (re-exported from /kit/client.mjs), and two scripts keep
vendored copies honest.
Re-sync after a kit change. Bump KIT_VERSION when you change any kit file,
then refresh each vendored copy:
node scripts/sync-kit.mjs <extension-dir>
This makes <extension-dir>/canvas-kit/ an exact mirror of kit/ —
overwriting changed files and pruning stale ones (a file removed upstream
won't linger) — and records the version into
<extension-dir>/canvas-kit/.kit-version.json.
Gate drift in CI (offline). Fail the build if any vendored kit has drifted
from kit/ — by file set, by file contents, or by recorded version:
node scripts/check-kit-freshness.mjs <extensions-dir>
Point it at a folder of extensions (e.g. .github/extensions), a single
extension dir, or a canvas-kit/ dir. Exit 0 = all fresh; exit 1 = drift, with
the offending files listed. It runs no network and needs no dependencies.
The .kit-version.json marker is metadata, not a kit file — the byte-parity
test (test/kit-parity.test.mjs) and the freshness check both treat it as
out-of-band, so it never counts against kit/ ↔ canvas-kit/ parity.
Validate visually — don't claim done without looking
A canvas isn't done because the server boots. Verify the UI:
- Run the standalone HTTP test to prove the runtime contract:
node test/http.test.mjs (and node test/client.test.mjs,
node test/kit-parity.test.mjs,
node test/generator.test.mjs, node test/tooling.test.mjs,
node test/vendor-lucide.test.mjs, node test/kit-runtime.test.mjs). A stamped canvas
ships its own test/smoke.test.mjs — run it from the canvas folder
(node test/smoke.test.mjs) to prove its actions over real HTTP.
- Open the canvas and look at it (Playwright or the browser canvas):
navigate to the panel, assert key text/controls render, take a screenshot.
- Drive an action from the UI and invoke the same action as the agent;
confirm both update the panel live (SSE) without losing input focus.
- Only then report it as working.
Reference + tests (study these)
reference/decision-log/ — a complete, working canvas in the exact installed
shape (kit nested as canvas-kit/). Best example of every contract above —
including the host model: a summarize action (silent ctx.ai) and a
hand_to_agent action (ctx.askAgent), each capturing model errors into state.
It also demonstrates every deep-link builder: a set_repo action, a per-decision
"Open in session" link, and an "Open in github-app" card (Chats, new automation,
issue/PR, open-by-session-id, and a hosted-launcher web-links toggle).
reference/deeplinks.md — the deep-link schema reference: routes, parameters, the
validation the builders apply, the webview mechanism, and how to add a route.
kit/ — the canonical kit you copy in. Source of truth.
test/http.test.mjs — boots the SDK-free runtime over real HTTP and checks
/state, /events, /action, static serving, path-traversal safety, and the
host-model actions (offline-error capture + a wired-host stub).
test/client.test.mjs — unit-tests the DOM-free client transport
(connectCanvas): the initial /state fetch, SSE state pushes + connected flag,
and invoke → POST /action (result + CanvasActionError on failure), with
stubbed fetch/EventSource (no browser needed).
test/kit-parity.test.mjs — guarantees kit/ and the reference's bundled
canvas-kit/ stay byte-identical.
test/deeplinks.test.mjs — checks the ghapp:// builders: input validation
(matching github-app), param encoding, injection resistance, and the launcher wrapper.
test/generator.test.mjs — stamps the list, data, and ai templates, runs their
smoke tests, and checks the kit API surface (format helpers, poll helper, theme
primitives, host-model wiring).
test/tooling.test.mjs — exercises the version stamp (kit/version.mjs),
scripts/sync-kit.mjs, and the offline scripts/check-kit-freshness.mjs drift gate.
test/vendor-lucide.test.mjs — checks the Lucide vendoring generator
(scripts/vendor-lucide.mjs): sorting, key-strip, alias mapping, determinism.
test/kit-runtime.test.mjs — exercises the runtime hardening: kit/validate.mjs
input/stateSchema validation (incl. prototype-key safety), the kit/net.mjs
SSRF guard (assertPublicUrl/safeFetch, IPv4-mapped-IPv6 forms), concurrency-safe
kit/storage.mjs saves + sessionStore, the kit/github-store.mjs shared store
(mocked fetch: load/decode, ETag 304 poll, 409 re-read+retry, 404 fallback)
and the server's syncState adopt-and-broadcast loop, prototype-safe
kit/icons.mjs name resolution, and the server's bounded SSE subscriber cap.
Footguns
- Never repaint with
innerHTML; render through Preact so pushes don't eat
keystrokes.
- Never key state by
instanceId — key by domain so panels stay in sync.
- Never hand
open_canvas an instanceId outside ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$
— ≤128 chars, an alphanumeric first character, then only A-Za-z0-9._-. A malformed
handle (leading -/_, spaces, over-long) is rejected by the runtime with
Invalid canvas instance ID before your canvas ever opens.
- Never hand-roll or CDN-load icons — use
/kit/client.mjs's Icon.
- Never put SDK imports outside
extension.mjs.
- Never pass
strokeWidth to Icon or invent pixel sizes off the scale.
- Never
fetch() in the view — network I/O belongs in an action handler,
always with AbortSignal.timeout(...).
- Never hand-roll a polling
setInterval — use pollWhileVisible so a
hidden panel stops hammering the network.
- Never
import { Fragment } — it isn't exported; use htm's <>…</>.
- Never reach for the model with
fetch/API keys or a client.createSession()
sub-session (it hangs from an extension) — use ctx.ai(...) for silent
generation, ctx.askAgent(...) to drive the main agent.
- Never hand-build a
ghapp:// URL, and never omit target="_blank" on a
deep-link anchor. Use the kit builders (buildSessionDeepLink, …) and render a
target="_blank" link so the canvas webview routes it into the app.
1---2name: create-canvas-app3description: Build a GitHub Copilot App canvas extension the right way, fast. Use when the user wants to create, scaffold, or improve an interactive canvas (a side-panel UI the agent can open and drive) — dashboards, editors, trackers, boards, document/preview surfaces. Provides a no-build Preact + htm kit with live SSE state, durable per-user storage, Primer theming, and the official GitHub Lucide icon set, plus a generator that stamps a working canvas in one command. Do NOT use for non-canvas extensions (plain agent tools) or for shipping web apps unrelated to Copilot canvases.4---56# Create Canvas App78A batteries-included way to build **Copilot App canvas extensions**. It exists9because hand-rolled canvases keep hitting the same walls: an `innerHTML` repaint10loop that eats keystrokes, state that lives only in one panel, ad-hoc styling11that ignores the host theme, and inconsistent icons. The kit fixes all four.1213## What a canvas is (and when to build one)1415A canvas is an interactive surface the agent opens in a side panel via16`open_canvas`. Both the **agent** and the **user** act on the **same state**17through the **same action handlers**. Reach for a canvas when chat text or a diff18isn't enough: live dashboards, editors, spreadsheets, trackers, kanban/board19views, document previews, tool-specific workflows.2021If the user just needs a non-visual agent tool, build a normal extension tool22instead — not a canvas.2324## Rendering tier — decide first2526| Tier | Use when | How |27| --- | --- | --- |28| **Static HTML string** | Read-only or near-static content; no inputs to protect | The runtime's vanilla scaffold (`extensions_manage scaffold kind:canvas`). |29| **Preact + htm + this kit** | Anything interactive: inputs, live updates, lists, forms, shared state | This kit. **Default choice for real canvases.** |3031The single most important reason to use the kit: **Preact diffs the DOM**, so a32live state push from the agent does **not** clobber focus, caret position, or33half-typed text in an input. The `innerHTML = ...` pattern most early canvases34use repaints the whole tree and loses keystrokes on every push. Don't do that.3536## The model (read this before coding)3738```text39extension.mjs ── the ONLY file that imports the Copilot SDK (thin adapter; also wires host AI)40canvas.mjs ── your canvas: id, schema, state load/save, action handlers (SDK-free)41canvas-kit/ ── the kit (copied in verbatim; do not edit)42web/index.html ── shell: loads /kit/theme.css and ./app.mjs43web/app.mjs ── your Preact view44```4546- **State is shared and durable.** It's keyed by a *domain id* resolved from the47 open input (`resolveDomainId`), **not** by `instanceId` — open the same domain48 in two panels and they show the same data. Persistence goes through49 `userStore(extName, file)` → `$COPILOT_HOME/extensions/<name>/artifacts/<domain>.json`.50 Two more tiers exist in `kit/storage.mjs` for non-durable/scoped state:51 `sessionStore(sessionId, extName, file)` (per-session scratch, discarded with52 the session) and `workspaceStore(workspacePath, file)` (rooted at the session53 workspace). All three write atomically (temp + rename) and **serialize54 concurrent saves to the same file**, so a racing agent + UI save can't corrupt55 or `EPERM` the durable file.56- **Shared, multiplayer state (optional).** For a board multiple *people* edit,57 swap the local tier for `githubStore({ owner, repo, path })` from58 `kit/github-store.mjs`: it persists the same JSON to a file in a (private) repo59 via the GitHub Contents API, so every collaborator with push access edits ONE60 document — GitHub is both the store and the access control. Wire it as61 `loadState`/`saveState`, and add `syncState: () => store.poll()` +62 `syncIntervalMs` so the runtime polls for other people's edits (cheap ETag `304`63 when unchanged) and adopts them live — but only while a panel is being viewed.64 Writes are optimistic-locked by blob SHA (a conflicting commit re-reads +65 retries; last-writer-wins by default, or pass a `merge(remote, mine)`). Token66 comes from `GH_TOKEN`/`GITHUB_TOKEN` or `gh auth token` and is only ever sent as67 an `Authorization` header.68- **Agent and UI share handlers.** An action invoked by the agent and the same69 action invoked from a button run the identical handler and produce the identical70 state mutation. Write the logic once, in `canvas.mjs`.71- **Live updates are automatic.** The kit serves `GET /state`, `GET /events`72 (SSE), and `POST /action`. `mountCanvas` wires them up; every state change fans73 out to all open panels.74- **`server.mjs` is SDK-free** so the whole runtime is testable with plain Node75 HTTP (see `test/http.test.mjs`). Keep SDK calls in `extension.mjs` only.7677### Two kinds of state in the view — keep them separate7879- **Shared domain state** arrives via SSE and is re-rendered on every push. Treat80 it as read-only in the view; change it only by calling `invoke(action, input)`.81- **Local UI state** (a draft input value, the active tab/filter) lives in82 `useState` and must never be pushed to the server until the user commits it.8384### Action results & errors — how failures surface8586Both callers (agent and UI) hit the same handler, and the runtime wraps every87invocation in the same envelope, so model failures deliberately:8889- **Return** a small plain object for success (`{ id, status }`, `{ count }`).90 `POST /action` answers `{ ok: true, result }`.91- **Throw** for a hard failure (bad input, missing id). The runtime answers92 `{ ok: false, code, message }` — HTTP **400** for a known kit error (e.g. an93 unknown action) and **500** for a handler `throw` — and `mountCanvas`'s94 `invoke` **rejects** with a `CanvasActionError` carrying that `code`/`message`,95 so a button handler can `try/catch` it. On the agent side, `extension.mjs` maps96 the same error to a `CanvasError`. A throw is *surfaced*, never a crash.97- For an **expected, transient** failure (a flaky upstream fetch on a background98 poll), don't throw — capture it into `state.error` and **return** so the error99 card shows while the poll stays quiet (see the data template's `refresh`).100101### Input & state are schema-validated at the boundary102103An action's `inputSchema` is **enforced by the runtime**, not just declared: a104payload that violates it (wrong type, missing required field, out-of-enum value,105or an unexpected property under `additionalProperties: false`) is rejected with106an `invalid_input` error (**HTTP 400**) *before* your handler runs — from the107agent side too. So `inputSchema` is your typed contract; write it precisely and108your handler only sees well-shaped input. **Business rules still live in the109handler** (a schema-valid but empty `" "` title reaches the handler, which110throws → 500). Open input is validated against `config.inputSchema` the same way.111112Optionally set `config.stateSchema` (same JSON-Schema subset, `kit/validate.mjs`)113to guard the **durable shape**: if a handler produces state that violates it, the114mutation is rolled back and the action fails loud (500) instead of persisting115corrupt state. It's opt-in — omit it and any state shape is allowed.116117## Icons — official GitHub Lucide, always118119The kit vendors the **exact Lucide set github-app ships** (`lucide-react@1.23.0`,120byte-identical). Use it for *every* icon; do not hand-write SVGs or pull a CDN.121122```js123import { Icon } from "/kit/client.mjs";124html`<${Icon} name="circle-check" size=${16} />`125```126127Contract: names are **kebab-case** (`circle-check`, `trash-2`, `list-todo`).128`viewBox` is fixed `0 0 24 24`, `stroke-width` is `2` — **never** pass129`strokeWidth`. Use sizes `12 · 14 · 16 · 20 · 24 · 32`. Match github-app's own130glyph choices when there's an obvious mapping (e.g. open=`circle-dot`,131done=`circle-check`, bookmark=`bookmark`). `iconNames()` / `hasIcon(name)` are132available if you need to validate.133134## Theme — Primer tokens, with fallbacks135136Link `/kit/theme.css` and build on its `ck-*` classes (`ck-card`, `ck-btn`,137`ck-btn-primary`, `ck-input`, `ck-badge`, `ck-tabs`, `ck-status`, `ck-empty`, …).138They map to the Primer tokens the runtime injects onto the canvas document, each139with a hardcoded GitHub-dark fallback, so a canvas looks right even if a token is140missing. Add canvas-specific CSS on top; don't restyle the primitives.141142**Badges are generic.** Use the semantic variants `ck-badge-success`,143`ck-badge-accent`, `ck-badge-muted`, `ck-badge-danger`, `ck-badge-attention` and144map *your* statuses to them in the view (the reference does145`open → success, decided → accent, parked → muted`). Don't add app-specific146status class names to the shared theme.147148**Loading & error primitives** (don't hand-roll these):149150- `ck-spinner` — spin animation; pair with a Lucide loader, e.g.151 `html\`<\${Icon} name="loader-circle" class="ck-spinner" />\``.152- `ck-skeleton` — shimmering placeholder block for content that's still loading.153- `ck-callout` / `ck-callout ck-error` — an inline notice / error surface for a154 failed fetch, e.g. `html\`<div class="ck-callout ck-error"><\${Icon} name="circle-x" size=\${16} /><span>\${state.error}</span></div>\``.155156**Motion safety is global.** `theme.css` already ships a157`@media (prefers-reduced-motion: reduce)` guard that neutralizes *all* animations158and transitions (kit spinners/skeletons and any you add). You do **not** repeat159it per canvas — but do keep motion decorative, never the only signal.160161## View API — hooks, helpers, fragments162163`/kit/client.mjs` re-exports the full vendored Preact hook set, not just164`useState`: `useEffect`, `useRef`, `useMemo`, `useCallback`, `useReducer`,165`useContext`, `useLayoutEffect`, `useImperativeHandle`. Call hooks166unconditionally at the top of a component — **before** any early `return`167(e.g. the `if (!state) return …` loading branch comes after your hooks).168169It also exports formatting + id helpers so you stop reinventing them:170171```js172import { nid, relativeTime, compactNumber, percent } from "/kit/client.mjs";173nid(); // "lq3k9f2a" — short unique id174relativeTime(iso); // "5m ago" / "3h ago" / "2d ago" / a date175compactNumber(2_300_000); // "2.3M"176percent(1.25); // "+1.25%"177```178179`nid` is also importable server-side in `canvas.mjs` via180`import { nid } from "./canvas-kit/format.mjs"` (the generator does this).181182**The formatters are locale-aware** — `relativeTime` ends in a183`toLocaleDateString()`, and `compactNumber`/`percent` go through184`Number.prototype.toLocaleString`, so their output depends on the host locale185(`"1.5K"` vs `"1,5 K"`, `2025-01-31` vs `31/01/2025`). Keep the **raw** values in186durable state (an ISO string, a `Number`) and format **in the view**. Never store187a formatted string and never parse one back — formatting is a presentation188concern, and a value formatted under one locale can't be reliably re-read under189another.190191**`Fragment` is NOT exported.** To return sibling nodes without a wrapper, use192htm's built-in empty-tag syntax — `` html`<><${A} /><${B} /></>` `` — or just193return an array of vnodes. Don't `import { Fragment }`; it isn't there.194195## External-data canvases — fetch + auto-refresh196197Most real canvases aren't user-entered CRUD lists; they pull from the network and198refresh. The shape:1992001. **`fetch()` lives in the action handler (or a helper it calls), never in the201 view.** Use the kit's **`safeFetch`** — it SSRF-guards the URL and applies a202 hard timeout, so a caller-influenced source can't reach the internal network203 and a slow upstream can't hang the panel:204205 ```js206 import { safeFetch } from "./canvas-kit/net.mjs";207208 async function fetchItems(url) {209 const res = await safeFetch(url, { headers: { Accept: "application/json" } });210 if (!res.ok) throw new Error(`HTTP ${res.status}`);211 return mapItems(await res.json());212 }213 ```214215 `safeFetch` runs server-side on the loopback runtime, so it enforces an **SSRF216 guard** (`kit/net.mjs`): it allows only `http`/`https` to a **public** host and217 rejects loopback, link-local (incl. cloud metadata), and private ranges —218 including every address the hostname resolves to — then fetches with an219 `AbortSignal.timeout`. `assertPublicUrl(url)` / `isBlockedAddress(ip)` are also220 exported if you need the check without the fetch. Anything you feed back to the221 agent (e.g. via `list_items`) is still attacker-influenced text, so keep the222 source one *you* choose and treat fetched content as untrusted (render it as223 text, never `innerHTML`).2242252. **Expose a `refresh` action** that fetches and writes the result into shared226 state. Capture failures into `state.error` and *return* (don't throw) so a227 background poll tick stays quiet while the error card shows. Capture the input228 **before** the `await`, and write back with a **functional `set`** so a229 concurrent action (another poll, a `set_source`) isn't clobbered:230231 ```js232 refresh: {233 inputSchema: { type: "object", properties: {}, additionalProperties: false },234 handler: async ({ state, set }) => {235 const sourceUrl = state.sourceUrl; // capture before await236 try {237 const items = await fetchItems(sourceUrl);238 set((cur) => cur.sourceUrl !== sourceUrl // drop a stale response239 ? cur240 : { ...cur, items, error: null, lastRefresh: new Date().toISOString() });241 return { count: items.length };242 } catch (err) {243 set((cur) => ({ ...cur, error: `Couldn't load: ${err.message}`, lastRefresh: new Date().toISOString() }));244 return { ok: false, error: String(err.message) };245 }246 },247 },248 ```2492503. **Poll with the kit, not a raw `setInterval`.** `pollWhileVisible(tick,251 seconds)` runs `tick` on an interval **only while the panel is visible** (so a252 backgrounded canvas stops hitting the network) and returns a cleanup function —253 a drop-in `useEffect` return:254255 ```js256 import { pollWhileVisible } from "/kit/client.mjs";257 // interval bound to durable state; rebuilds when it changes:258 useEffect(() => pollWhileVisible(() => invoke("refresh"), state.autoRefreshSec || 0),259 [state.autoRefreshSec]);260 ```261262 For a fixed interval you can skip the effect entirely:263 `mountCanvas({ view, poll: { action: "refresh", seconds: 60, immediate: true } })`.264265Stamp this whole shape with `node scripts/new-canvas.mjs <name> --template data`.266267## Build a canvas — fastest path2682691. **Stamp it** (one working canvas, kit already nested, a smoke test included):270 ```sh271 node scripts/new-canvas.mjs <name> --dir <target> --title "Display Name"272 ```273 Target `.github/extensions/<name>` for an in-repo extension (committed, shared274 with the team), `$COPILOT_HOME/extensions/<name>` for a personal one (local to275 you), or `$COPILOT_HOME/session-state/<sessionId>/extensions/<name>` for a276 throwaway canvas scoped to just the current session — it's discovered like the277 others (its `extensionId` is `session:<sessionId>:<name>`) and disappears when278 the session does. Add `--template data` for an external-data canvas (fetch +279 `refresh` + visibility-gated polling), or `--template ai` for a host-AI canvas280 (`ctx.ai` silent generation + `ctx.askAgent` handoff), instead of the default281 user-entered list.2822. **Reload + open.** Run `extensions_reload`, then `open_canvas` with283 `canvasId: "<name>"`. The list canvas works immediately.2843. **Shape your data.** In `canvas.mjs`, edit `createInitialState`, the action285 handlers (rename/replace `add_item`/`toggle_item`/`remove_item`/`list_items`),286 and `statusLine`. Keep each handler pure: read `state`, call `set(next)`,287 return a small result.2884. **Shape the view.** In `web/app.mjs`, render the shared state and call289 `invoke("<action>", input)` from controls. Keep drafts in `useState`.2905. **Add an action both sides use.** Give the agent a verb (e.g. `add_item`) and291 wire the same verb to a button — one handler, two callers.292293If you prefer to hand-assemble instead of using the generator: copy `kit/` into294your extension as `canvas-kit/`, then copy `reference/decision-log/extension.mjs`295verbatim and adapt `canvas.mjs` + `web/` from the reference.296297## Keeping your vendored kit in sync298299A shipped extension vendors the kit **verbatim** as `canvas-kit/` — there's no npm300package or build step, so nothing tells you whether the copy you shipped matches301the current `kit/`. The kit is version-stamped to close that gap: `kit/version.mjs`302exports `KIT_VERSION` (re-exported from `/kit/client.mjs`), and two scripts keep303vendored copies honest.304305- **Re-sync after a kit change.** Bump `KIT_VERSION` when you change any kit file,306 then refresh each vendored copy:307 ```sh308 node scripts/sync-kit.mjs <extension-dir>309 ```310 This makes `<extension-dir>/canvas-kit/` an exact mirror of `kit/` —311 overwriting changed files **and pruning stale ones** (a file removed upstream312 won't linger) — and records the version into313 `<extension-dir>/canvas-kit/.kit-version.json`.314315- **Gate drift in CI (offline).** Fail the build if any vendored kit has drifted316 from `kit/` — by file set, by file contents, or by recorded version:317 ```sh318 node scripts/check-kit-freshness.mjs <extensions-dir>319 ```320 Point it at a folder of extensions (e.g. `.github/extensions`), a single321 extension dir, or a `canvas-kit/` dir. Exit 0 = all fresh; exit 1 = drift, with322 the offending files listed. It runs no network and needs no dependencies.323324The `.kit-version.json` marker is metadata, **not** a kit file — the byte-parity325test (`test/kit-parity.test.mjs`) and the freshness check both treat it as326out-of-band, so it never counts against `kit/` ↔ `canvas-kit/` parity.327328- **Re-vendor the Lucide glyphs (rare).** `kit/vendor/lucide.mjs` is329 AUTO-GENERATED to match the exact `lucide-react` release the Copilot host app330 ships, so canvases render the same glyphs the host does. When the host bumps its331 Lucide version, regenerate the file deterministically rather than hand-editing:332 ```sh333 node scripts/vendor-lucide.mjs <version>334 ```335 Then bump `KIT_VERSION` and re-sync vendored copies. Pass `--icons-dir <path>`336 to vendor from an existing install instead of fetching one.337338## Validate visually — don't claim done without looking339340A canvas isn't done because the server boots. Verify the UI:3413421. Run the standalone HTTP test to prove the runtime contract:343 `node test/http.test.mjs` (and `node test/client.test.mjs`,344 `node test/kit-parity.test.mjs`,345 `node test/generator.test.mjs`, `node test/tooling.test.mjs`,346 `node test/vendor-lucide.test.mjs`, `node test/kit-runtime.test.mjs`). A stamped canvas347 ships its own `test/smoke.test.mjs` — run it from the canvas folder348 (`node test/smoke.test.mjs`) to prove its actions over real HTTP.3492. Open the canvas and **look at it** (Playwright or the browser canvas):350 navigate to the panel, assert key text/controls render, take a screenshot.3513. Drive an action from the UI **and** invoke the same action as the agent;352 confirm both update the panel live (SSE) without losing input focus.3534. Only then report it as working.354355## Reference + tests (study these)356357- `reference/decision-log/` — a complete, working canvas in the exact installed358 shape (kit nested as `canvas-kit/`). Best example of every contract above —359 including the host model: a `summarize` action (silent `ctx.ai`) and a360 `hand_to_agent` action (`ctx.askAgent`), each capturing model errors into state.361 It also demonstrates every deep-link builder: a `set_repo` action, a per-decision362 "Open in session" link, and an "Open in github-app" card (Chats, new automation,363 issue/PR, open-by-session-id, and a hosted-launcher web-links toggle).364- `reference/deeplinks.md` — the deep-link schema reference: routes, parameters, the365 validation the builders apply, the webview mechanism, and how to add a route.366- `kit/` — the canonical kit you copy in. Source of truth.367- `test/http.test.mjs` — boots the SDK-free runtime over real HTTP and checks368 `/state`, `/events`, `/action`, static serving, path-traversal safety, and the369 host-model actions (offline-error capture + a wired-host stub).370- `test/client.test.mjs` — unit-tests the DOM-free client transport371 (`connectCanvas`): the initial `/state` fetch, SSE state pushes + connected flag,372 and `invoke` → `POST /action` (result + `CanvasActionError` on failure), with373 stubbed `fetch`/`EventSource` (no browser needed).374- `test/kit-parity.test.mjs` — guarantees `kit/` and the reference's bundled375 `canvas-kit/` stay byte-identical.376- `test/deeplinks.test.mjs` — checks the `ghapp://` builders: input validation377 (matching github-app), param encoding, injection resistance, and the launcher wrapper.378- `test/generator.test.mjs` — stamps the list, data, and ai templates, runs their379 smoke tests, and checks the kit API surface (format helpers, poll helper, theme380 primitives, host-model wiring).381- `test/tooling.test.mjs` — exercises the version stamp (`kit/version.mjs`),382 `scripts/sync-kit.mjs`, and the offline `scripts/check-kit-freshness.mjs` drift gate.383- `test/vendor-lucide.test.mjs` — checks the Lucide vendoring generator384 (`scripts/vendor-lucide.mjs`): sorting, `key`-strip, alias mapping, determinism.385- `test/kit-runtime.test.mjs` — exercises the runtime hardening: `kit/validate.mjs`386 input/`stateSchema` validation (incl. prototype-key safety), the `kit/net.mjs`387 SSRF guard (`assertPublicUrl`/`safeFetch`, IPv4-mapped-IPv6 forms), concurrency-safe388 `kit/storage.mjs` saves + `sessionStore`, the `kit/github-store.mjs` shared store389 (mocked fetch: load/decode, ETag `304` poll, `409` re-read+retry, `404` fallback)390 and the server's `syncState` adopt-and-broadcast loop, prototype-safe391 `kit/icons.mjs` name resolution, and the server's bounded SSE subscriber cap.392393## Footguns394395- **Never** repaint with `innerHTML`; render through Preact so pushes don't eat396 keystrokes.397- **Never** key state by `instanceId` — key by domain so panels stay in sync.398- **Never** hand `open_canvas` an `instanceId` outside `^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`399 — ≤128 chars, an alphanumeric first character, then only `A-Za-z0-9._-`. A malformed400 handle (leading `-`/`_`, spaces, over-long) is rejected by the runtime with401 `Invalid canvas instance ID` before your canvas ever opens.402- **Never** hand-roll or CDN-load icons — use `/kit/client.mjs`'s `Icon`.403- **Never** put SDK imports outside `extension.mjs`.404- **Never** pass `strokeWidth` to `Icon` or invent pixel sizes off the scale.405- **Never** `fetch()` in the view — network I/O belongs in an action handler,406 always with `AbortSignal.timeout(...)`.407- **Never** hand-roll a polling `setInterval` — use `pollWhileVisible` so a408 hidden panel stops hammering the network.409- **Never** `import { Fragment }` — it isn't exported; use htm's `<>…</>`.410- **Never** reach for the model with `fetch`/API keys or a `client.createSession()`411 sub-session (it hangs from an extension) — use `ctx.ai(...)` for silent412 generation, `ctx.askAgent(...)` to drive the main agent.413- **Never** hand-build a `ghapp://` URL, and never omit `target="_blank"` on a414 deep-link anchor. Use the kit builders (`buildSessionDeepLink`, …) and render a415 `target="_blank"` link so the canvas webview routes it into the app.