{"id":621,"date":"2026-03-21T22:52:57","date_gmt":"2026-03-21T14:52:57","guid":{"rendered":"https:\/\/pa.yingzhi8.cn\/index.php\/2026\/03\/21\/plugins-architecture\/"},"modified":"2026-03-21T23:30:57","modified_gmt":"2026-03-21T15:30:57","slug":"plugins-architecture","status":"publish","type":"post","link":"https:\/\/pa.yingzhi8.cn\/index.php\/2026\/03\/21\/plugins-architecture\/","title":{"rendered":"\u63d2\u4ef6\u5185\u90e8\u673a\u5236"},"content":{"rendered":"<h1>Plugin Internals<\/h1>\n<p>\n  This page is for <strong>plugin developers and contributors<\/strong>. If you just want to<br \/>\n  install and use plugins, see <a href=\"\/tools\/plugin\">Plugins<\/a>. If you want to build<br \/>\n  a plugin, see <a href=\"\/plugins\/building-plugins\">Building Plugins<\/a>.\n<\/p>\n<p>This page covers the internal architecture of the OpenClaw plugin system.<\/p>\n<h2>Public capability model<\/h2>\n<p>Capabilities are the public <strong>native plugin<\/strong> model inside OpenClaw. Every<br \/>\nnative OpenClaw plugin registers against one or more capability types:<\/p>\n<table>\n<thead>\n<tr>\n<th>Capability<\/th>\n<th>Registration method<\/th>\n<th>Example plugins<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>Text inference<\/td>\n<td><code>api.registerProvider(...)<\/code><\/td>\n<td><code>openai<\/code>, <code>anthropic<\/code><\/td>\n<\/tr>\n<tr>\n<td>Speech<\/td>\n<td><code>api.registerSpeechProvider(...)<\/code><\/td>\n<td><code>elevenlabs<\/code>, <code>microsoft<\/code><\/td>\n<\/tr>\n<tr>\n<td>Media understanding<\/td>\n<td><code>api.registerMediaUnderstandingProvider(...)<\/code><\/td>\n<td><code>openai<\/code>, <code>google<\/code><\/td>\n<\/tr>\n<tr>\n<td>Image generation<\/td>\n<td><code>api.registerImageGenerationProvider(...)<\/code><\/td>\n<td><code>openai<\/code>, <code>google<\/code><\/td>\n<\/tr>\n<tr>\n<td>Web search<\/td>\n<td><code>api.registerWebSearchProvider(...)<\/code><\/td>\n<td><code>google<\/code><\/td>\n<\/tr>\n<tr>\n<td>Channel \/ messaging<\/td>\n<td><code>api.registerChannel(...)<\/code><\/td>\n<td><code>msteams<\/code>, <code>matrix<\/code><\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<p>A plugin that registers zero capabilities but provides hooks, tools, or<br \/>\nservices is a <strong>legacy hook-only<\/strong> plugin. That pattern is still fully supported.<\/p>\n<h3>External compatibility stance<\/h3>\n<p>The capability model is landed in core and used by bundled\/native plugins<br \/>\ntoday, but external plugin compatibility still needs a tighter bar than &#8220;it is<br \/>\nexported, therefore it is frozen.&#8221;<\/p>\n<p>Current guidance:<\/p>\n<ul>\n<li><strong>existing external plugins:<\/strong> keep hook-based integrations working; treat<br \/>\n  this as the compatibility baseline<\/li>\n<li><strong>new bundled\/native plugins:<\/strong> prefer explicit capability registration over<br \/>\n  vendor-specific reach-ins or new hook-only designs<\/li>\n<li><strong>external plugins adopting capability registration:<\/strong> allowed, but treat the<br \/>\n  capability-specific helper surfaces as evolving unless docs explicitly mark a<br \/>\n  contract as stable<\/li>\n<\/ul>\n<p>Practical rule:<\/p>\n<ul>\n<li>capability registration APIs are the intended direction<\/li>\n<li>legacy hooks remain the safest no-breakage path for external plugins during<br \/>\n  the transition<\/li>\n<li>exported helper subpaths are not all equal; prefer the narrow documented<br \/>\n  contract, not incidental helper exports<\/li>\n<\/ul>\n<h3>Plugin shapes<\/h3>\n<p>OpenClaw classifies every loaded plugin into a shape based on its actual<br \/>\nregistration behavior (not just static metadata):<\/p>\n<ul>\n<li><strong>plain-capability<\/strong> &#8212; registers exactly one capability type (for example a<br \/>\n  provider-only plugin like <code>mistral<\/code>)<\/li>\n<li><strong>hybrid-capability<\/strong> &#8212; registers multiple capability types (for example<br \/>\n  <code>openai<\/code> owns text inference, speech, media understanding, and image<br \/>\n  generation)<\/li>\n<li><strong>hook-only<\/strong> &#8212; registers only hooks (typed or custom), no capabilities,<br \/>\n  tools, commands, or services<\/li>\n<li><strong>non-capability<\/strong> &#8212; registers tools, commands, services, or routes but no<br \/>\n  capabilities<\/li>\n<\/ul>\n<p>Use <code>openclaw plugins inspect &lt;id&gt;<\/code> to see a plugin&#8217;s shape and capability<br \/>\nbreakdown. See <a href=\"\/cli\/plugins#inspect\">CLI reference<\/a> for details.<\/p>\n<h3>Legacy hooks<\/h3>\n<p>The <code>before_agent_start<\/code> hook remains supported as a compatibility path for<br \/>\nhook-only plugins. Legacy real-world plugins still depend on it.<\/p>\n<p>Direction:<\/p>\n<ul>\n<li>keep it working<\/li>\n<li>document it as legacy<\/li>\n<li>prefer <code>before_model_resolve<\/code> for model\/provider override work<\/li>\n<li>prefer <code>before_prompt_build<\/code> for prompt mutation work<\/li>\n<li>remove only after real usage drops and fixture coverage proves migration safety<\/li>\n<\/ul>\n<h3>Compatibility signals<\/h3>\n<p>When you run <code>openclaw doctor<\/code> or <code>openclaw plugins inspect &lt;id&gt;<\/code>, you may see<br \/>\none of these labels:<\/p>\n<table>\n<thead>\n<tr>\n<th>Signal<\/th>\n<th>Meaning<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td><strong>config valid<\/strong><\/td>\n<td>Config parses fine and plugins resolve<\/td>\n<\/tr>\n<tr>\n<td><strong>compatibility advisory<\/strong><\/td>\n<td>Plugin uses a supported-but-older pattern (e.g. <code>hook-only<\/code>)<\/td>\n<\/tr>\n<tr>\n<td><strong>legacy warning<\/strong><\/td>\n<td>Plugin uses <code>before_agent_start<\/code>, which is deprecated<\/td>\n<\/tr>\n<tr>\n<td><strong>hard error<\/strong><\/td>\n<td>Config is invalid or plugin failed to load<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<p>Neither <code>hook-only<\/code> nor <code>before_agent_start<\/code> will break your plugin today &#8212;<br \/>\n<code>hook-only<\/code> is advisory, and <code>before_agent_start<\/code> only triggers a warning. These<br \/>\nsignals also appear in <code>openclaw status --all<\/code> and <code>openclaw plugins doctor<\/code>.<\/p>\n<h2>Architecture overview<\/h2>\n<p>OpenClaw&#8217;s plugin system has four layers:<\/p>\n<ol>\n<li><strong>Manifest + discovery<\/strong><br \/>\n   OpenClaw finds candidate plugins from configured paths, workspace roots,<br \/>\n   global extension roots, and bundled extensions. Discovery reads native<br \/>\n   <code>openclaw.plugin.json<\/code> manifests plus supported bundle manifests first.<\/li>\n<li><strong>Enablement + validation<\/strong><br \/>\n   Core decides whether a discovered plugin is enabled, disabled, blocked, or<br \/>\n   selected for an exclusive slot such as memory.<\/li>\n<li><strong>Runtime loading<\/strong><br \/>\n   Native OpenClaw plugins are loaded in-process via jiti and register<br \/>\n   capabilities into a central registry. Compatible bundles are normalized into<br \/>\n   registry records without importing runtime code.<\/li>\n<li><strong>Surface consumption<\/strong><br \/>\n   The rest of OpenClaw reads the registry to expose tools, channels, provider<br \/>\n   setup, hooks, HTTP routes, CLI commands, and services.<\/li>\n<\/ol>\n<p>The important design boundary:<\/p>\n<ul>\n<li>discovery + config validation should work from <strong>manifest\/schema metadata<\/strong><br \/>\n  without executing plugin code<\/li>\n<li>native runtime behavior comes from the plugin module&#8217;s <code>register(api)<\/code> path<\/li>\n<\/ul>\n<p>That split lets OpenClaw validate config, explain missing\/disabled plugins, and<br \/>\nbuild UI\/schema hints before the full runtime is active.<\/p>\n<h3>Channel plugins and the shared message tool<\/h3>\n<p>Channel plugins do not need to register a separate send\/edit\/react tool for<br \/>\nnormal chat actions. OpenClaw keeps one shared <code>message<\/code> tool in core, and<br \/>\nchannel plugins own the channel-specific discovery and execution behind it.<\/p>\n<p>The current boundary is:<\/p>\n<ul>\n<li>core owns the shared <code>message<\/code> tool host, prompt wiring, session\/thread<br \/>\n  bookkeeping, and execution dispatch<\/li>\n<li>channel plugins own scoped action discovery, capability discovery, and any<br \/>\n  channel-specific schema fragments<\/li>\n<li>channel plugins execute the final action through their action adapter<\/li>\n<\/ul>\n<p>For channel plugins, the SDK surface is<br \/>\n<code>ChannelMessageActionAdapter.describeMessageTool(...)<\/code>. That unified discovery<br \/>\ncall lets a plugin return its visible actions, capabilities, and schema<br \/>\ncontributions together so those pieces do not drift apart.<\/p>\n<p>Core passes runtime scope into that discovery step. Important fields include:<\/p>\n<ul>\n<li><code>accountId<\/code><\/li>\n<li><code>currentChannelId<\/code><\/li>\n<li><code>currentThreadTs<\/code><\/li>\n<li><code>currentMessageId<\/code><\/li>\n<li><code>sessionKey<\/code><\/li>\n<li><code>sessionId<\/code><\/li>\n<li><code>agentId<\/code><\/li>\n<li>trusted inbound <code>requesterSenderId<\/code><\/li>\n<\/ul>\n<p>That matters for context-sensitive plugins. A channel can hide or expose<br \/>\nmessage actions based on the active account, current room\/thread\/message, or<br \/>\ntrusted requester identity without hardcoding channel-specific branches in the<br \/>\ncore <code>message<\/code> tool.<\/p>\n<p>This is why embedded-runner routing changes are still plugin work: the runner is<br \/>\nresponsible for forwarding the current chat\/session identity into the plugin<br \/>\ndiscovery boundary so the shared <code>message<\/code> tool exposes the right channel-owned<br \/>\nsurface for the current turn.<\/p>\n<p>For channel-owned execution helpers, bundled plugins should keep the execution<br \/>\nruntime inside their own extension modules. Core no longer owns the Discord,<br \/>\nSlack, Telegram, or WhatsApp message-action runtimes under <code>src\/agents\/tools<\/code>.<br \/>\nWe do not publish separate <code>plugin-sdk\/*-action-runtime<\/code> subpaths, and bundled<br \/>\nplugins should import their own local runtime code directly from their<br \/>\nextension-owned modules.<\/p>\n<p>For polls specifically, there are two execution paths:<\/p>\n<ul>\n<li><code>outbound.sendPoll<\/code> is the shared baseline for channels that fit the common<br \/>\n  poll model<\/li>\n<li><code>actions.handleAction(\"poll\")<\/code> is the preferred path for channel-specific<br \/>\n  poll semantics or extra poll parameters<\/li>\n<\/ul>\n<p>Core now defers shared poll parsing until after plugin poll dispatch declines<br \/>\nthe action, so plugin-owned poll handlers can accept channel-specific poll<br \/>\nfields without being blocked by the generic poll parser first.<\/p>\n<p>See <a href=\"#load-pipeline\">Load pipeline<\/a> for the full startup sequence.<\/p>\n<h2>Capability ownership model<\/h2>\n<p>OpenClaw treats a native plugin as the ownership boundary for a <strong>company<\/strong> or a<br \/>\n<strong>feature<\/strong>, not as a grab bag of unrelated integrations.<\/p>\n<p>That means:<\/p>\n<ul>\n<li>a company plugin should usually own all of that company&#8217;s OpenClaw-facing<br \/>\n  surfaces<\/li>\n<li>a feature plugin should usually own the full feature surface it introduces<\/li>\n<li>channels should consume shared core capabilities instead of re-implementing<br \/>\n  provider behavior ad hoc<\/li>\n<\/ul>\n<p>Examples:<\/p>\n<ul>\n<li>the bundled <code>openai<\/code> plugin owns OpenAI model-provider behavior and OpenAI<br \/>\n  speech + media-understanding + image-generation behavior<\/li>\n<li>the bundled <code>elevenlabs<\/code> plugin owns ElevenLabs speech behavior<\/li>\n<li>the bundled <code>microsoft<\/code> plugin owns Microsoft speech behavior<\/li>\n<li>the bundled <code>google<\/code> plugin owns Google model-provider behavior plus Google<br \/>\n  media-understanding + image-generation + web-search behavior<\/li>\n<li>the bundled <code>minimax<\/code>, <code>mistral<\/code>, <code>moonshot<\/code>, and <code>zai<\/code> plugins own their<br \/>\n  media-understanding backends<\/li>\n<li>the <code>voice-call<\/code> plugin is a feature plugin: it owns call transport, tools,<br \/>\n  CLI, routes, and runtime, but it consumes core TTS\/STT capability instead of<br \/>\n  inventing a second speech stack<\/li>\n<\/ul>\n<p>The intended end state is:<\/p>\n<ul>\n<li>OpenAI lives in one plugin even if it spans text models, speech, images, and<br \/>\n  future video<\/li>\n<li>another vendor can do the same for its own surface area<\/li>\n<li>channels do not care which vendor plugin owns the provider; they consume the<br \/>\n  shared capability contract exposed by core<\/li>\n<\/ul>\n<p>This is the key distinction:<\/p>\n<ul>\n<li><strong>plugin<\/strong> = ownership boundary<\/li>\n<li><strong>capability<\/strong> = core contract that multiple plugins can implement or consume<\/li>\n<\/ul>\n<p>So if OpenClaw adds a new domain such as video, the first question is not<br \/>\n&#8220;which provider should hardcode video handling?&#8221; The first question is &#8220;what is<br \/>\nthe core video capability contract?&#8221; Once that contract exists, vendor plugins<br \/>\ncan register against it and channel\/feature plugins can consume it.<\/p>\n<p>If the capability does not exist yet, the right move is usually:<\/p>\n<ol>\n<li>define the missing capability in core<\/li>\n<li>expose it through the plugin API\/runtime in a typed way<\/li>\n<li>wire channels\/features against that capability<\/li>\n<li>let vendor plugins register implementations<\/li>\n<\/ol>\n<p>This keeps ownership explicit while avoiding core behavior that depends on a<br \/>\nsingle vendor or a one-off plugin-specific code path.<\/p>\n<h3>Capability layering<\/h3>\n<p>Use this mental model when deciding where code belongs:<\/p>\n<ul>\n<li><strong>core capability layer<\/strong>: shared orchestration, policy, fallback, config<br \/>\n  merge rules, delivery semantics, and typed contracts<\/li>\n<li><strong>vendor plugin layer<\/strong>: vendor-specific APIs, auth, model catalogs, speech<br \/>\n  synthesis, image generation, future video backends, usage endpoints<\/li>\n<li><strong>channel\/feature plugin layer<\/strong>: Slack\/Discord\/voice-call\/etc. integration<br \/>\n  that consumes core capabilities and presents them on a surface<\/li>\n<\/ul>\n<p>For example, TTS follows this shape:<\/p>\n<ul>\n<li>core owns reply-time TTS policy, fallback order, prefs, and channel delivery<\/li>\n<li><code>openai<\/code>, <code>elevenlabs<\/code>, and <code>microsoft<\/code> own synthesis implementations<\/li>\n<li><code>voice-call<\/code> consumes the telephony TTS runtime helper<\/li>\n<\/ul>\n<p>That same pattern should be preferred for future capabilities.<\/p>\n<h3>Multi-capability company plugin example<\/h3>\n<p>A company plugin should feel cohesive from the outside. If OpenClaw has shared<br \/>\ncontracts for models, speech, media understanding, and web search, a vendor can<br \/>\nown all of its surfaces in one place:<\/p>\n<p>&#8220;`ts  theme={&#8220;theme&#8221;:{&#8220;light&#8221;:&#8221;min-light&#8221;,&#8221;dark&#8221;:&#8221;min-dark&#8221;}}<br \/>\nimport type { OpenClawPluginDefinition } from &#8220;openclaw\/plugin-sdk&#8221;;<br \/>\nimport {<br \/>\n  buildOpenAISpeechProvider,<br \/>\n  createPluginBackedWebSearchProvider,<br \/>\n  describeImageWithModel,<br \/>\n  transcribeOpenAiCompatibleAudio,<br \/>\n} from &#8220;openclaw\/plugin-sdk&#8221;;<\/p>\n<p>const plugin: OpenClawPluginDefinition = {<br \/>\n  id: &#8220;exampleai&#8221;,<br \/>\n  name: &#8220;ExampleAI&#8221;,<br \/>\n  register(api) {<br \/>\n    api.registerProvider({<br \/>\n      id: &#8220;exampleai&#8221;,<br \/>\n      \/\/ auth\/model catalog\/runtime hooks<br \/>\n    });<\/p>\n<pre><code>api.registerSpeechProvider(\n  buildOpenAISpeechProvider({\n    id: \"exampleai\",\n    \/\/ vendor speech config\n  }),\n);\n\napi.registerMediaUnderstandingProvider({\n  id: \"exampleai\",\n  capabilities: [\"image\", \"audio\", \"video\"],\n  async describeImage(req) {\n    return describeImageWithModel({\n      provider: \"exampleai\",\n      model: req.model,\n      input: req.input,\n    });\n  },\n  async transcribeAudio(req) {\n    return transcribeOpenAiCompatibleAudio({\n      provider: \"exampleai\",\n      model: req.model,\n      input: req.input,\n    });\n  },\n});\n\napi.registerWebSearchProvider(\n  createPluginBackedWebSearchProvider({\n    id: \"exampleai-search\",\n    \/\/ credential + fetch logic\n  }),\n);\n<\/code><\/pre>\n<p>},<br \/>\n};<\/p>\n<p>export default plugin;<\/p>\n<pre><code>\nWhat matters is not the exact helper names. The shape matters:\n\n* one plugin owns the vendor surface\n* core still owns the capability contracts\n* channels and feature plugins consume `api.runtime.*` helpers, not vendor code\n* contract tests can assert that the plugin registered the capabilities it\n  claims to own\n\n### Capability example: video understanding\n\nOpenClaw already treats image\/audio\/video understanding as one shared\ncapability. The same ownership model applies there:\n\n1. core defines the media-understanding contract\n2. vendor plugins register `describeImage`, `transcribeAudio`, and\n   `describeVideo` as applicable\n3. channels and feature plugins consume the shared core behavior instead of\n   wiring directly to vendor code\n\nThat avoids baking one provider's video assumptions into core. The plugin owns\nthe vendor surface; core owns the capability contract and fallback behavior.\n\nIf OpenClaw adds a new domain later, such as video generation, use the same\nsequence again: define the core capability first, then let vendor plugins\nregister implementations against it.\n\nNeed a concrete rollout checklist? See\n[Capability Cookbook](\/tools\/capability-cookbook).\n\n## Contracts and enforcement\n\nThe plugin API surface is intentionally typed and centralized in\n`OpenClawPluginApi`. That contract defines the supported registration points and\nthe runtime helpers a plugin may rely on.\n\nWhy this matters:\n\n* plugin authors get one stable internal standard\n* core can reject duplicate ownership such as two plugins registering the same\n  provider id\n* startup can surface actionable diagnostics for malformed registration\n* contract tests can enforce bundled-plugin ownership and prevent silent drift\n\nThere are two layers of enforcement:\n\n1. **runtime registration enforcement**\n   The plugin registry validates registrations as plugins load. Examples:\n   duplicate provider ids, duplicate speech provider ids, and malformed\n   registrations produce plugin diagnostics instead of undefined behavior.\n2. **contract tests**\n   Bundled plugins are captured in contract registries during test runs so\n   OpenClaw can assert ownership explicitly. Today this is used for model\n   providers, speech providers, web search providers, and bundled registration\n   ownership.\n\nThe practical effect is that OpenClaw knows, up front, which plugin owns which\nsurface. That lets core and channels compose seamlessly because ownership is\ndeclared, typed, and testable rather than implicit.\n\n### What belongs in a contract\n\nGood plugin contracts are:\n\n* typed\n* small\n* capability-specific\n* owned by core\n* reusable by multiple plugins\n* consumable by channels\/features without vendor knowledge\n\nBad plugin contracts are:\n\n* vendor-specific policy hidden in core\n* one-off plugin escape hatches that bypass the registry\n* channel code reaching straight into a vendor implementation\n* ad hoc runtime objects that are not part of `OpenClawPluginApi` or\n  `api.runtime`\n\nWhen in doubt, raise the abstraction level: define the capability first, then\nlet plugins plug into it.\n\n## Execution model\n\nNative OpenClaw plugins run **in-process** with the Gateway. They are not\nsandboxed. A loaded native plugin has the same process-level trust boundary as\ncore code.\n\nImplications:\n\n* a native plugin can register tools, network handlers, hooks, and services\n* a native plugin bug can crash or destabilize the gateway\n* a malicious native plugin is equivalent to arbitrary code execution inside\n  the OpenClaw process\n\nCompatible bundles are safer by default because OpenClaw currently treats them\nas metadata\/content packs. In current releases, that mostly means bundled\nskills.\n\nUse allowlists and explicit install\/load paths for non-bundled plugins. Treat\nworkspace plugins as development-time code, not production defaults.\n\nImportant trust note:\n\n* `plugins.allow` trusts **plugin ids**, not source provenance.\n* A workspace plugin with the same id as a bundled plugin intentionally shadows\n  the bundled copy when that workspace plugin is enabled\/allowlisted.\n* This is normal and useful for local development, patch testing, and hotfixes.\n\n## Export boundary\n\nOpenClaw exports capabilities, not implementation convenience.\n\nKeep capability registration public. Trim non-contract helper exports:\n\n* bundled-plugin-specific helper subpaths\n* runtime plumbing subpaths not intended as public API\n* vendor-specific convenience helpers\n* setup\/onboarding helpers that are implementation details\n\n## Load pipeline\n\nAt startup, OpenClaw does roughly this:\n\n1. discover candidate plugin roots\n2. read native or compatible bundle manifests and package metadata\n3. reject unsafe candidates\n4. normalize plugin config (`plugins.enabled`, `allow`, `deny`, `entries`,\n   `slots`, `load.paths`)\n5. decide enablement for each candidate\n6. load enabled native modules via jiti\n7. call native `register(api)` hooks and collect registrations into the plugin registry\n8. expose the registry to commands\/runtime surfaces\n\nThe safety gates happen **before** runtime execution. Candidates are blocked\nwhen the entry escapes the plugin root, the path is world-writable, or path\nownership looks suspicious for non-bundled plugins.\n\n### Manifest-first behavior\n\nThe manifest is the control-plane source of truth. OpenClaw uses it to:\n\n* identify the plugin\n* discover declared channels\/skills\/config schema or bundle capabilities\n* validate `plugins.entries.&lt;id&gt;.config`\n* augment Control UI labels\/placeholders\n* show install\/catalog metadata\n\nFor native plugins, the runtime module is the data-plane part. It registers\nactual behavior such as hooks, tools, commands, or provider flows.\n\n### What the loader caches\n\nOpenClaw keeps short in-process caches for:\n\n* discovery results\n* manifest registry data\n* loaded plugin registries\n\nThese caches reduce bursty startup and repeated command overhead. They are safe\nto think of as short-lived performance caches, not persistence.\n\nPerformance note:\n\n* Set `OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE=1` or\n  `OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE=1` to disable these caches.\n* Tune cache windows with `OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS` and\n  `OPENCLAW_PLUGIN_MANIFEST_CACHE_MS`.\n\n## Registry model\n\nLoaded plugins do not directly mutate random core globals. They register into a\ncentral plugin registry.\n\nThe registry tracks:\n\n* plugin records (identity, source, origin, status, diagnostics)\n* tools\n* legacy hooks and typed hooks\n* channels\n* providers\n* gateway RPC handlers\n* HTTP routes\n* CLI registrars\n* background services\n* plugin-owned commands\n\nCore features then read from that registry instead of talking to plugin modules\ndirectly. This keeps loading one-way:\n\n* plugin module -&gt; registry registration\n* core runtime -&gt; registry consumption\n\nThat separation matters for maintainability. It means most core surfaces only\nneed one integration point: &quot;read the registry&quot;, not &quot;special-case every plugin\nmodule&quot;.\n\n## Conversation binding callbacks\n\nPlugins that bind a conversation can react when an approval is resolved.\n\nUse `api.onConversationBindingResolved(...)` to receive a callback after a bind\nrequest is approved or denied:\n\n```ts  theme={&quot;theme&quot;:{&quot;light&quot;:&quot;min-light&quot;,&quot;dark&quot;:&quot;min-dark&quot;}}\nexport default {\n  id: &quot;my-plugin&quot;,\n  register(api) {\n    api.onConversationBindingResolved(async (event) =&gt; {\n      if (event.status === &quot;approved&quot;) {\n        \/\/ A binding now exists for this plugin + conversation.\n        console.log(event.binding?.conversationId);\n        return;\n      }\n\n      \/\/ The request was denied; clear any local pending state.\n      console.log(event.request.conversation.conversationId);\n    });\n  },\n};\n<\/code><\/pre>\n<p>Callback payload fields:<\/p>\n<ul>\n<li><code>status<\/code>: <code>\"approved\"<\/code> or <code>\"denied\"<\/code><\/li>\n<li><code>decision<\/code>: <code>\"allow-once\"<\/code>, <code>\"allow-always\"<\/code>, or <code>\"deny\"<\/code><\/li>\n<li><code>binding<\/code>: the resolved binding for approved requests<\/li>\n<li><code>request<\/code>: the original request summary, detach hint, sender id, and<br \/>\n  conversation metadata<\/li>\n<\/ul>\n<p>This callback is notification-only. It does not change who is allowed to bind a<br \/>\nconversation, and it runs after core approval handling finishes.<\/p>\n<h2>Provider runtime hooks<\/h2>\n<p>Provider plugins now have two layers:<\/p>\n<ul>\n<li>manifest metadata: <code>providerAuthEnvVars<\/code> for cheap env-auth lookup before<br \/>\n  runtime load, plus <code>providerAuthChoices<\/code> for cheap onboarding\/auth-choice<br \/>\n  labels and CLI flag metadata before runtime load<\/li>\n<li>config-time hooks: <code>catalog<\/code> \/ legacy <code>discovery<\/code><\/li>\n<li>runtime hooks: <code>resolveDynamicModel<\/code>, <code>prepareDynamicModel<\/code>, <code>normalizeResolvedModel<\/code>, <code>capabilities<\/code>, <code>prepareExtraParams<\/code>, <code>wrapStreamFn<\/code>, <code>formatApiKey<\/code>, <code>refreshOAuth<\/code>, <code>buildAuthDoctorHint<\/code>, <code>isCacheTtlEligible<\/code>, <code>buildMissingAuthMessage<\/code>, <code>suppressBuiltInModel<\/code>, <code>augmentModelCatalog<\/code>, <code>isBinaryThinking<\/code>, <code>supportsXHighThinking<\/code>, <code>resolveDefaultThinkingLevel<\/code>, <code>isModernModelRef<\/code>, <code>prepareRuntimeAuth<\/code>, <code>resolveUsageAuth<\/code>, <code>fetchUsageSnapshot<\/code><\/li>\n<\/ul>\n<p>OpenClaw still owns the generic agent loop, failover, transcript handling, and<br \/>\ntool policy. These hooks are the extension surface for provider-specific behavior without<br \/>\nneeding a whole custom inference transport.<\/p>\n<p>Use manifest <code>providerAuthEnvVars<\/code> when the provider has env-based credentials<br \/>\nthat generic auth\/status\/model-picker paths should see without loading plugin<br \/>\nruntime. Use manifest <code>providerAuthChoices<\/code> when onboarding\/auth-choice CLI<br \/>\nsurfaces should know the provider&#8217;s choice id, group labels, and simple<br \/>\none-flag auth wiring without loading provider runtime. Keep provider runtime<br \/>\n<code>envVars<\/code> for operator-facing hints such as onboarding labels or OAuth<br \/>\nclient-id\/client-secret setup vars.<\/p>\n<h3>Hook order and usage<\/h3>\n<p>For model\/provider plugins, OpenClaw calls hooks in this rough order.<br \/>\nThe &#8220;When to use&#8221; column is the quick decision guide.<\/p>\n<table>\n<thead>\n<tr>\n<th>#<\/th>\n<th>Hook<\/th>\n<th>What it does<\/th>\n<th>When to use<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>1<\/td>\n<td><code>catalog<\/code><\/td>\n<td>Publish provider config into <code>models.providers<\/code> during <code>models.json<\/code> generation<\/td>\n<td>Provider owns a catalog or base URL defaults<\/td>\n<\/tr>\n<tr>\n<td>&#8212;<\/td>\n<td><em>(built-in model lookup)<\/em><\/td>\n<td>OpenClaw tries the normal registry\/catalog path first<\/td>\n<td><em>(not a plugin hook)<\/em><\/td>\n<\/tr>\n<tr>\n<td>2<\/td>\n<td><code>resolveDynamicModel<\/code><\/td>\n<td>Sync fallback for provider-owned model ids not in the local registry yet<\/td>\n<td>Provider accepts arbitrary upstream model ids<\/td>\n<\/tr>\n<tr>\n<td>3<\/td>\n<td><code>prepareDynamicModel<\/code><\/td>\n<td>Async warm-up, then <code>resolveDynamicModel<\/code> runs again<\/td>\n<td>Provider needs network metadata before resolving unknown ids<\/td>\n<\/tr>\n<tr>\n<td>4<\/td>\n<td><code>normalizeResolvedModel<\/code><\/td>\n<td>Final rewrite before the embedded runner uses the resolved model<\/td>\n<td>Provider needs transport rewrites but still uses a core transport<\/td>\n<\/tr>\n<tr>\n<td>5<\/td>\n<td><code>capabilities<\/code><\/td>\n<td>Provider-owned transcript\/tooling metadata used by shared core logic<\/td>\n<td>Provider needs transcript\/provider-family quirks<\/td>\n<\/tr>\n<tr>\n<td>6<\/td>\n<td><code>prepareExtraParams<\/code><\/td>\n<td>Request-param normalization before generic stream option wrappers<\/td>\n<td>Provider needs default request params or per-provider param cleanup<\/td>\n<\/tr>\n<tr>\n<td>7<\/td>\n<td><code>wrapStreamFn<\/code><\/td>\n<td>Stream wrapper after generic wrappers are applied<\/td>\n<td>Provider needs request headers\/body\/model compat wrappers without a custom transport<\/td>\n<\/tr>\n<tr>\n<td>8<\/td>\n<td><code>formatApiKey<\/code><\/td>\n<td>Auth-profile formatter: stored profile becomes the runtime <code>apiKey<\/code> string<\/td>\n<td>Provider stores extra auth metadata and needs a custom runtime token shape<\/td>\n<\/tr>\n<tr>\n<td>9<\/td>\n<td><code>refreshOAuth<\/code><\/td>\n<td>OAuth refresh override for custom refresh endpoints or refresh-failure policy<\/td>\n<td>Provider does not fit the shared <code>pi-ai<\/code> refreshers<\/td>\n<\/tr>\n<tr>\n<td>10<\/td>\n<td><code>buildAuthDoctorHint<\/code><\/td>\n<td>Repair hint appended when OAuth refresh fails<\/td>\n<td>Provider needs provider-owned auth repair guidance after refresh failure<\/td>\n<\/tr>\n<tr>\n<td>11<\/td>\n<td><code>isCacheTtlEligible<\/code><\/td>\n<td>Prompt-cache policy for proxy\/backhaul providers<\/td>\n<td>Provider needs proxy-specific cache TTL gating<\/td>\n<\/tr>\n<tr>\n<td>12<\/td>\n<td><code>buildMissingAuthMessage<\/code><\/td>\n<td>Replacement for the generic missing-auth recovery message<\/td>\n<td>Provider needs a provider-specific missing-auth recovery hint<\/td>\n<\/tr>\n<tr>\n<td>13<\/td>\n<td><code>suppressBuiltInModel<\/code><\/td>\n<td>Stale upstream model suppression plus optional user-facing error hint<\/td>\n<td>Provider needs to hide stale upstream rows or replace them with a vendor hint<\/td>\n<\/tr>\n<tr>\n<td>14<\/td>\n<td><code>augmentModelCatalog<\/code><\/td>\n<td>Synthetic\/final catalog rows appended after discovery<\/td>\n<td>Provider needs synthetic forward-compat rows in <code>models list<\/code> and pickers<\/td>\n<\/tr>\n<tr>\n<td>15<\/td>\n<td><code>isBinaryThinking<\/code><\/td>\n<td>On\/off reasoning toggle for binary-thinking providers<\/td>\n<td>Provider exposes only binary thinking on\/off<\/td>\n<\/tr>\n<tr>\n<td>16<\/td>\n<td><code>supportsXHighThinking<\/code><\/td>\n<td><code>xhigh<\/code> reasoning support for selected models<\/td>\n<td>Provider wants <code>xhigh<\/code> on only a subset of models<\/td>\n<\/tr>\n<tr>\n<td>17<\/td>\n<td><code>resolveDefaultThinkingLevel<\/code><\/td>\n<td>Default <code>\/think<\/code> level for a specific model family<\/td>\n<td>Provider owns default <code>\/think<\/code> policy for a model family<\/td>\n<\/tr>\n<tr>\n<td>18<\/td>\n<td><code>isModernModelRef<\/code><\/td>\n<td>Modern-model matcher for live profile filters and smoke selection<\/td>\n<td>Provider owns live\/smoke preferred-model matching<\/td>\n<\/tr>\n<tr>\n<td>19<\/td>\n<td><code>prepareRuntimeAuth<\/code><\/td>\n<td>Exchange a configured credential into the actual runtime token\/key just before inference<\/td>\n<td>Provider needs a token exchange or short-lived request credential<\/td>\n<\/tr>\n<tr>\n<td>20<\/td>\n<td><code>resolveUsageAuth<\/code><\/td>\n<td>Resolve usage\/billing credentials for <code>\/usage<\/code> and related status surfaces<\/td>\n<td>Provider needs custom usage\/quota token parsing or a different usage credential<\/td>\n<\/tr>\n<tr>\n<td>21<\/td>\n<td><code>fetchUsageSnapshot<\/code><\/td>\n<td>Fetch and normalize provider-specific usage\/quota snapshots after auth is resolved<\/td>\n<td>Provider needs a provider-specific usage endpoint or payload parser<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<p>If the provider needs a fully custom wire protocol or custom request executor,<br \/>\nthat is a different class of extension. These hooks are for provider behavior<br \/>\nthat still runs on OpenClaw&#8217;s normal inference loop.<\/p>\n<h3>Provider example<\/h3>\n<p>&#8220;`ts  theme={&#8220;theme&#8221;:{&#8220;light&#8221;:&#8221;min-light&#8221;,&#8221;dark&#8221;:&#8221;min-dark&#8221;}}<br \/>\napi.registerProvider({<br \/>\n  id: &#8220;example-proxy&#8221;,<br \/>\n  label: &#8220;Example Proxy&#8221;,<br \/>\n  auth: [],<br \/>\n  catalog: {<br \/>\n    order: &#8220;simple&#8221;,<br \/>\n    run: async (ctx) =&gt; {<br \/>\n      const apiKey = ctx.resolveProviderApiKey(&#8220;example-proxy&#8221;).apiKey;<br \/>\n      if (!apiKey) {<br \/>\n        return null;<br \/>\n      }<br \/>\n      return {<br \/>\n        provider: {<br \/>\n          baseUrl: &#8220;https:\/\/proxy.example.com\/v1&#8221;,<br \/>\n          apiKey,<br \/>\n          api: &#8220;openai-completions&#8221;,<br \/>\n          models: [{ id: &#8220;auto&#8221;, name: &#8220;Auto&#8221; }],<br \/>\n        },<br \/>\n      };<br \/>\n    },<br \/>\n  },<br \/>\n  resolveDynamicModel: (ctx) =&gt; ({<br \/>\n    id: ctx.modelId,<br \/>\n    name: ctx.modelId,<br \/>\n    provider: &#8220;example-proxy&#8221;,<br \/>\n    api: &#8220;openai-completions&#8221;,<br \/>\n    baseUrl: &#8220;https:\/\/proxy.example.com\/v1&#8221;,<br \/>\n    reasoning: false,<br \/>\n    input: [&#8220;text&#8221;],<br \/>\n    cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },<br \/>\n    contextWindow: 128000,<br \/>\n    maxTokens: 8192,<br \/>\n  }),<br \/>\n  prepareRuntimeAuth: async (ctx) =&gt; {<br \/>\n    const exchanged = await exchangeToken(ctx.apiKey);<br \/>\n    return {<br \/>\n      apiKey: exchanged.token,<br \/>\n      baseUrl: exchanged.baseUrl,<br \/>\n      expiresAt: exchanged.expiresAt,<br \/>\n    };<br \/>\n  },<br \/>\n  resolveUsageAuth: async (ctx) =&gt; {<br \/>\n    const auth = await ctx.resolveOAuthToken();<br \/>\n    return auth ? { token: auth.token } : null;<br \/>\n  },<br \/>\n  fetchUsageSnapshot: async (ctx) =&gt; {<br \/>\n    return await fetchExampleProxyUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn);<br \/>\n  },<br \/>\n});<\/p>\n<pre><code>\n### Built-in examples\n\n* Anthropic uses `resolveDynamicModel`, `capabilities`, `buildAuthDoctorHint`,\n  `resolveUsageAuth`, `fetchUsageSnapshot`, `isCacheTtlEligible`,\n  `resolveDefaultThinkingLevel`, and `isModernModelRef` because it owns Claude\n  4.6 forward-compat, provider-family hints, auth repair guidance, usage\n  endpoint integration, prompt-cache eligibility, and Claude default\/adaptive\n  thinking policy.\n* OpenAI uses `resolveDynamicModel`, `normalizeResolvedModel`, and\n  `capabilities` plus `buildMissingAuthMessage`, `suppressBuiltInModel`,\n  `augmentModelCatalog`, `supportsXHighThinking`, and `isModernModelRef`\n  because it owns GPT-5.4 forward-compat, the direct OpenAI\n  `openai-completions` -&gt; `openai-responses` normalization, Codex-aware auth\n  hints, Spark suppression, synthetic OpenAI list rows, and GPT-5 thinking \/\n  live-model policy.\n* OpenRouter uses `catalog` plus `resolveDynamicModel` and\n  `prepareDynamicModel` because the provider is pass-through and may expose new\n  model ids before OpenClaw's static catalog updates; it also uses\n  `capabilities`, `wrapStreamFn`, and `isCacheTtlEligible` to keep\n  provider-specific request headers, routing metadata, reasoning patches, and\n  prompt-cache policy out of core.\n* GitHub Copilot uses `catalog`, `auth`, `resolveDynamicModel`, and\n  `capabilities` plus `prepareRuntimeAuth` and `fetchUsageSnapshot` because it\n  needs provider-owned device login, model fallback behavior, Claude transcript\n  quirks, a GitHub token -&gt; Copilot token exchange, and a provider-owned usage\n  endpoint.\n* OpenAI Codex uses `catalog`, `resolveDynamicModel`,\n  `normalizeResolvedModel`, `refreshOAuth`, and `augmentModelCatalog` plus\n  `prepareExtraParams`, `resolveUsageAuth`, and `fetchUsageSnapshot` because it\n  still runs on core OpenAI transports but owns its transport\/base URL\n  normalization, OAuth refresh fallback policy, default transport choice,\n  synthetic Codex catalog rows, and ChatGPT usage endpoint integration.\n* Google AI Studio and Gemini CLI OAuth use `resolveDynamicModel` and\n  `isModernModelRef` because they own Gemini 3.1 forward-compat fallback and\n  modern-model matching; Gemini CLI OAuth also uses `formatApiKey`,\n  `resolveUsageAuth`, and `fetchUsageSnapshot` for token formatting, token\n  parsing, and quota endpoint wiring.\n* Moonshot uses `catalog` plus `wrapStreamFn` because it still uses the shared\n  OpenAI transport but needs provider-owned thinking payload normalization.\n* Kilocode uses `catalog`, `capabilities`, `wrapStreamFn`, and\n  `isCacheTtlEligible` because it needs provider-owned request headers,\n  reasoning payload normalization, Gemini transcript hints, and Anthropic\n  cache-TTL gating.\n* Z.AI uses `resolveDynamicModel`, `prepareExtraParams`, `wrapStreamFn`,\n  `isCacheTtlEligible`, `isBinaryThinking`, `isModernModelRef`,\n  `resolveUsageAuth`, and `fetchUsageSnapshot` because it owns GLM-5 fallback,\n  `tool_stream` defaults, binary thinking UX, modern-model matching, and both\n  usage auth + quota fetching.\n* Mistral, OpenCode Zen, and OpenCode Go use `capabilities` only to keep\n  transcript\/tooling quirks out of core.\n* Catalog-only bundled providers such as `byteplus`, `cloudflare-ai-gateway`,\n  `huggingface`, `kimi-coding`, `modelstudio`, `nvidia`, `qianfan`,\n  `synthetic`, `together`, `venice`, `vercel-ai-gateway`, and `volcengine` use\n  `catalog` only.\n* Qwen portal uses `catalog`, `auth`, and `refreshOAuth`.\n* MiniMax and Xiaomi use `catalog` plus usage hooks because their `\/usage`\n  behavior is plugin-owned even though inference still runs through the shared\n  transports.\n\n## Runtime helpers\n\nPlugins can access selected core helpers via `api.runtime`. For TTS:\n\n```ts  theme={&quot;theme&quot;:{&quot;light&quot;:&quot;min-light&quot;,&quot;dark&quot;:&quot;min-dark&quot;}}\nconst clip = await api.runtime.tts.textToSpeech({\n  text: &quot;Hello from OpenClaw&quot;,\n  cfg: api.config,\n});\n\nconst result = await api.runtime.tts.textToSpeechTelephony({\n  text: &quot;Hello from OpenClaw&quot;,\n  cfg: api.config,\n});\n\nconst voices = await api.runtime.tts.listVoices({\n  provider: &quot;elevenlabs&quot;,\n  cfg: api.config,\n});\n<\/code><\/pre>\n<p>Notes:<\/p>\n<ul>\n<li><code>textToSpeech<\/code> returns the normal core TTS output payload for file\/voice-note surfaces.<\/li>\n<li>Uses core <code>messages.tts<\/code> configuration and provider selection.<\/li>\n<li>Returns PCM audio buffer + sample rate. Plugins must resample\/encode for providers.<\/li>\n<li><code>listVoices<\/code> is optional per provider. Use it for vendor-owned voice pickers or setup flows.<\/li>\n<li>Voice listings can include richer metadata such as locale, gender, and personality tags for provider-aware pickers.<\/li>\n<li>OpenAI and ElevenLabs support telephony today. Microsoft does not.<\/li>\n<\/ul>\n<p>Plugins can also register speech providers via <code>api.registerSpeechProvider(...)<\/code>.<\/p>\n<p>&#8220;`ts  theme={&#8220;theme&#8221;:{&#8220;light&#8221;:&#8221;min-light&#8221;,&#8221;dark&#8221;:&#8221;min-dark&#8221;}}<br \/>\napi.registerSpeechProvider({<br \/>\n  id: &#8220;acme-speech&#8221;,<br \/>\n  label: &#8220;Acme Speech&#8221;,<br \/>\n  isConfigured: ({ config }) =&gt; Boolean(config.messages?.tts),<br \/>\n  synthesize: async (req) =&gt; {<br \/>\n    return {<br \/>\n      audioBuffer: Buffer.from([]),<br \/>\n      outputFormat: &#8220;mp3&#8221;,<br \/>\n      fileExtension: &#8220;.mp3&#8221;,<br \/>\n      voiceCompatible: false,<br \/>\n    };<br \/>\n  },<br \/>\n});<\/p>\n<pre><code>\nNotes:\n\n* Keep TTS policy, fallback, and reply delivery in core.\n* Use speech providers for vendor-owned synthesis behavior.\n* Legacy Microsoft `edge` input is normalized to the `microsoft` provider id.\n* The preferred ownership model is company-oriented: one vendor plugin can own\n  text, speech, image, and future media providers as OpenClaw adds those\n  capability contracts.\n\nFor image\/audio\/video understanding, plugins register one typed\nmedia-understanding provider instead of a generic key\/value bag:\n\n```ts  theme={&quot;theme&quot;:{&quot;light&quot;:&quot;min-light&quot;,&quot;dark&quot;:&quot;min-dark&quot;}}\napi.registerMediaUnderstandingProvider({\n  id: &quot;google&quot;,\n  capabilities: [&quot;image&quot;, &quot;audio&quot;, &quot;video&quot;],\n  describeImage: async (req) =&gt; ({ text: &quot;...&quot; }),\n  transcribeAudio: async (req) =&gt; ({ text: &quot;...&quot; }),\n  describeVideo: async (req) =&gt; ({ text: &quot;...&quot; }),\n});\n<\/code><\/pre>\n<p>Notes:<\/p>\n<ul>\n<li>Keep orchestration, fallback, config, and channel wiring in core.<\/li>\n<li>Keep vendor behavior in the provider plugin.<\/li>\n<li>Additive expansion should stay typed: new optional methods, new optional<br \/>\n  result fields, new optional capabilities.<\/li>\n<li>If OpenClaw adds a new capability such as video generation later, define the<br \/>\n  core capability contract first, then let vendor plugins register against it.<\/li>\n<\/ul>\n<p>For media-understanding runtime helpers, plugins can call:<\/p>\n<p>&#8220;`ts  theme={&#8220;theme&#8221;:{&#8220;light&#8221;:&#8221;min-light&#8221;,&#8221;dark&#8221;:&#8221;min-dark&#8221;}}<br \/>\nconst image = await api.runtime.mediaUnderstanding.describeImageFile({<br \/>\n  filePath: &#8220;\/tmp\/inbound-photo.jpg&#8221;,<br \/>\n  cfg: api.config,<br \/>\n  agentDir: &#8220;\/tmp\/agent&#8221;,<br \/>\n});<\/p>\n<p>const video = await api.runtime.mediaUnderstanding.describeVideoFile({<br \/>\n  filePath: &#8220;\/tmp\/inbound-video.mp4&#8221;,<br \/>\n  cfg: api.config,<br \/>\n});<\/p>\n<pre><code>\nFor audio transcription, plugins can use either the media-understanding runtime\nor the older STT alias:\n\n```ts  theme={&quot;theme&quot;:{&quot;light&quot;:&quot;min-light&quot;,&quot;dark&quot;:&quot;min-dark&quot;}}\nconst { text } = await api.runtime.mediaUnderstanding.transcribeAudioFile({\n  filePath: &quot;\/tmp\/inbound-audio.ogg&quot;,\n  cfg: api.config,\n  \/\/ Optional when MIME cannot be inferred reliably:\n  mime: &quot;audio\/ogg&quot;,\n});\n<\/code><\/pre>\n<p>Notes:<\/p>\n<ul>\n<li><code>api.runtime.mediaUnderstanding.*<\/code> is the preferred shared surface for<br \/>\n  image\/audio\/video understanding.<\/li>\n<li>Uses core media-understanding audio configuration (<code>tools.media.audio<\/code>) and provider fallback order.<\/li>\n<li>Returns <code>{ text: undefined }<\/code> when no transcription output is produced (for example skipped\/unsupported input).<\/li>\n<li><code>api.runtime.stt.transcribeAudioFile(...)<\/code> remains as a compatibility alias.<\/li>\n<\/ul>\n<p>Plugins can also launch background subagent runs through <code>api.runtime.subagent<\/code>:<\/p>\n<p>&#8220;`ts  theme={&#8220;theme&#8221;:{&#8220;light&#8221;:&#8221;min-light&#8221;,&#8221;dark&#8221;:&#8221;min-dark&#8221;}}<br \/>\nconst result = await api.runtime.subagent.run({<br \/>\n  sessionKey: &#8220;agent:main:subagent:search-helper&#8221;,<br \/>\n  message: &#8220;Expand this query into focused follow-up searches.&#8221;,<br \/>\n  provider: &#8220;openai&#8221;,<br \/>\n  model: &#8220;gpt-4.1-mini&#8221;,<br \/>\n  deliver: false,<br \/>\n});<\/p>\n<pre><code>\nNotes:\n\n* `provider` and `model` are optional per-run overrides, not persistent session changes.\n* OpenClaw only honors those override fields for trusted callers.\n* For plugin-owned fallback runs, operators must opt in with `plugins.entries.&lt;id&gt;.subagent.allowModelOverride: true`.\n* Use `plugins.entries.&lt;id&gt;.subagent.allowedModels` to restrict trusted plugins to specific canonical `provider\/model` targets, or `&quot;*&quot;` to allow any target explicitly.\n* Untrusted plugin subagent runs still work, but override requests are rejected instead of silently falling back.\n\nFor web search, plugins can consume the shared runtime helper instead of\nreaching into the agent tool wiring:\n\n```ts  theme={&quot;theme&quot;:{&quot;light&quot;:&quot;min-light&quot;,&quot;dark&quot;:&quot;min-dark&quot;}}\nconst providers = api.runtime.webSearch.listProviders({\n  config: api.config,\n});\n\nconst result = await api.runtime.webSearch.search({\n  config: api.config,\n  args: {\n    query: &quot;OpenClaw plugin runtime helpers&quot;,\n    count: 5,\n  },\n});\n<\/code><\/pre>\n<p>Plugins can also register web-search providers via<br \/>\n<code>api.registerWebSearchProvider(...)<\/code>.<\/p>\n<p>Notes:<\/p>\n<ul>\n<li>Keep provider selection, credential resolution, and shared request semantics in core.<\/li>\n<li>Use web-search providers for vendor-specific search transports.<\/li>\n<li><code>api.runtime.webSearch.*<\/code> is the preferred shared surface for feature\/channel plugins that need search behavior without depending on the agent tool wrapper.<\/li>\n<\/ul>\n<h2>Gateway HTTP routes<\/h2>\n<p>Plugins can expose HTTP endpoints with <code>api.registerHttpRoute(...)<\/code>.<\/p>\n<p>&#8220;`ts  theme={&#8220;theme&#8221;:{&#8220;light&#8221;:&#8221;min-light&#8221;,&#8221;dark&#8221;:&#8221;min-dark&#8221;}}<br \/>\napi.registerHttpRoute({<br \/>\n  path: &#8220;\/acme\/webhook&#8221;,<br \/>\n  auth: &#8220;plugin&#8221;,<br \/>\n  match: &#8220;exact&#8221;,<br \/>\n  handler: async (_req, res) =&gt; {<br \/>\n    res.statusCode = 200;<br \/>\n    res.end(&#8220;ok&#8221;);<br \/>\n    return true;<br \/>\n  },<br \/>\n});<\/p>\n<pre><code>\nRoute fields:\n\n* `path`: route path under the gateway HTTP server.\n* `auth`: required. Use `&quot;gateway&quot;` to require normal gateway auth, or `&quot;plugin&quot;` for plugin-managed auth\/webhook verification.\n* `match`: optional. `&quot;exact&quot;` (default) or `&quot;prefix&quot;`.\n* `replaceExisting`: optional. Allows the same plugin to replace its own existing route registration.\n* `handler`: return `true` when the route handled the request.\n\nNotes:\n\n* `api.registerHttpHandler(...)` is obsolete. Use `api.registerHttpRoute(...)`.\n* Plugin routes must declare `auth` explicitly.\n* Exact `path + match` conflicts are rejected unless `replaceExisting: true`, and one plugin cannot replace another plugin's route.\n* Overlapping routes with different `auth` levels are rejected. Keep `exact`\/`prefix` fallthrough chains on the same auth level only.\n\n## Plugin SDK import paths\n\nUse SDK subpaths instead of the monolithic `openclaw\/plugin-sdk` import when\nauthoring plugins:\n\n* `openclaw\/plugin-sdk\/plugin-entry` for plugin registration primitives.\n* `openclaw\/plugin-sdk\/core` for the generic shared plugin-facing contract.\n* Stable channel primitives such as `openclaw\/plugin-sdk\/channel-setup`,\n  `openclaw\/plugin-sdk\/channel-pairing`,\n  `openclaw\/plugin-sdk\/channel-contract`,\n  `openclaw\/plugin-sdk\/channel-feedback`,\n  `openclaw\/plugin-sdk\/channel-inbound`,\n  `openclaw\/plugin-sdk\/channel-lifecycle`,\n  `openclaw\/plugin-sdk\/channel-reply-pipeline`,\n  `openclaw\/plugin-sdk\/command-auth`,\n  `openclaw\/plugin-sdk\/secret-input`, and\n  `openclaw\/plugin-sdk\/webhook-ingress` for shared setup\/auth\/reply\/webhook\n  wiring. `channel-inbound` is the shared home for debounce, mention matching,\n  envelope formatting, and inbound envelope context helpers.\n* Domain subpaths such as `openclaw\/plugin-sdk\/channel-config-helpers`,\n  `openclaw\/plugin-sdk\/allow-from`,\n  `openclaw\/plugin-sdk\/channel-config-schema`,\n  `openclaw\/plugin-sdk\/channel-policy`,\n  `openclaw\/plugin-sdk\/config-runtime`,\n  `openclaw\/plugin-sdk\/infra-runtime`,\n  `openclaw\/plugin-sdk\/agent-runtime`,\n  `openclaw\/plugin-sdk\/lazy-runtime`,\n  `openclaw\/plugin-sdk\/reply-history`,\n  `openclaw\/plugin-sdk\/routing`,\n  `openclaw\/plugin-sdk\/status-helpers`,\n  `openclaw\/plugin-sdk\/runtime-store`, and\n  `openclaw\/plugin-sdk\/directory-runtime` for shared runtime\/config helpers.\n* `openclaw\/plugin-sdk\/channel-runtime` remains only as a compatibility shim.\n  New code should import the narrower primitives instead.\n* Bundled extension internals remain private. External plugins should use only\n  `openclaw\/plugin-sdk\/*` subpaths. OpenClaw core\/test code may use the repo\n  public entry points under `extensions\/&lt;id&gt;\/index.js`, `api.js`, `runtime-api.js`,\n  `setup-entry.js`, and narrowly scoped files such as `login-qr-api.js`. Never\n  import `extensions\/&lt;id&gt;\/src\/*` from core or from another extension.\n* Repo entry point split:\n  `extensions\/&lt;id&gt;\/api.js` is the helper\/types barrel,\n  `extensions\/&lt;id&gt;\/runtime-api.js` is the runtime-only barrel,\n  `extensions\/&lt;id&gt;\/index.js` is the bundled plugin entry,\n  and `extensions\/&lt;id&gt;\/setup-entry.js` is the setup plugin entry.\n* No bundled channel-branded public subpaths remain. Channel-specific helper and\n  runtime seams live under `extensions\/&lt;id&gt;\/api.js` and `extensions\/&lt;id&gt;\/runtime-api.js`;\n  the public SDK contract is the generic shared primitives instead.\n\nCompatibility note:\n\n* Avoid the root `openclaw\/plugin-sdk` barrel for new code.\n* Prefer the narrow stable primitives first. The newer setup\/pairing\/reply\/\n  feedback\/contract\/inbound\/threading\/command\/secret-input\/webhook\/infra\/\n  allowlist\/status\/message-tool subpaths are the intended contract for new\n  bundled and external plugin work.\n  Target parsing\/matching belongs on `openclaw\/plugin-sdk\/channel-targets`.\n  Message action gates and reaction message-id helpers belong on\n  `openclaw\/plugin-sdk\/channel-actions`.\n* Bundled extension-specific helper barrels are not stable by default. If a\n  helper is only needed by a bundled extension, keep it behind the extension's\n  local `api.js` or `runtime-api.js` seam instead of promoting it into\n  `openclaw\/plugin-sdk\/&lt;extension&gt;`.\n* Channel-branded bundled bars stay private unless they are explicitly added\n  back to the public contract.\n* Capability-specific subpaths such as `image-generation`,\n  `media-understanding`, and `speech` exist because bundled\/native plugins use\n  them today. Their presence does not by itself mean every exported helper is a\n  long-term frozen external contract.\n\n## Message tool schemas\n\nPlugins should own channel-specific `describeMessageTool(...)` schema\ncontributions. Keep provider-specific fields in the plugin, not in shared core.\n\nFor shared portable schema fragments, reuse the generic helpers exported through\n`openclaw\/plugin-sdk\/channel-actions`:\n\n* `createMessageToolButtonsSchema()` for button-grid style payloads\n* `createMessageToolCardSchema()` for structured card payloads\n\nIf a schema shape only makes sense for one provider, define it in that plugin's\nown source instead of promoting it into the shared SDK.\n\n## Channel target resolution\n\nChannel plugins should own channel-specific target semantics. Keep the shared\noutbound host generic and use the messaging adapter surface for provider rules:\n\n* `messaging.inferTargetChatType({ to })` decides whether a normalized target\n  should be treated as `direct`, `group`, or `channel` before directory lookup.\n* `messaging.targetResolver.looksLikeId(raw, normalized)` tells core whether an\n  input should skip straight to id-like resolution instead of directory search.\n* `messaging.targetResolver.resolveTarget(...)` is the plugin fallback when\n  core needs a final provider-owned resolution after normalization or after a\n  directory miss.\n* `messaging.resolveOutboundSessionRoute(...)` owns provider-specific session\n  route construction once a target is resolved.\n\nRecommended split:\n\n* Use `inferTargetChatType` for category decisions that should happen before\n  searching peers\/groups.\n* Use `looksLikeId` for &quot;treat this as an explicit\/native target id&quot; checks.\n* Use `resolveTarget` for provider-specific normalization fallback, not for\n  broad directory search.\n* Keep provider-native ids like chat ids, thread ids, JIDs, handles, and room\n  ids inside `target` values or provider-specific params, not in generic SDK\n  fields.\n\n## Config-backed directories\n\nPlugins that derive directory entries from config should keep that logic in the\nplugin and reuse the shared helpers from\n`openclaw\/plugin-sdk\/directory-runtime`.\n\nUse this when a channel needs config-backed peers\/groups such as:\n\n* allowlist-driven DM peers\n* configured channel\/group maps\n* account-scoped static directory fallbacks\n\nThe shared helpers in `directory-runtime` only handle generic operations:\n\n* query filtering\n* limit application\n* deduping\/normalization helpers\n* building `ChannelDirectoryEntry[]`\n\nChannel-specific account inspection and id normalization should stay in the\nplugin implementation.\n\n## Provider catalogs\n\nProvider plugins can define model catalogs for inference with\n`registerProvider({ catalog: { run(...) { ... } } })`.\n\n`catalog.run(...)` returns the same shape OpenClaw writes into\n`models.providers`:\n\n* `{ provider }` for one provider entry\n* `{ providers }` for multiple provider entries\n\nUse `catalog` when the plugin owns provider-specific model ids, base URL\ndefaults, or auth-gated model metadata.\n\n`catalog.order` controls when a plugin's catalog merges relative to OpenClaw's\nbuilt-in implicit providers:\n\n* `simple`: plain API-key or env-driven providers\n* `profile`: providers that appear when auth profiles exist\n* `paired`: providers that synthesize multiple related provider entries\n* `late`: last pass, after other implicit providers\n\nLater providers win on key collision, so plugins can intentionally override a\nbuilt-in provider entry with the same provider id.\n\nCompatibility:\n\n* `discovery` still works as a legacy alias\n* if both `catalog` and `discovery` are registered, OpenClaw uses `catalog`\n\n## Read-only channel inspection\n\nIf your plugin registers a channel, prefer implementing\n`plugin.config.inspectAccount(cfg, accountId)` alongside `resolveAccount(...)`.\n\nWhy:\n\n* `resolveAccount(...)` is the runtime path. It is allowed to assume credentials\n  are fully materialized and can fail fast when required secrets are missing.\n* Read-only command paths such as `openclaw status`, `openclaw status --all`,\n  `openclaw channels status`, `openclaw channels resolve`, and doctor\/config\n  repair flows should not need to materialize runtime credentials just to\n  describe configuration.\n\nRecommended `inspectAccount(...)` behavior:\n\n* Return descriptive account state only.\n* Preserve `enabled` and `configured`.\n* Include credential source\/status fields when relevant, such as:\n  * `tokenSource`, `tokenStatus`\n  * `botTokenSource`, `botTokenStatus`\n  * `appTokenSource`, `appTokenStatus`\n  * `signingSecretSource`, `signingSecretStatus`\n* You do not need to return raw token values just to report read-only\n  availability. Returning `tokenStatus: &quot;available&quot;` (and the matching source\n  field) is enough for status-style commands.\n* Use `configured_unavailable` when a credential is configured via SecretRef but\n  unavailable in the current command path.\n\nThis lets read-only commands report &quot;configured but unavailable in this command\npath&quot; instead of crashing or misreporting the account as not configured.\n\n## Package packs\n\nA plugin directory may include a `package.json` with `openclaw.extensions`:\n\n```json  theme={&quot;theme&quot;:{&quot;light&quot;:&quot;min-light&quot;,&quot;dark&quot;:&quot;min-dark&quot;}}\n{\n  &quot;name&quot;: &quot;my-pack&quot;,\n  &quot;openclaw&quot;: {\n    &quot;extensions&quot;: [&quot;.\/src\/safety.ts&quot;, &quot;.\/src\/tools.ts&quot;],\n    &quot;setupEntry&quot;: &quot;.\/src\/setup-entry.ts&quot;\n  }\n}\n<\/code><\/pre>\n<p>Each entry becomes a plugin. If the pack lists multiple extensions, the plugin id<br \/>\nbecomes <code>name\/&lt;fileBase&gt;<\/code>.<\/p>\n<p>If your plugin imports npm deps, install them in that directory so<br \/>\n<code>node_modules<\/code> is available (<code>npm install<\/code> \/ <code>pnpm install<\/code>).<\/p>\n<p>Security guardrail: every <code>openclaw.extensions<\/code> entry must stay inside the plugin<br \/>\ndirectory after symlink resolution. Entries that escape the package directory are<br \/>\nrejected.<\/p>\n<p>Security note: <code>openclaw plugins install<\/code> installs plugin dependencies with<br \/>\n<code>npm install --ignore-scripts<\/code> (no lifecycle scripts). Keep plugin dependency<br \/>\ntrees &#8220;pure JS\/TS&#8221; and avoid packages that require <code>postinstall<\/code> builds.<\/p>\n<p>Optional: <code>openclaw.setupEntry<\/code> can point at a lightweight setup-only module.<br \/>\nWhen OpenClaw needs setup surfaces for a disabled channel plugin, or<br \/>\nwhen a channel plugin is enabled but still unconfigured, it loads <code>setupEntry<\/code><br \/>\ninstead of the full plugin entry. This keeps startup and setup lighter<br \/>\nwhen your main plugin entry also wires tools, hooks, or other runtime-only<br \/>\ncode.<\/p>\n<p>Optional: <code>openclaw.startup.deferConfiguredChannelFullLoadUntilAfterListen<\/code><br \/>\ncan opt a channel plugin into the same <code>setupEntry<\/code> path during the gateway&#8217;s<br \/>\npre-listen startup phase, even when the channel is already configured.<\/p>\n<p>Use this only when <code>setupEntry<\/code> fully covers the startup surface that must exist<br \/>\nbefore the gateway starts listening. In practice, that means the setup entry<br \/>\nmust register every channel-owned capability that startup depends on, such as:<\/p>\n<ul>\n<li>channel registration itself<\/li>\n<li>any HTTP routes that must be available before the gateway starts listening<\/li>\n<li>any gateway methods, tools, or services that must exist during that same window<\/li>\n<\/ul>\n<p>If your full entry still owns any required startup capability, do not enable<br \/>\nthis flag. Keep the plugin on the default behavior and let OpenClaw load the<br \/>\nfull entry during startup.<\/p>\n<p>Example:<\/p>\n<p>&#8220;`json  theme={&#8220;theme&#8221;:{&#8220;light&#8221;:&#8221;min-light&#8221;,&#8221;dark&#8221;:&#8221;min-dark&#8221;}}<br \/>\n{<br \/>\n  &#8220;name&#8221;: &#8220;@scope\/my-channel&#8221;,<br \/>\n  &#8220;openclaw&#8221;: {<br \/>\n    &#8220;extensions&#8221;: [&#8220;.\/index.ts&#8221;],<br \/>\n    &#8220;setupEntry&#8221;: &#8220;.\/setup-entry.ts&#8221;,<br \/>\n    &#8220;startup&#8221;: {<br \/>\n      &#8220;deferConfiguredChannelFullLoadUntilAfterListen&#8221;: true<br \/>\n    }<br \/>\n  }<br \/>\n}<\/p>\n<pre><code>\n### Channel catalog metadata\n\nChannel plugins can advertise setup\/discovery metadata via `openclaw.channel` and\ninstall hints via `openclaw.install`. This keeps the core catalog data-free.\n\nExample:\n\n```json  theme={&quot;theme&quot;:{&quot;light&quot;:&quot;min-light&quot;,&quot;dark&quot;:&quot;min-dark&quot;}}\n{\n  &quot;name&quot;: &quot;@openclaw\/nextcloud-talk&quot;,\n  &quot;openclaw&quot;: {\n    &quot;extensions&quot;: [&quot;.\/index.ts&quot;],\n    &quot;channel&quot;: {\n      &quot;id&quot;: &quot;nextcloud-talk&quot;,\n      &quot;label&quot;: &quot;Nextcloud Talk&quot;,\n      &quot;selectionLabel&quot;: &quot;Nextcloud Talk (self-hosted)&quot;,\n      &quot;docsPath&quot;: &quot;\/channels\/nextcloud-talk&quot;,\n      &quot;docsLabel&quot;: &quot;nextcloud-talk&quot;,\n      &quot;blurb&quot;: &quot;Self-hosted chat via Nextcloud Talk webhook bots.&quot;,\n      &quot;order&quot;: 65,\n      &quot;aliases&quot;: [&quot;nc-talk&quot;, &quot;nc&quot;]\n    },\n    &quot;install&quot;: {\n      &quot;npmSpec&quot;: &quot;@openclaw\/nextcloud-talk&quot;,\n      &quot;localPath&quot;: &quot;extensions\/nextcloud-talk&quot;,\n      &quot;defaultChoice&quot;: &quot;npm&quot;\n    }\n  }\n}\n<\/code><\/pre>\n<p>OpenClaw can also merge <strong>external channel catalogs<\/strong> (for example, an MPM<br \/>\nregistry export). Drop a JSON file at one of:<\/p>\n<ul>\n<li><code>~\/.openclaw\/mpm\/plugins.json<\/code><\/li>\n<li><code>~\/.openclaw\/mpm\/catalog.json<\/code><\/li>\n<li><code>~\/.openclaw\/plugins\/catalog.json<\/code><\/li>\n<\/ul>\n<p>Or point <code>OPENCLAW_PLUGIN_CATALOG_PATHS<\/code> (or <code>OPENCLAW_MPM_CATALOG_PATHS<\/code>) at<br \/>\none or more JSON files (comma\/semicolon\/<code>PATH<\/code>-delimited). Each file should<br \/>\ncontain <code>{ \"entries\": [ { \"name\": \"@scope\/pkg\", \"openclaw\": { \"channel\": {...}, \"install\": {...} } } ] }<\/code>.<\/p>\n<h2>Context engine plugins<\/h2>\n<p>Context engine plugins own session context orchestration for ingest, assembly,<br \/>\nand compaction. Register them from your plugin with<br \/>\n<code>api.registerContextEngine(id, factory)<\/code>, then select the active engine with<br \/>\n<code>plugins.slots.contextEngine<\/code>.<\/p>\n<p>Use this when your plugin needs to replace or extend the default context<br \/>\npipeline rather than just add memory search or hooks.<\/p>\n<p>&#8220;`ts  theme={&#8220;theme&#8221;:{&#8220;light&#8221;:&#8221;min-light&#8221;,&#8221;dark&#8221;:&#8221;min-dark&#8221;}}<br \/>\nexport default function (api) {<br \/>\n  api.registerContextEngine(&#8220;lossless-claw&#8221;, () =&gt; ({<br \/>\n    info: { id: &#8220;lossless-claw&#8221;, name: &#8220;Lossless Claw&#8221;, ownsCompaction: true },<br \/>\n    async ingest() {<br \/>\n      return { ingested: true };<br \/>\n    },<br \/>\n    async assemble({ messages }) {<br \/>\n      return { messages, estimatedTokens: 0 };<br \/>\n    },<br \/>\n    async compact() {<br \/>\n      return { ok: true, compacted: false };<br \/>\n    },<br \/>\n  }));<br \/>\n}<\/p>\n<pre><code>\nIf your engine does **not** own the compaction algorithm, keep `compact()`\nimplemented and delegate it explicitly:\n\n```ts  theme={&quot;theme&quot;:{&quot;light&quot;:&quot;min-light&quot;,&quot;dark&quot;:&quot;min-dark&quot;}}\nimport { delegateCompactionToRuntime } from &quot;openclaw\/plugin-sdk\/core&quot;;\n\nexport default function (api) {\n  api.registerContextEngine(&quot;my-memory-engine&quot;, () =&gt; ({\n    info: {\n      id: &quot;my-memory-engine&quot;,\n      name: &quot;My Memory Engine&quot;,\n      ownsCompaction: false,\n    },\n    async ingest() {\n      return { ingested: true };\n    },\n    async assemble({ messages }) {\n      return { messages, estimatedTokens: 0 };\n    },\n    async compact(params) {\n      return await delegateCompactionToRuntime(params);\n    },\n  }));\n}\n<\/code><\/pre>\n<h2>Adding a new capability<\/h2>\n<p>When a plugin needs behavior that does not fit the current API, do not bypass<br \/>\nthe plugin system with a private reach-in. Add the missing capability.<\/p>\n<p>Recommended sequence:<\/p>\n<ol>\n<li>define the core contract<br \/>\n   Decide what shared behavior core should own: policy, fallback, config merge,<br \/>\n   lifecycle, channel-facing semantics, and runtime helper shape.<\/li>\n<li>add typed plugin registration\/runtime surfaces<br \/>\n   Extend <code>OpenClawPluginApi<\/code> and\/or <code>api.runtime<\/code> with the smallest useful<br \/>\n   typed capability surface.<\/li>\n<li>wire core + channel\/feature consumers<br \/>\n   Channels and feature plugins should consume the new capability through core,<br \/>\n   not by importing a vendor implementation directly.<\/li>\n<li>register vendor implementations<br \/>\n   Vendor plugins then register their backends against the capability.<\/li>\n<li>add contract coverage<br \/>\n   Add tests so ownership and registration shape stay explicit over time.<\/li>\n<\/ol>\n<p>This is how OpenClaw stays opinionated without becoming hardcoded to one<br \/>\nprovider&#8217;s worldview. See the <a href=\"\/tools\/capability-cookbook\">Capability Cookbook<\/a><br \/>\nfor a concrete file checklist and worked example.<\/p>\n<h3>Capability checklist<\/h3>\n<p>When you add a new capability, the implementation should usually touch these<br \/>\nsurfaces together:<\/p>\n<ul>\n<li>core contract types in <code>src\/&lt;capability&gt;\/types.ts<\/code><\/li>\n<li>core runner\/runtime helper in <code>src\/&lt;capability&gt;\/runtime.ts<\/code><\/li>\n<li>plugin API registration surface in <code>src\/plugins\/types.ts<\/code><\/li>\n<li>plugin registry wiring in <code>src\/plugins\/registry.ts<\/code><\/li>\n<li>plugin runtime exposure in <code>src\/plugins\/runtime\/*<\/code> when feature\/channel<br \/>\n  plugins need to consume it<\/li>\n<li>capture\/test helpers in <code>src\/test-utils\/plugin-registration.ts<\/code><\/li>\n<li>ownership\/contract assertions in <code>src\/plugins\/contracts\/registry.ts<\/code><\/li>\n<li>operator\/plugin docs in <code>docs\/<\/code><\/li>\n<\/ul>\n<p>If one of those surfaces is missing, that is usually a sign the capability is<br \/>\nnot fully integrated yet.<\/p>\n<h3>Capability template<\/h3>\n<p>Minimal pattern:<\/p>\n<p>&#8220;`ts  theme={&#8220;theme&#8221;:{&#8220;light&#8221;:&#8221;min-light&#8221;,&#8221;dark&#8221;:&#8221;min-dark&#8221;}}<br \/>\n\/\/ core contract<br \/>\nexport type VideoGenerationProviderPlugin = {<br \/>\n  id: string;<br \/>\n  label: string;<br \/>\n  generateVideo: (req: VideoGenerationRequest) =&gt; Promise;<br \/>\n};<\/p>\n<p>\/\/ plugin API<br \/>\napi.registerVideoGenerationProvider({<br \/>\n  id: &#8220;openai&#8221;,<br \/>\n  label: &#8220;OpenAI&#8221;,<br \/>\n  async generateVideo(req) {<br \/>\n    return await generateOpenAiVideo(req);<br \/>\n  },<br \/>\n});<\/p>\n<p>\/\/ shared runtime helper for feature\/channel plugins<br \/>\nconst clip = await api.runtime.videoGeneration.generateFile({<br \/>\n  prompt: &#8220;Show the robot walking through the lab.&#8221;,<br \/>\n  cfg,<br \/>\n});<\/p>\n<pre><code>\nContract test pattern:\n\n```ts  theme={&quot;theme&quot;:{&quot;light&quot;:&quot;min-light&quot;,&quot;dark&quot;:&quot;min-dark&quot;}}\nexpect(findVideoGenerationProviderIdsForPlugin(&quot;openai&quot;)).toEqual([&quot;openai&quot;]);\n<\/code><\/pre>\n<p>That keeps the rule simple:<\/p>\n<ul>\n<li>core owns the capability contract + orchestration<\/li>\n<li>vendor plugins own vendor implementations<\/li>\n<li>feature\/channel plugins consume runtime helpers<\/li>\n<li>contract tests keep ownership explicit<\/li>\n<\/ul>\n","protected":false},"excerpt":{"rendered":"<p>Plugin Internals This page is for plugin developers and [&hellip;]<\/p>\n","protected":false},"author":0,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[2],"tags":[],"class_list":["post-621","post","type-post","status-publish","format-standard","hentry","category-docs"],"_links":{"self":[{"href":"https:\/\/pa.yingzhi8.cn\/index.php\/wp-json\/wp\/v2\/posts\/621","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/pa.yingzhi8.cn\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/pa.yingzhi8.cn\/index.php\/wp-json\/wp\/v2\/types\/post"}],"replies":[{"embeddable":true,"href":"https:\/\/pa.yingzhi8.cn\/index.php\/wp-json\/wp\/v2\/comments?post=621"}],"version-history":[{"count":4,"href":"https:\/\/pa.yingzhi8.cn\/index.php\/wp-json\/wp\/v2\/posts\/621\/revisions"}],"predecessor-version":[{"id":897,"href":"https:\/\/pa.yingzhi8.cn\/index.php\/wp-json\/wp\/v2\/posts\/621\/revisions\/897"}],"wp:attachment":[{"href":"https:\/\/pa.yingzhi8.cn\/index.php\/wp-json\/wp\/v2\/media?parent=621"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/pa.yingzhi8.cn\/index.php\/wp-json\/wp\/v2\/categories?post=621"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/pa.yingzhi8.cn\/index.php\/wp-json\/wp\/v2\/tags?post=621"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}