Openclaw · EN · Part 2

Ch 07 Plugin System And Extension Development

07-ch-07-Plugin-System-and-Extension-Development.md

第7章 Plugin System and Extension Development

Ninety-five plugins. Channels for every messaging platform. Providers for twenty AI models. Skills for every conceivable task. All of them share the same architecture, the same loading mechanism, the same public contract. This uniformity isn't accidental—it's the foundation that makes OpenClaw extendable without becoming chaotic. When you understand how one plugin works, you understand them all.

Plugin Architecture Overview

The plugin system follows a manifest-first philosophy. Before a single line of plugin code executes, OpenClaw reads a static JSON file that declares the plugin's identity, capabilities, and configuration schema. This enables three critical features:

  1. Fast startup: Configuration validation and discovery happen without runtime loading
  2. Clear boundaries: The public contract is explicitly defined, not inferred
  3. Safe extension: Plugins can be disabled or misconfigured without breaking the core system

Plugins are lazy-loaded by default. The Gateway discovers all available plugins at startup but only activates them when needed—provider plugins load when you use that model, channel plugins load when that channel is enabled, skill plugins load when invoked. This keeps memory usage reasonable even with nearly a hundred plugins installed.

Three Plugin Types

OpenClaw plugins fall into three categories based on what they extend:

TypePurposeExample
Channel PluginConnects a messaging platformDiscord, Telegram, Matrix
Provider PluginAdds an AI model/serviceOpenAI, Anthropic, Google Gemini
Skill PluginProvides custom tools/skillsWeb search, code execution, file operations

🤿 深入底层: All plugins share the same manifest format and loader, but each type has a specialized entry point contract. Channel plugins implement ChannelDefinition, providers implement ProviderDefinition, and skills implement ToolDefinition. These contracts live in src/plugin-sdk/—the public surface that all third-party plugins must use.

Plugin SDK Surface

