Building a Plugin

Scaffold a plugin package, declare a manifest, write contribution modules, and bundle it into a deployment.

This is the plugin author's path from an empty directory to a contribution running in a deployment. It assumes a checkout of the Owlat workspace; see Installing & Operating for the operator's side.

1. Scaffold the package

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

create writes files only — it never installs, imports, or executes anything, is idempotent on re-run, refuses to clobber a file whose content differs, and rolls back every file and directory it created if any write fails.

Defaults: package name @owlat/plugin-<id>, directory examples/plugins/<id>. Override with --name and --dir; preview with --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. Declare the manifest

The manifest is the whole contract. The host derives permissions, feature flags, the settings form, and the generated composition from this data without executing plugin code, so keep it a static, data-only declaration.

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 preserves literal TypeScript inference and validates at runtime, throwing PluginManifestError with a list of { code, path, message } issues. Two sibling forms exist for tooling: validatePluginManifest(value) returns { ok, manifest | issues } and never throws, and parsePluginManifest(value) throws.

Validation rejects unknown fields, accessor properties, malformed identifiers, duplicate capabilities or env vars, invalid budgets, unsafe export paths, and unknown contribution buckets. It inspects references as data and never invokes them.

Rules the validator enforces

  • A contribution bucket requires both its capability and an explicit flag. You cannot ship a contribution an operator has no way to turn off.
  • plugin-storage:read / plugin-storage:write require an explicit flag for the same reason.
  • llm:invoke requires an explicit flag and a valid llmBudget.dailyUsd (> 0, ≤ 1,000,000, at most six decimal places).
  • A feedback webhook's signature.secretEnvVar must also be listed in flag.requiredEnvVars. The route it feeds cannot verify anything without the secret, so enablement — not every individual delivery — is where its absence has to be caught.
  • Ceilings: 64 capabilities, 64 required env vars, 256 entries per contribution bucket, 64 settings fields.

3. Add contributions

Every contribution is a data descriptor whose executable half lives at one condition-independent package export, for example ./gate. Codegen verifies the export exists without running it and emits the static import.

The manifest below grows the same manifest.ts, so it adds only the capability constants — definePlugin is already imported above.

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,
        },
    ],
});

A navItems entry's section must be one of the core sidebar section keysinbox, postbox, chat, assistant, send, audience, knowledge, administration, preferences. A plugin cannot create a section, and the host drops an entry naming an unknown one fail-closed, so a wrong key silently never renders. Existing plugins that still target the former settings key are attached to administration during the compatibility window.

Every bucket and its semantics are listed in the Contribution Reference.

4. Write the modules

A module implements one small interface and receives host-mediated services only — never a Convex context, database handle, environment variable, credential, tenant id, or scheduler reference.

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' };
    },
};

Background work gets an abort signal, a logger, and — when llm:invoke is declared, granted, and within budget — the attributed host LLM dispatch:

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 });
    },
};

Longer-running hosted code receives a PluginContext with pluginId, permissions, storage, llm, logger, and scheduler. Storage methods take no organization or plugin argument: the host has already bound the service to the authenticated organization and the validated plugin, and rechecks the flag and the exact storage grant on every call.

Design for denial

Any hosted call can be denied at the last moment — the flag may have flipped, the grant may have been revoked, an env var may be missing. Write modules so that a PluginHostError or a timeout leaves no half-applied state; the host applies the contribution's declared safe fallback regardless.

5. Test the plugin

Contribution modules are ordinary TypeScript, so they test as units with vitest. Always assert the manifest itself: parsePluginManifest is the same validator codegen runs, so a manifest test fails the build before a broken package can be composed.

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

The reference plugins are the worked examples, one per tier: examples/plugins/escalation-guard (Tier 1 — agent step, draft strategy, automation trigger/step/condition, webhook event, nav and settings entries), examples/plugins/slack-approvals (Tier 2 — a connected app with a restrict-only hold gate), and examples/plugins/deliverability-lab (Tier 3 — a sandboxed seed-list job, plus a Tier-1 gate, cron and UI).

examples/conformance then replays all three through the real host, codegen and CLI — clean install, add, remove, disable, upgrade, and a full pipeline replay — so a contract change breaks the gallery in the same commit.

Never use vitest's globals via bun test

Run tests with vitest, not bun test. The workspace's vitest setup is what makes the suites meaningful.

6. Bundle it

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

add edits the checked-in plugins.config.ts and prints the capability diff the change introduces. codegen verifies package identity, lockfile integrity and realpath containment, imports only the manifest entry, and rewrites the generated composition files. Commit the config and the generated files together, then rebuild and deploy.

The plugin now exists in the deployment but does nothing: its feature flag is at its declared default and its capabilities are ungranted until an operator acts.

Publishing checklist

  • The manifest's default export path is one condition-independent root export, so Bun, Convex, Nuxt SSR, and the browser build all resolve the same manifest.
  • No postinstall or side effects at import time — codegen imports the manifest entry.
  • Every contribution's module.exportPath is a real, exact package export.
  • flag.requiredEnvVars lists only variables a shipped module actually needs; a missing required variable blocks enablement.
  • Version bumps follow semver; the @owlat/plugin-kit major you build against is your compatibility line.