Research: OpenClaw plugin system architecture — discovery, lifecycle, and extension points #195

Open
opened 2026-03-01 10:25:29 +00:00 by Hermes · 0 comments
Contributor

Context

OpenClaw has a mature plugin system that lets extensions register channels, tools, hooks, services, CLI commands, provider auth flows, and auto-reply commands — all in-process with the Gateway. Understanding this architecture is critical for designing cobot's own extensibility model.

This follows the same format as #121 (system prompt architecture).


Plugin System Overview

OpenClaw plugins are TypeScript modules loaded at runtime via jiti. They run in-process with the Gateway (trusted code). Config validation happens via a static JSON manifest — without executing plugin code.

As of 2026.2.26, OpenClaw ships 37 bundled plugins (most disabled by default). 5 are loaded in a typical agent setup (Telegram, Memory, Device Pairing, Phone Control, Talk Voice).


Plugin Manifest (openclaw.plugin.json)

Every plugin must ship a manifest in its root directory:

{
  "id": "my-plugin",
  "configSchema": {
    "type": "object",
    "additionalProperties": false,
    "properties": {
      "apiKey": { "type": "string" }
    }
  }
}

Required fields:

  • id (string): canonical plugin id
  • configSchema (object): JSON Schema for plugin config (even if empty)

Optional fields:

  • kind (string): plugin kind (e.g., "memory" for slot-based exclusion)
  • channels (array): channel ids this plugin registers
  • providers (array): provider ids this plugin registers
  • skills (array): skill directories to load (relative to plugin root)
  • name, description, version: display metadata
  • uiHints (object): config field labels/placeholders/sensitive flags for UI

Key design decision: Validation uses the manifest + JSON Schema only, never executes plugin code. This means a broken plugin can't crash config validation.


Plugin Discovery & Precedence

OpenClaw scans these locations in order (first match wins for duplicate ids):

1. Config paths — `plugins.load.paths`

Explicit file or directory paths. Highest precedence.

2. Workspace extensions
<workspace>/.openclaw/extensions/*.ts
<workspace>/.openclaw/extensions/*/index.ts
3. Global extensions
~/.openclaw/extensions/*.ts
~/.openclaw/extensions/*/index.ts
4. Bundled extensions (shipped with OpenClaw, disabled by default)
<openclaw>/extensions/*

Must be explicitly enabled via plugins.entries.<id>.enabled or openclaw plugins enable <id>.

Security hardening on discovery

  • Extension entry must resolve inside plugin root (symlink/path traversal blocked)
  • World-writable plugin paths are rejected
  • POSIX ownership must be current uid or root for non-bundled plugins
  • Non-bundled plugins without install provenance emit a warning
  • openclaw plugins install runs npm install --ignore-scripts (no lifecycle scripts)

Plugin API — Registration Points

Plugins export either a function (api) => { ... } or an object { id, name, configSchema, register(api) { ... } }.

The api object provides 8 registration methods:

1. `api.registerTool()` — Agent tools

Expose JSON-schema functions to the LLM. Can be required (always available) or optional (opt-in via allowlist).

api.registerTool({
  name: "my_tool",
  description: "Do a thing",
  parameters: Type.Object({ input: Type.String() }),
  async execute(_id, params) {
    return { content: [{ type: "text", text: params.input }] };
  },
}, { optional: true });

Optional tools must be enabled via agents.list[].tools.allow:

  • Specific tool name: "my_tool"
  • Plugin id (all tools from that plugin): "my-plugin"
  • All plugin tools: "group:plugins"
2. `api.registerChannel()` — Messaging channels

Register a full messaging channel (like Telegram, WhatsApp, etc.). Channel config lives under channels.<id>.

Required adapters:

  • config.listAccountIds + config.resolveAccount
  • capabilities (chat types, media, threads)
  • outbound.deliveryMode + outbound.sendText

Optional adapters: setup, security, status, gateway, mentions, threading, streaming, actions, commands

Onboarding hooks: configure, configureInteractive, configureWhenConfigured

Concrete example (Telegram plugin):

// extensions/telegram/index.ts
const plugin = {
  id: "telegram",
  name: "Telegram",
  description: "Telegram channel plugin",
  configSchema: emptyPluginConfigSchema(),
  register(api) {
    setTelegramRuntime(api.runtime);
    api.registerChannel({ plugin: telegramPlugin });
  },
};
export default plugin;

Channel metadata supports:

  • meta.aliases — alternate ids for CLI/normalization
  • meta.preferOver — auto-enable preference over other channels
  • meta.detailLabel / meta.systemImage — rich UI labels/icons
  • openclaw.channel + openclaw.install in package.json — catalog onboarding metadata
3. `api.registerHook()` — Event hooks

Register event-driven automation (e.g., run logic when /new is invoked).

api.registerHook("command:new", async () => { /* logic */ }, {
  name: "my-plugin.command-new",
  description: "Runs when /new is invoked",
});

