Why (and How) I Build Canvases for the GitHub Copilot App
I work across many PRs and issues each day. By the end of an agent session in the GitHub Copilot app, I often have to scroll back through the conversation to piece together what the agent investigated, changed, and tested.
The Files changed tab and PR overview each show part of the picture. I built a canvas to bring the findings, changes, tests, and plan together in one view.
What is a canvas?
Per the GitHub docs, a canvas extension is “a shared, interactive surface for a work artifact, such as a plan, triage board, browser session, release checklist, dashboard, incident, or spreadsheet.” The key word is shared: canvases are bidirectional. The agent updates the canvas while it works, and you edit that same surface. The canvas UI opens in the right side panel of the agent session, beside the conversation.
Under the hood, a canvas is less magical than it sounds:
- Its entry point is an
extension.mjsfile on disk. The Copilot app runs this code alongside the agent working in your session. - It’s usually backed by a loopback HTTP server that the extension spins up in its
open()handler. The host then renders the canvas in an iframe. - The app also has built-in canvases for Excel, Word, and PowerPoint files, plus an editor and a terminal. This post focuses on custom canvases served by an extension.
- For a team-shared canvas, put the entry file at
.github/extensions/<name>/extension.mjs, relative to the repository root in your session’s workspace, and commit it with the project. - For a personal canvas, put it at
~/.copilot/extensions/<name>/extension.mjsin your home directory. On Windows, that is%USERPROFILE%\.copilot\extensions\<name>\extension.mjs. These files live outside the repository and are available across your local projects.
The app looks for exactly two calls to bootstrap all of this: joinSession() registers your extension with the agent session hosting it, and createCanvas() inside it is what says “this extension has a canvas, here’s its open() handler and its actions.” That’s specific to the Copilot app’s extension SDK, not a cross-tool standard, but there’s no separate manifest file describing the canvas beyond that call. Here’s what that looks like in extension.mjs:
1
2
3
4
5
6
7
8
9
10
11
12
import { joinSession, createCanvas } from "@github/copilot-sdk/extension";
await joinSession({
canvases: [
createCanvas({
id: "my-canvas",
displayName: "My canvas",
description: "A canvas that does nothing yet.",
open: async () => ({ title: "My canvas", url: "http://127.0.0.1:PORT/" }),
}),
],
});
The sections, actions, and hooks in the rest of this post build on that example.
One small correction to the docs while I’m here: they mention a package.json as a commonly included file. In practice, don’t add one. The CLI auto-resolves @github/copilot-sdk for you.
How it works
Here’s how I built a review flow canvas with sections for findings, file changes, tests, investigation notes, and the current plan.
1. Start with /create-canvas
This skill is built into the GitHub Copilot app. Open or start an agent session for a local repository or folder. In that session’s prompt box, type /create-canvas and describe the workflow and capabilities you want.
Specify where Copilot should save the extension, too. For this review flow canvas, use .github/extensions/review-flow/extension.mjs in the session’s repository workspace if you want to commit it, or ~/.copilot/extensions/review-flow/extension.mjs if you want a personal copy outside the repository.
My prompt:
“Create a canvas that visualizes the agent’s dev workflow in real time: what it’s investigating, editing, testing, and planning next, so I can follow and steer the work as it happens instead of after the fact.”
From that single prompt, the agent scaffolded a real extension: a loopback HTTP server, five structured sections (issue, investigated, files changed, tests, plan), a live SSE feed so the canvas updates without a manual refresh, and a matching set of actions (set_issue, log_investigation, log_file_change, log_test, set_plan) it could call to fill each one in. When I opened it, all five sections were there, each showing an empty-state message:

Structurally, that first pass was already close to right. What the canvas didn’t have yet was any instruction telling the agent how to interact with it.
2. Tell Copilot how and when to use the canvas
If you don’t tell the /create-canvas skill how the agent should interact with the canvas, the canvas may never open on its own, or it’ll open once and sit there while the agent narrates everything in chat like it isn’t there. That’s what hooks are for: instructions fed into the agent at specific points in the session lifecycle. The two hooks below belong in the review-flow/extension.mjs file at the project or personal location you chose in step 1. Add them inside the same joinSession({...}) call, alongside the canvases array.
Open hook, fired at session start, gets the canvas on screen before any work happens:
1
2
3
4
5
hooks: {
onSessionStart: async () => ({
additionalContext: "This session works against the 'review-flow' canvas. Open it now via open_canvas, before touching any files, so there's a place for the issue, plan, and progress to live from the first turn."
})
}
Review-flow hook, fired after every tool call, keeps the canvas populated instead of static:
1
2
3
4
5
6
7
8
9
10
11
12
13
hooks: {
onPostToolUse: async (ctx) => {
const section = {
edit: "files changed", create: "files changed",
powershell: "tests",
grep: "investigated", view: "investigated", glob: "investigated",
}[ctx.toolName];
if (!section) return {};
return {
additionalContext: `The '${ctx.toolName}' tool just ran. Log this to the '${section}' section of the 'review-flow' canvas via invoke_canvas_action before continuing.`,
};
}
}
3. Interact with Copilot through your canvas
The first scaffold displayed progress but gave me no way to respond, so I added three controls to each logged item:
- “Ask Copilot” is for when an item raises a question rather than needing an answer typed out. On an investigation entry, it fires a
fetch()to a route on the canvas’s own loopback server, which reaches back into the session and asks the agent to expand on that specific note, so the answer comes back inline next to that note instead of in a fresh chat wall. - “Add to chat” is for pulling a logged item into the conversation as a visible message, the same as if I’d typed it myself, so I can ask “why this fix and not X” about the
SearchBar.tsxentry without retyping context the agent already has. - “Confirm & tell Copilot” is the quiet one: checking off a test entry attaches that confirmation as context for the agent’s next turn, without posting a message nobody needed to read. Confirming and correcting happen on the artifact itself, not in a side conversation about it.
4. Clean up the UX/UI
First-pass canvases tend to look like what they are: scaffolded output. Here’s mine squeezed down to actual side-panel width:

I audit canvases with /impeccable and Playwright MCP together: Playwright opens the canvas at its real loopback URL and resizes it to the width it’ll render at, and /impeccable audits the DOM at that width instead of a wide desktop screenshot. Install once with npx impeccable install, then run /impeccable <command> in the agent like any other slash command.
That combination caught two bugs in the screenshot above. Item rows were pinning the action buttons next to the text on a single non-wrapping line, clipping investigation notes and test names mid-word. The pass/fail status icons had no explicit size, so they inherited the SVG’s default dimensions and ballooned into oversized circles. The fix: stack each item’s text above its buttons instead of forcing one line, let long text wrap naturally, and size the status icons explicitly.

5. Distribute the canvas
Once a canvas is working the way you want, getting it in front of other people (or your other machines) is the easy part. A few ways to hand one off:
- Plugin, with a deep link. Package the canvas in a plugin and share a deep link that opens its installation form in the app. The recipient confirms the installation.
- Gist sharing. Ask Copilot to share the extension with
share_extension, then have the recipient install the returned gist URL withinstall_extension. - Repo folder URL.
install_extensionalso accepts a GitHub repo folder URL, so you can distribute the extension from a public repository. - Commit to the repo. Commit the extension under
.github/extensions/<name>/. Teammates who pull those files can use it as a project-scoped extension without a separate plugin installation.
The app discovers extensions in its project, user, and session extension directories when an agent session starts. Installing through install_extension also reloads extensions in the current session, so you can use the canvas without starting a new one.
Closing thought
Chat is where I define intent and work through ambiguity. The canvas is where I see what the agent investigated, changed, and tested, and respond in place. Start with the information you keep reconstructing from chat, and ask /create-canvas to put it in one view.