DEV Community

WonderLab
WonderLab

Posted on

OpenClaw Deep Dive (4): Plugin SDK and Extension Development

Scenario: A Third-Party Contributor Wants to Add Zalo

Suppose you're a Vietnamese developer who wants to connect OpenClaw to Zalo — Vietnam's largest instant messaging platform.

The core already ships Telegram, Discord, Slack... but not Zalo. You have two paths:

  1. Submit a PR to the main repo, wait for review and merge, then forever track OpenClaw's release cadence every time the Zalo API changes.
  2. Write a standalone extension package, publish it locally or to npm, and let anyone openclaw install it on demand.

The second path requires OpenClaw's core to provide a stable extension contract — no matter whether contributors use TypeScript or JavaScript, whether they publish .ts sources or compiled .js output, the core must correctly load, isolate, and run them. And if an extension crashes, the core must not go down with it.

This is the problem the Plugin SDK must solve.


1. Stable Contract: openclaw/plugin-sdk

Why a fixed import path?

If extensions do import { ... } from "../../src/plugins/types.js", any internal refactor breaks every extension in existence.

OpenClaw's solution is to use openclaw/plugin-sdk as the public API surface, hiding all internal implementation details behind this path:

// extensions/zalo/src/channel.ts
import type { ChannelPlugin, ChannelDock } from "openclaw/plugin-sdk";
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
Enter fullscreen mode Exit fullscreen mode
// extensions/memory-core/index.ts
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
Enter fullscreen mode Exit fullscreen mode

At runtime, jiti (a TypeScript runtime loader) aliases this path to the core's src/plugin-sdk/index.ts (dev) or dist/plugin-sdk/index.js (production):

// src/plugins/loader.ts (key excerpt)
jitiLoader = createJiti(import.meta.url, {
  interopDefault: true,
  extensions: [".ts", ".tsx", ".js", ".mjs", ".cjs", ".json"],
  alias: {
    "openclaw/plugin-sdk": pluginSdkAlias,  // points to src or dist
  },
});
Enter fullscreen mode Exit fullscreen mode

This alias mechanism solves three problems at once:

  • Extensions can publish .ts source — jiti handles real-time transpilation;
  • Extensions can publish compiled .js — normal require works;
  • openclaw/plugin-sdk always resolves to the currently running core, eliminating version mismatches.

2. Channel Extension Protocol: ChannelPlugin and ChannelDock

What does integrating a new messaging platform require?

To connect Zalo to OpenClaw, the contributor must tell the core at minimum:

  1. How to receive messages (inbound)?
  2. How to send messages (outbound)?
  3. What capabilities does this platform have (supports images? group chats?)?
  4. Which senders are allowed (allowFrom)?

OpenClaw models these concerns with two separate types:

ChannelDock (capability declaration): Statically describes what the platform can do and how to resolve routing decisions.

// extensions/zalo/src/channel.ts
export const zaloDock: ChannelDock = {
  id: "zalo",
  capabilities: {
    chatTypes: ["direct", "group"],
    media: true,
    blockStreaming: true,   // Zalo doesn't support streaming output
  },
  outbound: {
    textChunkLimit: 2000,  // max characters per message
  },
  config: {
    resolveAllowFrom: ..., // parse allowlist from config
    formatAllowFrom: ...,  // format allowlist for display
  },
  groups: {
    resolveRequireMention: () => true,  // group chats require @mention
  },
  threading: {
    resolveReplyToMode: () => "off",    // Zalo doesn't support threaded replies
  },
};
Enter fullscreen mode Exit fullscreen mode

ChannelPlugin (lifecycle): Implements the actual connect/disconnect/message logic, composed from optional sub-interfaces (Adapters):

interface ChannelPlugin {
  dock: ChannelDock;
  config?: ConfigAdapter;       // config validation and instantiation
  security?: SecurityAdapter;   // signature verification, replay protection
  outbound?: OutboundAdapter;   // send messages, upload media
  pairing?: PairingAdapter;     // device pairing flow
  groups?: GroupsAdapter;       // group member queries
  gateway?: GatewayAdapter;     // hooks at Gateway startup
  agentTools?: AgentToolsAdapter; // inject Agent-specific tools
}
Enter fullscreen mode Exit fullscreen mode

Why split into two objects?

Everything in ChannelDock is needed by the routing layer at message arrival time — "which platform did this come from? should it be allowed? what format to use for the reply?" These decisions must be fast, pure, and side-effect-free.

The ChannelPlugin logic involves network connections and stateful Adapter implementations — only needed at Gateway startup/shutdown. Different lifecycles, natural separation.


3. General Plugin Contract: OpenClawPluginDefinition

Not all extensions are messaging channels. memory-core provides memory search tools, diagnostics-otel provides OpenTelemetry instrumentation — these are general-purpose extensions.

Their contract is OpenClawPluginDefinition:

type OpenClawPluginDefinition = {
  id?: string;
  name?: string;
  description?: string;
  version?: string;
  kind?: PluginKind;          // "memory" | "channel" | "provider" | ...
  configSchema?: OpenClawPluginConfigSchema;
  register?: (api: OpenClawPluginApi) => void | Promise<void>;
  activate?: (api: OpenClawPluginApi) => void | Promise<void>;
};
Enter fullscreen mode Exit fullscreen mode

