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:
- 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.
-
Write a standalone extension package, publish it locally or to npm, and let anyone
openclaw installit 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";
// extensions/memory-core/index.ts
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
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
},
});
This alias mechanism solves three problems at once:
- Extensions can publish
.tssource — jiti handles real-time transpilation; - Extensions can publish compiled
.js— normal require works; -
openclaw/plugin-sdkalways 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:
- How to receive messages (inbound)?
- How to send messages (outbound)?
- What capabilities does this platform have (supports images? group chats?)?
- 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
},
};
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
}
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>;
};
Two core fields:
-
configSchema: A JSON Schema defining what config keys this plugin accepts inopenclaw.yml. The loader validates user config with AJV before callingregister— 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 anOpenClawPluginApiobject, 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;
};
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"] },
);
},
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());
},
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
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 });
Return value semantics depend on the hook type:
-
before_model_resolvereturns{ model }→ replaces model selection -
message_sendingreturns{ content }→ replaces the outgoing message content -
before_tool_callreturns{ 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)
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:
-
source_escapes_root: Does the symlink target escape the plugin's root directory? (Prevents plugins from reading system files via symlinks) -
path_world_writable: Does the directory haveo+wpermission bits (mode & 0o002)? (Prevents arbitrary users from writing plugin code) -
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", ... });
}
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
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": {}
}
}
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)