diff --git a/docs/automation/hooks.md b/docs/automation/hooks.md
index 254b4817b9b..f59ef9afb6e 100644
--- a/docs/automation/hooks.md
+++ b/docs/automation/hooks.md
@@ -207,7 +207,7 @@ Runs `BOOT.md` from the active workspace when the gateway starts.
Plugins can register hooks through the Plugin SDK for deeper integration: intercepting tool calls, modifying prompts, controlling message flow, and more. The Plugin SDK exposes 28 hooks covering model resolution, agent lifecycle, message flow, tool execution, subagent coordination, and gateway lifecycle.
-For the complete plugin hook reference including `before_tool_call`, `before_agent_reply`, `before_install`, and all other plugin hooks, see [Plugin Architecture](/plugins/architecture#provider-runtime-hooks).
+For the complete plugin hook reference including `before_tool_call`, `before_agent_reply`, `before_install`, and all other plugin hooks, see [Plugin Architecture](/plugins/architecture-internals#provider-runtime-hooks).
## Configuration
@@ -315,5 +315,5 @@ Check for missing binaries (PATH), environment variables, config values, or OS c
- [CLI Reference: hooks](/cli/hooks)
- [Webhooks](/automation/cron-jobs#webhooks)
-- [Plugin Architecture](/plugins/architecture#provider-runtime-hooks) — full plugin hook reference
+- [Plugin Architecture](/plugins/architecture-internals#provider-runtime-hooks) — full plugin hook reference
- [Configuration](/gateway/configuration-reference#hooks)
diff --git a/docs/cli/hooks.md b/docs/cli/hooks.md
index a6698079768..7d07dcefe85 100644
--- a/docs/cli/hooks.md
+++ b/docs/cli/hooks.md
@@ -15,7 +15,7 @@ Running `openclaw hooks` with no subcommand is equivalent to `openclaw hooks lis
Related:
- Hooks: [Hooks](/automation/hooks)
-- Plugin hooks: [Plugin hooks](/plugins/architecture#provider-runtime-hooks)
+- Plugin hooks: [Plugin hooks](/plugins/architecture-internals#provider-runtime-hooks)
## List All Hooks
diff --git a/docs/concepts/agent-loop.md b/docs/concepts/agent-loop.md
index fd5cda072a5..ca1c1e8014b 100644
--- a/docs/concepts/agent-loop.md
+++ b/docs/concepts/agent-loop.md
@@ -112,7 +112,7 @@ Hook decision rules for outbound/tool guards:
- `message_sending`: `{ cancel: true }` is terminal and stops lower-priority handlers.
- `message_sending`: `{ cancel: false }` is a no-op and does not clear a prior cancel.
-See [Plugin hooks](/plugins/architecture#provider-runtime-hooks) for the hook API and registration details.
+See [Plugin hooks](/plugins/architecture-internals#provider-runtime-hooks) for the hook API and registration details.
## Streaming + partial replies
diff --git a/docs/docs.json b/docs/docs.json
index 8a0ab627eaf..4b9e6162c49 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -1155,7 +1155,8 @@
"plugins/sdk-setup",
"plugins/sdk-testing",
"plugins/manifest",
- "plugins/architecture"
+ "plugins/architecture",
+ "plugins/architecture-internals"
]
}
]
diff --git a/docs/plugins/architecture-internals.md b/docs/plugins/architecture-internals.md
new file mode 100644
index 00000000000..c98900a057c
--- /dev/null
+++ b/docs/plugins/architecture-internals.md
@@ -0,0 +1,1040 @@
+---
+summary: "Plugin architecture internals: load pipeline, registry, runtime hooks, HTTP routes, and reference tables"
+read_when:
+ - Implementing provider runtime hooks, channel lifecycle, or package packs
+ - Debugging plugin load order or registry state
+ - Adding a new plugin capability or context engine plugin
+title: "Plugin architecture internals"
+---
+
+For the public capability model, plugin shapes, and ownership/execution
+contracts, see [Plugin architecture](/plugins/architecture). This page is the
+reference for the internal mechanics: load pipeline, registry, runtime hooks,
+Gateway HTTP routes, import paths, and schema tables.
+
+## Load pipeline
+
+At startup, OpenClaw does roughly this:
+
+1. discover candidate plugin roots
+2. read native or compatible bundle manifests and package metadata
+3. reject unsafe candidates
+4. normalize plugin config (`plugins.enabled`, `allow`, `deny`, `entries`,
+ `slots`, `load.paths`)
+5. decide enablement for each candidate
+6. load enabled native modules: built bundled modules use a native loader;
+ unbuilt native plugins use jiti
+7. call native `register(api)` hooks and collect registrations into the plugin registry
+8. expose the registry to commands/runtime surfaces
+
+
+`activate` is a legacy alias for `register` — the loader resolves whichever is present (`def.register ?? def.activate`) and calls it at the same point. All bundled plugins use `register`; prefer `register` for new plugins.
+
+
+The safety gates happen **before** runtime execution. Candidates are blocked
+when the entry escapes the plugin root, the path is world-writable, or path
+ownership looks suspicious for non-bundled plugins.
+
+### Manifest-first behavior
+
+The manifest is the control-plane source of truth. OpenClaw uses it to:
+
+- identify the plugin
+- discover declared channels/skills/config schema or bundle capabilities
+- validate `plugins.entries..config`
+- augment Control UI labels/placeholders
+- show install/catalog metadata
+- preserve cheap activation and setup descriptors without loading plugin runtime
+
+For native plugins, the runtime module is the data-plane part. It registers
+actual behavior such as hooks, tools, commands, or provider flows.
+
+Optional manifest `activation` and `setup` blocks stay on the control plane.
+They are metadata-only descriptors for activation planning and setup discovery;
+they do not replace runtime registration, `register(...)`, or `setupEntry`.
+The first live activation consumers now use manifest command, channel, and provider hints
+to narrow plugin loading before broader registry materialization:
+
+- CLI loading narrows to plugins that own the requested primary command
+- channel setup/plugin resolution narrows to plugins that own the requested
+ channel id
+- explicit provider setup/runtime resolution narrows to plugins that own the
+ requested provider id
+
+Setup discovery now prefers descriptor-owned ids such as `setup.providers` and
+`setup.cliBackends` to narrow candidate plugins before it falls back to
+`setup-api` for plugins that still need setup-time runtime hooks. If more than
+one discovered plugin claims the same normalized setup provider or CLI backend
+id, setup lookup refuses the ambiguous owner instead of relying on discovery
+order.
+
+### What the loader caches
+
+OpenClaw keeps short in-process caches for:
+
+- discovery results
+- manifest registry data
+- loaded plugin registries
+
+These caches reduce bursty startup and repeated command overhead. They are safe
+to think of as short-lived performance caches, not persistence.
+
+Performance note:
+
+- Set `OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE=1` or
+ `OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE=1` to disable these caches.
+- Tune cache windows with `OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS` and
+ `OPENCLAW_PLUGIN_MANIFEST_CACHE_MS`.
+
+## Registry model
+
+Loaded plugins do not directly mutate random core globals. They register into a
+central plugin registry.
+
+The registry tracks:
+
+- plugin records (identity, source, origin, status, diagnostics)
+- tools
+- legacy hooks and typed hooks
+- channels
+- providers
+- gateway RPC handlers
+- HTTP routes
+- CLI registrars
+- background services
+- plugin-owned commands
+
+Core features then read from that registry instead of talking to plugin modules
+directly. This keeps loading one-way:
+
+- plugin module -> registry registration
+- core runtime -> registry consumption
+
+That separation matters for maintainability. It means most core surfaces only
+need one integration point: "read the registry", not "special-case every plugin
+module".
+
+## Conversation binding callbacks
+
+Plugins that bind a conversation can react when an approval is resolved.
+
+Use `api.onConversationBindingResolved(...)` to receive a callback after a bind
+request is approved or denied:
+
+```ts
+export default {
+ id: "my-plugin",
+ register(api) {
+ api.onConversationBindingResolved(async (event) => {
+ if (event.status === "approved") {
+ // A binding now exists for this plugin + conversation.
+ console.log(event.binding?.conversationId);
+ return;
+ }
+
+ // The request was denied; clear any local pending state.
+ console.log(event.request.conversation.conversationId);
+ });
+ },
+};
+```
+
+Callback payload fields:
+
+- `status`: `"approved"` or `"denied"`
+- `decision`: `"allow-once"`, `"allow-always"`, or `"deny"`
+- `binding`: the resolved binding for approved requests
+- `request`: the original request summary, detach hint, sender id, and
+ conversation metadata
+
+This callback is notification-only. It does not change who is allowed to bind a
+conversation, and it runs after core approval handling finishes.
+
+## Provider runtime hooks
+
+Provider plugins have three layers:
+
+- **Manifest metadata** for cheap pre-runtime lookup: `providerAuthEnvVars`,
+ `providerAuthAliases`, `providerAuthChoices`, and `channelEnvVars`.
+- **Config-time hooks**: `catalog` (legacy `discovery`) plus
+ `applyConfigDefaults`.
+- **Runtime hooks**: 40+ optional hooks covering auth, model resolution,
+ stream wrapping, thinking levels, replay policy, and usage endpoints. See
+ the full list under [Hook order and usage](#hook-order-and-usage).
+
+OpenClaw still owns the generic agent loop, failover, transcript handling, and
+tool policy. These hooks are the extension surface for provider-specific
+behavior without needing a whole custom inference transport.
+
+Use manifest `providerAuthEnvVars` when the provider has env-based credentials
+that generic auth/status/model-picker paths should see without loading plugin
+runtime. Use manifest `providerAuthAliases` when one provider id should reuse
+another provider id's env vars, auth profiles, config-backed auth, and API-key
+onboarding choice. Use manifest `providerAuthChoices` when onboarding/auth-choice
+CLI surfaces should know the provider's choice id, group labels, and simple
+one-flag auth wiring without loading provider runtime. Keep provider runtime
+`envVars` for operator-facing hints such as onboarding labels or OAuth
+client-id/client-secret setup vars.
+
+Use manifest `channelEnvVars` when a channel has env-driven auth or setup that
+generic shell-env fallback, config/status checks, or setup prompts should see
+without loading channel runtime.
+
+### Hook order and usage
+
+For model/provider plugins, OpenClaw calls hooks in this rough order.
+The "When to use" column is the quick decision guide.
+
+| # | Hook | What it does | When to use |
+| --- | --------------------------------- | -------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
+| 1 | `catalog` | Publish provider config into `models.providers` during `models.json` generation | Provider owns a catalog or base URL defaults |
+| 2 | `applyConfigDefaults` | Apply provider-owned global config defaults during config materialization | Defaults depend on auth mode, env, or provider model-family semantics |
+| -- | _(built-in model lookup)_ | OpenClaw tries the normal registry/catalog path first | _(not a plugin hook)_ |
+| 3 | `normalizeModelId` | Normalize legacy or preview model-id aliases before lookup | Provider owns alias cleanup before canonical model resolution |
+| 4 | `normalizeTransport` | Normalize provider-family `api` / `baseUrl` before generic model assembly | Provider owns transport cleanup for custom provider ids in the same transport family |
+| 5 | `normalizeConfig` | Normalize `models.providers.` before runtime/provider resolution | Provider needs config cleanup that should live with the plugin; bundled Google-family helpers also backstop supported Google config entries |
+| 6 | `applyNativeStreamingUsageCompat` | Apply native streaming-usage compat rewrites to config providers | Provider needs endpoint-driven native streaming usage metadata fixes |
+| 7 | `resolveConfigApiKey` | Resolve env-marker auth for config providers before runtime auth loading | Provider has provider-owned env-marker API-key resolution; `amazon-bedrock` also has a built-in AWS env-marker resolver here |
+| 8 | `resolveSyntheticAuth` | Surface local/self-hosted or config-backed auth without persisting plaintext | Provider can operate with a synthetic/local credential marker |
+| 9 | `resolveExternalAuthProfiles` | Overlay provider-owned external auth profiles; default `persistence` is `runtime-only` for CLI/app-owned creds | Provider reuses external auth credentials without persisting copied refresh tokens; declare `contracts.externalAuthProviders` in the manifest |
+| 10 | `shouldDeferSyntheticProfileAuth` | Lower stored synthetic profile placeholders behind env/config-backed auth | Provider stores synthetic placeholder profiles that should not win precedence |
+| 11 | `resolveDynamicModel` | Sync fallback for provider-owned model ids not in the local registry yet | Provider accepts arbitrary upstream model ids |
+| 12 | `prepareDynamicModel` | Async warm-up, then `resolveDynamicModel` runs again | Provider needs network metadata before resolving unknown ids |
+| 13 | `normalizeResolvedModel` | Final rewrite before the embedded runner uses the resolved model | Provider needs transport rewrites but still uses a core transport |
+| 14 | `contributeResolvedModelCompat` | Contribute compat flags for vendor models behind another compatible transport | Provider recognizes its own models on proxy transports without taking over the provider |
+| 15 | `capabilities` | Provider-owned transcript/tooling metadata used by shared core logic | Provider needs transcript/provider-family quirks |
+| 16 | `normalizeToolSchemas` | Normalize tool schemas before the embedded runner sees them | Provider needs transport-family schema cleanup |
+| 17 | `inspectToolSchemas` | Surface provider-owned schema diagnostics after normalization | Provider wants keyword warnings without teaching core provider-specific rules |
+| 18 | `resolveReasoningOutputMode` | Select native vs tagged reasoning-output contract | Provider needs tagged reasoning/final output instead of native fields |
+| 19 | `prepareExtraParams` | Request-param normalization before generic stream option wrappers | Provider needs default request params or per-provider param cleanup |
+| 20 | `createStreamFn` | Fully replace the normal stream path with a custom transport | Provider needs a custom wire protocol, not just a wrapper |
+| 21 | `wrapStreamFn` | Stream wrapper after generic wrappers are applied | Provider needs request headers/body/model compat wrappers without a custom transport |
+| 22 | `resolveTransportTurnState` | Attach native per-turn transport headers or metadata | Provider wants generic transports to send provider-native turn identity |
+| 23 | `resolveWebSocketSessionPolicy` | Attach native WebSocket headers or session cool-down policy | Provider wants generic WS transports to tune session headers or fallback policy |
+| 24 | `formatApiKey` | Auth-profile formatter: stored profile becomes the runtime `apiKey` string | Provider stores extra auth metadata and needs a custom runtime token shape |
+| 25 | `refreshOAuth` | OAuth refresh override for custom refresh endpoints or refresh-failure policy | Provider does not fit the shared `pi-ai` refreshers |
+| 26 | `buildAuthDoctorHint` | Repair hint appended when OAuth refresh fails | Provider needs provider-owned auth repair guidance after refresh failure |
+| 27 | `matchesContextOverflowError` | Provider-owned context-window overflow matcher | Provider has raw overflow errors generic heuristics would miss |
+| 28 | `classifyFailoverReason` | Provider-owned failover reason classification | Provider can map raw API/transport errors to rate-limit/overload/etc |
+| 29 | `isCacheTtlEligible` | Prompt-cache policy for proxy/backhaul providers | Provider needs proxy-specific cache TTL gating |
+| 30 | `buildMissingAuthMessage` | Replacement for the generic missing-auth recovery message | Provider needs a provider-specific missing-auth recovery hint |
+| 31 | `suppressBuiltInModel` | Stale upstream model suppression plus optional user-facing error hint | Provider needs to hide stale upstream rows or replace them with a vendor hint |
+| 32 | `augmentModelCatalog` | Synthetic/final catalog rows appended after discovery | Provider needs synthetic forward-compat rows in `models list` and pickers |
+| 33 | `resolveThinkingProfile` | Model-specific `/think` level set, display labels, and default | Provider exposes a custom thinking ladder or binary label for selected models |
+| 34 | `isBinaryThinking` | On/off reasoning toggle compatibility hook | Provider exposes only binary thinking on/off |
+| 35 | `supportsXHighThinking` | `xhigh` reasoning support compatibility hook | Provider wants `xhigh` on only a subset of models |
+| 36 | `resolveDefaultThinkingLevel` | Default `/think` level compatibility hook | Provider owns default `/think` policy for a model family |
+| 37 | `isModernModelRef` | Modern-model matcher for live profile filters and smoke selection | Provider owns live/smoke preferred-model matching |
+| 38 | `prepareRuntimeAuth` | Exchange a configured credential into the actual runtime token/key just before inference | Provider needs a token exchange or short-lived request credential |
+| 39 | `resolveUsageAuth` | Resolve usage/billing credentials for `/usage` and related status surfaces | Provider needs custom usage/quota token parsing or a different usage credential |
+| 40 | `fetchUsageSnapshot` | Fetch and normalize provider-specific usage/quota snapshots after auth is resolved | Provider needs a provider-specific usage endpoint or payload parser |
+| 41 | `createEmbeddingProvider` | Build a provider-owned embedding adapter for memory/search | Memory embedding behavior belongs with the provider plugin |
+| 42 | `buildReplayPolicy` | Return a replay policy controlling transcript handling for the provider | Provider needs custom transcript policy (for example, thinking-block stripping) |
+| 43 | `sanitizeReplayHistory` | Rewrite replay history after generic transcript cleanup | Provider needs provider-specific replay rewrites beyond shared compaction helpers |
+| 44 | `validateReplayTurns` | Final replay-turn validation or reshaping before the embedded runner | Provider transport needs stricter turn validation after generic sanitation |
+| 45 | `onModelSelected` | Run provider-owned post-selection side effects | Provider needs telemetry or provider-owned state when a model becomes active |
+
+`normalizeModelId`, `normalizeTransport`, and `normalizeConfig` first check the
+matched provider plugin, then fall through other hook-capable provider plugins
+until one actually changes the model id or transport/config. That keeps
+alias/compat provider shims working without requiring the caller to know which
+bundled plugin owns the rewrite. If no provider hook rewrites a supported
+Google-family config entry, the bundled Google config normalizer still applies
+that compatibility cleanup.
+
+If the provider needs a fully custom wire protocol or custom request executor,
+that is a different class of extension. These hooks are for provider behavior
+that still runs on OpenClaw's normal inference loop.
+
+### Provider example
+
+```ts
+api.registerProvider({
+ id: "example-proxy",
+ label: "Example Proxy",
+ auth: [],
+ catalog: {
+ order: "simple",
+ run: async (ctx) => {
+ const apiKey = ctx.resolveProviderApiKey("example-proxy").apiKey;
+ if (!apiKey) {
+ return null;
+ }
+ return {
+ provider: {
+ baseUrl: "https://proxy.example.com/v1",
+ apiKey,
+ api: "openai-completions",
+ models: [{ id: "auto", name: "Auto" }],
+ },
+ };
+ },
+ },
+ resolveDynamicModel: (ctx) => ({
+ id: ctx.modelId,
+ name: ctx.modelId,
+ provider: "example-proxy",
+ api: "openai-completions",
+ baseUrl: "https://proxy.example.com/v1",
+ reasoning: false,
+ input: ["text"],
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+ contextWindow: 128000,
+ maxTokens: 8192,
+ }),
+ prepareRuntimeAuth: async (ctx) => {
+ const exchanged = await exchangeToken(ctx.apiKey);
+ return {
+ apiKey: exchanged.token,
+ baseUrl: exchanged.baseUrl,
+ expiresAt: exchanged.expiresAt,
+ };
+ },
+ resolveUsageAuth: async (ctx) => {
+ const auth = await ctx.resolveOAuthToken();
+ return auth ? { token: auth.token } : null;
+ },
+ fetchUsageSnapshot: async (ctx) => {
+ return await fetchExampleProxyUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn);
+ },
+});
+```
+
+### Built-in examples
+
+Bundled provider plugins combine the hooks above to fit each vendor's catalog,
+auth, thinking, replay, and usage needs. The authoritative hook set lives with
+each plugin under `extensions/`; this page illustrates the shapes rather than
+mirroring the list.
+
+
+
+ OpenRouter, Kilocode, Z.AI, xAI register `catalog` plus
+ `resolveDynamicModel` / `prepareDynamicModel` so they can surface upstream
+ model ids ahead of OpenClaw's static catalog.
+
+
+ GitHub Copilot, Gemini CLI, ChatGPT Codex, MiniMax, Xiaomi, z.ai pair
+ `prepareRuntimeAuth` or `formatApiKey` with `resolveUsageAuth` +
+ `fetchUsageSnapshot` to own token exchange and `/usage` integration.
+
+
+ Shared named families (`google-gemini`, `passthrough-gemini`,
+ `anthropic-by-model`, `hybrid-anthropic-openai`) let providers opt into
+ transcript policy via `buildReplayPolicy` instead of each plugin
+ re-implementing cleanup.
+
+
+ `byteplus`, `cloudflare-ai-gateway`, `huggingface`, `kimi-coding`, `nvidia`,
+ `qianfan`, `synthetic`, `together`, `venice`, `vercel-ai-gateway`, and
+ `volcengine` register just `catalog` and ride the shared inference loop.
+
+
+ Beta headers, `/fast` / `serviceTier`, and `context1m` live inside the
+ Anthropic plugin's public `api.ts` / `contract-api.ts` seam
+ (`wrapAnthropicProviderStream`, `resolveAnthropicBetas`,
+ `resolveAnthropicFastMode`, `resolveAnthropicServiceTier`) rather than in
+ the generic SDK.
+
+
+
+## Runtime helpers
+
+Plugins can access selected core helpers via `api.runtime`. For TTS:
+
+```ts
+const clip = await api.runtime.tts.textToSpeech({
+ text: "Hello from OpenClaw",
+ cfg: api.config,
+});
+
+const result = await api.runtime.tts.textToSpeechTelephony({
+ text: "Hello from OpenClaw",
+ cfg: api.config,
+});
+
+const voices = await api.runtime.tts.listVoices({
+ provider: "elevenlabs",
+ cfg: api.config,
+});
+```
+
+Notes:
+
+- `textToSpeech` returns the normal core TTS output payload for file/voice-note surfaces.
+- Uses core `messages.tts` configuration and provider selection.
+- Returns PCM audio buffer + sample rate. Plugins must resample/encode for providers.
+- `listVoices` is optional per provider. Use it for vendor-owned voice pickers or setup flows.
+- Voice listings can include richer metadata such as locale, gender, and personality tags for provider-aware pickers.
+- OpenAI and ElevenLabs support telephony today. Microsoft does not.
+
+Plugins can also register speech providers via `api.registerSpeechProvider(...)`.
+
+```ts
+api.registerSpeechProvider({
+ id: "acme-speech",
+ label: "Acme Speech",
+ isConfigured: ({ config }) => Boolean(config.messages?.tts),
+ synthesize: async (req) => {
+ return {
+ audioBuffer: Buffer.from([]),
+ outputFormat: "mp3",
+ fileExtension: ".mp3",
+ voiceCompatible: false,
+ };
+ },
+});
+```
+
+Notes:
+
+- Keep TTS policy, fallback, and reply delivery in core.
+- Use speech providers for vendor-owned synthesis behavior.
+- Legacy Microsoft `edge` input is normalized to the `microsoft` provider id.
+- The preferred ownership model is company-oriented: one vendor plugin can own
+ text, speech, image, and future media providers as OpenClaw adds those
+ capability contracts.
+
+For image/audio/video understanding, plugins register one typed
+media-understanding provider instead of a generic key/value bag:
+
+```ts
+api.registerMediaUnderstandingProvider({
+ id: "google",
+ capabilities: ["image", "audio", "video"],
+ describeImage: async (req) => ({ text: "..." }),
+ transcribeAudio: async (req) => ({ text: "..." }),
+ describeVideo: async (req) => ({ text: "..." }),
+});
+```
+
+Notes:
+
+- Keep orchestration, fallback, config, and channel wiring in core.
+- Keep vendor behavior in the provider plugin.
+- Additive expansion should stay typed: new optional methods, new optional
+ result fields, new optional capabilities.
+- Video generation already follows the same pattern:
+ - core owns the capability contract and runtime helper
+ - vendor plugins register `api.registerVideoGenerationProvider(...)`
+ - feature/channel plugins consume `api.runtime.videoGeneration.*`
+
+For media-understanding runtime helpers, plugins can call:
+
+```ts
+const image = await api.runtime.mediaUnderstanding.describeImageFile({
+ filePath: "/tmp/inbound-photo.jpg",
+ cfg: api.config,
+ agentDir: "/tmp/agent",
+});
+
+const video = await api.runtime.mediaUnderstanding.describeVideoFile({
+ filePath: "/tmp/inbound-video.mp4",
+ cfg: api.config,
+});
+```
+
+For audio transcription, plugins can use either the media-understanding runtime
+or the older STT alias:
+
+```ts
+const { text } = await api.runtime.mediaUnderstanding.transcribeAudioFile({
+ filePath: "/tmp/inbound-audio.ogg",
+ cfg: api.config,
+ // Optional when MIME cannot be inferred reliably:
+ mime: "audio/ogg",
+});
+```
+
+Notes:
+
+- `api.runtime.mediaUnderstanding.*` is the preferred shared surface for
+ image/audio/video understanding.
+- Uses core media-understanding audio configuration (`tools.media.audio`) and provider fallback order.
+- Returns `{ text: undefined }` when no transcription output is produced (for example skipped/unsupported input).
+- `api.runtime.stt.transcribeAudioFile(...)` remains as a compatibility alias.
+
+Plugins can also launch background subagent runs through `api.runtime.subagent`:
+
+```ts
+const result = await api.runtime.subagent.run({
+ sessionKey: "agent:main:subagent:search-helper",
+ message: "Expand this query into focused follow-up searches.",
+ provider: "openai",
+ model: "gpt-4.1-mini",
+ deliver: false,
+});
+```
+
+Notes:
+
+- `provider` and `model` are optional per-run overrides, not persistent session changes.
+- OpenClaw only honors those override fields for trusted callers.
+- For plugin-owned fallback runs, operators must opt in with `plugins.entries..subagent.allowModelOverride: true`.
+- Use `plugins.entries..subagent.allowedModels` to restrict trusted plugins to specific canonical `provider/model` targets, or `"*"` to allow any target explicitly.
+- Untrusted plugin subagent runs still work, but override requests are rejected instead of silently falling back.
+
+For web search, plugins can consume the shared runtime helper instead of
+reaching into the agent tool wiring:
+
+```ts
+const providers = api.runtime.webSearch.listProviders({
+ config: api.config,
+});
+
+const result = await api.runtime.webSearch.search({
+ config: api.config,
+ args: {
+ query: "OpenClaw plugin runtime helpers",
+ count: 5,
+ },
+});
+```
+
+Plugins can also register web-search providers via
+`api.registerWebSearchProvider(...)`.
+
+Notes:
+
+- Keep provider selection, credential resolution, and shared request semantics in core.
+- Use web-search providers for vendor-specific search transports.
+- `api.runtime.webSearch.*` is the preferred shared surface for feature/channel plugins that need search behavior without depending on the agent tool wrapper.
+
+### `api.runtime.imageGeneration`
+
+```ts
+const result = await api.runtime.imageGeneration.generate({
+ config: api.config,
+ args: { prompt: "A friendly lobster mascot", size: "1024x1024" },
+});
+
+const providers = api.runtime.imageGeneration.listProviders({
+ config: api.config,
+});
+```
+
+- `generate(...)`: generate an image using the configured image-generation provider chain.
+- `listProviders(...)`: list available image-generation providers and their capabilities.
+
+## Gateway HTTP routes
+
+Plugins can expose HTTP endpoints with `api.registerHttpRoute(...)`.
+
+```ts
+api.registerHttpRoute({
+ path: "/acme/webhook",
+ auth: "plugin",
+ match: "exact",
+ handler: async (_req, res) => {
+ res.statusCode = 200;
+ res.end("ok");
+ return true;
+ },
+});
+```
+
+Route fields:
+
+- `path`: route path under the gateway HTTP server.
+- `auth`: required. Use `"gateway"` to require normal gateway auth, or `"plugin"` for plugin-managed auth/webhook verification.
+- `match`: optional. `"exact"` (default) or `"prefix"`.
+- `replaceExisting`: optional. Allows the same plugin to replace its own existing route registration.
+- `handler`: return `true` when the route handled the request.
+
+Notes:
+
+- `api.registerHttpHandler(...)` was removed and will cause a plugin-load error. Use `api.registerHttpRoute(...)` instead.
+- Plugin routes must declare `auth` explicitly.
+- Exact `path + match` conflicts are rejected unless `replaceExisting: true`, and one plugin cannot replace another plugin's route.
+- Overlapping routes with different `auth` levels are rejected. Keep `exact`/`prefix` fallthrough chains on the same auth level only.
+- `auth: "plugin"` routes do **not** receive operator runtime scopes automatically. They are for plugin-managed webhooks/signature verification, not privileged Gateway helper calls.
+- `auth: "gateway"` routes run inside a Gateway request runtime scope, but that scope is intentionally conservative:
+ - shared-secret bearer auth (`gateway.auth.mode = "token"` / `"password"`) keeps plugin-route runtime scopes pinned to `operator.write`, even if the caller sends `x-openclaw-scopes`
+ - trusted identity-bearing HTTP modes (for example `trusted-proxy` or `gateway.auth.mode = "none"` on a private ingress) honor `x-openclaw-scopes` only when the header is explicitly present
+ - if `x-openclaw-scopes` is absent on those identity-bearing plugin-route requests, runtime scope falls back to `operator.write`
+- Practical rule: do not assume a gateway-auth plugin route is an implicit admin surface. If your route needs admin-only behavior, require an identity-bearing auth mode and document the explicit `x-openclaw-scopes` header contract.
+
+## Plugin SDK import paths
+
+Use narrow SDK subpaths instead of the monolithic `openclaw/plugin-sdk` root
+barrel when authoring new plugins. Core subpaths:
+
+| Subpath | Purpose |
+| ----------------------------------- | -------------------------------------------------- |
+| `openclaw/plugin-sdk/plugin-entry` | Plugin registration primitives |
+| `openclaw/plugin-sdk/channel-core` | Channel entry/build helpers |
+| `openclaw/plugin-sdk/core` | Generic shared helpers and umbrella contract |
+| `openclaw/plugin-sdk/config-schema` | Root `openclaw.json` Zod schema (`OpenClawSchema`) |
+
+Channel plugins pick from a family of narrow seams — `channel-setup`,
+`setup-runtime`, `setup-adapter-runtime`, `setup-tools`, `channel-pairing`,
+`channel-contract`, `channel-feedback`, `channel-inbound`, `channel-lifecycle`,
+`channel-reply-pipeline`, `command-auth`, `secret-input`, `webhook-ingress`,
+`channel-targets`, and `channel-actions`. Approval behavior should consolidate
+on one `approvalCapability` contract rather than mixing across unrelated
+plugin fields. See [Channel plugins](/plugins/sdk-channel-plugins).
+
+Runtime and config helpers live under matching `*-runtime` subpaths
+(`approval-runtime`, `config-runtime`, `infra-runtime`, `agent-runtime`,
+`lazy-runtime`, `directory-runtime`, `text-runtime`, `runtime-store`, etc.).
+
+
+`openclaw/plugin-sdk/channel-runtime` is deprecated — a compatibility shim for
+older plugins. New code should import narrower generic primitives instead.
+
+
+Repo-internal entry points (per bundled plugin package root):
+
+- `index.js` — bundled plugin entry
+- `api.js` — helper/types barrel
+- `runtime-api.js` — runtime-only barrel
+- `setup-entry.js` — setup plugin entry
+
+External plugins should only import `openclaw/plugin-sdk/*` subpaths. Never
+import another plugin package's `src/*` from core or from another plugin.
+Facade-loaded entry points prefer the active runtime config snapshot when one
+exists, then fall back to the resolved config file on disk.
+
+Capability-specific subpaths such as `image-generation`, `media-understanding`,
+and `speech` exist because bundled plugins use them today. They are not
+automatically long-term frozen external contracts — check the relevant SDK
+reference page when relying on them.
+
+## Message tool schemas
+
+Plugins should own channel-specific `describeMessageTool(...)` schema
+contributions for non-message primitives such as reactions, reads, and polls.
+Shared send presentation should use the generic `MessagePresentation` contract
+instead of provider-native button, component, block, or card fields.
+See [Message Presentation](/plugins/message-presentation) for the contract,
+fallback rules, provider mapping, and plugin author checklist.
+
+Send-capable plugins declare what they can render through message capabilities:
+
+- `presentation` for semantic presentation blocks (`text`, `context`, `divider`, `buttons`, `select`)
+- `delivery-pin` for pinned-delivery requests
+
+Core decides whether to render the presentation natively or degrade it to text.
+Do not expose provider-native UI escape hatches from the generic message tool.
+Deprecated SDK helpers for legacy native schemas remain exported for existing
+third-party plugins, but new plugins should not use them.
+
+## Channel target resolution
+
+Channel plugins should own channel-specific target semantics. Keep the shared
+outbound host generic and use the messaging adapter surface for provider rules:
+
+- `messaging.inferTargetChatType({ to })` decides whether a normalized target
+ should be treated as `direct`, `group`, or `channel` before directory lookup.
+- `messaging.targetResolver.looksLikeId(raw, normalized)` tells core whether an
+ input should skip straight to id-like resolution instead of directory search.
+- `messaging.targetResolver.resolveTarget(...)` is the plugin fallback when
+ core needs a final provider-owned resolution after normalization or after a
+ directory miss.
+- `messaging.resolveOutboundSessionRoute(...)` owns provider-specific session
+ route construction once a target is resolved.
+
+Recommended split:
+
+- Use `inferTargetChatType` for category decisions that should happen before
+ searching peers/groups.
+- Use `looksLikeId` for "treat this as an explicit/native target id" checks.
+- Use `resolveTarget` for provider-specific normalization fallback, not for
+ broad directory search.
+- Keep provider-native ids like chat ids, thread ids, JIDs, handles, and room
+ ids inside `target` values or provider-specific params, not in generic SDK
+ fields.
+
+## Config-backed directories
+
+Plugins that derive directory entries from config should keep that logic in the
+plugin and reuse the shared helpers from
+`openclaw/plugin-sdk/directory-runtime`.
+
+Use this when a channel needs config-backed peers/groups such as:
+
+- allowlist-driven DM peers
+- configured channel/group maps
+- account-scoped static directory fallbacks
+
+The shared helpers in `directory-runtime` only handle generic operations:
+
+- query filtering
+- limit application
+- deduping/normalization helpers
+- building `ChannelDirectoryEntry[]`
+
+Channel-specific account inspection and id normalization should stay in the
+plugin implementation.
+
+## Provider catalogs
+
+Provider plugins can define model catalogs for inference with
+`registerProvider({ catalog: { run(...) { ... } } })`.
+
+`catalog.run(...)` returns the same shape OpenClaw writes into
+`models.providers`:
+
+- `{ provider }` for one provider entry
+- `{ providers }` for multiple provider entries
+
+Use `catalog` when the plugin owns provider-specific model ids, base URL
+defaults, or auth-gated model metadata.
+
+`catalog.order` controls when a plugin's catalog merges relative to OpenClaw's
+built-in implicit providers:
+
+- `simple`: plain API-key or env-driven providers
+- `profile`: providers that appear when auth profiles exist
+- `paired`: providers that synthesize multiple related provider entries
+- `late`: last pass, after other implicit providers
+
+Later providers win on key collision, so plugins can intentionally override a
+built-in provider entry with the same provider id.
+
+Compatibility:
+
+- `discovery` still works as a legacy alias
+- if both `catalog` and `discovery` are registered, OpenClaw uses `catalog`
+
+## Read-only channel inspection
+
+If your plugin registers a channel, prefer implementing
+`plugin.config.inspectAccount(cfg, accountId)` alongside `resolveAccount(...)`.
+
+Why:
+
+- `resolveAccount(...)` is the runtime path. It is allowed to assume credentials
+ are fully materialized and can fail fast when required secrets are missing.
+- Read-only command paths such as `openclaw status`, `openclaw status --all`,
+ `openclaw channels status`, `openclaw channels resolve`, and doctor/config
+ repair flows should not need to materialize runtime credentials just to
+ describe configuration.
+
+Recommended `inspectAccount(...)` behavior:
+
+- Return descriptive account state only.
+- Preserve `enabled` and `configured`.
+- Include credential source/status fields when relevant, such as:
+ - `tokenSource`, `tokenStatus`
+ - `botTokenSource`, `botTokenStatus`
+ - `appTokenSource`, `appTokenStatus`
+ - `signingSecretSource`, `signingSecretStatus`
+- You do not need to return raw token values just to report read-only
+ availability. Returning `tokenStatus: "available"` (and the matching source
+ field) is enough for status-style commands.
+- Use `configured_unavailable` when a credential is configured via SecretRef but
+ unavailable in the current command path.
+
+This lets read-only commands report "configured but unavailable in this command
+path" instead of crashing or misreporting the account as not configured.
+
+## Package packs
+
+A plugin directory may include a `package.json` with `openclaw.extensions`:
+
+```json
+{
+ "name": "my-pack",
+ "openclaw": {
+ "extensions": ["./src/safety.ts", "./src/tools.ts"],
+ "setupEntry": "./src/setup-entry.ts"
+ }
+}
+```
+
+Each entry becomes a plugin. If the pack lists multiple extensions, the plugin id
+becomes `name/`.
+
+If your plugin imports npm deps, install them in that directory so
+`node_modules` is available (`npm install` / `pnpm install`).
+
+Security guardrail: every `openclaw.extensions` entry must stay inside the plugin
+directory after symlink resolution. Entries that escape the package directory are
+rejected.
+
+Security note: `openclaw plugins install` installs plugin dependencies with
+`npm install --omit=dev --ignore-scripts` (no lifecycle scripts, no dev dependencies at runtime). Keep plugin dependency
+trees "pure JS/TS" and avoid packages that require `postinstall` builds.
+
+Optional: `openclaw.setupEntry` can point at a lightweight setup-only module.
+When OpenClaw needs setup surfaces for a disabled channel plugin, or
+when a channel plugin is enabled but still unconfigured, it loads `setupEntry`
+instead of the full plugin entry. This keeps startup and setup lighter
+when your main plugin entry also wires tools, hooks, or other runtime-only
+code.
+
+Optional: `openclaw.startup.deferConfiguredChannelFullLoadUntilAfterListen`
+can opt a channel plugin into the same `setupEntry` path during the gateway's
+pre-listen startup phase, even when the channel is already configured.
+
+Use this only when `setupEntry` fully covers the startup surface that must exist
+before the gateway starts listening. In practice, that means the setup entry
+must register every channel-owned capability that startup depends on, such as:
+
+- channel registration itself
+- any HTTP routes that must be available before the gateway starts listening
+- any gateway methods, tools, or services that must exist during that same window
+
+If your full entry still owns any required startup capability, do not enable
+this flag. Keep the plugin on the default behavior and let OpenClaw load the
+full entry during startup.
+
+Bundled channels can also publish setup-only contract-surface helpers that core
+can consult before the full channel runtime is loaded. The current setup
+promotion surface is:
+
+- `singleAccountKeysToMove`
+- `namedAccountPromotionKeys`
+- `resolveSingleAccountPromotionTarget(...)`
+
+Core uses that surface when it needs to promote a legacy single-account channel
+config into `channels..accounts.*` without loading the full plugin entry.
+Matrix is the current bundled example: it moves only auth/bootstrap keys into a
+named promoted account when named accounts already exist, and it can preserve a
+configured non-canonical default-account key instead of always creating
+`accounts.default`.
+
+Those setup patch adapters keep bundled contract-surface discovery lazy. Import
+time stays light; the promotion surface is loaded only on first use instead of
+re-entering bundled channel startup on module import.
+
+When those startup surfaces include gateway RPC methods, keep them on a
+plugin-specific prefix. Core admin namespaces (`config.*`,
+`exec.approvals.*`, `wizard.*`, `update.*`) remain reserved and always resolve
+to `operator.admin`, even if a plugin requests a narrower scope.
+
+Example:
+
+```json
+{
+ "name": "@scope/my-channel",
+ "openclaw": {
+ "extensions": ["./index.ts"],
+ "setupEntry": "./setup-entry.ts",
+ "startup": {
+ "deferConfiguredChannelFullLoadUntilAfterListen": true
+ }
+ }
+}
+```
+
+### Channel catalog metadata
+
+Channel plugins can advertise setup/discovery metadata via `openclaw.channel` and
+install hints via `openclaw.install`. This keeps the core catalog data-free.
+
+Example:
+
+```json
+{
+ "name": "@openclaw/nextcloud-talk",
+ "openclaw": {
+ "extensions": ["./index.ts"],
+ "channel": {
+ "id": "nextcloud-talk",
+ "label": "Nextcloud Talk",
+ "selectionLabel": "Nextcloud Talk (self-hosted)",
+ "docsPath": "/channels/nextcloud-talk",
+ "docsLabel": "nextcloud-talk",
+ "blurb": "Self-hosted chat via Nextcloud Talk webhook bots.",
+ "order": 65,
+ "aliases": ["nc-talk", "nc"]
+ },
+ "install": {
+ "npmSpec": "@openclaw/nextcloud-talk",
+ "localPath": "",
+ "defaultChoice": "npm"
+ }
+ }
+}
+```
+
+Useful `openclaw.channel` fields beyond the minimal example:
+
+- `detailLabel`: secondary label for richer catalog/status surfaces
+- `docsLabel`: override link text for the docs link
+- `preferOver`: lower-priority plugin/channel ids this catalog entry should outrank
+- `selectionDocsPrefix`, `selectionDocsOmitLabel`, `selectionExtras`: selection-surface copy controls
+- `markdownCapable`: marks the channel as markdown-capable for outbound formatting decisions
+- `exposure.configured`: hide the channel from configured-channel listing surfaces when set to `false`
+- `exposure.setup`: hide the channel from interactive setup/configure pickers when set to `false`
+- `exposure.docs`: mark the channel as internal/private for docs navigation surfaces
+- `showConfigured` / `showInSetup`: legacy aliases still accepted for compatibility; prefer `exposure`
+- `quickstartAllowFrom`: opt the channel into the standard quickstart `allowFrom` flow
+- `forceAccountBinding`: require explicit account binding even when only one account exists
+- `preferSessionLookupForAnnounceTarget`: prefer session lookup when resolving announce targets
+
+OpenClaw can also merge **external channel catalogs** (for example, an MPM
+registry export). Drop a JSON file at one of:
+
+- `~/.openclaw/mpm/plugins.json`
+- `~/.openclaw/mpm/catalog.json`
+- `~/.openclaw/plugins/catalog.json`
+
+Or point `OPENCLAW_PLUGIN_CATALOG_PATHS` (or `OPENCLAW_MPM_CATALOG_PATHS`) at
+one or more JSON files (comma/semicolon/`PATH`-delimited). Each file should
+contain `{ "entries": [ { "name": "@scope/pkg", "openclaw": { "channel": {...}, "install": {...} } } ] }`. The parser also accepts `"packages"` or `"plugins"` as legacy aliases for the `"entries"` key.
+
+## Context engine plugins
+
+Context engine plugins own session context orchestration for ingest, assembly,
+and compaction. Register them from your plugin with
+`api.registerContextEngine(id, factory)`, then select the active engine with
+`plugins.slots.contextEngine`.
+
+Use this when your plugin needs to replace or extend the default context
+pipeline rather than just add memory search or hooks.
+
+```ts
+import { buildMemorySystemPromptAddition } from "openclaw/plugin-sdk/core";
+
+export default function (api) {
+ api.registerContextEngine("lossless-claw", () => ({
+ info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true },
+ async ingest() {
+ return { ingested: true };
+ },
+ async assemble({ messages, availableTools, citationsMode }) {
+ return {
+ messages,
+ estimatedTokens: 0,
+ systemPromptAddition: buildMemorySystemPromptAddition({
+ availableTools: availableTools ?? new Set(),
+ citationsMode,
+ }),
+ };
+ },
+ async compact() {
+ return { ok: true, compacted: false };
+ },
+ }));
+}
+```
+
+If your engine does **not** own the compaction algorithm, keep `compact()`
+implemented and delegate it explicitly:
+
+```ts
+import {
+ buildMemorySystemPromptAddition,
+ delegateCompactionToRuntime,
+} from "openclaw/plugin-sdk/core";
+
+export default function (api) {
+ api.registerContextEngine("my-memory-engine", () => ({
+ info: {
+ id: "my-memory-engine",
+ name: "My Memory Engine",
+ ownsCompaction: false,
+ },
+ async ingest() {
+ return { ingested: true };
+ },
+ async assemble({ messages, availableTools, citationsMode }) {
+ return {
+ messages,
+ estimatedTokens: 0,
+ systemPromptAddition: buildMemorySystemPromptAddition({
+ availableTools: availableTools ?? new Set(),
+ citationsMode,
+ }),
+ };
+ },
+ async compact(params) {
+ return await delegateCompactionToRuntime(params);
+ },
+ }));
+}
+```
+
+## Adding a new capability
+
+When a plugin needs behavior that does not fit the current API, do not bypass
+the plugin system with a private reach-in. Add the missing capability.
+
+Recommended sequence:
+
+1. define the core contract
+ Decide what shared behavior core should own: policy, fallback, config merge,
+ lifecycle, channel-facing semantics, and runtime helper shape.
+2. add typed plugin registration/runtime surfaces
+ Extend `OpenClawPluginApi` and/or `api.runtime` with the smallest useful
+ typed capability surface.
+3. wire core + channel/feature consumers
+ Channels and feature plugins should consume the new capability through core,
+ not by importing a vendor implementation directly.
+4. register vendor implementations
+ Vendor plugins then register their backends against the capability.
+5. add contract coverage
+ Add tests so ownership and registration shape stay explicit over time.
+
+This is how OpenClaw stays opinionated without becoming hardcoded to one
+provider's worldview. See the [Capability Cookbook](/tools/capability-cookbook)
+for a concrete file checklist and worked example.
+
+### Capability checklist
+
+When you add a new capability, the implementation should usually touch these
+surfaces together:
+
+- core contract types in `src//types.ts`
+- core runner/runtime helper in `src//runtime.ts`
+- plugin API registration surface in `src/plugins/types.ts`
+- plugin registry wiring in `src/plugins/registry.ts`
+- plugin runtime exposure in `src/plugins/runtime/*` when feature/channel
+ plugins need to consume it
+- capture/test helpers in `src/test-utils/plugin-registration.ts`
+- ownership/contract assertions in `src/plugins/contracts/registry.ts`
+- operator/plugin docs in `docs/`
+
+If one of those surfaces is missing, that is usually a sign the capability is
+not fully integrated yet.
+
+### Capability template
+
+Minimal pattern:
+
+```ts
+// core contract
+export type VideoGenerationProviderPlugin = {
+ id: string;
+ label: string;
+ generateVideo: (req: VideoGenerationRequest) => Promise;
+};
+
+// plugin API
+api.registerVideoGenerationProvider({
+ id: "openai",
+ label: "OpenAI",
+ async generateVideo(req) {
+ return await generateOpenAiVideo(req);
+ },
+});
+
+// shared runtime helper for feature/channel plugins
+const clip = await api.runtime.videoGeneration.generate({
+ prompt: "Show the robot walking through the lab.",
+ cfg,
+});
+```
+
+Contract test pattern:
+
+```ts
+expect(findVideoGenerationProviderIdsForPlugin("openai")).toEqual(["openai"]);
+```
+
+That keeps the rule simple:
+
+- core owns the capability contract + orchestration
+- vendor plugins own vendor implementations
+- feature/channel plugins consume runtime helpers
+- contract tests keep ownership explicit
+
+## Related
+
+- [Plugin architecture](/plugins/architecture) — public capability model and shapes
+- [Plugin SDK subpaths](/plugins/sdk-subpaths)
+- [Plugin SDK setup](/plugins/sdk-setup)
+- [Building plugins](/plugins/building-plugins)
diff --git a/docs/plugins/architecture.md b/docs/plugins/architecture.md
index d81e801c569..1783fb6bfa7 100644
--- a/docs/plugins/architecture.md
+++ b/docs/plugins/architecture.md
@@ -498,1025 +498,12 @@ map for compatibility and bundled-plugin maintenance. Current examples include
reserved implementation-detail exports, not as the recommended SDK pattern for
new third-party plugins.
-## Load pipeline
+## Internals and reference
-At startup, OpenClaw does roughly this:
-
-1. discover candidate plugin roots
-2. read native or compatible bundle manifests and package metadata
-3. reject unsafe candidates
-4. normalize plugin config (`plugins.enabled`, `allow`, `deny`, `entries`,
- `slots`, `load.paths`)
-5. decide enablement for each candidate
-6. load enabled native modules: built bundled modules use a native loader;
- unbuilt native plugins use jiti
-7. call native `register(api)` hooks and collect registrations into the plugin registry
-8. expose the registry to commands/runtime surfaces
-
-
-`activate` is a legacy alias for `register` — the loader resolves whichever is present (`def.register ?? def.activate`) and calls it at the same point. All bundled plugins use `register`; prefer `register` for new plugins.
-
-
-The safety gates happen **before** runtime execution. Candidates are blocked
-when the entry escapes the plugin root, the path is world-writable, or path
-ownership looks suspicious for non-bundled plugins.
-
-### Manifest-first behavior
-
-The manifest is the control-plane source of truth. OpenClaw uses it to:
-
-- identify the plugin
-- discover declared channels/skills/config schema or bundle capabilities
-- validate `plugins.entries..config`
-- augment Control UI labels/placeholders
-- show install/catalog metadata
-- preserve cheap activation and setup descriptors without loading plugin runtime
-
-For native plugins, the runtime module is the data-plane part. It registers
-actual behavior such as hooks, tools, commands, or provider flows.
-
-Optional manifest `activation` and `setup` blocks stay on the control plane.
-They are metadata-only descriptors for activation planning and setup discovery;
-they do not replace runtime registration, `register(...)`, or `setupEntry`.
-The first live activation consumers now use manifest command, channel, and provider hints
-to narrow plugin loading before broader registry materialization:
-
-- CLI loading narrows to plugins that own the requested primary command
-- channel setup/plugin resolution narrows to plugins that own the requested
- channel id
-- explicit provider setup/runtime resolution narrows to plugins that own the
- requested provider id
-
-Setup discovery now prefers descriptor-owned ids such as `setup.providers` and
-`setup.cliBackends` to narrow candidate plugins before it falls back to
-`setup-api` for plugins that still need setup-time runtime hooks. If more than
-one discovered plugin claims the same normalized setup provider or CLI backend
-id, setup lookup refuses the ambiguous owner instead of relying on discovery
-order.
-
-### What the loader caches
-
-OpenClaw keeps short in-process caches for:
-
-- discovery results
-- manifest registry data
-- loaded plugin registries
-
-These caches reduce bursty startup and repeated command overhead. They are safe
-to think of as short-lived performance caches, not persistence.
-
-Performance note:
-
-- Set `OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE=1` or
- `OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE=1` to disable these caches.
-- Tune cache windows with `OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS` and
- `OPENCLAW_PLUGIN_MANIFEST_CACHE_MS`.
-
-## Registry model
-
-Loaded plugins do not directly mutate random core globals. They register into a
-central plugin registry.
-
-The registry tracks:
-
-- plugin records (identity, source, origin, status, diagnostics)
-- tools
-- legacy hooks and typed hooks
-- channels
-- providers
-- gateway RPC handlers
-- HTTP routes
-- CLI registrars
-- background services
-- plugin-owned commands
-
-Core features then read from that registry instead of talking to plugin modules
-directly. This keeps loading one-way:
-
-- plugin module -> registry registration
-- core runtime -> registry consumption
-
-That separation matters for maintainability. It means most core surfaces only
-need one integration point: "read the registry", not "special-case every plugin
-module".
-
-## Conversation binding callbacks
-
-Plugins that bind a conversation can react when an approval is resolved.
-
-Use `api.onConversationBindingResolved(...)` to receive a callback after a bind
-request is approved or denied:
-
-```ts
-export default {
- id: "my-plugin",
- register(api) {
- api.onConversationBindingResolved(async (event) => {
- if (event.status === "approved") {
- // A binding now exists for this plugin + conversation.
- console.log(event.binding?.conversationId);
- return;
- }
-
- // The request was denied; clear any local pending state.
- console.log(event.request.conversation.conversationId);
- });
- },
-};
-```
-
-Callback payload fields:
-
-- `status`: `"approved"` or `"denied"`
-- `decision`: `"allow-once"`, `"allow-always"`, or `"deny"`
-- `binding`: the resolved binding for approved requests
-- `request`: the original request summary, detach hint, sender id, and
- conversation metadata
-
-This callback is notification-only. It does not change who is allowed to bind a
-conversation, and it runs after core approval handling finishes.
-
-## Provider runtime hooks
-
-Provider plugins have three layers:
-
-- **Manifest metadata** for cheap pre-runtime lookup: `providerAuthEnvVars`,
- `providerAuthAliases`, `providerAuthChoices`, and `channelEnvVars`.
-- **Config-time hooks**: `catalog` (legacy `discovery`) plus
- `applyConfigDefaults`.
-- **Runtime hooks**: 40+ optional hooks covering auth, model resolution,
- stream wrapping, thinking levels, replay policy, and usage endpoints. See
- the full list under [Hook order and usage](#hook-order-and-usage).
-
-OpenClaw still owns the generic agent loop, failover, transcript handling, and
-tool policy. These hooks are the extension surface for provider-specific
-behavior without needing a whole custom inference transport.
-
-Use manifest `providerAuthEnvVars` when the provider has env-based credentials
-that generic auth/status/model-picker paths should see without loading plugin
-runtime. Use manifest `providerAuthAliases` when one provider id should reuse
-another provider id's env vars, auth profiles, config-backed auth, and API-key
-onboarding choice. Use manifest `providerAuthChoices` when onboarding/auth-choice
-CLI surfaces should know the provider's choice id, group labels, and simple
-one-flag auth wiring without loading provider runtime. Keep provider runtime
-`envVars` for operator-facing hints such as onboarding labels or OAuth
-client-id/client-secret setup vars.
-
-Use manifest `channelEnvVars` when a channel has env-driven auth or setup that
-generic shell-env fallback, config/status checks, or setup prompts should see
-without loading channel runtime.
-
-### Hook order and usage
-
-For model/provider plugins, OpenClaw calls hooks in this rough order.
-The "When to use" column is the quick decision guide.
-
-| # | Hook | What it does | When to use |
-| --- | --------------------------------- | -------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
-| 1 | `catalog` | Publish provider config into `models.providers` during `models.json` generation | Provider owns a catalog or base URL defaults |
-| 2 | `applyConfigDefaults` | Apply provider-owned global config defaults during config materialization | Defaults depend on auth mode, env, or provider model-family semantics |
-| -- | _(built-in model lookup)_ | OpenClaw tries the normal registry/catalog path first | _(not a plugin hook)_ |
-| 3 | `normalizeModelId` | Normalize legacy or preview model-id aliases before lookup | Provider owns alias cleanup before canonical model resolution |
-| 4 | `normalizeTransport` | Normalize provider-family `api` / `baseUrl` before generic model assembly | Provider owns transport cleanup for custom provider ids in the same transport family |
-| 5 | `normalizeConfig` | Normalize `models.providers.` before runtime/provider resolution | Provider needs config cleanup that should live with the plugin; bundled Google-family helpers also backstop supported Google config entries |
-| 6 | `applyNativeStreamingUsageCompat` | Apply native streaming-usage compat rewrites to config providers | Provider needs endpoint-driven native streaming usage metadata fixes |
-| 7 | `resolveConfigApiKey` | Resolve env-marker auth for config providers before runtime auth loading | Provider has provider-owned env-marker API-key resolution; `amazon-bedrock` also has a built-in AWS env-marker resolver here |
-| 8 | `resolveSyntheticAuth` | Surface local/self-hosted or config-backed auth without persisting plaintext | Provider can operate with a synthetic/local credential marker |
-| 9 | `resolveExternalAuthProfiles` | Overlay provider-owned external auth profiles; default `persistence` is `runtime-only` for CLI/app-owned creds | Provider reuses external auth credentials without persisting copied refresh tokens; declare `contracts.externalAuthProviders` in the manifest |
-| 10 | `shouldDeferSyntheticProfileAuth` | Lower stored synthetic profile placeholders behind env/config-backed auth | Provider stores synthetic placeholder profiles that should not win precedence |
-| 11 | `resolveDynamicModel` | Sync fallback for provider-owned model ids not in the local registry yet | Provider accepts arbitrary upstream model ids |
-| 12 | `prepareDynamicModel` | Async warm-up, then `resolveDynamicModel` runs again | Provider needs network metadata before resolving unknown ids |
-| 13 | `normalizeResolvedModel` | Final rewrite before the embedded runner uses the resolved model | Provider needs transport rewrites but still uses a core transport |
-| 14 | `contributeResolvedModelCompat` | Contribute compat flags for vendor models behind another compatible transport | Provider recognizes its own models on proxy transports without taking over the provider |
-| 15 | `capabilities` | Provider-owned transcript/tooling metadata used by shared core logic | Provider needs transcript/provider-family quirks |
-| 16 | `normalizeToolSchemas` | Normalize tool schemas before the embedded runner sees them | Provider needs transport-family schema cleanup |
-| 17 | `inspectToolSchemas` | Surface provider-owned schema diagnostics after normalization | Provider wants keyword warnings without teaching core provider-specific rules |
-| 18 | `resolveReasoningOutputMode` | Select native vs tagged reasoning-output contract | Provider needs tagged reasoning/final output instead of native fields |
-| 19 | `prepareExtraParams` | Request-param normalization before generic stream option wrappers | Provider needs default request params or per-provider param cleanup |
-| 20 | `createStreamFn` | Fully replace the normal stream path with a custom transport | Provider needs a custom wire protocol, not just a wrapper |
-| 21 | `wrapStreamFn` | Stream wrapper after generic wrappers are applied | Provider needs request headers/body/model compat wrappers without a custom transport |
-| 22 | `resolveTransportTurnState` | Attach native per-turn transport headers or metadata | Provider wants generic transports to send provider-native turn identity |
-| 23 | `resolveWebSocketSessionPolicy` | Attach native WebSocket headers or session cool-down policy | Provider wants generic WS transports to tune session headers or fallback policy |
-| 24 | `formatApiKey` | Auth-profile formatter: stored profile becomes the runtime `apiKey` string | Provider stores extra auth metadata and needs a custom runtime token shape |
-| 25 | `refreshOAuth` | OAuth refresh override for custom refresh endpoints or refresh-failure policy | Provider does not fit the shared `pi-ai` refreshers |
-| 26 | `buildAuthDoctorHint` | Repair hint appended when OAuth refresh fails | Provider needs provider-owned auth repair guidance after refresh failure |
-| 27 | `matchesContextOverflowError` | Provider-owned context-window overflow matcher | Provider has raw overflow errors generic heuristics would miss |
-| 28 | `classifyFailoverReason` | Provider-owned failover reason classification | Provider can map raw API/transport errors to rate-limit/overload/etc |
-| 29 | `isCacheTtlEligible` | Prompt-cache policy for proxy/backhaul providers | Provider needs proxy-specific cache TTL gating |
-| 30 | `buildMissingAuthMessage` | Replacement for the generic missing-auth recovery message | Provider needs a provider-specific missing-auth recovery hint |
-| 31 | `suppressBuiltInModel` | Stale upstream model suppression plus optional user-facing error hint | Provider needs to hide stale upstream rows or replace them with a vendor hint |
-| 32 | `augmentModelCatalog` | Synthetic/final catalog rows appended after discovery | Provider needs synthetic forward-compat rows in `models list` and pickers |
-| 33 | `resolveThinkingProfile` | Model-specific `/think` level set, display labels, and default | Provider exposes a custom thinking ladder or binary label for selected models |
-| 34 | `isBinaryThinking` | On/off reasoning toggle compatibility hook | Provider exposes only binary thinking on/off |
-| 35 | `supportsXHighThinking` | `xhigh` reasoning support compatibility hook | Provider wants `xhigh` on only a subset of models |
-| 36 | `resolveDefaultThinkingLevel` | Default `/think` level compatibility hook | Provider owns default `/think` policy for a model family |
-| 37 | `isModernModelRef` | Modern-model matcher for live profile filters and smoke selection | Provider owns live/smoke preferred-model matching |
-| 38 | `prepareRuntimeAuth` | Exchange a configured credential into the actual runtime token/key just before inference | Provider needs a token exchange or short-lived request credential |
-| 39 | `resolveUsageAuth` | Resolve usage/billing credentials for `/usage` and related status surfaces | Provider needs custom usage/quota token parsing or a different usage credential |
-| 40 | `fetchUsageSnapshot` | Fetch and normalize provider-specific usage/quota snapshots after auth is resolved | Provider needs a provider-specific usage endpoint or payload parser |
-| 41 | `createEmbeddingProvider` | Build a provider-owned embedding adapter for memory/search | Memory embedding behavior belongs with the provider plugin |
-| 42 | `buildReplayPolicy` | Return a replay policy controlling transcript handling for the provider | Provider needs custom transcript policy (for example, thinking-block stripping) |
-| 43 | `sanitizeReplayHistory` | Rewrite replay history after generic transcript cleanup | Provider needs provider-specific replay rewrites beyond shared compaction helpers |
-| 44 | `validateReplayTurns` | Final replay-turn validation or reshaping before the embedded runner | Provider transport needs stricter turn validation after generic sanitation |
-| 45 | `onModelSelected` | Run provider-owned post-selection side effects | Provider needs telemetry or provider-owned state when a model becomes active |
-
-`normalizeModelId`, `normalizeTransport`, and `normalizeConfig` first check the
-matched provider plugin, then fall through other hook-capable provider plugins
-until one actually changes the model id or transport/config. That keeps
-alias/compat provider shims working without requiring the caller to know which
-bundled plugin owns the rewrite. If no provider hook rewrites a supported
-Google-family config entry, the bundled Google config normalizer still applies
-that compatibility cleanup.
-
-If the provider needs a fully custom wire protocol or custom request executor,
-that is a different class of extension. These hooks are for provider behavior
-that still runs on OpenClaw's normal inference loop.
-
-### Provider example
-
-```ts
-api.registerProvider({
- id: "example-proxy",
- label: "Example Proxy",
- auth: [],
- catalog: {
- order: "simple",
- run: async (ctx) => {
- const apiKey = ctx.resolveProviderApiKey("example-proxy").apiKey;
- if (!apiKey) {
- return null;
- }
- return {
- provider: {
- baseUrl: "https://proxy.example.com/v1",
- apiKey,
- api: "openai-completions",
- models: [{ id: "auto", name: "Auto" }],
- },
- };
- },
- },
- resolveDynamicModel: (ctx) => ({
- id: ctx.modelId,
- name: ctx.modelId,
- provider: "example-proxy",
- api: "openai-completions",
- baseUrl: "https://proxy.example.com/v1",
- reasoning: false,
- input: ["text"],
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
- contextWindow: 128000,
- maxTokens: 8192,
- }),
- prepareRuntimeAuth: async (ctx) => {
- const exchanged = await exchangeToken(ctx.apiKey);
- return {
- apiKey: exchanged.token,
- baseUrl: exchanged.baseUrl,
- expiresAt: exchanged.expiresAt,
- };
- },
- resolveUsageAuth: async (ctx) => {
- const auth = await ctx.resolveOAuthToken();
- return auth ? { token: auth.token } : null;
- },
- fetchUsageSnapshot: async (ctx) => {
- return await fetchExampleProxyUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn);
- },
-});
-```
-
-### Built-in examples
-
-Bundled provider plugins combine the hooks above to fit each vendor's catalog,
-auth, thinking, replay, and usage needs. The authoritative hook set lives with
-each plugin under `extensions/`; this page illustrates the shapes rather than
-mirroring the list.
-
-
-
- OpenRouter, Kilocode, Z.AI, xAI register `catalog` plus
- `resolveDynamicModel` / `prepareDynamicModel` so they can surface upstream
- model ids ahead of OpenClaw's static catalog.
-
-
- GitHub Copilot, Gemini CLI, ChatGPT Codex, MiniMax, Xiaomi, z.ai pair
- `prepareRuntimeAuth` or `formatApiKey` with `resolveUsageAuth` +
- `fetchUsageSnapshot` to own token exchange and `/usage` integration.
-
-
- Shared named families (`google-gemini`, `passthrough-gemini`,
- `anthropic-by-model`, `hybrid-anthropic-openai`) let providers opt into
- transcript policy via `buildReplayPolicy` instead of each plugin
- re-implementing cleanup.
-
-
- `byteplus`, `cloudflare-ai-gateway`, `huggingface`, `kimi-coding`, `nvidia`,
- `qianfan`, `synthetic`, `together`, `venice`, `vercel-ai-gateway`, and
- `volcengine` register just `catalog` and ride the shared inference loop.
-
-
- Beta headers, `/fast` / `serviceTier`, and `context1m` live inside the
- Anthropic plugin's public `api.ts` / `contract-api.ts` seam
- (`wrapAnthropicProviderStream`, `resolveAnthropicBetas`,
- `resolveAnthropicFastMode`, `resolveAnthropicServiceTier`) rather than in
- the generic SDK.
-
-
-
-## Runtime helpers
-
-Plugins can access selected core helpers via `api.runtime`. For TTS:
-
-```ts
-const clip = await api.runtime.tts.textToSpeech({
- text: "Hello from OpenClaw",
- cfg: api.config,
-});
-
-const result = await api.runtime.tts.textToSpeechTelephony({
- text: "Hello from OpenClaw",
- cfg: api.config,
-});
-
-const voices = await api.runtime.tts.listVoices({
- provider: "elevenlabs",
- cfg: api.config,
-});
-```
-
-Notes:
-
-- `textToSpeech` returns the normal core TTS output payload for file/voice-note surfaces.
-- Uses core `messages.tts` configuration and provider selection.
-- Returns PCM audio buffer + sample rate. Plugins must resample/encode for providers.
-- `listVoices` is optional per provider. Use it for vendor-owned voice pickers or setup flows.
-- Voice listings can include richer metadata such as locale, gender, and personality tags for provider-aware pickers.
-- OpenAI and ElevenLabs support telephony today. Microsoft does not.
-
-Plugins can also register speech providers via `api.registerSpeechProvider(...)`.
-
-```ts
-api.registerSpeechProvider({
- id: "acme-speech",
- label: "Acme Speech",
- isConfigured: ({ config }) => Boolean(config.messages?.tts),
- synthesize: async (req) => {
- return {
- audioBuffer: Buffer.from([]),
- outputFormat: "mp3",
- fileExtension: ".mp3",
- voiceCompatible: false,
- };
- },
-});
-```
-
-Notes:
-
-- Keep TTS policy, fallback, and reply delivery in core.
-- Use speech providers for vendor-owned synthesis behavior.
-- Legacy Microsoft `edge` input is normalized to the `microsoft` provider id.
-- The preferred ownership model is company-oriented: one vendor plugin can own
- text, speech, image, and future media providers as OpenClaw adds those
- capability contracts.
-
-For image/audio/video understanding, plugins register one typed
-media-understanding provider instead of a generic key/value bag:
-
-```ts
-api.registerMediaUnderstandingProvider({
- id: "google",
- capabilities: ["image", "audio", "video"],
- describeImage: async (req) => ({ text: "..." }),
- transcribeAudio: async (req) => ({ text: "..." }),
- describeVideo: async (req) => ({ text: "..." }),
-});
-```
-
-Notes:
-
-- Keep orchestration, fallback, config, and channel wiring in core.
-- Keep vendor behavior in the provider plugin.
-- Additive expansion should stay typed: new optional methods, new optional
- result fields, new optional capabilities.
-- Video generation already follows the same pattern:
- - core owns the capability contract and runtime helper
- - vendor plugins register `api.registerVideoGenerationProvider(...)`
- - feature/channel plugins consume `api.runtime.videoGeneration.*`
-
-For media-understanding runtime helpers, plugins can call:
-
-```ts
-const image = await api.runtime.mediaUnderstanding.describeImageFile({
- filePath: "/tmp/inbound-photo.jpg",
- cfg: api.config,
- agentDir: "/tmp/agent",
-});
-
-const video = await api.runtime.mediaUnderstanding.describeVideoFile({
- filePath: "/tmp/inbound-video.mp4",
- cfg: api.config,
-});
-```
-
-For audio transcription, plugins can use either the media-understanding runtime
-or the older STT alias:
-
-```ts
-const { text } = await api.runtime.mediaUnderstanding.transcribeAudioFile({
- filePath: "/tmp/inbound-audio.ogg",
- cfg: api.config,
- // Optional when MIME cannot be inferred reliably:
- mime: "audio/ogg",
-});
-```
-
-Notes:
-
-- `api.runtime.mediaUnderstanding.*` is the preferred shared surface for
- image/audio/video understanding.
-- Uses core media-understanding audio configuration (`tools.media.audio`) and provider fallback order.
-- Returns `{ text: undefined }` when no transcription output is produced (for example skipped/unsupported input).
-- `api.runtime.stt.transcribeAudioFile(...)` remains as a compatibility alias.
-
-Plugins can also launch background subagent runs through `api.runtime.subagent`:
-
-```ts
-const result = await api.runtime.subagent.run({
- sessionKey: "agent:main:subagent:search-helper",
- message: "Expand this query into focused follow-up searches.",
- provider: "openai",
- model: "gpt-4.1-mini",
- deliver: false,
-});
-```
-
-Notes:
-
-- `provider` and `model` are optional per-run overrides, not persistent session changes.
-- OpenClaw only honors those override fields for trusted callers.
-- For plugin-owned fallback runs, operators must opt in with `plugins.entries..subagent.allowModelOverride: true`.
-- Use `plugins.entries..subagent.allowedModels` to restrict trusted plugins to specific canonical `provider/model` targets, or `"*"` to allow any target explicitly.
-- Untrusted plugin subagent runs still work, but override requests are rejected instead of silently falling back.
-
-For web search, plugins can consume the shared runtime helper instead of
-reaching into the agent tool wiring:
-
-```ts
-const providers = api.runtime.webSearch.listProviders({
- config: api.config,
-});
-
-const result = await api.runtime.webSearch.search({
- config: api.config,
- args: {
- query: "OpenClaw plugin runtime helpers",
- count: 5,
- },
-});
-```
-
-Plugins can also register web-search providers via
-`api.registerWebSearchProvider(...)`.
-
-Notes:
-
-- Keep provider selection, credential resolution, and shared request semantics in core.
-- Use web-search providers for vendor-specific search transports.
-- `api.runtime.webSearch.*` is the preferred shared surface for feature/channel plugins that need search behavior without depending on the agent tool wrapper.
-
-### `api.runtime.imageGeneration`
-
-```ts
-const result = await api.runtime.imageGeneration.generate({
- config: api.config,
- args: { prompt: "A friendly lobster mascot", size: "1024x1024" },
-});
-
-const providers = api.runtime.imageGeneration.listProviders({
- config: api.config,
-});
-```
-
-- `generate(...)`: generate an image using the configured image-generation provider chain.
-- `listProviders(...)`: list available image-generation providers and their capabilities.
-
-## Gateway HTTP routes
-
-Plugins can expose HTTP endpoints with `api.registerHttpRoute(...)`.
-
-```ts
-api.registerHttpRoute({
- path: "/acme/webhook",
- auth: "plugin",
- match: "exact",
- handler: async (_req, res) => {
- res.statusCode = 200;
- res.end("ok");
- return true;
- },
-});
-```
-
-Route fields:
-
-- `path`: route path under the gateway HTTP server.
-- `auth`: required. Use `"gateway"` to require normal gateway auth, or `"plugin"` for plugin-managed auth/webhook verification.
-- `match`: optional. `"exact"` (default) or `"prefix"`.
-- `replaceExisting`: optional. Allows the same plugin to replace its own existing route registration.
-- `handler`: return `true` when the route handled the request.
-
-Notes:
-
-- `api.registerHttpHandler(...)` was removed and will cause a plugin-load error. Use `api.registerHttpRoute(...)` instead.
-- Plugin routes must declare `auth` explicitly.
-- Exact `path + match` conflicts are rejected unless `replaceExisting: true`, and one plugin cannot replace another plugin's route.
-- Overlapping routes with different `auth` levels are rejected. Keep `exact`/`prefix` fallthrough chains on the same auth level only.
-- `auth: "plugin"` routes do **not** receive operator runtime scopes automatically. They are for plugin-managed webhooks/signature verification, not privileged Gateway helper calls.
-- `auth: "gateway"` routes run inside a Gateway request runtime scope, but that scope is intentionally conservative:
- - shared-secret bearer auth (`gateway.auth.mode = "token"` / `"password"`) keeps plugin-route runtime scopes pinned to `operator.write`, even if the caller sends `x-openclaw-scopes`
- - trusted identity-bearing HTTP modes (for example `trusted-proxy` or `gateway.auth.mode = "none"` on a private ingress) honor `x-openclaw-scopes` only when the header is explicitly present
- - if `x-openclaw-scopes` is absent on those identity-bearing plugin-route requests, runtime scope falls back to `operator.write`
-- Practical rule: do not assume a gateway-auth plugin route is an implicit admin surface. If your route needs admin-only behavior, require an identity-bearing auth mode and document the explicit `x-openclaw-scopes` header contract.
-
-## Plugin SDK import paths
-
-Use narrow SDK subpaths instead of the monolithic `openclaw/plugin-sdk` root
-barrel when authoring new plugins. Core subpaths:
-
-| Subpath | Purpose |
-| ----------------------------------- | -------------------------------------------------- |
-| `openclaw/plugin-sdk/plugin-entry` | Plugin registration primitives |
-| `openclaw/plugin-sdk/channel-core` | Channel entry/build helpers |
-| `openclaw/plugin-sdk/core` | Generic shared helpers and umbrella contract |
-| `openclaw/plugin-sdk/config-schema` | Root `openclaw.json` Zod schema (`OpenClawSchema`) |
-
-Channel plugins pick from a family of narrow seams — `channel-setup`,
-`setup-runtime`, `setup-adapter-runtime`, `setup-tools`, `channel-pairing`,
-`channel-contract`, `channel-feedback`, `channel-inbound`, `channel-lifecycle`,
-`channel-reply-pipeline`, `command-auth`, `secret-input`, `webhook-ingress`,
-`channel-targets`, and `channel-actions`. Approval behavior should consolidate
-on one `approvalCapability` contract rather than mixing across unrelated
-plugin fields. See [Channel plugins](/plugins/sdk-channel-plugins).
-
-Runtime and config helpers live under matching `*-runtime` subpaths
-(`approval-runtime`, `config-runtime`, `infra-runtime`, `agent-runtime`,
-`lazy-runtime`, `directory-runtime`, `text-runtime`, `runtime-store`, etc.).
-
-
-`openclaw/plugin-sdk/channel-runtime` is deprecated — a compatibility shim for
-older plugins. New code should import narrower generic primitives instead.
-
-
-Repo-internal entry points (per bundled plugin package root):
-
-- `index.js` — bundled plugin entry
-- `api.js` — helper/types barrel
-- `runtime-api.js` — runtime-only barrel
-- `setup-entry.js` — setup plugin entry
-
-External plugins should only import `openclaw/plugin-sdk/*` subpaths. Never
-import another plugin package's `src/*` from core or from another plugin.
-Facade-loaded entry points prefer the active runtime config snapshot when one
-exists, then fall back to the resolved config file on disk.
-
-Capability-specific subpaths such as `image-generation`, `media-understanding`,
-and `speech` exist because bundled plugins use them today. They are not
-automatically long-term frozen external contracts — check the relevant SDK
-reference page when relying on them.
-
-## Message tool schemas
-
-Plugins should own channel-specific `describeMessageTool(...)` schema
-contributions for non-message primitives such as reactions, reads, and polls.
-Shared send presentation should use the generic `MessagePresentation` contract
-instead of provider-native button, component, block, or card fields.
-See [Message Presentation](/plugins/message-presentation) for the contract,
-fallback rules, provider mapping, and plugin author checklist.
-
-Send-capable plugins declare what they can render through message capabilities:
-
-- `presentation` for semantic presentation blocks (`text`, `context`, `divider`, `buttons`, `select`)
-- `delivery-pin` for pinned-delivery requests
-
-Core decides whether to render the presentation natively or degrade it to text.
-Do not expose provider-native UI escape hatches from the generic message tool.
-Deprecated SDK helpers for legacy native schemas remain exported for existing
-third-party plugins, but new plugins should not use them.
-
-## Channel target resolution
-
-Channel plugins should own channel-specific target semantics. Keep the shared
-outbound host generic and use the messaging adapter surface for provider rules:
-
-- `messaging.inferTargetChatType({ to })` decides whether a normalized target
- should be treated as `direct`, `group`, or `channel` before directory lookup.
-- `messaging.targetResolver.looksLikeId(raw, normalized)` tells core whether an
- input should skip straight to id-like resolution instead of directory search.
-- `messaging.targetResolver.resolveTarget(...)` is the plugin fallback when
- core needs a final provider-owned resolution after normalization or after a
- directory miss.
-- `messaging.resolveOutboundSessionRoute(...)` owns provider-specific session
- route construction once a target is resolved.
-
-Recommended split:
-
-- Use `inferTargetChatType` for category decisions that should happen before
- searching peers/groups.
-- Use `looksLikeId` for "treat this as an explicit/native target id" checks.
-- Use `resolveTarget` for provider-specific normalization fallback, not for
- broad directory search.
-- Keep provider-native ids like chat ids, thread ids, JIDs, handles, and room
- ids inside `target` values or provider-specific params, not in generic SDK
- fields.
-
-## Config-backed directories
-
-Plugins that derive directory entries from config should keep that logic in the
-plugin and reuse the shared helpers from
-`openclaw/plugin-sdk/directory-runtime`.
-
-Use this when a channel needs config-backed peers/groups such as:
-
-- allowlist-driven DM peers
-- configured channel/group maps
-- account-scoped static directory fallbacks
-
-The shared helpers in `directory-runtime` only handle generic operations:
-
-- query filtering
-- limit application
-- deduping/normalization helpers
-- building `ChannelDirectoryEntry[]`
-
-Channel-specific account inspection and id normalization should stay in the
-plugin implementation.
-
-## Provider catalogs
-
-Provider plugins can define model catalogs for inference with
-`registerProvider({ catalog: { run(...) { ... } } })`.
-
-`catalog.run(...)` returns the same shape OpenClaw writes into
-`models.providers`:
-
-- `{ provider }` for one provider entry
-- `{ providers }` for multiple provider entries
-
-Use `catalog` when the plugin owns provider-specific model ids, base URL
-defaults, or auth-gated model metadata.
-
-`catalog.order` controls when a plugin's catalog merges relative to OpenClaw's
-built-in implicit providers:
-
-- `simple`: plain API-key or env-driven providers
-- `profile`: providers that appear when auth profiles exist
-- `paired`: providers that synthesize multiple related provider entries
-- `late`: last pass, after other implicit providers
-
-Later providers win on key collision, so plugins can intentionally override a
-built-in provider entry with the same provider id.
-
-Compatibility:
-
-- `discovery` still works as a legacy alias
-- if both `catalog` and `discovery` are registered, OpenClaw uses `catalog`
-
-## Read-only channel inspection
-
-If your plugin registers a channel, prefer implementing
-`plugin.config.inspectAccount(cfg, accountId)` alongside `resolveAccount(...)`.
-
-Why:
-
-- `resolveAccount(...)` is the runtime path. It is allowed to assume credentials
- are fully materialized and can fail fast when required secrets are missing.
-- Read-only command paths such as `openclaw status`, `openclaw status --all`,
- `openclaw channels status`, `openclaw channels resolve`, and doctor/config
- repair flows should not need to materialize runtime credentials just to
- describe configuration.
-
-Recommended `inspectAccount(...)` behavior:
-
-- Return descriptive account state only.
-- Preserve `enabled` and `configured`.
-- Include credential source/status fields when relevant, such as:
- - `tokenSource`, `tokenStatus`
- - `botTokenSource`, `botTokenStatus`
- - `appTokenSource`, `appTokenStatus`
- - `signingSecretSource`, `signingSecretStatus`
-- You do not need to return raw token values just to report read-only
- availability. Returning `tokenStatus: "available"` (and the matching source
- field) is enough for status-style commands.
-- Use `configured_unavailable` when a credential is configured via SecretRef but
- unavailable in the current command path.
-
-This lets read-only commands report "configured but unavailable in this command
-path" instead of crashing or misreporting the account as not configured.
-
-## Package packs
-
-A plugin directory may include a `package.json` with `openclaw.extensions`:
-
-```json
-{
- "name": "my-pack",
- "openclaw": {
- "extensions": ["./src/safety.ts", "./src/tools.ts"],
- "setupEntry": "./src/setup-entry.ts"
- }
-}
-```
-
-Each entry becomes a plugin. If the pack lists multiple extensions, the plugin id
-becomes `name/`.
-
-If your plugin imports npm deps, install them in that directory so
-`node_modules` is available (`npm install` / `pnpm install`).
-
-Security guardrail: every `openclaw.extensions` entry must stay inside the plugin
-directory after symlink resolution. Entries that escape the package directory are
-rejected.
-
-Security note: `openclaw plugins install` installs plugin dependencies with
-`npm install --omit=dev --ignore-scripts` (no lifecycle scripts, no dev dependencies at runtime). Keep plugin dependency
-trees "pure JS/TS" and avoid packages that require `postinstall` builds.
-
-Optional: `openclaw.setupEntry` can point at a lightweight setup-only module.
-When OpenClaw needs setup surfaces for a disabled channel plugin, or
-when a channel plugin is enabled but still unconfigured, it loads `setupEntry`
-instead of the full plugin entry. This keeps startup and setup lighter
-when your main plugin entry also wires tools, hooks, or other runtime-only
-code.
-
-Optional: `openclaw.startup.deferConfiguredChannelFullLoadUntilAfterListen`
-can opt a channel plugin into the same `setupEntry` path during the gateway's
-pre-listen startup phase, even when the channel is already configured.
-
-Use this only when `setupEntry` fully covers the startup surface that must exist
-before the gateway starts listening. In practice, that means the setup entry
-must register every channel-owned capability that startup depends on, such as:
-
-- channel registration itself
-- any HTTP routes that must be available before the gateway starts listening
-- any gateway methods, tools, or services that must exist during that same window
-
-If your full entry still owns any required startup capability, do not enable
-this flag. Keep the plugin on the default behavior and let OpenClaw load the
-full entry during startup.
-
-Bundled channels can also publish setup-only contract-surface helpers that core
-can consult before the full channel runtime is loaded. The current setup
-promotion surface is:
-
-- `singleAccountKeysToMove`
-- `namedAccountPromotionKeys`
-- `resolveSingleAccountPromotionTarget(...)`
-
-Core uses that surface when it needs to promote a legacy single-account channel
-config into `channels..accounts.*` without loading the full plugin entry.
-Matrix is the current bundled example: it moves only auth/bootstrap keys into a
-named promoted account when named accounts already exist, and it can preserve a
-configured non-canonical default-account key instead of always creating
-`accounts.default`.
-
-Those setup patch adapters keep bundled contract-surface discovery lazy. Import
-time stays light; the promotion surface is loaded only on first use instead of
-re-entering bundled channel startup on module import.
-
-When those startup surfaces include gateway RPC methods, keep them on a
-plugin-specific prefix. Core admin namespaces (`config.*`,
-`exec.approvals.*`, `wizard.*`, `update.*`) remain reserved and always resolve
-to `operator.admin`, even if a plugin requests a narrower scope.
-
-Example:
-
-```json
-{
- "name": "@scope/my-channel",
- "openclaw": {
- "extensions": ["./index.ts"],
- "setupEntry": "./setup-entry.ts",
- "startup": {
- "deferConfiguredChannelFullLoadUntilAfterListen": true
- }
- }
-}
-```
-
-### Channel catalog metadata
-
-Channel plugins can advertise setup/discovery metadata via `openclaw.channel` and
-install hints via `openclaw.install`. This keeps the core catalog data-free.
-
-Example:
-
-```json
-{
- "name": "@openclaw/nextcloud-talk",
- "openclaw": {
- "extensions": ["./index.ts"],
- "channel": {
- "id": "nextcloud-talk",
- "label": "Nextcloud Talk",
- "selectionLabel": "Nextcloud Talk (self-hosted)",
- "docsPath": "/channels/nextcloud-talk",
- "docsLabel": "nextcloud-talk",
- "blurb": "Self-hosted chat via Nextcloud Talk webhook bots.",
- "order": 65,
- "aliases": ["nc-talk", "nc"]
- },
- "install": {
- "npmSpec": "@openclaw/nextcloud-talk",
- "localPath": "",
- "defaultChoice": "npm"
- }
- }
-}
-```
-
-Useful `openclaw.channel` fields beyond the minimal example:
-
-- `detailLabel`: secondary label for richer catalog/status surfaces
-- `docsLabel`: override link text for the docs link
-- `preferOver`: lower-priority plugin/channel ids this catalog entry should outrank
-- `selectionDocsPrefix`, `selectionDocsOmitLabel`, `selectionExtras`: selection-surface copy controls
-- `markdownCapable`: marks the channel as markdown-capable for outbound formatting decisions
-- `exposure.configured`: hide the channel from configured-channel listing surfaces when set to `false`
-- `exposure.setup`: hide the channel from interactive setup/configure pickers when set to `false`
-- `exposure.docs`: mark the channel as internal/private for docs navigation surfaces
-- `showConfigured` / `showInSetup`: legacy aliases still accepted for compatibility; prefer `exposure`
-- `quickstartAllowFrom`: opt the channel into the standard quickstart `allowFrom` flow
-- `forceAccountBinding`: require explicit account binding even when only one account exists
-- `preferSessionLookupForAnnounceTarget`: prefer session lookup when resolving announce targets
-
-OpenClaw can also merge **external channel catalogs** (for example, an MPM
-registry export). Drop a JSON file at one of:
-
-- `~/.openclaw/mpm/plugins.json`
-- `~/.openclaw/mpm/catalog.json`
-- `~/.openclaw/plugins/catalog.json`
-
-Or point `OPENCLAW_PLUGIN_CATALOG_PATHS` (or `OPENCLAW_MPM_CATALOG_PATHS`) at
-one or more JSON files (comma/semicolon/`PATH`-delimited). Each file should
-contain `{ "entries": [ { "name": "@scope/pkg", "openclaw": { "channel": {...}, "install": {...} } } ] }`. The parser also accepts `"packages"` or `"plugins"` as legacy aliases for the `"entries"` key.
-
-## Context engine plugins
-
-Context engine plugins own session context orchestration for ingest, assembly,
-and compaction. Register them from your plugin with
-`api.registerContextEngine(id, factory)`, then select the active engine with
-`plugins.slots.contextEngine`.
-
-Use this when your plugin needs to replace or extend the default context
-pipeline rather than just add memory search or hooks.
-
-```ts
-import { buildMemorySystemPromptAddition } from "openclaw/plugin-sdk/core";
-
-export default function (api) {
- api.registerContextEngine("lossless-claw", () => ({
- info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true },
- async ingest() {
- return { ingested: true };
- },
- async assemble({ messages, availableTools, citationsMode }) {
- return {
- messages,
- estimatedTokens: 0,
- systemPromptAddition: buildMemorySystemPromptAddition({
- availableTools: availableTools ?? new Set(),
- citationsMode,
- }),
- };
- },
- async compact() {
- return { ok: true, compacted: false };
- },
- }));
-}
-```
-
-If your engine does **not** own the compaction algorithm, keep `compact()`
-implemented and delegate it explicitly:
-
-```ts
-import {
- buildMemorySystemPromptAddition,
- delegateCompactionToRuntime,
-} from "openclaw/plugin-sdk/core";
-
-export default function (api) {
- api.registerContextEngine("my-memory-engine", () => ({
- info: {
- id: "my-memory-engine",
- name: "My Memory Engine",
- ownsCompaction: false,
- },
- async ingest() {
- return { ingested: true };
- },
- async assemble({ messages, availableTools, citationsMode }) {
- return {
- messages,
- estimatedTokens: 0,
- systemPromptAddition: buildMemorySystemPromptAddition({
- availableTools: availableTools ?? new Set(),
- citationsMode,
- }),
- };
- },
- async compact(params) {
- return await delegateCompactionToRuntime(params);
- },
- }));
-}
-```
-
-## Adding a new capability
-
-When a plugin needs behavior that does not fit the current API, do not bypass
-the plugin system with a private reach-in. Add the missing capability.
-
-Recommended sequence:
-
-1. define the core contract
- Decide what shared behavior core should own: policy, fallback, config merge,
- lifecycle, channel-facing semantics, and runtime helper shape.
-2. add typed plugin registration/runtime surfaces
- Extend `OpenClawPluginApi` and/or `api.runtime` with the smallest useful
- typed capability surface.
-3. wire core + channel/feature consumers
- Channels and feature plugins should consume the new capability through core,
- not by importing a vendor implementation directly.
-4. register vendor implementations
- Vendor plugins then register their backends against the capability.
-5. add contract coverage
- Add tests so ownership and registration shape stay explicit over time.
-
-This is how OpenClaw stays opinionated without becoming hardcoded to one
-provider's worldview. See the [Capability Cookbook](/tools/capability-cookbook)
-for a concrete file checklist and worked example.
-
-### Capability checklist
-
-When you add a new capability, the implementation should usually touch these
-surfaces together:
-
-- core contract types in `src//types.ts`
-- core runner/runtime helper in `src//runtime.ts`
-- plugin API registration surface in `src/plugins/types.ts`
-- plugin registry wiring in `src/plugins/registry.ts`
-- plugin runtime exposure in `src/plugins/runtime/*` when feature/channel
- plugins need to consume it
-- capture/test helpers in `src/test-utils/plugin-registration.ts`
-- ownership/contract assertions in `src/plugins/contracts/registry.ts`
-- operator/plugin docs in `docs/`
-
-If one of those surfaces is missing, that is usually a sign the capability is
-not fully integrated yet.
-
-### Capability template
-
-Minimal pattern:
-
-```ts
-// core contract
-export type VideoGenerationProviderPlugin = {
- id: string;
- label: string;
- generateVideo: (req: VideoGenerationRequest) => Promise;
-};
-
-// plugin API
-api.registerVideoGenerationProvider({
- id: "openai",
- label: "OpenAI",
- async generateVideo(req) {
- return await generateOpenAiVideo(req);
- },
-});
-
-// shared runtime helper for feature/channel plugins
-const clip = await api.runtime.videoGeneration.generate({
- prompt: "Show the robot walking through the lab.",
- cfg,
-});
-```
-
-Contract test pattern:
-
-```ts
-expect(findVideoGenerationProviderIdsForPlugin("openai")).toEqual(["openai"]);
-```
-
-That keeps the rule simple:
-
-- core owns the capability contract + orchestration
-- vendor plugins own vendor implementations
-- feature/channel plugins consume runtime helpers
-- contract tests keep ownership explicit
+For the load pipeline, registry model, provider runtime hooks, Gateway HTTP
+routes, message tool schemas, channel target resolution, provider catalogs,
+context engine plugins, and the guide to adding a new capability, see
+[Plugin architecture internals](/plugins/architecture-internals).
## Related
diff --git a/docs/plugins/building-plugins.md b/docs/plugins/building-plugins.md
index 3efd942b98e..28e44aeccea 100644
--- a/docs/plugins/building-plugins.md
+++ b/docs/plugins/building-plugins.md
@@ -164,7 +164,7 @@ A single plugin can register any number of capabilities via the `api` object:
| Agent tools | `api.registerTool(...)` | Below |
| Custom commands | `api.registerCommand(...)` | [Entry Points](/plugins/sdk-entrypoints) |
| Event hooks | `api.registerHook(...)` | [Entry Points](/plugins/sdk-entrypoints) |
-| HTTP routes | `api.registerHttpRoute(...)` | [Internals](/plugins/architecture#gateway-http-routes) |
+| HTTP routes | `api.registerHttpRoute(...)` | [Internals](/plugins/architecture-internals#gateway-http-routes) |
| CLI subcommands | `api.registerCli(...)` | [Entry Points](/plugins/sdk-entrypoints) |
For the full registration API, see [SDK Overview](/plugins/sdk-overview#registration-api).
diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md
index 06834714714..7788b0d2b82 100644
--- a/docs/plugins/manifest.md
+++ b/docs/plugins/manifest.md
@@ -683,7 +683,7 @@ See [Configuration reference](/gateway/configuration) for the full `plugins.*` s
- `channels`, `providers`, `cliBackends`, and `skills` can all be omitted when a plugin does not need them.
- Exclusive plugin kinds are selected through `plugins.slots.*`: `kind: "memory"` via `plugins.slots.memory`, `kind: "context-engine"` via `plugins.slots.contextEngine` (default `legacy`).
- Env-var metadata (`providerAuthEnvVars`, `channelEnvVars`) is declarative only. Status, audit, cron delivery validation, and other read-only surfaces still apply plugin trust and effective activation policy before treating an env var as configured.
-- For runtime wizard metadata that requires provider code, see [Provider runtime hooks](/plugins/architecture#provider-runtime-hooks).
+- For runtime wizard metadata that requires provider code, see [Provider runtime hooks](/plugins/architecture-internals#provider-runtime-hooks).
- If your plugin depends on native modules, document the build steps and any package-manager allowlist requirements (for example, pnpm `allow-build-scripts` + `pnpm rebuild `).
## Related
diff --git a/docs/plugins/message-presentation.md b/docs/plugins/message-presentation.md
index a45aa28fb1d..509f69fdb7c 100644
--- a/docs/plugins/message-presentation.md
+++ b/docs/plugins/message-presentation.md
@@ -332,5 +332,5 @@ messages where the provider supports those operations.
- [Message CLI](/cli/message)
- [Plugin SDK Overview](/plugins/sdk-overview)
-- [Plugin Architecture](/plugins/architecture#message-tool-schemas)
+- [Plugin Architecture](/plugins/architecture-internals#message-tool-schemas)
- [Channel Presentation Refactor Plan](/plan/ui-channels)
diff --git a/docs/plugins/sdk-channel-plugins.md b/docs/plugins/sdk-channel-plugins.md
index 77d830651cb..6ea998874df 100644
--- a/docs/plugins/sdk-channel-plugins.md
+++ b/docs/plugins/sdk-channel-plugins.md
@@ -631,7 +631,7 @@ Write colocated tests in `src/channel.test.ts`:
describeMessageTool and action discovery
-
+
inferTargetChatType, looksLikeId, resolveTarget
diff --git a/docs/plugins/sdk-provider-plugins.md b/docs/plugins/sdk-provider-plugins.md
index 75b80584bda..978555aea6c 100644
--- a/docs/plugins/sdk-provider-plugins.md
+++ b/docs/plugins/sdk-provider-plugins.md
@@ -474,7 +474,7 @@ API key auth, and dynamic model resolution.
- `resolveConfigApiKey` uses the provider hook when exposed. The bundled `amazon-bedrock` path also has a built-in AWS env-marker resolver here, even though Bedrock runtime auth itself still uses the AWS SDK default chain.
- `resolveSystemPromptContribution` lets a provider inject cache-aware system-prompt guidance for a model family. Prefer it over `before_prompt_build` when the behavior belongs to one provider/model family and should preserve the stable/dynamic cache split.
- For detailed descriptions and real-world examples, see [Internals: Provider Runtime Hooks](/plugins/architecture#provider-runtime-hooks).
+ For detailed descriptions and real-world examples, see [Internals: Provider Runtime Hooks](/plugins/architecture-internals#provider-runtime-hooks).
@@ -717,7 +717,7 @@ providers:
- [Channel Plugins](/plugins/sdk-channel-plugins) — if your plugin also provides a channel
- [SDK Runtime](/plugins/sdk-runtime) — `api.runtime` helpers (TTS, search, subagent)
- [SDK Overview](/plugins/sdk-overview) — full subpath import reference
-- [Plugin Internals](/plugins/architecture#provider-runtime-hooks) — hook details and bundled examples
+- [Plugin Internals](/plugins/architecture-internals#provider-runtime-hooks) — hook details and bundled examples
## Related