Two core fields:

  • configSchema: A JSON Schema defining what config keys this plugin accepts in openclaw.yml. The loader validates user config with AJV before calling register — invalid config means the plugin is rejected before it even loads. Bad data never reaches the extension.
  • register(api): The extension's entry point. The core injects an OpenClawPluginApi object, and the extension uses it to declare what it contributes.

4. Capability Injection: OpenClawPluginApi

OpenClawPluginApi is the permission token the core injects into each extension. Extensions can only use this object to tell the core what they're registering — they cannot directly manipulate any internal core state.

type OpenClawPluginApi = {
  // Register LLM tools (functions the Agent can call)
  registerTool(tool, opts?): void;

  // Register lifecycle hooks
  registerHook(events, handler, opts?): void;
  on<K extends PluginHookName>(hookName, handler, opts?): void;

  // Register HTTP endpoints (called by Web UI)
  registerHttpHandler(handler): void;
  registerHttpRoute(params): void;

  // Register a messaging channel
  registerChannel(registration | ChannelPlugin): void;

  // Register Gateway control-plane methods
  registerGatewayMethod(method, handler): void;

  // Register CLI subcommands
  registerCli(registrar, opts?): void;

  // Register background services (with start/stop lifecycle)
  registerService(service): void;

  // Register an LLM provider (Anthropic/OpenAI/etc.)
  registerProvider(provider): void;

  // Register LLM-bypass slash commands (e.g. /tts)
  registerCommand(command): void;
};
Enter fullscreen mode Exit fullscreen mode

Two real extensions demonstrate how this API is used:

memory-core (tools + CLI commands):

// extensions/memory-core/index.ts
register(api: OpenClawPluginApi) {
  // Register LLM tools: called when the Agent decides to search memory
  api.registerTool(
    (ctx) => {
      const memorySearchTool = api.runtime.tools.createMemorySearchTool({
        config: ctx.config,
        agentSessionKey: ctx.sessionKey,
      });
      return [memorySearchTool, memoryGetTool];
    },
    { names: ["memory_search", "memory_get"] },
  );

  // Register CLI command: user can run `openclaw memory ...`
  api.registerCli(
    ({ program }) => { api.runtime.tools.registerMemoryCli(program); },
    { commands: ["memory"] },
  );
},
Enter fullscreen mode Exit fullscreen mode

diagnostics-otel (background service):

// extensions/diagnostics-otel/index.ts
register(api: OpenClawPluginApi) {
  // Register background service: start() at Gateway start, stop() at shutdown
  api.registerService(createDiagnosticsOtelService());
},
Enter fullscreen mode Exit fullscreen mode

registerService accepts an object implementing { start(): Promise<void>; stop(): Promise<void> }. The core calls these at the right points in the Gateway lifecycle — the extension never needs to manage startup ordering.


5. Lifecycle Hooks: 24 Observation Points

Problem: an extension wants to intercept/modify intermediate state

If an extension wants to do "message filtering" — replacing sensitive words before a message is sent — it needs a chance to intervene at the exact moment a message is about to go out.

OpenClaw provides 24 named hooks, covering the full lifecycle from Gateway startup through message handling to Agent execution:

Gateway layer:
  gateway_start         → Gateway has finished starting up
  gateway_stop          → Gateway is about to shut down

Message layer:
  message_received      → Inbound message arrives at routing layer (interceptable)
  message_sending       → Reply is about to be sent (content modifiable)
  message_sent          → Reply has been delivered

Agent layer:
  before_model_resolve  → Before deciding which model to use (replaceable)
  before_prompt_build   → Before building the Prompt (can inject system prompt)
  before_agent_start    → Before Agent begins execution (can abort)
  llm_input             → Complete input about to go to LLM (observable)
  llm_output            → Complete output returned from LLM (observable)
  agent_end             → Agent execution finished

Tool layer:
  before_tool_call      → Before a tool is called (interceptable/replaceable)
  after_tool_call       → After a tool call returns (result modifiable)
  tool_result_persist   → When tool result is being persisted

Session layer:
  session_start / session_end
  before_message_write  → Before a message is written to session file
  before_compaction / after_compaction  → Around context compaction
  before_reset          → Before session reset

Sub-Agent layer:
  subagent_spawning     → About to spawn a sub-Agent (target modifiable)
  subagent_spawned      → Sub-Agent has been spawned
  subagent_delivery_target → Sub-Agent reply delivery target decision
  subagent_ended        → Sub-Agent finished
Enter fullscreen mode Exit fullscreen mode

Hooks support priority ordering when registered: higher numbers run first.

api.on("before_model_resolve", async (event) => {
  // Dynamically choose model based on conversation length
  if (event.session.messageCount > 100) {
    return { model: "claude-haiku-4-5" }; // switch to fast model for long conversations
  }
}, { priority: 10 });
Enter fullscreen mode Exit fullscreen mode

