【#4】 OpenClaw を読み解く — コアに能力を注ぐ、たった一つの窓口
本記事のコード参照は OpenClaw
mainのcee2aca409(version2026.6.10)時点。行番号は更新でズレ得ます。
連載「OpenClaw を読み解く」
#01 で「コアはプラグイン非依存」という最重要原則を見ました。今回はそれを実際に成立させている仕組み——マニフェスト、ローダー、capability レジストリ、そして 300 を超えるサブパスエントリポイントを持つプラグイン SDK——を src/plugins/ と src/plugin-sdk/ から読み解きます。
すべては宣言から始まる — マニフェストという名乗り
各ネイティブプラグインのルートには openclaw.plugin.json(PLUGIN_MANIFEST_FILENAME, src/plugins/manifest.ts:26)が必須です。中身を表す型 PluginManifest(src/plugins/manifest.ts:297)が、プラグインが何を提供するかを宣言します。
export type PluginManifest = {
id: string;
configSchema: JsonSchemaObject;
requiresPlugins?: string[];
enabledByDefault?: boolean;
channels?: string[]; // 例: ["telegram"]
providers?: string[]; // 例: ["anthropic"]
modelSupport?: PluginManifestModelSupport;
contracts?: PluginManifestContracts; // capability の静的所有スナップショット
activation?: PluginManifestActivation;
setup?: PluginManifestSetup;
providerAuthChoices?: PluginManifestProviderAuthChoice[];
// ... 20 以上のフィールド
};
特に効いているのが contracts(manifest.ts:406)です。
export type PluginManifestContracts = {
embeddingProviders?: string[];
speechProviders?: string[];
imageGenerationProviders?: string[];
webFetchProviders?: string[];
webSearchProviders?: string[];
tools?: string[];
// ... さらに capability カテゴリが続く
};
これは「このプラグインは speechProviders として cloud-tts を持つ」といった能力の静的目録です。コアはこの目録を読むだけで「誰が何を提供できるか」を、プラグインのコードを実行せずに把握できます。
二つの面 — メタデータの面と、実行の面
src/plugins/AGENTS.md が要に据えるのが、control plane と runtime plane の分離です。
Keep control-plane and runtime-plane concerns separate: discovery, manifest parsing, config validation, setup/onboarding hints, and activation planning belong to the control plane; actual plugin execution belongs to runtime resolution.
ローダーの流れ(公開エントリ loadOpenClawPlugins(), src/plugins/loader.ts:1821)はこの2面で構成されます。
-
Discovery scan(control plane) — プラグインのルートを探し、
openclaw.plugin.jsonとpackage.jsonをコードを実行せずに読む。 - Manifest registry(control plane) — スキーマ・環境変数依存・認証メタデータを検証。
- Registry assembly(runtime) — ここで初めてプラグインのモジュールを読み込み、登録フックを呼び、capability レジストリを組み立てる。
「メタデータだけで動く部分」と「実行が必要な部分」を厳密に分けることで、openclaw --help のような軽い操作でプラグイン本体を読み込まずに済む——#02 の fast-path とまさに同じ思想です。
プラグインの出自も型で区別されます(PluginCandidate, src/plugins/discovery.ts:69)。origin(PluginOrigin, src/plugins/plugin-origin.types.ts:2)は 'bundled' | 'global' | 'workspace' | 'config'、format(PluginFormat, src/plugins/manifest-types.ts:12)は 'openclaw' | 'bundle'。バンドル形式(PluginBundleFormat, manifest-types.ts:15:'codex' | 'claude' | 'cursor')も識別され、コア配布物に同梱される内部プラグインと、外部プラグインを正しく扱い分けます。
コアが名前を知らずに済む理由 — 汎用レジストリの妙
src/plugins/registry.ts:407 の createPluginRegistry() が肝です。ポイントは、レジストリが capability 駆動であってプラグイン駆動ではないこと。
export function createPluginRegistry(registryParams: PluginRegistryParams) {
const registry = createEmptyPluginRegistry();
const { registerModelCatalogProvider, registerSpeechProvider, /* ... */ } =
createModelCatalogRegistrationHandlers({ registry, pushDiagnostic });
// コアは "openai" や "anthropic" を誰が所有するか一切知らない。
// 汎用ハンドラを呼ぶだけ:
return api.registerSpeechProvider({ id: "cloud-tts", ...speechProvider });
}
コードに "telegram" や "anthropic" という分岐は現れません。プラグインが汎用の registerSpeechProvider(...) のような capability 登録 API を呼び、コアは「cloud-tts という ID の speech provider が登録された」とだけ知る。だからプラグインを差し替え・無効化・上書きしても、コアのコードは一切変わりません。これが #01 の「No bundled ids/defaults/policy in core」の実装です。
さらに src/plugins/manifest-contract-runtime.ts:16 の resolveManifestContractRuntimePluginResolution() は、capability(例: speechProviders)にマッチするプラグイン ID を、ランタイムを起こさずにスナップショットから解決します。「まずメタデータで解く、実行は最後」の徹底ぶりがうかがえます。
なぜ入口は 300 を超えて細かいのか — 規律としての分割
SDK の入口はとにかく細かく分かれています。実測では scripts/lib/plugin-sdk-entrypoints.json のエントリポイント目録が 340 件、ルート package.json の exports が 325 件、スタンドアロンの packages/plugin-sdk/package.json の exports が 63 件。./plugin-sdk/reply-runtime, ./plugin-sdk/agent-runtime, ./plugin-sdk/ssrf-policy …と #01 で package.json を覗いたときの圧巻のリストです。これには明確な設計理由があります。src/plugin-sdk/AGENTS.md から。
Prefer a small versioned host/kernel seam plus narrow documented SDK entrypoints over broad convenience barrels. Keep public SDK entrypoints cheap at module load. If a helper is only needed on async paths such as send, monitor, probe, directory-live, login, or setup, prefer a narrow
*.runtimesubpath over re-exporting it through a broad SDK barrel that hot channel entrypoints import on startup.
噛み砕くと、「大きな便利バレル」を避け、用途別の細いエントリに割るということ。理由は3つです。
-
遅延ロード境界: チャネルの起動 hot path は
./coreや./channel-runtimeだけを import すれば済み、重いポリシーモジュールを巻き込まない。 - プラグイン非依存の貫徹: config / auth / state など各ランタイムサービスを独立した契約にし、プラグインが必要な能力ひとつだけに依存できる。
-
バンドルプラグインのファサード:
./discordや./lmstudioのような一部サブパスはバンドルプラグインのモジュールへのエイリアスで、外部プラグインからは import できないようビルド時にガードされる(supportedBundledFacadeSdkEntrypoints)。
*.runtime という命名規約(例: time-runtime, text-runtime)が「非同期パスでのみ必要な重い API」を表し、./core が「全チャネルが起動時に eager import する軽い契約」を表す——この粒度設計が、巨大 SDK を「起動が遅くならない」状態に保っています。
越えてはならぬ一線 — 境界の鉄則
src/plugin-sdk/AGENTS.md と src/plugins/AGENTS.md から、プラグイン作者・コア開発者双方が守るべき鉄則を抜き出します。
-
ホストは内部に手を伸ばさせない: 「Host loads plugins; plugins should not reach through the SDK into arbitrary host internals.」
src/channels/**,src/agents/**,src/plugins/**の実装都合を、意図的な公開契約でない限り SDK に晒さない。 -
エントリは module load 時に安いこと: 起動 hot path が重い import を引かないよう、send/monitor/probe/login/setup 用のヘルパは
*.runtimeサブパスへ。 -
同一ランタイム面で static と dynamic import を混ぜない(#12 のビルド回で扱う
[INEFFECTIVE_DYNAMIC_IMPORT]検査に直結)。 -
plugin-owned を core-owned に滲ませない: 「
plugins.entries.<id>.configを無関係なコア経路から直接読む」ことを禁止。汎用ヘルパ・プラグインランタイムフック・マニフェストメタデータを使う。
一枚に畳む — プラグインがコアに届くまでの道のり
openclaw.plugin.json (宣言)
│ discovery scan ── コードを実行せず読む(control plane)
▼
manifest registry ── スキーマ/env/auth を検証(control plane)
│ 必要になって初めて…
▼
registry assembly ── プラグイン本体を読み込み register フック実行(runtime)
│ 汎用ハンドラ registerXxxProvider(id, impl) を呼ぶ
▼
capability registry ── コアは「ID と能力」だけを知る(plugin-agnostic)
まとめ — 無秩序ではなく、規律の産物
- プラグインは
openclaw.plugin.jsonで 能力を宣言し、コアはその目録(contracts)をコード実行前に読む。 - ローダーは control plane(メタデータ)/ runtime plane(実行)を分離し、laziness を守る。
- レジストリは capability 駆動の汎用ハンドラで、コアにプラグイン名の分岐が現れない。
- SDK の細いサブパス群は「起動を軽く保つための遅延ロード境界」であり、無秩序ではなく規律の産物。
次回予告 — 運ぶことに徹するチャネルへ
#05 は、もっとも数の多いプラグイン種別、**チャネル層(transport-only)**を読み解きます。20 以上のメッセージングサービスを「商品ロジックを持たない純粋な配送層」としてどう抽象化しているのか。受信イベントの正規化、ドラフトストリーミング、そして「チャネルにコマンドを推測させない」という設計則を、extensions/telegram を題材に追います。