Shows up in openclaw hooks list with plugin:<id> prefix. Cannot be toggled independently — enable/disable the plugin instead.

4. `api.registerService()` — Background services

Long-running background processes with start/stop lifecycle.

api.registerService({
  id: "my-service",
  start: () => api.logger.info("ready"),
  stop: () => api.logger.info("bye"),
});
5. `api.registerProvider()` — Model provider auth

Register OAuth/API-key auth flows for model providers.

api.registerProvider({
  id: "acme",
  label: "AcmeAI",
  auth: [{
    id: "oauth",
    label: "OAuth",
    kind: "oauth",
    run: async (ctx) => ({
      profiles: [{ profileId: "acme:default", credential: { ... } }],
      defaultModel: "acme/opus-1",
    }),
  }],
});

Powers openclaw models auth login --provider <id>. Context provides prompter, runtime, openUrl, oauth.createVpsAwareHandlers.

6. `api.registerGatewayMethod()` — Gateway RPC

Register custom WebSocket RPC methods on the Gateway.

api.registerGatewayMethod("myplugin.status", ({ respond }) => {
  respond(true, { ok: true });
});

Convention: pluginId.action naming.

7. `api.registerCli()` — CLI commands

Register top-level CLI commands.

api.registerCli(({ program }) => {
  program.command("mycmd").action(() => console.log("Hello"));
}, { commands: ["mycmd"] });
8. `api.registerCommand()` — Auto-reply slash commands

Register slash commands that execute without invoking the AI agent — processed before built-in commands.

api.registerCommand({
  name: "mystatus",
  description: "Show plugin status",
  handler: (ctx) => ({ text: `Plugin running on ${ctx.channel}` }),
});

Options: acceptsArgs, requireAuth. Reserved command names cannot be overridden.


Plugin Configuration

{
  plugins: {
    enabled: true,           // master toggle
    allow: ["voice-call"],   // allowlist (optional)
    deny: ["untrusted"],     // denylist (deny wins)
    load: { paths: ["~/my-plugin"] },  // extra plugin paths
    entries: {
      "voice-call": {
        enabled: true,
        config: { provider: "twilio" }
      }
    },
    slots: {
      memory: "memory-core"  // exclusive category slots
    },
    installs: { ... }        // npm install tracking
  }
}

Validation rules (strict):

  • Unknown plugin ids in entries, allow, deny, slotserror
  • Unknown channels.<id> keys → error (unless declared by a plugin manifest)
  • Plugin config validated against manifest's JSON Schema
  • Disabled plugin config preserved with warning

Plugin slots — exclusive categories where only one plugin can be active:

plugins: { slots: { memory: "memory-lancedb" } }
// or "none" to disable that category entirely

Plugin Lifecycle

Discovery → Manifest Validation → Config Validation → Registration → Start
    ↓              ↓                     ↓                  ↓           ↓
  Scan paths    Parse JSON Schema    Validate against     Call         Start
  + security    from manifest        schema + check       register()  services
  checks                             allowlist/denylist
  1. Discovery: Scan config paths → workspace extensions → global extensions → bundled (first match wins per id)
  2. Security check: Path traversal, world-writable, ownership
  3. Manifest validation: Parse openclaw.plugin.json, extract JSON Schema
  4. Config validation: Validate plugins.entries.<id>.config against schema (no code execution)
  5. Registration: Call plugin's register(api) function — registers tools, channels, hooks, etc.
  6. Start services: Background services get start() called
  7. Shutdown: Services get stop() called on gateway shutdown

Package Packs

A plugin directory can contain multiple extensions via package.json:

{
  "name": "my-pack",
  "openclaw": {
    "extensions": ["./src/safety.ts", "./src/tools.ts"]
  }
}

Each entry becomes a separate plugin. Multi-extension packs get ids like name/<fileBase>. All entries must stay inside the package directory (symlink escape = rejected).


External Channel Catalogs

OpenClaw can merge external plugin catalogs for channel discovery:

~/.openclaw/mpm/plugins.json
~/.openclaw/mpm/catalog.json
~/.openclaw/plugins/catalog.json

Or via OPENCLAW_PLUGIN_CATALOG_PATHS env var. Format: { "entries": [{ "name": "@scope/pkg", "openclaw": { "channel": {...}, "install": {...} } }] }