The Plugin SDK defines the boundary between core and extension code. This is intentional: third-party plugins should never import from src/** directly. The SDK surface at openclaw/plugin-sdk/* provides:

  • Entry point contracts: plugin-entry.ts, provider-entry.ts, channel-contract.ts
  • Type definitions: Runtime types for configs, tools, and manifests
  • Helper functions: Common operations without exposing core internals

This boundary enforcement prevents plugins from reaching into internal implementation details that might change. When core needs to expose new functionality to plugins, it must go through the SDK contract.

Rendering diagram...

The Plugin Manifest

The openclaw.plugin.json manifest file is the single source of truth for plugin metadata. It must be present in the plugin's root directory and contains everything needed for discovery, validation, and setup.

Manifest Fields and Structure

Let's examine a minimal but complete manifest:

{
  "id": "my-awesome-plugin",
  "version": "1.0.0",
  "kind": "skill",
  "openclaw": {
    "minVersion": "2024.1.0"
  },
  "displayName": "My Awesome Plugin",
  "description": "Does awesome things with OpenClaw",
  "contracts": {
    "tools": [
      {
        "name": "awesome_tool",
        "description": "Performs an awesome operation",
        "inputSchema": {
          "type": "object",
          "properties": {
            "input": {
              "type": "string",
              "description": "Input to process"
            }
          },
          "required": ["input"]
        }
      }
    ]
  },
  "config": {
    "schema": {
      "awesomeSetting": {
        "type": "string",
        "description": "A configurable setting"
      }
    },
    "defaults": {
      "awesomeSetting": "default value"
    }
  }
}

Key fields explained:

  • id: Unique identifier (must be alphanumeric with hyphens)
  • kind: One of channel, provider, or skill
  • contracts: Declares what the plugin provides (tools, channels, models)
  • config.schema: Runtime configuration schema using Zod-like validation
  • config.defaults: Default values for configuration options

⚠️ 避坑指南: The id field becomes permanent once your plugin is in use. Changing it breaks existing configurations and requires migration. Choose wisely and stick with it.

Validation Rules

The manifest loader enforces several validation rules:

  1. Required fields: id, version, kind, displayName
  2. ID format: Must match ^[a-z0-9]+(-[a-z0-9]+)*$ (lowercase, alphanumeric, hyphens)
  3. Version: Must be valid semver
  4. Kind: Must be one of the three supported types
  5. Contract consistency: Declared contracts must match the plugin type

Validation happens before runtime loading, so manifest errors prevent activation rather than causing runtime failures.

Setup Without Runtime Loading

A powerful feature of manifest-first design: openclaw config and openclaw doctor can read and validate plugin manifests without loading any plugin code. This enables:

  • Fast CLI operations: openclaw config plugins works even with broken plugin code
  • Safe migration: Config migrations run before plugin activation
  • Offline inspection: You can examine plugin capabilities without running the Gateway

Manifest Drift Prevention

The manifest must stay in sync with the actual plugin implementation. OpenClaw includes drift detection that warns when:

  • Declared tools don't match registered tools
  • Config schema doesn't match runtime validation
  • Version numbers are inconsistent between manifest and package.json

☕ 茶水间时间: Why not auto-generate the manifest from code? OpenClaw tried that approach early on. It made fast iteration harder (every change required a build) and made docs less accessible (manifests were buried in build output). The static JSON approach trades some redundancy for clarity and toolability.

Plugin Entry Points and Lifecycle

Plugins aren't just dropped in a folder and magically work—they have a well-defined activation sequence with specific entry points for different plugin types.

Plugin Activation and Deactivation

Every plugin goes through this lifecycle:

Rendering diagram...

The loader handles failures gracefully: a misconfigured plugin fails activation without bringing down the Gateway. Error messages specifically identify whether the issue is in the manifest, config, or runtime code.

plugin-entry.ts Contract

The base entry point contract in src/plugin-sdk/plugin-entry.ts defines the activation interface:

export interface PluginEntryPoint {
  activate: (context: PluginContext) => Promise<void> | void;
  deactivate?: (context: PluginContext) => Promise<void> | void;
}

export interface PluginContext {
  config: unknown; // Validated config object
  registerTool: (tool: ToolDefinition) => void;
  log: Logger;
  events: EventEmitter;
}

A minimal skill plugin entry:

import type { PluginEntryPoint, PluginContext } from 'openclaw/plugin-sdk';

const activate = async (context: PluginContext) => {
  context.registerTool({
    name: 'echo',
    description: 'Echoes back the input',
    inputSchema: {
      type: 'object',
      properties: {
        text: { type: 'string' }
      },
      required: ['text']
    },
    handler: async ({ text }) => ({ result: text })
  });
};

export default { activate } satisfies PluginEntryPoint;

🤿 深入底层: The PluginContext is deliberately narrow. It doesn't expose the full Gateway API—only what plugins need. This prevents tight coupling and makes core refactoring safer. If you find yourself needing more from the context, that's often a sign the SDK contract should expand, not that you should reach into core internals.

provider-entry.ts for Model Providers

Provider plugins have a specialized entry point that deals with AI model integration:

export interface ProviderEntryPoint {
  activate: (context: ProviderContext) => Promise<void> | void;
  getModels?: () => ModelDefinition[];
}

export interface ProviderContext extends PluginContext {
  registerProvider: (provider: ProviderDefinition) => void;
}

Provider plugins register models with specific capabilities (streaming, vision, tools, etc.). The Gateway uses these registrations for model routing and configuration validation.

channel-contract.ts for Messaging

Channel plugins have the most complex contract because messaging platforms vary widely:

export interface ChannelDefinition {
  id: string;
  send: (envelope: OutboundEnvelope) => Promise<void>;
  inboundHandler?: (envelope: InboundEnvelope) => Promise<void>;
  pair?: () => Promise<PairingResult>;
  status?: () => Promise<ChannelStatus>;
}

Channels handle inbound messages, outbound replies, pairing (for DM channels), and status reporting. The contract abstracts platform differences while preserving unique capabilities.

Building a Custom Plugin

Let's build a complete skill plugin from scratch. We'll create a "joke generator" that produces different types of humor.

Start with Skill Plugin

Create a new directory for your plugin:

mkdir extensions/joke-generator
cd extensions/joke-generator
pnpm init

Create the manifest (openclaw.plugin.json):

{
  "id": "joke-generator",
  "version": "1.0.0",
  "kind": "skill",
  "displayName": "Joke Generator",
  "description": "Generates jokes on demand",
  "contracts": {
    "tools": [
      {
        "name": "tell_joke",
        "description": "Tells a joke of the specified category",
        "inputSchema": {
          "type": "object",
          "properties": {
            "category": {
              "type": "string",
              "enum": ["pun", "dad", "programming"],
              "description": "Type of joke to tell"
            }
          },
          "required": ["category"]
        }
      }
    ]
  }
}

Add Configuration

Let's make the joke categories configurable. Update the manifest:

{
  "config": {
    "schema": {
      "enabledCategories": {
        "type": "array",
        "items": { "type": "string" },
        "description": "Joke categories to enable"
      },
      "defaultCategory": {
        "type": "string",
        "description": "Fallback category when none specified"
      }
    },
    "defaults": {
      "enabledCategories": ["pun", "dad", "programming"],
      "defaultCategory": "pun"
    }
  }
}

Implement Tool Registration

Create the entry point (src/index.ts):

import type { PluginEntryPoint, PluginContext } from 'openclaw/plugin-sdk';

// Type for our validated config
interface JokeConfig {
  enabledCategories: string[];
  defaultCategory: string;
}

// Joke database
const jokes = {
  pun: [
    "Why don't scientists trust atoms? Because they make up everything!",
    "I told my wife she was drawing her eyebrows too high. She looked surprised."
  ],
  dad: [
    "I'm afraid for the calendar. Its days are numbered.",
    "My wife told me to stop impersonating a flamingo. I had to put my foot down."
  ],
  programming: [
    "A SQL query walks into a bar, walks up to two tables, and asks: 'Can I join you?'",
    "There are only 10 types of people: those who understand binary and those who don't."
  ]
};

const activate = async (context: PluginContext) => {
  const config = context.config as JokeConfig;
  
  context.registerTool({
    name: 'tell_joke',
    description: 'Tells a joke of the specified category',
    inputSchema: {
      type: 'object',
      properties: {
        category: {
          type: 'string',
          enum: config.enabledCategories,
          description: 'Type of joke to tell'
        }
      },
      required: []
    },
    handler: async ({ category }) => {
      const selectedCategory = category || config.defaultCategory;
      const categoryJokes = jokes[selectedCategory as keyof typeof jokes];
      const joke = categoryJokes[Math.floor(Math.random() * categoryJokes.length)];
      
      return { joke, category: selectedCategory };
    }
  });
};

export default { activate } satisfies PluginEntryPoint;

Write Tests

Create a test file (src/index.test.ts):

import { describe, it, expect, vi } from 'vitest';
import plugin from './index';

describe('joke-generator plugin', () => {
  it('should register the tell_joke tool', async () => {
    const mockRegisterTool = vi.fn();
    const mockLog = { info: vi.fn(), error: vi.fn() };
    
    await plugin.activate({
      config: {
        enabledCategories: ['pun', 'dad'],
        defaultCategory: 'pun'
      },
      registerTool: mockRegisterTool,
      log: mockLog,
      events: {} as any
    });
    
    expect(mockRegisterTool).toHaveBeenCalled();
    const tool = mockRegisterTool.mock.calls[0][0];
    expect(tool.name).toBe('tell_joke');
  });
  
  it('should return a joke when invoked', async () => {
    const mockContext = {
      config: {
        enabledCategories: ['programming'],
        defaultCategory: 'programming'
      },
      registerTool: vi.fn(),
      log: { info: vi.fn(), error: vi.fn() },
      events: {} as any
    };
    
    await plugin.activate(mockContext);
    const tool = mockContext.registerTool.mock.calls[0][0];
    const result = await tool.handler({ category: 'programming' });
    
    expect(result).toHaveProperty('joke');
    expect(result).toHaveProperty('category', 'programming');
    expect(typeof result.joke).toBe('string');
  });
});

Run the tests:

pnpm test src/index.test.ts

Chapter Exercises

  1. Create a skill plugin with custom tools that perform a useful operation (e.g., weather lookup, file conversion, data transformation)
  2. Add a complex config schema to your plugin with nested objects, arrays, and validation rules
  3. Write integration tests that verify your plugin's behavior with various config states

Plugins extend OpenClaw's functionality, but they themselves need configuration—schemas, defaults, validation, and runtime access. The next chapter explores OpenClaw's comprehensive configuration system, which manages settings for the Gateway, agents, channels, and the entire plugin ecosystem.