diff --git a/.github/extensions/splinter-canvas/extension.mjs b/.github/extensions/splinter-canvas/extension.mjs
new file mode 100644
index 0000000..dbffd8f
--- /dev/null
+++ b/.github/extensions/splinter-canvas/extension.mjs
@@ -0,0 +1,89 @@
+// Extension: splinter-canvas
+// A starter canvas extension for the Splinter project
+//
+// This single-file skeleton is a starting point. For more complex canvases
+// (multiple actions with non-trivial logic, shared state, a custom renderer,
+// etc.) prefer splitting things out: move each action handler into its own
+// function, extract `open`/`onClose` into helpers, and pull large units
+// (renderer assets, schema definitions, shared utilities) into sibling files
+// imported from this entry point. Keep extension.mjs focused on wiring.
+
+import { createServer } from "node:http";
+import { joinSession, createCanvas } from "@github/copilot-sdk/extension";
+
+// One local HTTP server per open canvas instance. Each instance gets its own
+// ephemeral port so multiple canvases (or multiple opens of the same canvas)
+// don't collide. Replace this with your real renderer — point a static-file
+// server, a Vite/Next dev server, or any framework you like at the same URL.
+const servers = new Map();
+
+function renderHtml(instanceId) {
+ return `
+
+
splinter-canvas
+
+ splinter-canvas
+ Hello from a local canvas server.
+ Instance: ${instanceId}
+
+`;
+}
+
+async function startServer(instanceId) {
+ const server = createServer((req, res) => {
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
+ res.end(renderHtml(instanceId));
+ });
+ // Port 0 = let the OS pick a free ephemeral port. Bind to loopback only.
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+ const address = server.address();
+ const port = typeof address === "object" && address ? address.port : 0;
+ return { server, url: `http://127.0.0.1:${port}/` };
+}
+
+const session = await joinSession({
+ canvases: [
+ createCanvas({
+ id: "splinter-canvas",
+ displayName: "splinter-canvas",
+ description: "Example canvas - replace with your implementation",
+ // Optional JSON Schema describing the input passed to open():
+ // inputSchema: { type: "object", properties: {} },
+ actions: [
+ {
+ name: "example_action",
+ description: "Example agent-callable action on this canvas",
+ // Optional JSON Schema for the action input:
+ // inputSchema: { type: "object", properties: {} },
+ handler: async (ctx) => {
+ return { ok: true, instanceId: ctx.instanceId };
+ },
+ },
+ ],
+ // Called when the agent or host opens the canvas. We boot a local
+ // HTTP server on an ephemeral port and hand its URL back to the
+ // host so it can render the canvas. Re-opens with the same
+ // instanceId reuse the existing server.
+ open: async (ctx) => {
+ let entry = servers.get(ctx.instanceId);
+ if (!entry) {
+ entry = await startServer(ctx.instanceId);
+ servers.set(ctx.instanceId, entry);
+ }
+ return {
+ title: "splinter-canvas",
+ url: entry.url,
+ };
+ },
+ // Tear the per-instance server down when the canvas is closed so
+ // ports are not leaked across the lifetime of the extension.
+ onClose: async (ctx) => {
+ const entry = servers.get(ctx.instanceId);
+ if (entry) {
+ servers.delete(ctx.instanceId);
+ await new Promise((resolve) => entry.server.close(() => resolve()));
+ }
+ },
+ }),
+ ],
+});