Runtime Helpers

Plugins access core functionality via api.runtime:

// TTS for telephony
const result = await api.runtime.tts.textToSpeechTelephony({
  text: "Hello from OpenClaw",
  cfg: api.config,
});

CLI Management

openclaw plugins list                    # Show all plugins (loaded/disabled)
openclaw plugins info <id>               # Plugin details
openclaw plugins install <npm-spec>      # Install from npm (--pin for exact version)
openclaw plugins install <path>          # Install from local path/archive
openclaw plugins install -l <path>       # Link (no copy, for dev)
openclaw plugins update <id|--all>       # Update npm-installed plugins
openclaw plugins enable <id>             # Enable a plugin
openclaw plugins disable <id>            # Disable a plugin
openclaw plugins uninstall <id>          # Remove plugin + config
openclaw plugins doctor                  # Diagnose plugin issues

Relevance to Cobot

  1. Extensibility model: OpenClaw's 8 registration points cover the full surface area — tools, channels, hooks, services, providers, RPC, CLI, and commands. This is a comprehensive blueprint for cobot's plugin system.

  2. Static validation: Manifest + JSON Schema validation without executing plugin code is a strong safety pattern. Broken plugins can't crash the config layer.

  3. Discovery precedence: Config paths > workspace > global > bundled gives clear override semantics.

  4. Security: Path traversal checks, ownership validation, --ignore-scripts on npm install, allowlist/denylist — defense in depth for trusted-code plugins.

  5. Slot system: Exclusive categories (like memory) prevent conflicting plugins. Clean pattern for cobot's own exclusive-category extensions.

  6. Channel abstraction: The channel plugin interface (config, capabilities, outbound, onboarding) is well-factored and could be adapted for cobot's communication layer.

  7. Package distribution: npm-based distribution with integrity checking is mature and reusable.


Filed by Hermes 🪽 on behalf of k9ert