Return value semantics depend on the hook type:

  • before_model_resolve returns { model } → replaces model selection
  • message_sending returns { content } → replaces the outgoing message content
  • before_tool_call returns { skip: true } → blocks the tool call

6. Plugin Discovery: Four-Tier Origins and Security Checks

Problem: which plugins should be loaded?

There are many packages installed on a system — not all are OpenClaw plugins, and not all OpenClaw plugins deserve trust.

Four-tier origin priority (higher overrides lower; same id is only loaded from the highest tier):

config (explicit paths)      ← Highest priority; user specifies paths in openclaw.yml
  workspace                  ← .openclaw/extensions/ (project-level)
    global                   ← ~/.openclaw/extensions/ (user-level)
      bundled                ← Core-bundled (lowest; always the fallback)
Enter fullscreen mode Exit fullscreen mode

This design lets users override global plugins at the project level, and override built-ins at the user level, while keeping core-bundled plugins as a fallback.

Security checks at load time (src/plugins/discovery.ts):

Before adding a candidate path to the load queue, the discoverer performs three checks:

  1. source_escapes_root: Does the symlink target escape the plugin's root directory? (Prevents plugins from reading system files via symlinks)
  2. path_world_writable: Does the directory have o+w permission bits (mode & 0o002)? (Prevents arbitrary users from writing plugin code)
  3. path_suspicious_ownership: Is the file uid neither the current user nor root? (Prevents loading plugins planted by others)
// src/plugins/discovery.ts (simplified)
if (stat.mode & 0o002) {
  candidate.diagnostics.push({ kind: "path_world_writable", ... });
}
if (stat.uid !== ownershipUid && stat.uid !== 0) {
  candidate.diagnostics.push({ kind: "path_suspicious_ownership", ... });
}
Enter fullscreen mode Exit fullscreen mode

Additionally, before require-ing the plugin entry file, the loader performs one more boundary file check (openBoundaryFileSync): it uses an open(fd) syscall to verify that the plugin source file — after realpath resolution — still lives inside the plugin's root directory. This guards against TOCTOU (time-of-check/time-of-use) race attacks.


7. Loader: From Discovery to Activation

The loader (src/plugins/loader.ts) is the pipeline connecting the discoverer to the runtime. The core flow:

discoverOpenClawPlugins()     → scan four origins, produce candidate list
  ↓
loadPluginManifestRegistry()  → read openclaw.plugin.json for each candidate:
                                 id / kind / configSchema
  ↓
for (candidate of candidates):
  1. resolveEffectiveEnableState()  → check plugins.allow / plugins.disable
  2. validatePluginConfig()         → AJV-validate user config
  3. jiti(safeSource)               → load the module (.ts or .js)
  4. resolvePluginModuleExport()    → extract the register function
  5. register(api)                  → call synchronously, inject OpenClawPluginApi
  6. registry.plugins.push(record) → record load result
Enter fullscreen mode Exit fullscreen mode

Two key design decisions:

Registration must be synchronous. After calling register(api), if the return value is a Promise, the loader logs a warning and ignores the async result. The reason: at Gateway startup time, the core needs to know exactly which tools/channels/hooks are ready. Async registration introduces an indeterminate readiness window. Anything needing async initialization should go into registerService (which has an explicit start() callback).

Plugin crashes don't bring down the core. Every plugin's register call is wrapped in try/catch. Errors are recorded to registry.diagnostics, and other plugins continue loading. Users can inspect load failures with openclaw plugins status.


8. Plugin Manifest: openclaw.plugin.json

Every extension root must contain an openclaw.plugin.json declaring the plugin's static metadata:

{
  "id": "memory-core",
  "kind": "memory",
  "configSchema": {
    "type": "object",
    "additionalProperties": false,
    "properties": {}
  }
}
Enter fullscreen mode Exit fullscreen mode

configSchema is a required field — even if the plugin has no configuration options at all, it must provide an empty schema (the emptyPluginConfigSchema() helper exists for this). This design is intentional: it forces plugin authors to explicitly declare "I need no config" rather than letting the core guess, preventing unexpected config leakage into plugins.


Summary

The entire Plugin SDK design revolves around one central question: How do we let third-party code safely extend OpenClaw without breaking the core's stability or security boundaries?

Mechanism Problem Solved
Fixed openclaw/plugin-sdk path Internal refactors don't break extensions
ChannelDock + ChannelPlugin separation Routing decisions (hot path) decoupled from connection lifecycle
OpenClawPluginApi injection Extensions can't directly manipulate core internal state
24 named hooks Observation and intervention points across the full execution chain
Four-tier origin priority Project-level/user-level plugins can override built-in behavior
Three security checks + boundary file verification Prevents malicious/planted plugins from loading
Synchronous register + registerService Clearly separates registration time from initialization time
openclaw.plugin.json manifest Static id/kind/schema declaration before any code runs

In the next article, we'll dive into OpenClaw's model and provider system — exploring how the core routes between Anthropic, OpenAI, local Ollama, and other LLM backends, and how provider extensions plug in through the Plugin SDK.

Top comments (0)