Communitygithub.com

Canvas App erstellen

Ein Baukasten für KI-Codierungsagenten (GitHub Copilot, Claude und Codex), mit dem sich interaktive Canvas-Erweiterungen erstellen lassen. Installierbar mit der Skills-CLI: `npx skills add`.

Was ist Canvas App erstellen?

Dieser Skill bietet eine strukturierte Möglichkeit, Canvas-Erweiterungen für GitHub Copilot App zu bauen. Ein Canvas ist ein interaktives Bedienfeld, das sowohl vom Agenten als auch vom Benutzer über denselben gemeinsam genutzten Zustand und dieselben Aktions-Handler gesteuert wird. Das Kit löst typische Probleme handgebauter Canvases: Es verwendet Preact für effizientes DOM-Diffing, um Fokus und Tastatureingaben zu schützen, bietet ein einheitliches Theming über Primer-Token, integriert offizielle Lucide-Icons und ermöglicht Live-Updates via SSE. Die Architektur ist klar in SDK-freie (`canvas.mjs`, `server.mjs`) und SDK-abhängige (`extension.mjs`) Teile getrennt, was Tests mit einfachem Node HTTP erlaubt. Der Skill unterstützt externe Datenquellen mit integrierter Abruffunktion und Polling, das nur bei sichtbarem Panel aktiv ist. Er ist für GitHub Copilot, Claude und Codex optimiert und wird über die Skills-CLI (`npx skills add`) installiert.

Funktioniert mit✓Claude Code✓Codex CLI~Cursor
npx skills add jongio/skills

In Ihrer bevorzugten KI fragen

Öffnet einen neuen Chat, in dem dieser Agent-Skill bereits geladen ist.

Dokumentation

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

StufeVerwendung wannWie
Statischer HTML-StringSchreibgeschützte oder nahezu statische Inhalte; keine Eingaben zu schützenDas Vanilla-Gerüst der Laufzeit (extensions_manage scaffold kind:canvas).
Preact + htm + dieses KitAlles Interaktive: Eingaben, Live-Updates, Listen, Formulare, gemeinsam genutzter ZustandDieses 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 über instanceId – öffnen Sie dieselbe Domäne in zwei Bereichen und sie zeigen dieselben Daten. Die Persistenz erfolgt über userStore(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) und POST /action. mountCanvas verdrahtet sie; jede Zustandsänderung wird an alle offenen Bereiche verteilt.
  • server.mjs ist SDK-frei, sodass die gesamte Laufzeit mit einfachem Node HTTP testbar ist (siehe test/http.test.mjs). Halten Sie SDK-Aufrufe nur in extension.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 useState und 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 /action antwortet 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 Handler throw – und der invoke von mountCanvas lehnt ab mit einem CanvasActionError, der diesen code/message enthält, sodass ein Button-Handler es mit try/catch abfangen kann. Auf der Agentenseite bildet extension.mjs denselben Fehler auf einen CanvasError ab. 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.error und geben Sie zurück, damit die Fehlerkarte angezeigt wird, während der Poll ruhig bleibt (siehe die refresh-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:

  1. 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 nur http/https zu 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. über list_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 als innerHTML).

  2. Stellen Sie eine refresh-Aktion bereit, die abruft und das Ergebnis in den gemeinsam genutzten Zustand schreibt. Erfassen Sie Fehler in state.error und 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 dem await, und schreiben Sie mit einem funktionalen set zurück, damit eine gleichzeitige Aktion (ein anderer Poll, ein set_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) };
        }
      },
    },
    
  3. Pollen Sie mit dem Kit, nicht mit einem rohen setInterval. pollWhileVisible(tick, seconds) führt tick in 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, der domain: "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 namens profile. 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 einen buildCourse(...)-ä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.
  • statusLine wird einmal beim Öffnen berechnet. Die Status-Zeichenfolge des Panels wird bei openInstance gesetzt und nicht neu geschoben, wenn eine Aktion den Zustand ändert, daher wird ein Zähler in statusLine veraltet. Zeigen Sie Live-Zählungen innerhalb des Canvas-Körpers an (der bei jedem SSE-Push neu rendert), und behandeln Sie statusLine als eine Zusammenfassung nur zum Öffnungszeitpunkt.
  • Aktionen mit umfangreichen Nutzdaten. inputSchemas von Aktionen standardmäßig auf additionalProperties: 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 Sie additionalProperties: true auf dem inputSchema dieser 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 } – kein package.json), extension.mjs, canvas.mjs, web/ und das verschachtelte canvas-kit/. Das Kit ist einfaches ESM, das Node direkt ausführt – kein Bundler, keine Transpilation, kein Build-Schritt, kein package.json in der Erweiterung (der gestempelte test/smoke.test.mjs läuft mit nacktem node). Der Host löst @github/copilot-sdk/extension für Sie auf; extension.mjs ist die einzige Datei, die es importiert. Fügen Sie einem ausgelieferten Canvas kein package.json oder einen Build-Schritt hinzu.

Bauen Sie ein Canvas – schnellster Weg

  1. Stempeln Sie es (ein funktionierendes Canvas, Kit bereits verschachtelt, ein Smoke-Test enthalten):

    node scripts/new-canvas.mjs <Name> [--template basic|data]
    

    data ist die übliche Wahl für externe-Daten-Canvases; basic für alles andere.

  2. 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 };
          },
        },
      },
    };
    
  3. 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 });
    
  4. 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;
    
  5. Testen Sie es (lokal, ohne Copilot):

    node test/smoke.test.mjs
    

    Oder starten Sie den Entwicklungsserver:

    node extension.mjs --dev
    

    Dann öffnen Sie http://localhost:3000 in 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.css nach, was verfügbar ist.
  • Implementieren Sie Polling für Live-Daten
  • Schreiben Sie Unit-Tests für Handler (test/http.test.mjs als 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 Paket
  • skills – das Skills-CLI-Tool zum Verwalten von Agent-Fähigkeiten
  • copilot-extensions – GitHub Copilot Extension SDK

Verwandte Skills