## Context OpenClaw has a mature **plugin system** that lets extensions register channels, tools, hooks, services, CLI commands, provider auth flows, and auto-reply commands — all in-process with the Gateway. Understanding this architecture is critical for designing cobot's own extensibility model. This follows the same format as [#121](https://forgejo.tail593e12.ts.net/ultanio/cobot/issues/121) (system prompt architecture). --- ## Plugin System Overview OpenClaw plugins are **TypeScript modules** loaded at runtime via [jiti](https://github.com/unjs/jiti). They run **in-process** with the Gateway (trusted code). Config validation happens via a static JSON manifest — **without executing plugin code**. As of 2026.2.26, OpenClaw ships **37 bundled plugins** (most disabled by default). 5 are loaded in a typical agent setup (Telegram, Memory, Device Pairing, Phone Control, Talk Voice). --- ## Plugin Manifest (`openclaw.plugin.json`) Every plugin **must** ship a manifest in its root directory: ```json { "id": "my-plugin", "configSchema": { "type": "object", "additionalProperties": false, "properties": { "apiKey": { "type": "string" } } } } ``` **Required fields:** - `id` (string): canonical plugin id - `configSchema` (object): JSON Schema for plugin config (even if empty) **Optional fields:** - `kind` (string): plugin kind (e.g., `"memory"` for slot-based exclusion) - `channels` (array): channel ids this plugin registers - `providers` (array): provider ids this plugin registers - `skills` (array): skill directories to load (relative to plugin root) - `name`, `description`, `version`: display metadata - `uiHints` (object): config field labels/placeholders/sensitive flags for UI **Key design decision:** Validation uses the manifest + JSON Schema only, never executes plugin code. This means a broken plugin can't crash config validation. --- ## Plugin Discovery & Precedence OpenClaw scans these locations **in order** (first match wins for duplicate ids): <details> <summary><strong>1. Config paths</strong> — `plugins.load.paths`</summary> Explicit file or directory paths. Highest precedence. </details> <details> <summary><strong>2. Workspace extensions</strong></summary> ``` <workspace>/.openclaw/extensions/*.ts <workspace>/.openclaw/extensions/*/index.ts ``` </details> <details> <summary><strong>3. Global extensions</strong></summary> ``` ~/.openclaw/extensions/*.ts ~/.openclaw/extensions/*/index.ts ``` </details> <details> <summary><strong>4. Bundled extensions</strong> (shipped with OpenClaw, disabled by default)</summary> ``` <openclaw>/extensions/* ``` Must be explicitly enabled via `plugins.entries.<id>.enabled` or `openclaw plugins enable <id>`. </details> ### Security hardening on discovery - Extension entry must resolve **inside** plugin root (symlink/path traversal blocked) - World-writable plugin paths are **rejected** - POSIX ownership must be current uid or root for non-bundled plugins - Non-bundled plugins without install provenance emit a warning - `openclaw plugins install` runs `npm install --ignore-scripts` (no lifecycle scripts) --- ## Plugin API — Registration Points Plugins export either a function `(api) => { ... }` or an object `{ id, name, configSchema, register(api) { ... } }`. The `api` object provides **8 registration methods**: <details> <summary><strong>1. `api.registerTool()` — Agent tools</strong></summary> Expose JSON-schema functions to the LLM. Can be **required** (always available) or **optional** (opt-in via allowlist). ```ts api.registerTool({ name: "my_tool", description: "Do a thing", parameters: Type.Object({ input: Type.String() }), async execute(_id, params) { return { content: [{ type: "text", text: params.input }] }; }, }, { optional: true }); ``` Optional tools must be enabled via `agents.list[].tools.allow`: - Specific tool name: `"my_tool"` - Plugin id (all tools from that plugin): `"my-plugin"` - All plugin tools: `"group:plugins"` </details> <details> <summary><strong>2. `api.registerChannel()` — Messaging channels</strong></summary> Register a full messaging channel (like Telegram, WhatsApp, etc.). Channel config lives under `channels.<id>`. Required adapters: - `config.listAccountIds` + `config.resolveAccount` - `capabilities` (chat types, media, threads) - `outbound.deliveryMode` + `outbound.sendText` Optional adapters: `setup`, `security`, `status`, `gateway`, `mentions`, `threading`, `streaming`, `actions`, `commands` Onboarding hooks: `configure`, `configureInteractive`, `configureWhenConfigured` Concrete example (Telegram plugin): ```ts // extensions/telegram/index.ts const plugin = { id: "telegram", name: "Telegram", description: "Telegram channel plugin", configSchema: emptyPluginConfigSchema(), register(api) { setTelegramRuntime(api.runtime); api.registerChannel({ plugin: telegramPlugin }); }, }; export default plugin; ``` Channel metadata supports: - `meta.aliases` — alternate ids for CLI/normalization - `meta.preferOver` — auto-enable preference over other channels - `meta.detailLabel` / `meta.systemImage` — rich UI labels/icons - `openclaw.channel` + `openclaw.install` in `package.json` — catalog onboarding metadata </details> <details> <summary><strong>3. `api.registerHook()` — Event hooks</strong></summary> Register event-driven automation (e.g., run logic when `/new` is invoked). ```ts api.registerHook("command:new", async () => { /* logic */ }, { name: "my-plugin.command-new", description: "Runs when /new is invoked", }); ``` Shows up in `openclaw hooks list` with `plugin:<id>` prefix. Cannot be toggled independently — enable/disable the plugin instead. </details> <details> <summary><strong>4. `api.registerService()` — Background services</strong></summary> Long-running background processes with start/stop lifecycle. ```ts api.registerService({ id: "my-service", start: () => api.logger.info("ready"), stop: () => api.logger.info("bye"), }); ``` </details> <details> <summary><strong>5. `api.registerProvider()` — Model provider auth</strong></summary> Register OAuth/API-key auth flows for model providers. ```ts api.registerProvider({ id: "acme", label: "AcmeAI", auth: [{ id: "oauth", label: "OAuth", kind: "oauth", run: async (ctx) => ({ profiles: [{ profileId: "acme:default", credential: { ... } }], defaultModel: "acme/opus-1", }), }], }); ``` Powers `openclaw models auth login --provider <id>`. Context provides `prompter`, `runtime`, `openUrl`, `oauth.createVpsAwareHandlers`. </details> <details> <summary><strong>6. `api.registerGatewayMethod()` — Gateway RPC</strong></summary> Register custom WebSocket RPC methods on the Gateway. ```ts api.registerGatewayMethod("myplugin.status", ({ respond }) => { respond(true, { ok: true }); }); ``` Convention: `pluginId.action` naming. </details> <details> <summary><strong>7. `api.registerCli()` — CLI commands</strong></summary> Register top-level CLI commands. ```ts api.registerCli(({ program }) => { program.command("mycmd").action(() => console.log("Hello")); }, { commands: ["mycmd"] }); ``` </details> <details> <summary><strong>8. `api.registerCommand()` — Auto-reply slash commands</strong></summary> Register slash commands that execute **without invoking the AI agent** — processed before built-in commands. ```ts api.registerCommand({ name: "mystatus", description: "Show plugin status", handler: (ctx) => ({ text: `Plugin running on ${ctx.channel}` }), }); ``` Options: `acceptsArgs`, `requireAuth`. Reserved command names cannot be overridden. </details> --- ## Plugin Configuration ```json5 { plugins: { enabled: true, // master toggle allow: ["voice-call"], // allowlist (optional) deny: ["untrusted"], // denylist (deny wins) load: { paths: ["~/my-plugin"] }, // extra plugin paths entries: { "voice-call": { enabled: true, config: { provider: "twilio" } } }, slots: { memory: "memory-core" // exclusive category slots }, installs: { ... } // npm install tracking } } ``` **Validation rules (strict):** - Unknown plugin ids in `entries`, `allow`, `deny`, `slots` → **error** - Unknown `channels.<id>` keys → **error** (unless declared by a plugin manifest) - Plugin config validated against manifest's JSON Schema - Disabled plugin config preserved with **warning** **Plugin slots** — exclusive categories where only one plugin can be active: ```json5 plugins: { slots: { memory: "memory-lancedb" } } // or "none" to disable that category entirely ``` --- ## Plugin Lifecycle ``` Discovery → Manifest Validation → Config Validation → Registration → Start ↓ ↓ ↓ ↓ ↓ Scan paths Parse JSON Schema Validate against Call Start + security from manifest schema + check register() services checks allowlist/denylist ``` 1. **Discovery**: Scan config paths → workspace extensions → global extensions → bundled (first match wins per id) 2. **Security check**: Path traversal, world-writable, ownership 3. **Manifest validation**: Parse `openclaw.plugin.json`, extract JSON Schema 4. **Config validation**: Validate `plugins.entries.<id>.config` against schema (no code execution) 5. **Registration**: Call plugin's `register(api)` function — registers tools, channels, hooks, etc. 6. **Start services**: Background services get `start()` called 7. **Shutdown**: Services get `stop()` called on gateway shutdown --- ## Package Packs A plugin directory can contain multiple extensions via `package.json`: ```json { "name": "my-pack", "openclaw": { "extensions": ["./src/safety.ts", "./src/tools.ts"] } } ``` Each entry becomes a separate plugin. Multi-extension packs get ids like `name/<fileBase>`. All entries must stay inside the package directory (symlink escape = rejected). --- ## External Channel Catalogs OpenClaw can merge external plugin catalogs for channel discovery: ``` ~/.openclaw/mpm/plugins.json ~/.openclaw/mpm/catalog.json ~/.openclaw/plugins/catalog.json ``` Or via `OPENCLAW_PLUGIN_CATALOG_PATHS` env var. Format: `{ "entries": [{ "name": "@scope/pkg", "openclaw": { "channel": {...}, "install": {...} } }] }` --- ## Runtime Helpers Plugins access core functionality via `api.runtime`: ```ts // TTS for telephony const result = await api.runtime.tts.textToSpeechTelephony({ text: "Hello from OpenClaw", cfg: api.config, }); ``` --- ## CLI Management ```bash openclaw plugins list # Show all plugins (loaded/disabled) openclaw plugins info <id> # Plugin details openclaw plugins install <npm-spec> # Install from npm (--pin for exact version) openclaw plugins install <path> # Install from local path/archive openclaw plugins install -l <path> # Link (no copy, for dev) openclaw plugins update <id|--all> # Update npm-installed plugins openclaw plugins enable <id> # Enable a plugin openclaw plugins disable <id> # Disable a plugin openclaw plugins uninstall <id> # Remove plugin + config openclaw plugins doctor # Diagnose plugin issues ``` --- ## Relevance to Cobot 1. **Extensibility model**: OpenClaw's 8 registration points cover the full surface area — tools, channels, hooks, services, providers, RPC, CLI, and commands. This is a comprehensive blueprint for cobot's plugin system. 2. **Static validation**: Manifest + JSON Schema validation without executing plugin code is a strong safety pattern. Broken plugins can't crash the config layer. 3. **Discovery precedence**: Config paths > workspace > global > bundled gives clear override semantics. 4. **Security**: Path traversal checks, ownership validation, `--ignore-scripts` on npm install, allowlist/denylist — defense in depth for trusted-code plugins. 5. **Slot system**: Exclusive categories (like memory) prevent conflicting plugins. Clean pattern for cobot's own exclusive-category extensions. 6. **Channel abstraction**: The channel plugin interface (config, capabilities, outbound, onboarding) is well-factored and could be adapted for cobot's communication layer. 7. **Package distribution**: npm-based distribution with integrity checking is mature and reusable. --- *Filed by Hermes 🪽 on behalf of k9ert*
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
ultanio/cobot#195
No description provided.