Ein Plugin bauen

Ein Plugin-Paket scaffolden, ein Manifest deklarieren, Contribution-Module schreiben und alles in ein Deployment bündeln.

Dies ist der Weg des Plugin-Autors vom leeren Verzeichnis bis zur Contribution, die in einem Deployment läuft. Ein Checkout des Owlat-Workspace wird vorausgesetzt; die Betreiberseite finden Sie unter Installation & Betrieb.

1. Das Paket scaffolden

bun run plugins:prepare                                  # build the plugin-kit contracts once
bun packages/plugin-cli/src/index.ts create hello-owlat

create schreibt ausschließlich Dateien — es installiert, importiert oder führt niemals etwas aus, ist bei erneutem Lauf idempotent, verweigert das Überschreiben einer Datei mit abweichendem Inhalt und rollt jede angelegte Datei und jedes angelegte Verzeichnis zurück, wenn ein Schreibvorgang fehlschlägt.

Standardwerte: Paketname @owlat/plugin-<id>, Verzeichnis examples/plugins/<id>. Überschreiben Sie sie mit --name und --dir; eine Vorschau erhalten Sie mit --dry-run.

examples/plugins/hello-owlat/
├── package.json          # depends on @owlat/plugin-kit (workspace:*)
├── tsconfig.json         # extends the workspace base config
├── vitest.config.ts      # aliases @owlat/plugin-kit to its sources
├── README.md
└── src/
    ├── manifest.ts       # the definePlugin declaration
    ├── index.ts          # re-exports the manifest
    └── __tests__/manifest.test.ts

2. Das Manifest deklarieren

Das Manifest ist der gesamte Vertrag. Der Host leitet Berechtigungen, Feature-Flags, das Einstellungsformular und die generierte Komposition aus diesen Daten ab, ohne Plugin-Code auszuführen — halten Sie es deshalb als statische, rein datenbasierte Deklaration.

import { definePlugin } from '@owlat/plugin-kit';

export const helloPlugin = definePlugin({
    id: 'hello-owlat',
    version: '0.1.0',
    capabilities: ['plugin-storage:read', 'plugin-storage:write'],
    // Storage capabilities REQUIRE a flag: access must be revocable at runtime.
    flag: { default: false },
});

definePlugin erhält die literale TypeScript-Inferenz und validiert zur Laufzeit; im Fehlerfall wird ein PluginManifestError mit einer Liste von { code, path, message }-Issues geworfen. Für Tooling existieren zwei Geschwisterformen: validatePluginManifest(value) liefert { ok, manifest | issues } und wirft nie, parsePluginManifest(value) wirft.

Die Validierung weist unbekannte Felder, Accessor-Properties, fehlerhafte Bezeichner, doppelte Capabilities oder Umgebungsvariablen, ungültige Budgets, unsichere Export-Pfade und unbekannte Contribution-Buckets zurück. Referenzen werden als Daten inspiziert und nie aufgerufen.

Regeln, die der Validator durchsetzt

  • Ein Contribution-Bucket erfordert sowohl seine Capability als auch ein explizites flag. Sie können keine Contribution ausliefern, die ein Betreiber nicht abschalten kann.
  • plugin-storage:read / plugin-storage:write erfordern aus demselben Grund ein explizites flag.
  • llm:invoke erfordert ein explizites flag und ein gültiges llmBudget.dailyUsd (> 0, ≤ 1.000.000, höchstens sechs Nachkommastellen).
  • Die signature.secretEnvVar eines Feedback-Webhooks muss zusätzlich in flag.requiredEnvVars aufgeführt sein. Die Route, die sie speist, kann ohne das Secret nichts verifizieren — deshalb muss ihr Fehlen bei der Aktivierung auffallen und nicht bei jeder einzelnen Zustellung.
  • Obergrenzen: 64 Capabilities, 64 erforderliche Umgebungsvariablen, 256 Einträge pro Contribution-Bucket, 64 Einstellungsfelder.

3. Contributions hinzufügen

Jede Contribution ist ein Daten-Deskriptor, dessen ausführbare Hälfte an genau einem bedingungsunabhängigen Paket-Export liegt, zum Beispiel ./gate. Die Codegen prüft, dass der Export existiert, ohne ihn auszuführen, und erzeugt den statischen Import.

Das Manifest unten erweitert dieselbe manifest.ts und ergänzt daher nur die Capability-Konstanten — definePlugin ist oben bereits importiert.

