Duck typing for plugins: why we killed the plugin interface

← All posts

Most plugin systems start the same way: define an IPlugin interface, make everyone implement it, use the type system to enforce the contract. It's clean, it's familiar, and it creates a dependency that will haunt you for years.

KB Labs doesn't have a plugin interface. Instead, we use duck typing: if a package has a manifest with schema: 'kb.plugin/3', it's a plugin. No base class. No implements. No runtime registration call.

The problem with interfaces

An IPlugin interface couples every plugin to the core at compile time. That means:

We tried this. The interface grew to 14 optional methods. Plugins implemented 2–3 of them and left the rest as no-ops. The type system was satisfied; the design was not.

What we do instead

A plugin is any npm package whose manifest declares schema: 'kb.plugin/3'. The manifest is a plain object — not an instance of a class, not a return value from a factory function.

// plugins/commit/entry/src/manifest.ts
export const manifest = {
  schema: 'kb.plugin/3',
  id: '@kb-labs/commit',
  version: '1.0.0',
  display: { name: 'Commit Generator', description: '...' },
  permissions: combinePermissions()
    .with(gitWorkflowPreset)
    .with(kbPlatformPreset)
    .build(),
  cli: {
    commands: [
      { name: 'commit', handler: './dist/commands/commit.js' }
    ]
  }
};

Capabilities are declared as sections. A plugin with CLI commands has a cli section. A plugin with REST routes has a rest section. A plugin with workflow handlers has a workflows section. You only declare what you use:

interface ManifestV3 {
  schema: 'kb.plugin/3';
  id: string;
  version: string;
  display?: DisplayMetadata;
  permissions?: PermissionSpec;
  cli?: { commands: CliCommandDecl[] };
  rest?: RestConfig;
  ws?: WebSocketConfig;
  workflows?: { handlers: WorkflowHandlerDecl[] };
  webhooks?: { handlers: WebhookHandlerDecl[] };
  jobs?: JobsConfig;
  cron?: { schedules: CronDecl[] };
  studio?: StudioConfig;
  lifecycle?: LifecycleHooks;
}

How discovery works without an interface

The DiscoveryManager in core-discovery reads the marketplace.lock file — the single source of truth for installed plugins. For each entry, it resolves the manifest through three fallback paths:

  1. The kb.manifest field in package.json
  2. A kb.plugin.json file at the package root
  3. The default export of dist/index.js

The manifest is validated with a type guard (isManifestV3), and capabilities are extracted by inspecting which sections are present. A plugin with cli and workflows sections gets registered as both a CLI command provider and a workflow handler provider — automatically, with no explicit registration call.

The result

Duck typing isn't the right pattern for everything. But for plugin systems — where the core must be maximally stable and the extensions must be maximally free — it's the pattern that lets both sides evolve without breaking each other.