Plugin Architecture Internals: Load Pipeline, Registry, Hooks, Routes
This page details OpenClaw's internal plugin mechanics: load pipeline, registry, runtime hooks, HTTP routes, import paths, and schema tables. It is intended for developers extending or debugging the plugin system.
Read this 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
For the public capability model, plugin shapes, and ownership/execution contracts, see Plugin architecture. This page covers the internal mechanics: load pipeline, registry, runtime hooks, Gateway HTTP routes, import paths, and schema tables.
Load pipeline
At startup, OpenClaw performs these steps:
- locate candidate plugin roots
- read native or compatible bundle manifests and package metadata
- filter out unsafe candidates
- normalize plugin config (
plugins.enabled,allow,deny,entries,slots,load.paths) - determine which candidates are enabled
- load enabled native modules: built bundled modules use a native loader; third-party local source TypeScript uses the emergency Jiti fallback
- invoke native
register(api)hooks and gather registrations into the plugin registry - expose the registry to commands/runtime surfaces
Safety gates run before runtime execution. Discovery rejects a candidate when:
- its resolved entry escapes the plugin root
- its path (or its root directory) is world-writable
- for non-bundled plugins, path ownership does not match the current uid (or root)
World-writable bundled directories get an in-place chmod repair attempt first (npm/global installs can ship package dirs at 0777) before the gate re-checks; ownership checks are skipped for bundled origin entirely.
Blocked candidates still carry their plugin id in the emitted diagnostic when one is known (including ids resolved from a manifest inside an otherwise-rejected directory), so config referencing that id sees a blocked plugin tied to a path-safety warning instead of an unrelated "unknown plugin" error.
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.<id>.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. Live activation consumers 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
- Gateway startup planning uses
activation.onStartupfor explicit startup imports; plugins without startup metadata load only through narrower activation triggers
The activation planner exposes both an ids-only API for existing callers and a plan API for diagnostics. Plan entries report why a plugin was selected, separating explicit activation.* hints from manifest-ownership fallback:
Reason (from activation.* hints) | Reason (from manifest ownership) |
|---|---|
activation-agent-harness-hint | , |
activation-capability-hint | , |
activation-channel-hint | manifest-channel-owner (channels) |
activation-command-hint | manifest-command-alias (commandAliases) |
activation-provider-hint | manifest-provider-owner (providers), manifest-setup-provider-owner (setup.providers) |
activation-route-hint | , |
| , (hook trigger has no hint variant) | manifest-hook-owner (hooks), manifest-tool-contract (contracts.tools) |
That reason split is the compatibility boundary: existing plugin metadata keeps working, while new code can detect broad hints or fallback behavior without changing runtime loading semantics.
Request-time runtime preloads that ask for the broad all scope still derive an explicit effective plugin id set from config, startup planning, configured channels, slots, and auto-enable rules (resolveEffectivePluginIds in src/plugins/effective-plugin-ids.ts). If that derived set is empty, OpenClaw keeps the scope empty instead of widening to every discoverable plugin.
Setup discovery prefers descriptor-owned ids such as setup.providers and setup.cliBackends to narrow candidate plugins before falling back to setup-api for plugins that still need setup-time runtime hooks. Provider setup lists use manifest providerAuthChoices, descriptor-derived setup choices, and install-catalog metadata without loading provider runtime. Explicit setup.requiresRuntime: false is a descriptor-only cutoff; omitted requiresRuntime keeps the legacy setup-api fallback for compatibility. 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. When setup runtime executes, registry diagnostics reject undeclared provider and CLI backend registrations. CLI backend descriptors also report missing runtime registrations; provider descriptors may stay metadata-only while the setup module contributes other setup hooks.
Plugin cache boundary
OpenClaw does not cache plugin discovery results or direct manifest registry data behind wall-clock windows. Installs, manifest edits, and load-path changes must become visible on the next explicit metadata read or snapshot rebuild. The manifest file parser keeps a bounded file-signature cache keyed by the opened manifest path plus device/inode, size, and mtime/ctime; that cache only avoids re-parsing unchanged bytes and must not cache discovery, registry, owner, or policy answers.
The safe metadata fast path is explicit object ownership, not a hidden cache. Gateway startup hot paths should pass the current PluginMetadataSnapshot, the derived PluginLookUpTable, or an explicit manifest registry through the call chain. Config validation, startup auto-enable, plugin bootstrap, and provider selection can reuse those objects while they represent the current config and plugin inventory. Setup lookup still reconstructs manifest metadata on demand unless the specific setup path receives an explicit manifest registry; keep that as a cold-path fallback rather than adding hidden lookup caches. When the input changes, rebuild and replace the snapshot instead of mutating it or keeping historical copies. Views over the active plugin registry and bundled channel bootstrap helpers should be recomputed from the current registry/root. Short-lived maps are fine inside one call to dedupe work or guard reentry; they must not become process metadata caches.
For plugin loading, the persistent cache layer is runtime loading. It may reuse loader state when code or installed artifacts are actually loaded, such as:
PluginLoaderCacheStateand compatible active runtime registries- jiti/module caches and public-surface loader caches used to avoid importing the same runtime surface repeatedly
- filesystem caches for installed plugin artifacts
- short-lived per-call maps for path normalization or duplicate resolution
Those caches are data-plane implementation details. They must not answer control-plane questions such as "which plugin owns this provider?" unless the caller deliberately asked for runtime loading.
Do not add persistent or wall-clock caches for:
- discovery results
- direct manifest registries
- manifest registries reconstructed from the installed plugin index
- provider owner lookup, model suppression, provider policy, or public-artifact metadata
- any other manifest-derived answer where a changed manifest, installed index, or load path should be visible on the next metadata read
Callers that rebuild manifest metadata from the persisted installed plugin index reconstruct that registry on demand. The installed index is durable source-plane state; it is not a hidden in-process metadata cache.
Registry model
Loaded plugins do not directly mutate random core globals. They register into a central plugin registry (PluginRegistry in src/plugins/registry-types.ts), which tracks plugin records (identity, source, origin, status, diagnostics) plus arrays for every capability: tools, legacy hooks and typed hooks, channels, providers, gateway RPC handlers, HTTP routes, CLI registrars, background services, plugin-owned commands, and dozens more typed provider families (speech, embeddings, image/video/music generation, web fetch/search, agent harnesses, session actions, and so on).
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
When a conversation-bound plugin is active, it can respond to the outcome of an approval decision.
To get notified once a bind request has been either accepted or rejected, call api.onConversationBindingResolved(...):
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);
});
},
};
Fields carried by the callback payload:
status: either"approved"or"denied"decision: one of"allow-once","allow-always", or"deny"binding: the binding that was settled, present only when the request was approvedrequest: the original request details, including the detach hint, sender id, and conversation metadata
This callback serves purely as a notification. It has no effect on binding permissions, and it fires only after the core approval process has completed.
Provider runtime hooks
Provider plugins are organized into three tiers:
- Manifest metadata, which enables inexpensive lookup before runtime starts:
setup.providers[].envVars,providerAuthAliases,providerAuthChoices, andchannelConfigs. - Config-time hooks:
catalogtogether withapplyConfigDefaults. - Runtime hooks: more than 40 optional hooks that span auth, model resolution, stream wrapping, thinking levels, replay policy, and usage endpoints. Refer to Hook order and usage for details.
OpenClaw retains control over the generic agent loop, failover, transcript handling, and tool policy. These hooks give providers a way to extend behavior without building a fully custom inference transport.
When a provider relies on environment-based credentials that the generic auth, status, and model-picker paths should recognize without loading the plugin runtime, use manifest setup.providers[].envVars. If one provider id needs to share another provider id's env vars, auth profiles, config-backed auth, and API-key onboarding selection, use manifest providerAuthAliases. When the onboarding or auth-choice CLI surfaces need to know the provider's choice id, group labels, and a straightforward one-flag auth setup without loading provider runtime, use manifest providerAuthChoices. Keep provider runtime envVars for operator-facing hints like onboarding labels or OAuth client-id and client-secret setup variables.
Use the owning channelConfigs.<id>.schema and setup descriptors to describe env-driven channel configuration and authentication.
Hook order and usage
For model and provider plugins, OpenClaw invokes hooks in approximately this sequence. The "When to use" column acts as a quick reference for deciding which hook fits. Compatibility-only provider fields that OpenClaw no longer invokes, including ProviderPlugin.capabilities and suppressBuiltInModel, are deliberately omitted from this list.
| Hook | What it does | When to use |
|---|---|---|
catalog | Inject provider configuration into models.providers as part of models.json generation | Provider supplies catalog or base URL defaults |
applyConfigDefaults | Set provider-level global config defaults when materializing configuration | Defaults hinge on auth mode, environment, or provider model-family semantics |
| (built-in model lookup) | OpenClaw attempts the standard registry/catalog route first | (not a plugin hook) |
normalizeModelId | Clean up legacy or preview model-id aliases prior to lookup | Provider handles alias cleanup before canonical model resolution |
normalizeTransport | Standardize provider-family api / baseUrl ahead of generic model assembly | Provider manages transport cleanup for custom provider ids within the same transport family |
normalizeConfig | Standardize models.providers.<id> before runtime/provider resolution | Provider requires config cleanup tied to the plugin; bundled Google-family helpers also backstop supported Google config entries |
applyNativeStreamingUsageCompat | Apply native streaming-usage compat rewrites to config providers | Provider needs endpoint-driven native streaming usage metadata fixes |
resolveConfigApiKey | Handle env-marker auth for config providers prior to runtime auth loading | Providers expose their own env-marker API-key resolution hooks |
resolveSyntheticAuth | Expose local/self-hosted or config-backed auth without storing plaintext | Provider can operate with a synthetic/local credential marker |
resolveExternalAuthProfiles | Layer 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 |
shouldDeferSyntheticProfileAuth | Demote stored synthetic profile placeholders behind env/config-backed auth | Provider stores synthetic placeholder profiles that should not win precedence |
resolveDynamicModel | Fallback sync for provider-owned model ids absent from the local registry yet | Provider accepts arbitrary upstream model ids |
prepareDynamicModel | Hand back an asynchronously prepared model, or warm reusable metadata before retrying resolveDynamicModel | Provider needs network metadata before resolving unknown ids |
normalizeResolvedModel | Final rewrite before the embedded runner consumes the resolved model | Provider needs transport rewrites but still uses a core transport |
normalizeToolSchemas | Standardize tool schemas before the embedded runner sees them | Provider needs transport-family schema cleanup |
inspectToolSchemas | Expose provider-owned schema diagnostics after normalization | Provider wants keyword warnings without teaching core provider-specific rules |
resolveReasoningOutputMode | Choose native vs tagged reasoning-output contract | Provider needs tagged reasoning/final output instead of native fields |
prepareExtraParams | Request-param normalization before generic stream option wrappers | Provider needs default request params or per-provider param cleanup |
createStreamFn | Completely swap the normal stream path for a custom transport | Provider needs a custom wire protocol, not just a wrapper |
wrapStreamFn | Stream wrapper applied after generic wrappers | Provider needs request headers/body/model compat wrappers without a custom transport |
resolveTransportTurnState | Add native per-turn headers, metadata, or WebSocket policy | Provider wants generic transports to send provider-native turn identity or tune WebSocket headers and fallback cool-down |
resolveWebSocketSessionPolicy | Deprecated compatibility hook for WebSocket policy | Existing plugins migrate WebSocket fields into resolveTransportTurnState |
formatApiKey | Auth-profile formatter: stored profile becomes the runtime apiKey string | Provider stores extra auth metadata and needs a custom runtime token shape |
refreshOAuth | OAuth refresh override for custom refresh endpoints or refresh-failure policy | Provider does not fit the shared OpenClaw refreshers |
buildAuthDoctorHint | Repair hint appended when OAuth refresh fails | Provider needs provider-owned auth repair guidance after refresh failure |
matchesContextOverflowError | Provider-owned context-window overflow matcher | Provider has raw overflow errors generic heuristics would miss |
classifyFailoverReason | Provider-owned failover reason classification | Provider can map raw API/transport errors to rate-limit/overload/etc |
isCacheTtlEligible | Prompt-cache policy for proxy/backhaul providers | Provider needs proxy-specific cache TTL gating |
buildMissingAuthMessage | Replacement for the generic missing-auth recovery message | Provider needs a provider-specific missing-auth recovery hint |
augmentModelCatalog | Synthetic/final catalog rows appended after discovery (deprecated, see below) | Provider needs synthetic forward-compat rows in models list and pickers |
resolveThinkingProfile | Model-specific /think level set, display labels, and default | Provider exposes a custom thinking ladder or binary label for selected models |
isBinaryThinking | On/off reasoning toggle compatibility hook | Provider exposes only binary thinking on/off |
supportsXHighThinking | xhigh reasoning support compatibility hook | Provider wants xhigh on only a subset of models |
resolveDefaultThinkingLevel | Default /think level compatibility hook | Provider owns default /think policy for a model family |
isModernModelRef | Modern-model matcher for live profile filters and smoke selection | Provider owns live/smoke preferred-model matching |
prepareRuntimeAuth | Swap a configured credential for the actual runtime token/key just before inference | Provider needs a token exchange or short-lived request credential |
resolveUsageAuth | Resolve usage/billing credentials for /usage and related status surfaces | Provider needs custom usage/quota token parsing or a different usage credential |
fetchUsageSnapshot | Fetch and normalize provider-specific usage/quota snapshots after auth is resolved | Provider needs a provider-specific usage endpoint or payload parser |
createEmbeddingProvider | Build a provider-owned embedding adapter for memory/search | Memory embedding behavior belongs with the provider plugin |
buildReplayPolicy | Return a replay policy controlling transcript handling for the provider | Provider needs custom transcript policy (for example, thinking-block stripping) |
sanitizeReplayHistory | Rewrite replay history after generic transcript cleanup | Provider needs provider-specific replay rewrites beyond shared compaction helpers |
validateReplayTurns | Final replay-turn validation or reshaping before the embedded runner | Provider transport needs stricter turn validation after generic sanitation |
onModelSelected | Run provider-owned post-selection side effects | Provider needs telemetry or provider-owned state when a model becomes active |
normalizeModelId, normalizeTransport, and normalizeConfig begin by examining the matched provider plugin, then proceed through other hook-capable provider plugins until one actually modifies the model id or transport/config. This mechanism ensures alias/compat provider shims function correctly without the caller needing to identify which bundled plugin owns the rewrite. In cases where no provider hook rewrites a supported Google-family config entry, the bundled Google config normalizer still performs that compatibility cleanup.
A fully custom wire protocol or custom request executor falls into a separate extension category. These hooks apply only to provider behavior that continues to operate on OpenClaw's standard inference loop.
resolveUsageAuth determines whether OpenClaw invokes fetchUsageSnapshot or defaults to generic credential resolution for usage/status surfaces. When the provider has a usage credential, return { token, accountId?, subscriptionType?, rateLimitTier? } (the optional plan metadata flows into fetchUsageSnapshot); when provider-owned usage auth has handled the request and must block generic API-key/OAuth fallback, return { handled: true }; and return null or undefined when the provider did not handle usage auth.
Organization or billing credentials are declared in manifest providerUsageAuthEnvVars. This allows generic discovery and secret-scrubbing surfaces to recognize them without treating them as inference auth candidates.
Provider example
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 described above to accommodate each vendor's catalog, auth, thinking, replay, and usage requirements. The authoritative hook set resides with each plugin under extensions/; this page demonstrates the shapes rather than reproducing the full list.
Pass-through catalog providers
OpenRouter, Kilocode, Z.AI, xAI register catalog along with resolveDynamicModel / prepareDynamicModel so they can present upstream model ids ahead of OpenClaw's static catalog.
OAuth and usage endpoint providers
GitHub Copilot, Gemini CLI, ChatGPT Codex, MiniMax, Xiaomi, z.ai pair prepareRuntimeAuth or formatApiKey with resolveUsageAuth plus fetchUsageSnapshot to manage token exchange and /usage integration.
Replay and transcript cleanup families
Shared named families (google-gemini, passthrough-gemini, anthropic-by-model, hybrid-anthropic-openai) enable providers to opt into transcript policy through buildReplayPolicy instead of each plugin re-implementing cleanup.
Catalog-only providers
byteplus, cloudflare-ai-gateway, huggingface, kimi-coding, nvidia, qianfan, synthetic, together, venice, vercel-ai-gateway, and volcengine register only catalog and rely on the shared inference loop.
Anthropic-specific stream helpers
Beta headers, /fast / serviceTier, and context1m reside within 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:
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:
textToSpeechhands back the standard core TTS output payload intended for file and voice-note surfaces.- Relies on the core
ttsconfiguration along with provider selection. - Delivers a PCM audio buffer plus sample rate. It falls to plugins to resample or encode for the target providers.
listVoicesis provider-optional. Turn to it for vendor-controlled voice pickers or setup workflows.- The core forwards a resolved request deadline into provider
listVoiceshooks; provider-specific timeout settings can take precedence. - Voice listings may carry richer metadata, including locale, gender, and personality tags, which helps provider-aware pickers.
- Telephony is currently supported by OpenAI and ElevenLabs. Microsoft has no such support.
Speech providers can also be registered by plugins through api.registerSpeechProvider(...).
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 behavior, and reply delivery inside core.
- Rely on speech providers when synthesis behavior is vendor-owned.
- Legacy Microsoft
edgeinput gets normalized to themicrosoftprovider id. - The preferred ownership model is company-oriented: a single vendor plugin can own text, speech, image, and future media providers as OpenClaw introduces those capability contracts.
For image, audio, and video understanding, plugins register one typed media-understanding provider rather than a generic key/value bag:
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 inside the provider plugin.
- Additive expansion must remain typed: new optional methods, new optional result fields, new optional capabilities.
- Video generation already mirrors this pattern:
- core holds the capability contract and runtime helper
- vendor plugins register
api.registerVideoGenerationProvider(...) - feature and channel plugins consume
api.runtime.videoGeneration.*
For media-understanding runtime helpers, plugins may call:
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,
});
const extraction = await api.runtime.mediaUnderstanding.extractStructuredWithModel({
provider: "codex",
model: "gpt-5.6-sol",
input: [
{
type: "image",
buffer: receiptImageBuffer,
fileName: "receipt.png",
mime: "image/png",
},
{ type: "text", text: "Use the printed fields as the source of truth." },
],
instructions: "Return entities and searchable tags.",
schemaName: "example.evidence",
jsonSchema: {
type: "object",
properties: {
entities: { type: "array", items: { type: "string" } },
tags: { type: "array", items: { type: "string" } },
},
},
cfg: api.config,
});
For audio transcription, plugins can go through either the media-understanding runtime or the older STT alias:
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.*stands as the preferred shared surface for image, audio, and video understanding.extractStructuredWithModel(...)is the plugin-facing seam for bounded provider-owned image-first extraction. At least one image input is required; text inputs only add context. Product plugins own their routes and schemas while OpenClaw owns the provider and runtime boundary.- Uses core media-understanding audio configuration (
tools.media.audio) and provider fallback order. - Returns
{ text: undefined }when no transcription output is produced, for instance when input is skipped or unsupported.
Plugins can also kick off background subagent runs via api.runtime.subagent:
const result = await api.runtime.subagent.run({
sessionKey: "agent:main:subagent:search-helper",
message: "Expand this query into focused follow-up searches.",
toolsAlsoAllow: ["my_plugin_progress"],
provider: "openai",
model: "gpt-4.1-mini",
deliver: false,
});
Notes:
providerandmodelact as optional per-run overrides, not persistent session changes.toolsAlsoAllowaccepts exact, uniquely owned tool names registered by the calling plugin. Core and ambiguous names get rejected. It stacks on top of the normal profile, but operator allowlists and denies still hold authority.- OpenClaw honors those override fields only for trusted callers.
- For plugin-owned fallback runs, operators must opt in with
plugins.entries.<id>.subagent.allowModelOverride: true. - Use
plugins.entries.<id>.subagent.allowedModelsto confine trusted plugins to specific canonicalprovider/modeltargets, or"*"to permit any target explicitly. - Untrusted plugin subagent runs still function, but override requests are refused instead of silently degrading.
- Plugin-created subagent sessions carry the creating plugin id as a tag. Fallback
api.runtime.subagent.deleteSession(...)may remove only those owned sessions; arbitrary session removal still demands an admin-scoped Gateway request.
For web search, plugins can use the shared runtime helper instead of tapping into the agent tool wiring:
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,
},
});
Web-search providers can also be registered by plugins through
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 and channel plugins that need search behavior without depending on the agent tool wrapper.
api.runtime.imageGeneration
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(...): produce an image through the configured image-generation provider chain.listProviders(...): enumerate available image-generation providers along with their capabilities.
Gateway HTTP routes
Plugins can expose HTTP endpoints using api.registerHttpRoute(...).
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,"gateway"or"plugin". Pick"gateway"to demand normal gateway auth, or"plugin"for plugin-managed auth or webhook verification.match: optional."exact"(default) or"prefix".handleUpgrade: optional handler for WebSocket upgrade requests on the same route.replaceExisting: optional. Needed only for dynamic lifecycle registration to replace its own existing route.handler: returntruewhen the route handled the request.
Notes:
api.registerHttpHandler(...)has been dropped, and attempting to load it now triggers a plugin-load error. Switch toapi.registerHttpRoute(...).- Every plugin route needs an explicit declaration of
auth. - Paths that are canonically equivalent and share the same
matchmode collapse into a single route. When the same plugin issues staticapi.registerHttpRoute(...)calls, that route gets replaced; no other plugin can perform the replacement. - Routes that overlap but differ in their
authlevels get rejected outright. Restrictexact/prefixfallthrough chains so they only operate within one auth level. - Lifecycle code that runs dynamically and pulls in
registerPluginHttpRoute(...)fromopenclaw/plugin-sdk/webhook-ingressmust assignreplaceExisting: trueto refresh its own canonical route. Named registrations are limited to replacing the same nonemptypluginId; if either side sets a routesource, both have to set the same nonempty source. For shipped SDK callers, same-plugin refreshes from source-less to source-less and from anonymous to anonymous are still allowed, but a named route and an anonymous route can never swap with each other. - View route
sourceas a durable sub-owner tag for the same plugin, not as a diagnostic string. Existing source-less callers can continue leaving it out; source-aware callers must preserve it unchanged through every refresh. - When dynamic lifecycle registration is rejected, the default behavior logs the event and hands back a no-op unregister callback. If readiness hinges on that route, set
throwOnFailure: true; required bundled webhook transports enforce strict registration, so they cannot declare readiness while live ingress is missing. - Operator runtime scopes are not handed out automatically to
auth: "plugin"routes. Those routes exist for plugin-managed webhooks and signature verification, not for privileged Gateway helper calls. auth: "gateway"routes execute inside a Gateway request runtime scope. The default surface (gatewayRuntimeScopeSurface: "write-default") stays deliberately minimal:- shared-secret bearer auth (
gateway.auth.mode = "token"/"password") and every non-trusted-proxy auth method receive a singleoperator.writescope, even when the caller suppliesx-openclaw-scopes trusted-proxycallers that omit an explicitx-openclaw-scopesheader continue to get the legacyoperator.write-only surfacetrusted-proxycallers that do includex-openclaw-scopesreceive the declared scopes instead- a route can enable
gatewayRuntimeScopeSurface: "trusted-operator"to always respectx-openclaw-scopesfor identity-bearing auth modes, falling back to the full CLI default scope set when the header is absent
- shared-secret bearer auth (
- Sandboxed external Control UI tabs backed by
auth: "gateway"routes rely on a short-lived signed cookie grant that only authenticated bootstrap can mint; plugin-auth tabs keep their direct iframe path. Before mounting, the parent runs a route-owned probe inside the same opaque sandbox and fails closed if browser privacy policy blocks the cookie. The grant ties itself to the owning plugin, the matched route root, and the current auth generation; its process-random cookie name stops trusted same-host Gateways from overwriting each other, though cookies never isolate TCP ports. That makes the Gateway hostname a single credential boundary: do not host mutually untrusted services together on that hostname, other ports included. Route dispatch refuses reuse against a nested route owned by a different plugin. Since sandbox descendants count as cross-site for cookies, the grant only acceptsGETandHEADwithoperator.read; mutations and WebSocket upgrades stay on explicit Gateway-authenticated surfaces. The cookie deliberately avoids CHIPS: current browsers fold a cross-site-ancestor bit into the partition key, so nested opaque sandbox frames would lose access to same-route assets. A secure context and browser permission for cross-site cookies are both required, which means gateway-auth external tabs do not work on plain-HTTP LAN origins or under full third-party-cookie blocking; use HTTPS/Tailscale Serve or browser-trusted loopback with a compatible cookie policy. - The grant stops Gateway bearer-token leakage and accidental route/scope reuse; it does not establish a security boundary between native plugins. Native plugin code and the UI content it serves stay inside the same trusted in-process plugin boundary.
- Working rule: never treat a gateway-auth plugin route as an implicit admin surface. For admin-only behavior, opt into the
trusted-operatorscope surface, demand an identity-bearing auth mode, and spell out the explicitx-openclaw-scopesheader contract. - Startup plugins register HTTP routes with their full runtime only after the Gateway begins listening. Until startup sidecars are ready, an otherwise-unclaimed HTTP request gets
503withRetry-After: 1; core routes keep dispatching normally. This generic fallback covers plugin routes before the runtime registry can identify their owners. - After route matching and authentication, ordinary handlers join Gateway root-work admission. A prepared or restarting Gateway returns
503before the handler runs. The narrow exception is a manifest-entitledauth: "gateway"route that also opts into the route-specifictrusted-operatorsurface; it stays reachable so suspension control dispatch is never stranded, while ordinary sibling routes from the same plugin remain behind the admission boundary. WebSockethandleUpgradeownership follows the same atomic admission boundary; once the handler accepts a socket, the socket's later lifetime belongs to the plugin and is not tracked by this boundary.
Plugin SDK import paths
When writing new plugins, prefer narrow SDK subpaths over the monolithic openclaw/plugin-sdk root barrel. 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 |
Channel plugins draw from a narrow set of integration seams: channel-setup,
setup-runtime, setup-tools, channel-pairing,
channel-contract, channel-feedback, channel-inbound, channel-outbound,
command-auth, secret-input, webhook-ingress,
channel-targets, and channel-actions. Approval logic should converge on a single
approvalCapability contract instead of spreading across unrelated plugin fields.
Refer to Channel plugins.
Focused *-runtime subpaths house the runtime and configuration helpers
(approval-runtime, agent-runtime, lazy-runtime, directory-runtime,
text-utility-runtime, runtime-store, system-event-runtime, heartbeat-runtime,
channel-activity-runtime, and others). Choose config-contracts,
plugin-config-runtime, runtime-config-snapshot, and config-mutation
over the wide config-runtime compatibility barrel.
Info
openclaw/plugin-sdk/channel-lifecycle, compact channel helper wrappers,openclaw/plugin-sdk/config-runtime, andopenclaw/plugin-sdk/infra-runtimeserve as deprecated compatibility shims for legacy plugins. Fresh code should pull in narrower generic primitives instead.
Entry points internal to the repo, per bundled plugin package root:
index.js, entry for bundled pluginsapi.js, barrel for helpers and typesruntime-api.js, barrel limited to runtimesetup-entry.js, entry for setup plugins
External plugins must import only openclaw/plugin-sdk/* subpaths. Importing another
plugin's src/* from core or from a different plugin is forbidden.
Facade-loaded entry points use the active runtime config snapshot when available,
otherwise they fall back to the resolved config file on disk.
Capability-specific subpaths like image-generation, media-understanding,
and speech exist because bundled plugins rely on them today. They are not
guaranteed long-term external contracts, check the relevant SDK
reference page before depending on them.
Message tool schemas
Channel plugins should supply channel-specific describeMessageTool(...) schema
contributions for non-message primitives such as reactions, reads, and polls.
Shared send presentation should rely on the generic MessagePresentation contract
rather than provider-native button, component, block, or card fields.
See Message Presentation for the contract,
fallback rules, provider mapping, and plugin author checklist.
Send-capable plugins advertise their rendering capabilities via message capabilities:
presentationfor semantic presentation blocks (text,context,divider,chart,table,buttons,select)delivery-pinfor pinned-delivery requests
Core determines 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 avoid 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 })determines whether a normalized target gets classified asdirect,group, orchannelprior to any directory lookup. That direct classification is a prerequisite for implicit owner heartbeat delivery; without it, Gateway status reports come back aswaiting for route.messaging.targetResolver.looksLikeId(raw, normalized)signals to core that an input should bypass directory search and go straight to id-like resolution.messaging.targetResolver.reservedLiteralsenumerates bare words that serve as channel or session references for that provider. Resolution honors configured directory entries before rejecting reserved literals, then closes with a failure on a directory miss.messaging.targetResolver.resolveTarget(...)acts as the plugin fallback when core needs a final provider-owned resolution after normalization or a directory miss.messaging.resolveOutboundSessionRoute(...)handles provider-specific session route construction once a target has been resolved.
Suggested division:
- Apply
inferTargetChatTypefor category decisions that must occur before peer or group searches. - Apply
looksLikeIdfor checks that say "treat this as an explicit or native target id". - Apply
resolveTargetfor provider-specific normalization fallback, not for general directory searching. - Keep provider-native ids, including chat ids, thread ids, JIDs, handles, and room ids, inside
targetvalues or provider-specific params rather than generic SDK fields.
Config-backed directories
When a plugin derives directory entries from config, that logic should live in the plugin and reuse the shared helpers from openclaw/plugin-sdk/directory-runtime.
Use this when a channel needs config-backed peers or groups, for example:
- allowlist-driven DM peers
- configured channel or group maps
- account-scoped static directory fallbacks
The shared helpers in directory-runtime cover only generic operations:
- query filtering
- limit application
- deduping and normalization helpers
- building
ChannelDirectoryEntry[]
Channel-specific account inspection and id normalization remain in the plugin implementation.
Provider catalogs
Provider plugins can define model catalogs for inference using registerProvider({ catalog: { run(...) { ... } } }).
catalog.run(...) returns the same shape OpenClaw writes into models.providers:
{ provider }for a single 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 governs when a plugin's catalog merges relative to OpenClaw's built-in implicit providers:
simple: plain API-key or env-driven providersprofile: providers that surface when auth profiles existpaired: providers that combine several related provider entrieslate: final pass, after other implicit providers
Later providers win on key collision, so plugins can intentionally override a built-in provider entry that shares the same provider id.
Plugins can also publish read-only model rows through api.registerModelCatalogProvider({ provider, kinds, staticCatalog, liveCatalog }). This is the forward path for list, help, and picker surfaces and supports text, voice, image_generation, video_generation, and music_generation rows. Provider plugins still own live endpoint calls, token exchange, and vendor response mapping; core owns the common row shape, source labels, and media tool help formatting. Media-generation provider registrations synthesize static catalog rows automatically from defaultModel, models, and capabilities.
Compatibility:
discoverystill works as a legacy alias, but emits a deprecation warning- if both
cataloganddiscoveryare registered, OpenClaw usescatalogand emits a warning augmentModelCatalogis deprecated; bundled providers should publish supplemental rows throughregisterModelCatalogProvider
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 can 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 or config repair flows should not need to materialize runtime credentials just to describe configuration.
Recommended inspectAccount(...) behavior:
- Hand back only the descriptive account state.
- Keep
enabledandconfiguredintact. - When applicable, add credential source and status fields, for instance:
tokenSource,tokenStatusbotTokenSource,botTokenStatusappTokenSource,appTokenStatussigningSecretSource,signingSecretStatus
- Raw token values are not required merely to indicate read-only presence. Supplying
tokenStatus: "available"along with the corresponding source field suffices for status-oriented commands. - Use
configured_unavailablewhen a credential is set through SecretRef but cannot be accessed in the current command context.
This approach lets read-only commands say "configured but unavailable in this command path" rather than failing or incorrectly labeling the account as unconfigured.
Package packs
A plugin directory can hold a package.json containing openclaw.extensions:
{
"name": "my-pack",
"openclaw": {
"extensions": ["./src/safety.ts", "./src/tools.ts"],
"setupEntry": "./src/setup-entry.ts"
}
}
Each entry becomes its own plugin. When the pack lists multiple extensions, the plugin id turns into <manifestOrPackageName>/<fileBase> (the manifest id takes precedence if present; otherwise the unscoped package.json name applies).
If your plugin depends on npm packages, place them in that directory so node_modules is accessible (npm install / pnpm install).
Security guardrail: after symlink resolution, every openclaw.extensions entry must remain within the plugin directory. Any entry that leaves the package directory gets rejected.
Security note: openclaw plugins install installs plugin dependencies using a project-local npm install --omit=dev --ignore-scripts (no lifecycle scripts, no dev dependencies at runtime), overriding any inherited global npm install settings. Keep plugin dependency trees "pure JS/TS" and steer clear of packages needing postinstall builds.
Optional: openclaw.setupEntry can reference a lightweight setup-only module. When OpenClaw requires setup surfaces for a disabled channel plugin, or when a channel plugin is enabled but not yet configured, 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.
Bundled channels can also expose setup-only contract-surface helpers that core can consult before the full channel runtime loads. The current setup promotion surface consists of:
singleAccountKeysToMovenamedAccountPromotionKeysresolveSingleAccountPromotionTarget(...)
Core uses that surface when it must promote a legacy single-account channel config into channels.<id>.accounts.* without loading the full plugin entry. Matrix serves as the current bundled example: it moves only auth/bootstrap keys into a named promoted account when named accounts already exist, and it can keep a configured non-canonical default-account key instead of always creating accounts.default.
These setup patch adapters keep bundled contract-surface discovery lazy. Import time stays light; the promotion surface loads only on first use rather than re-entering bundled channel startup at module import.
When setup surfaces include gateway RPC methods, place them on a plugin-specific prefix. Core admin namespaces (config.*, exec.approvals.*, wizard.*, update.*) stay reserved and always resolve to operator.admin, even if a plugin asks for a narrower scope.
Channel catalog metadata
Channel plugins can advertise setup/discovery metadata via openclaw.channel and install hints via openclaw.install. This keeps the core catalog free of data.
Example:
{
"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": "<bundled-plugin-local-path>",
"defaultChoice": "npm"
}
}
}
Useful openclaw.channel fields beyond the minimal example:
detailLabel: secondary label for richer catalog/status surfacesdocsLabel: override link text for the docs linkpreferOver: lower-priority plugin/channel ids this catalog entry should outrankselectionDocsPrefix,selectionDocsOmitLabel,selectionExtras: selection-surface copy controlsmarkdownCapable: marks the channel as markdown-capable for outbound formatting decisionsexposure.configured: hide the channel from configured-channel listing surfaces when set tofalseexposure.setup: hide the channel from interactive setup/configure pickers when set tofalseexposure.docs: mark the channel as internal/private for docs navigation surfacesquickstartAllowFrom: opt the channel into the standard quickstartallowFromflowforceAccountBinding: require explicit account binding even when only one account existspreferSessionLookupForAnnounceTarget: 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
Alternatively, set OPENCLAW_PLUGIN_CATALOG_PATHS (or OPENCLAW_MPM_CATALOG_PATHS) to reference
one or more JSON files, using commas, semicolons, or PATH as
delimiters. Every file is expected to hold { "entries": [ { "name": "@scope/pkg", "openclaw": { "channel": {...}, "install": {...} } } ] }. For backward
compatibility, the parser recognizes "packages" and "plugins" as older
names for the "entries" key.
Both generated channel catalog entries and provider install catalog entries
present normalized install-source details alongside the unprocessed
openclaw.install block. These normalized details indicate whether the npm spec
is pinned to an exact version or uses a floating selector, whether the expected
integrity metadata is included, and whether a local source path is also
available. When the catalog or package identity is known, the normalized
details flag any mismatch between the parsed npm package name and that
identity. They also flag an invalid defaultChoice or one that references an
unavailable source, as well as npm integrity metadata that lacks a valid npm
source. Consumers should view installSource as an optional additive field,
so manually constructed entries and catalog shims are not forced to generate
it. This approach lets onboarding and diagnostics describe source-plane state
without pulling in the plugin runtime.
For official external npm entries, an exact npmSpec paired with
expectedIntegrity is the preferred approach. Bare package names and dist-tags
remain functional for compatibility, but they trigger source-plane warnings,
allowing the catalog to shift toward pinned, integrity-checked installs without
disrupting existing plugins. When onboarding installs from a local catalog
path, it creates a managed plugin index entry that includes source: "path"
and, when feasible, a workspace-relative sourcePath. The absolute
operational load path is stored in plugins.load.paths; the install record avoids
embedding local workstation paths in long-lived configuration. This keeps local
development installs visible to source-plane diagnostics without introducing a
second raw filesystem-path disclosure surface. The persisted installed_plugin_index
SQLite table serves as the authoritative install source and can be refreshed
without loading plugin runtime modules. Its installRecords map remains
durable even when a plugin manifest is missing or invalid, while its
plugins payload offers a rebuildable view of the manifest.
Context engine plugins
Session context orchestration for ingest, assembly, and compaction is handled by
context engine plugins. To register one from your plugin, use
api.registerContextEngine(id, factory), then pick the active engine with
plugins.slots.contextEngine.
This is the right choice when your plugin must replace or extend the default context pipeline, rather than simply adding memory search or hooks.
import { buildMemorySystemPromptAddition } from "openclaw/plugin-sdk/core";
export default function (api) {
api.registerContextEngine("lossless-claw", (ctx) => ({
info: {
id: "lossless-claw",
name: "Lossless Claw",
ownsCompaction: true,
acceptedHostParams: ["sessionKey"],
},
async ingest() {
return { ingested: true };
},
async assemble({ messages, sessionKey, availableTools, citationsMode }) {
return {
messages,
estimatedTokens: 0,
systemPromptAddition: buildMemorySystemPromptAddition({
availableTools: availableTools ?? new Set(),
citationsMode,
agentSessionKey: sessionKey,
}),
};
},
async compact() {
return { ok: true, compacted: false };
},
}));
}
At construction time, the factory ctx makes optional
config, agentDir, and workspaceDir values available
for initialization.
Before invoking a non-legacy engine's assemble(), the host finishes
preparing registered async memory prompts. While assemble() is running,
buildMemorySystemPromptAddition(...) remains synchronous and reads that immutable run snapshot.
Pass the supplied tool and citation context through unchanged, ensuring the
snapshot cannot leak across run boundaries.
When the active harness has a persistent backend thread, assemble() may
return contextProjection. For legacy per-turn projection, omit it. If the
assembled context should be injected once into a backend thread and reused
until the epoch changes, return { mode: "thread_bootstrap", epoch }. Update the epoch whenever the
engine's semantic context shifts, such as after an engine-owned compaction
pass. Hosts can preserve tool-call metadata, input shape, and redacted tool
results in a thread-bootstrap projection, so fresh backend threads keep tool
continuity without copying raw secret-bearing payloads.
If your engine does not own the compaction algorithm, keep compact()
implemented and delegate it explicitly:
import {
buildMemorySystemPromptAddition,
delegateCompactionToRuntime,
} from "openclaw/plugin-sdk/core";
export default function (api) {
api.registerContextEngine("my-memory-engine", (ctx) => ({
info: {
id: "my-memory-engine",
name: "My Memory Engine",
ownsCompaction: false,
},
async ingest() {
return { ingested: true };
},
async assemble({ messages, sessionKey, availableTools, citationsMode }) {
return {
messages,
estimatedTokens: 0,
systemPromptAddition: buildMemorySystemPromptAddition({
availableTools: availableTools ?? new Set(),
citationsMode,
agentSessionKey: sessionKey,
}),
};
},
async compact(params) {
return await delegateCompactionToRuntime(params);
},
}));
}
Adding a new capability
When a plugin needs behavior that the current API cannot accommodate, do not reach around the plugin system with a private workaround. Add the missing capability instead.
Recommended sequence:
- Define the core contract. Decide which shared behavior core should own: policy, fallback, config merge, lifecycle, channel-facing semantics, and runtime helper shape.
- Add typed plugin registration/runtime surfaces. Extend
OpenClawPluginApiand/orapi.runtimewith the smallest useful typed capability surface. - Wire core + channel/feature consumers. Channels and feature plugins should consume the new capability through core, not by importing a vendor implementation directly.
- Register vendor implementations. Vendor plugins then register their backends against the capability.
- 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 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/<capability>/types.ts - core runner/runtime helper in
src/<capability>/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:
// core contract
export type VideoGenerationProviderPlugin = {
id: string;
label: string;
generateVideo: (req: VideoGenerationRequest) => Promise<VideoGenerationResult>;
};
// 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 (src/plugins/contracts/registry.ts exposes ownership
lookups such as providerContractPluginIds; tests assert a plugin's
contracts.videoGenerationProviders list matches what it actually registers):
expect(pluginManifest.contracts?.videoGenerationProviders).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, the public capability model and its shapes
- Plugin SDK subpaths
- Plugin SDK setup
- Building plugins