Extension Contract

Focus Deep-Dive

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

Primary role

Define a uniform discovery, loading, and registration protocol for all plugin types.

Main inputs

package.json manifest with openclaw.extensions entry points; TypeScript module with default export.

Key decisions

General plugin vs. channel plugin; which api.register*() methods to call; which config schema to declare.

Outputs / side effects

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

DecisionWhat controls itWhy it matters
General vs. channel entrydefinePluginEntry() vs. defineChannelPluginEntry()Channel plugins get automatic gateway wiring, account binding, and adapter scaffolding
Config schema presenceconfigSchema param in entry definitionDetermines whether the extension appears in config UI and gets validated settings
Multi-capability registrationWhich api.register*() methods are calledA single extension can register multiple providers, tools, hooks, and routes
Import boundaryopenclaw/plugin-sdk/* subpath exportsExtensions 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"]
  }
}
Key detail: The 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);
  }
}
Boundary rule: Extensions must only import from 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:

RuleStatusExample
Import from openclaw/plugin-sdk/*DOimport { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"
Import from src/**DON’Timport { something } from "../../src/core/engine"
Import from src/plugin-sdk-internal/**DON’Timport { internal } from "openclaw/src/plugin-sdk-internal/..."
Relative imports escaping package rootDON’Timport { util } from "../../../core/utils"
Export public API through api.ts barrelDOexport * from "./src/accounts.js"
Keep manifest metadata accurateDOMatch id and name fields between manifest and entry point

Edge Cases & Error Handling

ScenarioBehaviorLocation
Missing openclaw field in package.jsonDirectory is silently skipped; not treated as an extensionPlugin loader
Import error in entry pointLogged as warning, extension marked as Failed, other extensions continue loadingPlugin loader
Duplicate extension idSecond registration overwrites the first; warning loggedCore registry
register() throws synchronouslyExtension marked as Failed, partial registrations are rolled backPlugin loader
Config change triggers reloadIf reload.configPrefixes matches, the extension is re-registered without process restartConfig watcher

Configuration

Knob / ParameterDefaultDescription
openclaw.extensions[]Array of entry point paths relative to package root
configSchemaemptyPluginConfigSchemaJSON 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

See also:

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