Plugin Contract

Deep Dive

The OpenClawPluginDefinition interface and register() lifecycle

Contract Interface Jiti Loader Config Schema TypeScript

📚 What is this & why does it matter?

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.

💡 Real-world analogy

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.

Contents

Overview

Primary Role

Define the contract that all plugins must satisfy — a module with a register(api) function.

Main Inputs

The plugin module export (default export or named register).

Key Decisions

How to resolve module exports (default vs named), when to call register(), how to handle config schema.

Outputs

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.

How It Works

Module Import via Jiti

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.

Export Resolution

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 register() Call

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.

Config Schema Validation

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.

Optional setup() Hook

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.

Async register(): While 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.

Lifecycle State Machine

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.

Annotated Code Walkthrough

Walk through the key stages of the plugin contract lifecycle:

Module Export Resolution

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;
}

The register(api) Call

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

Config Schema Validation

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
  }
}

Plugin Runtime Creation

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;
}

Edge Cases & Error Handling

ScenarioBehaviorImpact
Plugin has no register exportresolvePluginModuleExport() returns null; diagnostic loggedPlugin silently skipped, other plugins unaffected
register() throwsException caught by loader; plugin marked as failedPartial registrations may exist; loader logs the error
register() is asyncLoader awaits the promise but logs a warningOther plugins may load before async registration completes
Plugin config fails validationconfigSchema.parse() returns errors; plugin skippedDiagnostic message includes validation errors for debugging
Multiple plugins register conflicting hook namesBoth hooks are registered; execution order by priorityHooks with equal priority use plugin id as tiebreaker

Directory Map

Source files for Plugin Contract
📁 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