import {
    PLUGIN_AUTONOMY_GATE_CAPABILITY,
    PLUGIN_CRON_CAPABILITY,
    PLUGIN_NAV_ITEM_CAPABILITY,
    PLUGIN_SETTINGS_PANEL_CAPABILITY,
    PLUGIN_WORKER_CAPABILITY,
} from '@owlat/plugin-kit';

export const preflightPlugin = definePlugin({
    id: 'preflight',
    version: '1.0.0',
    capabilities: [
        PLUGIN_AUTONOMY_GATE_CAPABILITY,
        PLUGIN_CRON_CAPABILITY,
        PLUGIN_NAV_ITEM_CAPABILITY,
        PLUGIN_SETTINGS_PANEL_CAPABILITY,
        PLUGIN_WORKER_CAPABILITY,
        'llm:invoke',
    ],
    // Contributions REQUIRE an explicit flag: an operator must be able to turn
    // the plugin off without a redeploy.
    flag: { default: false, requiredEnvVars: ['PREFLIGHT_API_KEY'] },
    // `llm:invoke` additionally requires a hard daily budget in USD.
    llmBudget: { dailyUsd: 1.5 },
    contributes: {
        sendGates: [
            {
                id: 'preflight',
                label: 'Pre-send preflight',
                module: { exportPath: './gate' },
                timeoutMs: 15_000,
            },
        ],
        crons: [
            {
                id: 'refresh-rules',
                label: 'Refresh preflight rules',
                module: { exportPath: './cron' },
                schedule: { intervalMinutes: 360 },
                timeoutMs: 60_000,
            },
        ],
        navItems: [
            {
                id: 'dashboard',
                section: 'administration',
                name: 'Preflight',
                href: '/dashboard/admin/instance/plugins/preflight',
                icon: 'lucide:radar',
            },
        ],
        settingsPanels: [
            {
                id: 'settings',
                name: 'Preflight',
                href: '/dashboard/admin/instance/plugins/preflight',
                icon: 'lucide:radar',
            },
        ],
    },
    settingsSchema: [
        {
            kind: 'boolean',
            key: 'holdOnFail',
            label: 'Hold sends that fail preflight',
            default: true,
        },
        {
            kind: 'secret',
            key: 'vendorApiKey',
            envVar: 'PLUGIN_VENDOR_API_KEY',
            label: 'Vendor API key',
            required: false,
        },
    ],
});

Die section eines navItems-Eintrags muss einer der Kern-Sidebar-Section-Keys sein — inbox, postbox, chat, assistant, send, audience, knowledge, administration, preferences. Ein Plugin kann keine Section anlegen, und der Host verwirft einen Eintrag mit unbekannter Section fail-closed — ein falscher Key wird also stillschweigend nie gerendert. Bestehende Plugins, die noch den früheren Key settings verwenden, werden während des Kompatibilitätsfensters an administration gehängt.

Jeder Bucket und seine Semantik sind in der Contribution-Referenz aufgeführt.

4. Die Module schreiben

Ein Modul implementiert eine kleine Schnittstelle und erhält ausschließlich host-vermittelte Services — niemals einen Convex-Context, ein Datenbank-Handle, eine Umgebungsvariable, ein Credential, eine Tenant-ID oder eine Scheduler-Referenz.

import type {
    PluginAutonomyGateInput,
    PluginAutonomyGateModule,
    PluginAutonomyGateResult,
    PluginAutonomyGateServices,
} from '@owlat/plugin-kit';

/** A gate may object or stand aside. There is deliberately no "approve" result. */
export const gate: PluginAutonomyGateModule = {
    async evaluate(
        input: PluginAutonomyGateInput,
        services: PluginAutonomyGateServices
    ): Promise<PluginAutonomyGateResult> {
        if (services.signal.aborted) return { outcome: 'objection', reason: 'preflight cancelled' };
        if (input.draftBody.includes('http://')) {
            return { outcome: 'objection', reason: 'draft contains a plaintext HTTP link' };
        }
        return { outcome: 'no-objection' };
    },
};

Hintergrundarbeit erhält ein Abort-Signal, einen Logger und — sofern llm:invoke deklariert, gewährt und im Budget ist — den attribuierten LLM-Dispatch des Hosts:

import type { PluginCronModule, PluginCronServices } from '@owlat/plugin-kit';

