Canvas-App erstellen
Ein rundum-sorglos-Paket zum Erstellen von Copilot-App-Canvas-Erweiterungen. Es existiert, weil handgebaute Canvases immer wieder an dieselben Grenzen stoßen: eine innerHTML-Repaint-Schleife, die Tastatureingaben frisst, ein Zustand, der nur in einem Bereich lebt, ad-hoc Styling, das das Host-Theme ignoriert, und inkonsistente Icons. Das Kit behebt alle vier Probleme.
Was ein Canvas ist (und wann man eines baut)
Ein Canvas ist eine interaktive Oberfläche, die der Agent über open_canvas in einem Seitenbereich öffnet. Sowohl der Agent als auch der Benutzer arbeiten mit demselben Zustand über dieselben Aktions-Handler. Greifen Sie zu einem Canvas, wenn Chat-Text oder ein Diff nicht ausreicht: Live-Dashboards, Editoren, Tabellenkalkulationen, Tracker, Kanban-/Board-Ansichten, Dokumentenvorschauen, tool-spezifische Workflows.
Wenn der Benutzer nur ein nicht-visuelles Agenten-Tool benötigt, erstellen Sie stattdessen ein normales Erweiterungs-Tool – kein Canvas.
Rendering-Stufe – zuerst entscheiden
| Stufe | Verwendung wann | Wie |
|---|---|---|
| Statischer HTML-String | Schreibgeschützte oder nahezu statische Inhalte; keine Eingaben zu schützen | Das Vanilla-Gerüst der Laufzeit (extensions_manage scaffold kind:canvas). |
| Preact + htm + dieses Kit | Alles Interaktive: Eingaben, Live-Updates, Listen, Formulare, gemeinsam genutzter Zustand | Dieses Kit. Standardwahl für echte Canvases. |
Der wichtigste Grund, das Kit zu verwenden: Preact diffed das DOM, sodass ein Live-Zustands-Push vom Agenten nicht den Fokus, die Cursor-Position oder halbgetippten Text in einem Eingabefeld überschreibt. Das innerHTML = ...-Muster, das die meisten frühen Canvases verwenden, malt den gesamten Baum neu und verliert bei jedem Push Tastatureingaben. Tun Sie das nicht.
Das Modell (lesen Sie dies vor dem Programmieren)
extension.mjs ── die EINZIGE Datei, die das Copilot SDK importiert (dünner Adapter)
canvas.mjs ── Ihr Canvas: ID, Schema, Zustand laden/speichern, Aktions-Handler (SDK-frei)
canvas-kit/ ── das Kit (wörtlich kopiert; nicht bearbeiten)
web/index.html ── Hülle: lädt /kit/theme.css und ./app.mjs
web/app.mjs ── Ihre Preact-Ansicht
- Zustand wird gemeinsam genutzt und ist dauerhaft. Er wird über eine Domänen-ID identifiziert, die aus der offenen Eingabe aufgelöst wird (
resolveDomainId), nicht überinstanceId– öffnen Sie dieselbe Domäne in zwei Bereichen und sie zeigen dieselben Daten. Die Persistenz erfolgt überuserStore(extName, file)→$COPILOT_HOME/extensions/<name>/artifacts/<domain>.json. - Agent und UI teilen sich Handler. Eine vom Agenten aufgerufene Aktion und dieselbe Aktion, die über einen Button aufgerufen wird, führen denselben Handler aus und erzeugen dieselbe Zustandsänderung. Schreiben Sie die Logik einmal, in
canvas.mjs. - Live-Updates sind automatisch. Das Kit bedient
GET /state,GET /events(SSE) undPOST /action.mountCanvasverdrahtet sie; jede Zustandsänderung wird an alle offenen Bereiche verteilt. server.mjsist SDK-frei, sodass die gesamte Laufzeit mit einfachem Node HTTP testbar ist (siehetest/http.test.mjs). Halten Sie SDK-Aufrufe nur inextension.mjs.
Zwei Arten von Zuständen in der Ansicht – halten Sie sie getrennt
- Gemeinsam genutzter Domänenzustand kommt über SSE und wird bei jedem Push neu gerendert. Behandeln Sie ihn in der Ansicht als schreibgeschützt; ändern Sie ihn nur durch Aufruf von
invoke(action, input). - Lokaler UI-Zustand (ein Entwurfswert, der aktive Tab/Filter) lebt in
useStateund darf niemals auf den Server geschoben werden, bis der Benutzer ihn bestätigt.
Aktionsergebnisse & Fehler – wie Fehler sichtbar werden
Beide Aufrufer (Agent und UI) treffen denselben Handler, und die Laufzeit umschließt jede Ausführung mit derselben Hülle, also modellieren Sie Fehler bewusst:
- Geben Sie ein kleines, einfaches Objekt für Erfolg zurück (
{ id, status },{ count }).POST /actionantwortet mit{ ok: true, result }. - Werfen Sie für einen harten Fehler (falsche Eingabe, fehlende ID). Die Laufzeit antwortet mit
{ ok: false, code, message }– HTTP 400 für einen bekannten Kit-Fehler (z.B. eine unbekannte Aktion) und 500 für einen Handlerthrow– und derinvokevonmountCanvaslehnt ab mit einemCanvasActionError, der diesencode/messageenthält, sodass ein Button-Handler es mittry/catchabfangen kann. Auf der Agentenseite bildetextension.mjsdenselben Fehler auf einenCanvasErrorab. Ein Throw wird angezeigt, niemals ein Absturz. - Für einen erwarteten, vorübergehenden Fehler (einen unzuverlässigen Upstream-Fetch bei einem Hintergrund-Poll) werfen Sie nicht – erfassen Sie ihn in
state.errorund geben Sie zurück, damit die Fehlerkarte angezeigt wird, während der Poll ruhig bleibt (siehe dierefresh-Datenvorlage).
Icons – offizielle GitHub Lucide, immer
Das Kit liefert die exakte Lucide-Sammlung, die github-app ausliefert ([email protected], byte-identisch). Verwenden Sie sie für jedes Icon; schreiben Sie keine SVGs von Hand und ziehen Sie kein CDN.
import { Icon } from "/kit/client.mjs";
html`<${Icon} name="circle-check" size=${16} />`
Vertrag: Namen sind kebab-case (circle-check, trash-2, list-todo). viewBox ist fest 0 0 24 24, stroke-width ist 2 – niemals strokeWidth übergeben. Verwenden Sie Größen 12 · 14 · 16 · 20 · 24 · 32. Passen Sie die Glyphenwahl von github-app an, wenn es eine offensichtliche Zuordnung gibt (z.B. open=circle-dot, done=circle-check, bookmark=bookmark). iconNames() / hasIcon(name) sind verfügbar, wenn Sie validieren müssen.
Theme – Primer-Token, mit Fallbacks
Verlinken Sie /kit/theme.css und bauen Sie auf seinen ck-*-Klassen auf (ck-card, ck-btn, ck-btn-primary, ck-input, ck-badge, ck-tabs, ck-status, ck-empty, …). Sie werden auf die Primer-Token abgebildet, die die Laufzeit in das Canvas-Dokument einfügt, jede mit einem hartcodierten GitHub-Dark-Fallback, sodass ein Canvas auch dann richtig aussieht, wenn ein Token fehlt. Fügen Sie oben drauf canvas-spezifisches CSS hinzu; stylen Sie die Primitiven nicht neu.
Badges sind generisch. Verwenden Sie die semantischen Varianten ck-badge-success, ck-badge-accent, ck-badge-muted, ck-badge-danger, ck-badge-attention und bilden Sie Ihre Status in der Ansicht darauf ab (die Referenz macht open → success, decided → accent, parked → muted). Fügen Sie keine app-spezifischen Status-Klassennamen zum gemeinsamen Theme hinzu.
Lade- & Fehler-Primitive (diese nicht selbst bauen):
ck-spinner– Spin-Animation; kombinieren Sie mit einem Lucide-Loader, z.B.html\<${Icon} name="loader-circle" class="ck-spinner" />``.ck-skeleton– flimmernder Platzhalterblock für noch ladende Inhalte.ck-callout/ck-callout ck-error– eine Inline-Notiz / Fehleroberfläche für einen fehlgeschlagenen Fetch, z.B.html\<${Icon} name="circle-x" size=${16} />${state.error}``.
Bewegungssicherheit ist global. theme.css enthält bereits eine @media (prefers-reduced-motion: reduce)-Absicherung, die alle Animationen und Übergänge neutralisiert (Kit-Spinner/Skelette und alle, die Sie hinzufügen). Sie wiederholen es nicht pro Canvas – halten Sie Bewegung jedoch dekorativ, niemals das einzige Signal.
View API – Hooks, Helfer, Fragmente
/kit/client.mjs exportiert den gesamten vendorierten Preact-Hook-Satz neu, nicht nur useState: useEffect, useRef, useMemo, useCallback, useReducer, useContext, useLayoutEffect, useImperativeHandle. Rufen Sie Hooks bedingungslos am Anfang einer Komponente auf – vor jedem frühen return (z.B. die if (!state) return … Lade-Verzweigung kommt nach Ihren Hooks).
Es exportiert auch Formatierungs- + ID-Helfer, damit Sie sie nicht neu erfinden:
import { nid, relativeTime, compactNumber, percent } from "/kit/client.mjs";
nid(); // "lq3k9f2a" – kurze eindeutige ID
relativeTime(iso); // "5m ago" / "3h ago" / "2d ago" / ein Datum
compactNumber(2_300_000); // "2.3M"
percent(1.25); // "+1.25%"
nid ist auch serverseitig in canvas.mjs importierbar über import { nid } from "./canvas-kit/format.mjs" (der Generator macht dies).
Die Formatierer sind lokalisiert – relativeTime endet in einem toLocaleDateString(), und compactNumber/percent gehen durch Number.prototype.toLocaleString, sodass ihre Ausgabe vom Host-Gebietsschema abhängt ("1.5K" vs "1,5 K", 2025-01-31 vs 31/01/2025). Behalten Sie die rohen Werte im dauerhaften Zustand (einen ISO-String, eine Number) und formatieren Sie in der Ansicht. Speichern Sie niemals einen formatierten String und parsen Sie niemals einen zurück – Formatierung ist ein Präsentationsbelang, und ein in einem Gebietsschema formatierter Wert kann in einem anderen nicht zuverlässig zurückgelesen werden.
Fragment wird NICHT exportiert. Um Geschwister-Knoten ohne einen Wrapper zurückzugeben, verwenden Sie die eingebaute htm-Syntax für leere Tags – html`<><${A} /><${B} /></>` – oder geben Sie einfach ein Array von VNodes zurück. Importieren Sie nicht import { Fragment }; es ist nicht da.
Externe-Daten-Canvases – Fetch + Auto-Refresh
Die meisten echten Canvases sind keine benutzereingegebenen CRUD-Listen; sie ziehen Daten aus dem Netzwerk und aktualisieren sich. Die Form:
-
fetch()lebt im Aktions-Handler (oder einem Helfer, den er aufruft), niemals in der Ansicht. Binden Sie ihn immer mit einem Timeout, damit ein langsamer Upstream das Panel nicht aufhängen kann:async function fetchItems(url) { const res = await fetch(url, { signal: AbortSignal.timeout(12000) }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return mapItems(await res.json()); }Der Fetch läuft serverseitig auf der Loopback-Laufzeit, daher schützt die generierte
data-Vorlage ihn mit einer SSRF-Prüfung: sie erlaubt nurhttp/httpszu einem öffentlichen Host und lehnt Loopback, Link-Local (einschließlich Cloud-Metadaten) und private Bereiche ab – einschließlich jeder Adresse, zu der der Hostname aufgelöst wird. Alles, was Sie zurück an den Agenten geben (z.B. überlist_items), ist immer noch angreiferbeeinflusster Text, also behalten Sie die Quelle, die Sie wählen, und behandeln Sie abgerufene Inhalte als nicht vertrauenswürdig (rendern Sie sie als Text, niemals alsinnerHTML). -
Stellen Sie eine
refresh-Aktion bereit, die abruft und das Ergebnis in den gemeinsam genutzten Zustand schreibt. Erfassen Sie Fehler instate.errorund geben Sie zurück (werfen Sie nicht), sodass ein Hintergrund-Poll-Tick ruhig bleibt, während die Fehlerkarte angezeigt wird. Erfassen Sie die Eingabe vor demawait, und schreiben Sie mit einem funktionalensetzurück, damit eine gleichzeitige Aktion (ein anderer Poll, einset_source) nicht überschrieben wird:refresh: { inputSchema: { type: "object", properties: {}, additionalProperties: false }, handler: async ({ state, set }) => { const sourceUrl = state.sourceUrl; // vor await erfassen try { const items = await fetchItems(sourceUrl); set((cur) => cur.sourceUrl !== sourceUrl // veraltete Antwort verwerfen ? cur : { ...cur, items, error: null, lastRefresh: new Date().toISOString() }); return { count: items.length }; } catch (err) { set((cur) => ({ ...cur, error: `Konnte nicht laden: ${err.message}`, lastRefresh: new Date().toISOString() })); return { ok: false, error: String(err.message) }; } }, }, -
Pollen Sie mit dem Kit, nicht mit einem rohen
setInterval.pollWhileVisible(tick, seconds)führttickin einem Intervall nur aus, während das Panel sichtbar ist (sodass ein im Hintergrund befindliches Canvas aufhört, das Netzwerk zu treffen) und gibt eine Bereinigungsfunktion zurück – einen Drop-in-useEffect-Rückgabewert:import { pollWhileVisible } from "/kit/client.mjs"; // Intervall an dauerhaften Zustand gebunden; wird neu aufgebaut, wenn es sich ändert: useEffect(() => pollWhileVisible(() => invoke("refresh"), state.autoRefreshSec || 0), [state.autoRefreshSec]);Für ein festes Intervall können Sie den Effekt ganz überspringen:
mountCanvas({ view, poll: { action: "refresh", seconds: 60, immediate: true } }).
Prägen Sie diese ganze Form mit node scripts/new-canvas.mjs <name> --template data auf.
Domain-Strategie – gemeinsames Board vs. persönliches Profil
Der Zustand wird über die Domain-ID verschlüsselt, die resolveDomainId zurückgibt. Zwei Absichten, identische Mechanik (fileFor bereinigt die ID zu einem Dateinamen – kopieren Sie es wörtlich):
- Gemeinsames Board (die meisten Canvases): Schlüsseln Sie ein Thema ab, das sowohl der Agent als auch der Benutzer benennen.
resolveDomainId: (input) => (input?.domain ? String(input.domain) : "default"). Jeder, derdomain: "tech"öffnet, sieht dasselbe Board. - Persönlicher Speicher: Schlüsseln Sie eine Identität ab, nicht ein Thema.
resolveDomainId: (input) => (input?.profile ? String(input.profile) : "default"), mit der Eingabe-Schema-Eigenschaft namensprofile. Verwenden Sie dies für lerner-/benutzerspezifische Daten, die nicht über Themen hinweg sichtbar sein sollen.
Wählen Sie das Nomen (domain vs profile) bewusst – es ist der gesamte Vertrag darüber, wer was teilt.
Muster & Einschränkungen (kennen Sie diese)
- Gebündelter Katalog. Für kuratierte Startdaten (Kurse, Voreinstellungen) liefern Sie eine separate
catalog.mjs: einfache Daten plus einenbuildCourse(...)-ähnlichen Builder, der deterministische Positions-IDs zuweist (es-u1-l2), sodass derselbe Katalog immer dieselben IDs erzeugt. Halten Sie die Massendaten mit winzigen Konstruktor-Helfern kurz. statusLinewird einmal beim Öffnen berechnet. Die Status-Zeichenfolge des Panels wird beiopenInstancegesetzt und nicht neu geschoben, wenn eine Aktion den Zustand ändert, daher wird ein Zähler instatusLineveraltet. Zeigen Sie Live-Zählungen innerhalb des Canvas-Körpers an (der bei jedem SSE-Push neu rendert), und behandeln SiestatusLineals eine Zusammenfassung nur zum Öffnungszeitpunkt.- Aktionen mit umfangreichen Nutzdaten.
inputSchemas von Aktionen standardmäßig aufadditionalProperties: false: Das Schema ist der Vertrag, den der Agent ausfüllt, und ein strenges Schema verhindert, dass das Modell unvalidierte/vertippte Felder einschmuggelt – nur die von Ihnen deklarierten Eigenschaften erreichen Ihren Handler. Wenn die UI dem Handler tatsächlich ein ganzes Objekt (z.B. den zu speichernden Artikel) statt skalarer Felder übergeben muss, setzen SieadditionalProperties: trueauf deminputSchemadieser Aktion und denormalisieren Sie, was Sie benötigen, im Handler in den dauerhaften Zustand. Setzen Sie es bewusst, pro Aktion – nicht als pauschale Standardeinstellung. - Installationslayout / SDK-Auflösung. Ein ausgelieferter Erweiterungsordner enthält nur
copilot-extension.json({ "name", "version": 1 }– keinpackage.json),extension.mjs,canvas.mjs,web/und das verschachteltecanvas-kit/. Das Kit ist einfaches ESM, das Node direkt ausführt – kein Bundler, keine Transpilation, kein Build-Schritt, keinpackage.jsonin der Erweiterung (der gestempeltetest/smoke.test.mjsläuft mit nacktemnode). Der Host löst@github/copilot-sdk/extensionfür Sie auf;extension.mjsist die einzige Datei, die es importiert. Fügen Sie einem ausgelieferten Canvas keinpackage.jsonoder einen Build-Schritt hinzu.
Bauen Sie ein Canvas – schnellster Weg
-
Stempeln Sie es (ein funktionierendes Canvas, Kit bereits verschachtelt, ein Smoke-Test enthalten):
node scripts/new-canvas.mjs <Name> [--template basic|data]dataist die übliche Wahl für externe-Daten-Canvases;basicfür alles andere. -
Definieren Sie Ihr Schema & Handler in
canvas.mjs:export const canvas = { id: "my-canvas", // eindeutiger Bezeichner inputSchema: { type: "object", properties: { item: { type: "string" } }, additionalProperties: false }, resolveDomainId: (input) => (input?.domain ? String(input.domain) : "default"), loadState: async ({ state }) => state || { items: [] }, actions: { add: { inputSchema: { type: "object", properties: { text: { type: "string" } }, required: ["text"] }, handler: async ({ state, input, set }) => { const items = [...(state.items || []), { id: nid(), text: input.text }]; set({ ...state, items }); return { id: items[items.length - 1].id }; }, }, }, }; -
Schreiben Sie Ihre Ansicht in
web/app.mjs:import { html } from "htm/preact"; import { useState, useEffect, useCallback } from "preact/hooks"; import { mountCanvas, Icon, invoke, nid, relativeTime } from "/kit/client.mjs"; function View({ state, invoke }) { return html` <div class="ck-card"> <h2>Meine Canvas-App</h2> <p>Elemente: ${state.items?.length || 0}</p> </div> `; } mountCanvas({ view: View }); -
Verbinden Sie es in
extension.mjs:import { Extension } from "@github/copilot-sdk/extension"; import { canvas } from "./canvas.mjs"; const ext = new Extension(); ext.registerCanvas(canvas); export default ext; -
Testen Sie es (lokal, ohne Copilot):
node test/smoke.test.mjsOder starten Sie den Entwicklungsserver:
node extension.mjs --devDann öffnen Sie
http://localhost:3000in einem Browser.
Nächste Schritte
Nachdem Ihr Canvas funktioniert:
- Fügen Sie Aktionen hinzu für Agenteninteraktionen (
list_items,set_source, …) - Stylen Sie die Ansicht mit
ck-*-Klassen – sehen Sie in/kit/theme.cssnach, was verfügbar ist. - Implementieren Sie Polling für Live-Daten
- Schreiben Sie Unit-Tests für Handler (
test/http.test.mjsals Vorlage) - Veröffentlichen Sie die Erweiterung als GitHub App oder teilen Sie sie als Skill
Verwandte Skills
create-canvas-kit– das gleiche Kit, aber als eigenständiges Paketskills– das Skills-CLI-Tool zum Verwalten von Agent-Fähigkeitencopilot-extensions– GitHub Copilot Extension SDK