Primary Role
Define the contract that all plugins must satisfy — a module with a register(api) function.
The OpenClawPluginDefinition interface and register() lifecycle
Contract Interface Jiti Loader Config Schema TypeScript
The Plugin Contract defines the shape every OpenClaw plugin must satisfy: a module that exports an OpenClawPluginDefinition with a register(api) function. The loader resolves module exports (default or named), validates config schemas, and calls register() to let the plugin wire itself into the system. Without this contract, adding plugins would require modifying core code for each new capability.
Think of the plugin contract like a standardized electrical outlet. Every appliance (plugin) has a plug that fits a specific socket shape (the OpenClawPluginDefinition interface). The wall socket (loader) doesn’t care if it’s a lamp or a refrigerator — it just needs the right prong configuration. The circuit breaker (config schema validation) prevents overloaded or incompatible devices from causing damage.
Define the contract that all plugins must satisfy — a module with a register(api) function.
The plugin module export (default export or named register).
How to resolve module exports (default vs named), when to call register(), how to handle config schema.
Side effects on the PluginRegistry — tools, hooks, channels, providers registered.
flowchart TD
Module["Plugin Module"] --> Export{"Module Export"}
Export -->|"default export"| Resolve["resolvePluginModuleExport()"]
Export -->|"named register"| Resolve
Resolve --> Definition["OpenClawPluginDefinition"]
Definition --> Register["definition.register(api)"]
Register --> Tools["registerTool()"]
Register --> Hooks["registerHook()"]
Register --> Channels["registerChannel()"]
Register --> Providers["registerProvider()"]
Plugins export a definition object that the loader resolves. The loader then calls register(api), passing a scoped OpenClawPluginApi instance. Inside register(), the plugin wires its capabilities into the central registry.
Plugin code is imported through Jiti, a TypeScript-aware runtime loader. This means plugins can be written in TypeScript without a pre-compilation step. Jiti resolves import/require and handles .ts, .tsx, and .js files transparently.
resolvePluginModuleExport() inspects the imported module to find the plugin definition. It checks for a default export first, then falls back to a named register function. If neither is found, a diagnostic error is logged and the plugin is skipped.
The loader calls definition.register(api) with the constructed OpenClawPluginApi. Inside this function, the plugin makes registration calls like api.registerTool(), api.registerHook(), api.registerChannel(), etc. The registration is synchronous by default — all capabilities should be registered before register() returns.
An optional configSchema provides Zod-like validation for plugin configuration. When present, the loader calls configSchema.parse(pluginConfig) before invoking register(). If validation fails, the plugin is skipped with a diagnostic message.
Plugins can also export a setup() function for one-time initialization that runs before register(). This is useful for global state, environment checks, or resource acquisition.
register() is expected to be synchronous, the loader does handle async returns. However, async registration introduces ordering uncertainty — capabilities may not be available when other plugins expect them. Prefer synchronous registration.
stateDiagram-v2 [*] --> Import: Jiti imports module Import --> Resolve: resolvePluginModuleExport() Resolve --> HasDefault: Has default export Resolve --> HasNamed: Has named register Resolve --> Invalid: No valid export HasDefault --> CallRegister: definition.register(api) HasNamed --> CallRegister CallRegister --> Registering: Plugin registering capabilities Registering --> Done: All capabilities registered Invalid --> Error: Log diagnostic Error --> [*] Done --> [*]
The lifecycle flows from import through export resolution to the register() call. Invalid exports are caught early and logged. Successful registration leads to the plugin’s capabilities being active in the system.
Walk through the key stages of the plugin contract lifecycle:
The loader checks for default then named exports. The resolution function handles both CommonJS and ESM module formats:
// resolvePluginModuleExport — checks default then named exports
function resolvePluginModuleExport(
mod: Record<string, unknown>
): OpenClawPluginDefinition | null {
// Prefer default export
if (mod.default && typeof (mod.default as any).register === "function") {
return mod.default as OpenClawPluginDefinition;
}
// Fall back to named register export
if (typeof mod.register === "function") {
return { register: mod.register } as OpenClawPluginDefinition;
}
// No valid export found
return null;
}
Once the definition is resolved, the loader creates a scoped API and calls register(). All capability registration happens synchronously within this call:
// The loader invokes register() with a scoped API
const api = buildPluginApi({
id: definition.id,
name: definition.name,
config: configSnapshot,
runtime: pluginRuntime,
logger: createPluginLogger(definition.id),
handlers: registryHandlers,
});
definition.register(api);
// After this returns, all tools/hooks/channels are registered
If the plugin declares a configSchema, it’s validated before register() is called. Invalid config prevents the plugin from loading:
// Config schema validation — runs before register()
if (definition.configSchema) {
const result = definition.configSchema.parse(pluginConfig);
if (!result.success) {
logger.warn("Plugin config validation failed", {
pluginId: definition.id,
errors: result.errors,
});
return; // Skip this plugin
}
}
The runtime binds the config snapshot and logger to the plugin context. It provides access to shared services like HTTP clients, storage, and event emitters:
// Plugin runtime — bound config snapshot and logger
interface PluginRuntime {
config: Readonly<OpenClawConfig>;
logger: PluginLogger;
httpClient: HttpClient;
storage: PluginStorage;
events: PluginEventEmitter;
}
| Scenario | Behavior | Impact |
|---|---|---|
Plugin has no register export | resolvePluginModuleExport() returns null; diagnostic logged | Plugin silently skipped, other plugins unaffected |
register() throws | Exception caught by loader; plugin marked as failed | Partial registrations may exist; loader logs the error |
register() is async | Loader awaits the promise but logs a warning | Other plugins may load before async registration completes |
| Plugin config fails validation | configSchema.parse() returns errors; plugin skipped | Diagnostic message includes validation errors for debugging |
| Multiple plugins register conflicting hook names | Both hooks are registered; execution order by priority | Hooks with equal priority use plugin id as tiebreaker |
buildPluginApi() constructs the API object passed to register()api.registerTool(), api.registerHook(), etc.📁 src/plugin-sdk/
├── plugin-definition.ts # OpenClawPluginDefinition interface
├── plugin-module-resolve.ts # resolvePluginModuleExport()
├── plugin-config-schema.ts # Config schema validation utilities
├── plugin-entry.ts # definePluginEntry() factory
├── plugin-loader.ts # Module import via Jiti, register() orchestration
└── plugin-runtime.ts # PluginRuntime creation and binding