export const cron: PluginCronModule = {
    async run(services: PluginCronServices): Promise<void> {
        // Cancellation is cooperative: the host aborts `signal` at the declared
        // timeout and stops waiting either way.
        if (services.signal.aborted) return;
        const summary = await services.llm.generate({
            tier: 'fast',
            prompt: 'Summarise this week of deliverability tips in one sentence.',
        });
        services.logger.info('refreshed preflight rules', { length: summary.text.length });
    },
};

Länger laufender gehosteter Code erhält einen PluginContext mit pluginId, permissions, storage, llm, logger und scheduler. Storage-Methoden nehmen kein Organisations- oder Plugin-Argument entgegen: Der Host hat den Service bereits an die authentifizierte Organisation und das validierte Plugin gebunden und prüft bei jedem Aufruf das Flag und die exakte Storage-Grant erneut.

Für die Verweigerung entwerfen

Jeder gehostete Aufruf kann im letzten Moment verweigert werden — das Flag kann umgelegt worden sein, die Grant kann widerrufen sein, eine Umgebungsvariable kann fehlen. Schreiben Sie Module so, dass ein PluginHostError oder ein Timeout keinen halb angewendeten Zustand hinterlässt; der Host wendet in jedem Fall den deklarierten sicheren Fallback der Contribution an.

5. Das Plugin testen

Contribution-Module sind gewöhnliches TypeScript und lassen sich daher als Units mit vitest testen. Prüfen Sie immer auch das Manifest selbst: parsePluginManifest ist derselbe Validator, den die Codegen ausführt — ein Manifest-Test lässt den Build also scheitern, bevor ein defektes Paket komponiert werden kann.

bun run --cwd examples/plugins/hello-owlat test
bun run --cwd examples/plugins/hello-owlat typecheck

Die Referenz-Plugins sind die ausgearbeiteten Beispiele, eines je Tier: examples/plugins/escalation-guard (Tier 1 — Agent-Step, Draft-Strategie, Automation-Trigger/-Step/-Condition, Webhook-Event, Navigations- und Einstellungseinträge), examples/plugins/slack-approvals (Tier 2 — eine Connected App mit einem ausschließlich einschränkenden Hold-Gate) und examples/plugins/deliverability-lab (Tier 3 — ein gesandboxter Seed-List-Job plus ein Tier-1-Gate, Cron und UI).

examples/conformance spielt anschließend alle drei durch den echten Host, die Codegen und das CLI — saubere Installation, add, remove, disable, upgrade sowie ein vollständiger Pipeline-Replay —, sodass eine Vertragsänderung die Galerie im selben Commit bricht.

Niemals vitests Globals über bun test verwenden

Führen Sie Tests mit vitest aus, nicht mit bun test. Erst das vitest-Setup des Workspace macht die Suites aussagekräftig.

6. Bündeln

bun packages/plugin-cli/src/index.ts add @owlat/plugin-hello-owlat
bun run plugins:codegen

add bearbeitet die eingecheckte plugins.config.ts und gibt das Capability-Diff aus, das die Änderung einführt. codegen prüft Paketidentität, Lockfile-Integrität und Realpath-Containment, importiert ausschließlich den Manifest-Entry und schreibt die generierten Kompositionsdateien neu. Committen Sie die Konfiguration und die generierten Dateien gemeinsam, bauen Sie dann neu und deployen Sie.

Das Plugin existiert nun im Deployment, tut aber nichts: Sein Feature-Flag steht auf dem deklarierten Standardwert und seine Capabilities sind ungewährt, bis ein Betreiber handelt.

Checkliste vor der Veröffentlichung

  • Der Standard-Export-Pfad des Manifests ist ein bedingungsunabhängiger Root-Export, damit Bun, Convex, Nuxt SSR und der Browser-Build alle dasselbe Manifest auflösen.
  • Kein postinstall und keine Seiteneffekte zur Importzeit — die Codegen importiert den Manifest-Entry.
  • Der module.exportPath jeder Contribution ist ein echter, exakter Paket-Export.
  • flag.requiredEnvVars führt nur Variablen auf, die ein ausgeliefertes Modul tatsächlich benötigt; eine fehlende erforderliche Variable blockiert die Aktivierung.
  • Versionssprünge folgen semver; die @owlat/plugin-kit-Major-Version, gegen die Sie bauen, ist Ihre Kompatibilitätslinie.