Define a uniform discovery, loading, and registration protocol for all plugin types.
Extension Contract
The shared interface every extension implements
Every extension in OpenClaw — whether it adds an AI provider, a messaging channel, or a utility tool — follows the same discovery-and-registration contract built on definePluginEntry(), manifest-driven discovery, and the OpenClawPluginApi registration surface.
📚 What is this & why does it matter?
The Extension Contract is the set of rules and interfaces that make every OpenClaw plugin interchangeable and safely isolatable. Without this contract, adding a new AI provider would mean modifying core source code. With it, a developer creates a self-contained directory, writes a package.json manifest, exports a definePluginEntry() default, and the core discovers and wires it up automatically.
💡 Real-world analogy
Think of the extension contract like a shipping container spec. Every container (extension) has the same corner castings (entry point shape), standard dimensions (manifest fields), and ISO markings (id, name, capabilities). A crane (plugin loader) can pick up any container without knowing what’s inside, and a ship (OpenClaw core) can carry any mix of containers as long as they meet the spec.
Contents
Overview
package.json manifest with openclaw.extensions entry points; TypeScript module with default export.
General plugin vs. channel plugin; which api.register*() methods to call; which config schema to declare.
Registered providers, channels, hooks, tools, HTTP routes, and services in the core registry.
flowchart TD Manifest["package.json\nopenclaw.extensions: ['./index.ts']"] Loader["Plugin Loader"] DefineEntry["definePluginEntry()"] DefineChannel["defineChannelPluginEntry()"] API["OpenClawPluginApi"] Registry["Core Registry"] Manifest --> Loader Loader --> DefineEntry Loader --> DefineChannel DefineEntry --> API DefineChannel --> API API --> Registry
The plugin loader reads each extension’s package.json, resolves the entry point(s) listed in openclaw.extensions, dynamically imports the default export, and passes a scoped OpenClawPluginApi instance to the extension’s register callback.
Decision Points
| Decision | What controls it | Why it matters |
|---|---|---|
| General vs. channel entry | definePluginEntry() vs. defineChannelPluginEntry() | Channel plugins get automatic gateway wiring, account binding, and adapter scaffolding |
| Config schema presence | configSchema param in entry definition | Determines whether the extension appears in config UI and gets validated settings |
| Multi-capability registration | Which api.register*() methods are called | A single extension can register multiple providers, tools, hooks, and routes |
| Import boundary | openclaw/plugin-sdk/* subpath exports | Extensions that import from src/** break the contract and may fail on upgrades |
How It Works
Phase 1: Manifest Discovery
The plugin loader scans all directories under extensions/ looking for package.json files with an openclaw field. The openclaw.extensions array lists relative paths to TypeScript entry point files.
// extensions/openai/package.json
{
"name": "@openclaw/openai-provider",
"version": "2026.4.1-beta.1",
"private": true,
"type": "module",
"openclaw": {
"extensions": ["./index.ts"]
}
}
openclaw.extensions array can contain multiple entry points. Each entry point is loaded independently and receives its own OpenClawPluginApi instance, allowing a single package to contribute multiple logical extensions.
Phase 2: Entry Point Definition
Each entry point file must export a default value created by definePluginEntry() or defineChannelPluginEntry(). These factory functions wrap the plugin metadata and registration callback into a typed DefinedPluginEntry object.
// The DefinePluginEntryOptions type
interface DefinePluginEntryOptions {
id: string; // Unique plugin identifier
name: string; // Human-readable display name
description?: string; // Brief description
kind?: string; // Plugin kind hint
configSchema?: PluginConfigSchema; // JSON schema for config validation
register: (api: OpenClawPluginApi) => void; // Registration callback
}
// Usage — general plugin
export default definePluginEntry({
id: "openai",
name: "OpenAI Provider",
description: "Bundled OpenAI provider plugins",
register(api) {
api.registerProvider(buildOpenAIProvider());
api.registerSpeechProvider(buildOpenAISpeechProvider());
},
});
Phase 2b: Channel Entry Point (Alternative)
Channel extensions use defineChannelPluginEntry() which adds automatic gateway registration, account binding, and a setRuntime hook for dependency injection:
// The DefineChannelPluginEntryOptions type
interface DefineChannelPluginEntryOptions<TPlugin> {
id: string;
name: string;
description?: string;
plugin: TPlugin; // The ChannelPlugin implementation
configSchema?: PluginConfigSchema;
setRuntime?: (runtime: PluginRuntime) => void;
registerCliMetadata?: (api: OpenClawPluginApi) => void;
registerFull?: (api: OpenClawPluginApi) => void;
}
// Usage — channel plugin
export default defineChannelPluginEntry({
id: "telegram",
name: "Telegram",
description: "Telegram channel plugin",
plugin: telegramPlugin as ChannelPlugin,
setRuntime: setTelegramRuntime,
});
Phase 3: Registration
The core calls the register(api) callback, passing a scoped OpenClawPluginApi. The extension uses this API to register its capabilities:
// OpenClawPluginApi — key registration methods
interface OpenClawPluginApi {
// Context (read-only)
id: string;
config: OpenClawConfig;
logger: PluginLogger;
runtime: PluginRuntime;
// Registration surface
registerProvider(provider: ProviderPlugin): void;
registerChannel(channel: ChannelPlugin): void;
registerTool(tool: AnyAgentTool): void;
registerHook(events: string | string[], handler: InternalHookHandler): void;
registerHttpRoute(params: OpenClawPluginHttpRouteParams): void;
registerService(service: OpenClawPluginService): void;
registerCliBackend(backend: CliBackendPlugin): void;
registerSpeechProvider(provider: SpeechProviderPlugin): void;
registerImageGenerationProvider(provider: ImageGenerationProviderPlugin): void;
registerMediaUnderstandingProvider(provider: MediaUnderstandingProviderPlugin): void;
registerWebSearchProvider(provider: WebSearchProviderPlugin): void;
registerWebFetchProvider(provider: WebFetchProviderPlugin): void;
registerMemoryRuntime(runtime: MemoryPluginRuntime): void;
registerCommand(command: OpenClawPluginCommandDefinition): void;
registerContextEngine(id: string, factory: ContextEngineFactory): void;
}
Lifecycle State Machine
stateDiagram-v2 [*] --> Discovered : Manifest found Discovered --> Loading : Import entry point Loading --> Defining : definePluginEntry() called Loading --> Failed : Import error Defining --> Registering : register(api) called Registering --> Active : All api.register*() complete Registering --> Failed : Registration error Active --> Reloading : Config change matches reload.configPrefixes Reloading --> Active : Re-registration complete Active --> [*] : Shutdown Failed --> [*] : Logged and skipped
Extensions transition through five states: Discovered (manifest parsed), Loading (module importing), Defining (factory invoked), Registering (API calls in progress), and Active (serving requests). A hot-reload cycle returns an active extension to the Registering state without restarting the process.
Annotated Code Walkthrough
Walk through a complete minimal extension from manifest to registration:
Create the package manifest declaring the extension entry point:
// extensions/my-tool/package.json
{
"name": "@openclaw/my-tool",
"version": "1.0.0",
"private": true,
"type": "module",
"openclaw": {
"extensions": ["./index.ts"]
}
}
Define the extension entry point with metadata and the registration callback:
// extensions/my-tool/index.ts
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
export default definePluginEntry({
id: "my-tool",
name: "My Custom Tool",
description: "Adds a custom agent tool",
register(api) {
api.registerTool({
name: "my_tool",
description: "Does something useful",
parameters: { type: "object", properties: { query: { type: "string" } } },
execute: async (params) => {
api.logger.info("my-tool executing", { query: params.query });
return { result: `Processed: ${params.query}` };
},
});
api.registerHook("session_start", (ctx) => {
api.logger.debug("Session started, my-tool is active");
});
},
});
The plugin loader resolves the entry, creates a scoped API, and calls register. The tool and hook are now available to the agent engine:
// Internal: plugin-loader.ts (simplified)
async function loadExtension(dir: string): Promise<void> {
const pkg = await readJson(join(dir, "package.json"));
const entryPaths = pkg.openclaw?.extensions ?? [];
for (const rel of entryPaths) {
const mod = await import(join(dir, rel));
const entry: DefinedPluginEntry = mod.default;
const api = createScopedPluginApi({
id: entry.id,
name: entry.name,
config: globalConfig,
logger: createLogger(entry.id),
});
entry.register(api);
registry.activate(entry.id);
}
}
openclaw/plugin-sdk/* subpaths. Importing from src/** or src/plugin-sdk-internal/** violates the contract and will break on core upgrades.
Import Boundary Rules
The AGENTS.md file in the extensions root defines strict import boundaries:
| Rule | Status | Example |
|---|---|---|
Import from openclaw/plugin-sdk/* | DO | import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry" |
Import from src/** | DON’T | import { something } from "../../src/core/engine" |
Import from src/plugin-sdk-internal/** | DON’T | import { internal } from "openclaw/src/plugin-sdk-internal/..." |
| Relative imports escaping package root | DON’T | import { util } from "../../../core/utils" |
Export public API through api.ts barrel | DO | export * from "./src/accounts.js" |
| Keep manifest metadata accurate | DO | Match id and name fields between manifest and entry point |
Edge Cases & Error Handling
| Scenario | Behavior | Location |
|---|---|---|
Missing openclaw field in package.json | Directory is silently skipped; not treated as an extension | Plugin loader |
| Import error in entry point | Logged as warning, extension marked as Failed, other extensions continue loading | Plugin loader |
Duplicate extension id | Second registration overwrites the first; warning logged | Core registry |
register() throws synchronously | Extension marked as Failed, partial registrations are rolled back | Plugin loader |
| Config change triggers reload | If reload.configPrefixes matches, the extension is re-registered without process restart | Config watcher |
Configuration
| Knob / Parameter | Default | Description |
|---|---|---|
openclaw.extensions | [] | Array of entry point paths relative to package root |
configSchema | emptyPluginConfigSchema | JSON Schema for extension-specific settings; validated at startup |
reload.configPrefixes | [] | Config key prefixes that trigger hot-reload of this extension |
reload.noopPrefixes | [] | Config prefixes that are acknowledged but do not trigger reload |
Related Topics
See also:
- Extensions overview — the parent module page with all categories
- Provider Adapters — how
ProviderPluginbuilds on this contract - Channel Extensions — how
ChannelPluginextends the channel entry point
Directory Map
Source files for the extension contract
extensions/
├── shared/
│ ├── runtime.ts ← Re-exports resolveLoggerBackedRuntime
│ ├── deferred.ts ← Deferred/Promise utilities
│ ├── config-schema-helpers.ts ← Config schema validation helpers
│ ├── passive-monitor.ts ← Passive monitoring utilities
│ ├── channel-status-summary.ts ← Channel health reporting
│ └── status-issues.ts ← Status issue tracking
├── AGENTS.md ← Import boundary rules and contracts
├── <extension>/
│ ├── package.json ← Manifest with openclaw.extensions field
│ ├── index.ts ← Entry point (definePluginEntry default export)
│ ├── api.ts ← Public API barrel export
│ └── src/ ← Internal implementation
└── openclaw/src/plugin-sdk/ ← SDK types (imported by extensions)
├── plugin-entry.ts ← definePluginEntry(), defineChannelPluginEntry()
├── core.ts ← Core types (ChannelPlugin, ProviderPlugin)
└── types.ts ← OpenClawPluginApi, registration types