Plugin Architecture Internals: Load Pipeline, Registry, and Runtime Hooks
This page explains the internal workings of the plugin architecture, including the load pipeline, registry, runtime hooks, and Gateway HTTP routes. It is intended for developers and system integrators who need to understand plugin loading and safety checks.
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 details on the public capability model, plugin shapes, and ownership or execution contracts, refer to Plugin architecture. This document explains the internal workings: the load pipeline, registry, runtime hooks, Gateway HTTP routes, import paths, and schema tables.
Load pipeline
During startup, OpenClaw follows these steps:
- Locate potential plugin roots.
- Parse native or compatible bundle manifests and package metadata.
- Filter out unsafe candidates.
- Standardize plugin configuration (
plugins.enabled,allow,deny,entries,slots,load.paths). - Determine which candidates should be enabled.
- Load enabled native modules: bundled modules use a native loader; third-party local TypeScript relies on the emergency Jiti fallback.
- Execute native
register(api)hooks and store registrations in the plugin registry. - Make the registry available to commands and runtime surfaces.
Safety checks happen before any runtime execution. A candidate is blocked during discovery when:
- Its resolved entry point falls outside the plugin root.
- Its path or root directory has world-writable permissions.
- For non-bundled plugins, the path owner does not match the current user ID or root.
For world-writable bundled directories, an in-place chmod repair is attempted first (npm or global installs may create package directories at 0777) before the gate re-evaluates. Ownership checks are skipped entirely for bundled origins.
Blocked candidates still include their plugin ID in the diagnostic output when one is known, including IDs resolved from a manifest inside an otherwise rejected directory. This way, configuration referencing that ID shows a blocked plugin linked to a path-safety warning instead of a generic "unknown plugin" error.
Manifest-first behavior
The manifest serves as the authoritative source for the control plane. OpenClaw uses it to:
- Identify the plugin.
- Find declared channels, skills, config schemas, or bundle capabilities.
- Validate
plugins.entries.<id>.config. - Enhance Control UI labels and placeholders.
- Display install and catalog metadata.
- Store lightweight activation and setup descriptors without loading the plugin runtime.
For native plugins, the runtime module handles the data plane. It registers actual behavior, including hooks, tools, commands, or provider flows.
Optional manifest activation and setup blocks remain on the control plane. These 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 or plugin resolution narrows to plugins that own the requested channel ID.
- Explicit provider setup or 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 provides an IDs-only API for existing callers and a plan API for diagnostics. Plan entries explain 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) |
This reason split marks the compatibility boundary. Existing plugin metadata continues to work, while new code can detect broad hints or fallback behavior without altering runtime loading semantics.
Request-time runtime preloads that ask for the broad all scope still derive an explicit effective plugin ID set from configuration, 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 expanding 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 the 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 does execute, registry diagnostics report drift between setup.providers or setup.cliBackends and the providers or CLI backends actually registered by the setup API, without blocking legacy plugins.
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 and inode, size, and mtime or ctime. This cache only avoids re-parsing unchanged bytes and must not cache discovery, registry, owner, or policy answers.
The safe metadata fast path relies on 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 configuration and plugin inventory. Setup lookup still reconstructs manifest metadata on demand unless the specific setup path receives an explicit manifest registry. Treat this 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 or root. Short-lived maps are fine within 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 or 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.
These caches are data plane implementation details. They must not answer control plane questions such as "which plugin owns this provider?" unless the caller deliberately requested 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 modify random core globals. Instead, they register into a central plugin registry (PluginRegistry in src/plugins/registry-types.ts). This registry tracks plugin records, including identity, source, origin, status, and 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 such as speech, embeddings, image, video, and music generation, web fetch and search, agent harnesses, session actions, and so on.
Core features then read from that registry instead of interacting with plugin modules directly. This keeps loading one way:
- Plugin module to registry registration.
- Core runtime to registry consumption.
This 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 is bound by a plugin, it can respond once an approval has been finalized.
To get a notification after a bind request is either accepted or rejected, use 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 in the callback payload:
status: either"approved"or"denied"decision: one of"allow-once","allow-always", or"deny"binding: the binding that was resolved, present for approved requestsrequest: the original request summary, along with the detach hint, sender id, and conversation metadata
This callback is for notification purposes only. It has no effect on who can bind a conversation, and it executes after the core approval process is complete.
Provider runtime hooks
Provider plugins are structured into three levels:
- Manifest metadata for inexpensive lookups before runtime:
setup.providers[].envVars,providerAuthAliases,providerAuthChoices, andchannelConfigs. - Config-time hooks:
catalogtogether withapplyConfigDefaults. - Runtime hooks: over 40 optional hooks that cover authentication, model resolution, stream wrapping, thinking levels, replay policy, and usage endpoints. Refer to Hook order and usage.
OpenClaw continues to manage the generic agent loop, failover, transcript handling, and tool policy. These hooks serve as the extension surface for provider-specific behavior, removing the need for a full custom inference transport.
Use the manifest setup.providers[].envVars when the provider relies on environment-based credentials that the generic auth, status, and model picker paths should detect without loading the plugin runtime. Use the manifest providerAuthAliases when one provider id should inherit the environment variables, auth profiles, config-backed auth, and API key onboarding choice of another provider id. Use the manifest providerAuthChoices when the onboarding or auth choice CLI surfaces need to know the provider's choice id, group labels, and simple single-flag auth wiring without loading the provider runtime. Keep the provider runtime envVars for operator-facing hints like onboarding labels or OAuth client ID and client secret setup variables.
Describe environment-driven channel setup and authentication through the owning channelConfigs.<id>.schema and setup descriptors.
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 decision guide. Compatibility-only provider fields that OpenClaw no longer calls, for instance ProviderPlugin.capabilities and suppressBuiltInModel, are intentionally excluded from this list.
| Hook | What it does | When to use |
|---|---|---|
catalog | During models.json generation, inject provider config into models.providers | The provider manages a catalog or sets base URL defaults |
applyConfigDefaults | When config materializes, enforce provider-level global defaults | Defaults rely on authentication mode, environment, or provider model-family conventions |
| (built-in model lookup) | OpenClaw first checks the standard registry or catalog path | (not a plugin hook) |
normalizeModelId | Before looking up a model, clean up legacy or preview model-id aliases | The provider is responsible for alias normalization prior to canonical model resolution |
normalizeTransport | Before generic model assembly, normalize provider-family api and baseUrl | The provider manages transport cleanup for custom provider IDs within the same transport family |
normalizeConfig | Before runtime or provider resolution, normalize models.providers.<id> | The provider needs config cleanup tied to the plugin; bundled Google-family helpers also backstop supported Google config entries |
applyNativeStreamingUsageCompat | Apply native streaming-usage compatibility rewrites to config providers | The provider requires endpoint-driven fixes for native streaming usage metadata |
resolveConfigApiKey | Before runtime auth loading, resolve env-marker authentication for config providers | Providers expose their own hooks for env-marker API-key resolution |
resolveSyntheticAuth | Expose local, self-hosted, or config-backed authentication without storing plaintext | The provider can function with a synthetic or local credential marker |
resolveExternalAuthProfiles | Overlay provider-owned external authentication profiles; the default for persistence is runtime-only for CLI or app-owned credentials | The provider reuses external auth credentials without persisting copied refresh tokens; declare contracts.externalAuthProviders in the manifest |
shouldDeferSyntheticProfileAuth | Behind env or config-backed auth, demote stored synthetic placeholder profiles | The provider stores synthetic placeholder profiles that should not take precedence |
resolveDynamicModel | As a fallback, sync provider-owned model IDs not yet in the local registry | The provider accepts arbitrary upstream model IDs |
prepareDynamicModel | Warm up asynchronously, then rerun resolveDynamicModel | The provider needs network metadata before resolving unknown IDs |
normalizeResolvedModel | Before the embedded runner uses the resolved model, perform a final rewrite | The provider needs transport rewrites but still relies on a core transport |
normalizeToolSchemas | Before the embedded runner sees tool schemas, normalize them | The provider requires transport-family schema cleanup |
inspectToolSchemas | After normalization, expose provider-owned schema diagnostics | The provider wants keyword warnings without embedding provider-specific rules in the core |
resolveReasoningOutputMode | Choose between native and tagged reasoning-output contracts | The provider needs tagged reasoning or final output instead of native fields |
prepareExtraParams | Before generic stream option wrappers, normalize request parameters | The provider needs default request parameters or per-provider parameter cleanup |
createStreamFn | Completely replace the normal stream path with a custom transport | The provider requires a custom wire protocol, not merely a wrapper |
wrapStreamFn | After generic wrappers are applied, wrap the stream | The provider needs request headers, body, or model compatibility wrappers without a custom transport |
resolveTransportTurnState | Attach native per-turn transport headers or metadata | The provider wants generic transports to send provider-native turn identity |
resolveWebSocketSessionPolicy | Attach native WebSocket headers or a session cool-down policy | The provider wants generic WS transports to adjust session headers or fallback policy |
formatApiKey | Format an auth profile so the stored profile becomes the runtime apiKey string | The provider stores extra auth metadata and requires a custom runtime token shape |
refreshOAuth | Override OAuth refresh for custom refresh endpoints or refresh-failure policy | The provider does not fit the shared OpenClaw refreshers |
buildAuthDoctorHint | When OAuth refresh fails, append a repair hint | The provider needs provider-owned auth repair guidance after a refresh failure |
matchesContextOverflowError | Match provider-owned context-window overflow errors | The provider has raw overflow errors that generic heuristics would miss |
classifyFailoverReason | Classify provider-owned failover reasons | The provider can map raw API or transport errors to rate-limit, overload, or similar |
isCacheTtlEligible | Set a prompt-cache policy for proxy or backhaul providers | The provider needs proxy-specific cache TTL gating |
buildMissingAuthMessage | Replace the generic missing-auth recovery message | The provider needs a provider-specific missing-auth recovery hint |
augmentModelCatalog | After discovery, append synthetic or final catalog rows (deprecated, see below) | The provider needs synthetic forward-compat rows in models list and pickers |
resolveThinkingProfile | Set model-specific /think levels, display labels, and defaults | The provider exposes a custom thinking ladder or binary label for selected models |
isBinaryThinking | Toggle reasoning on or off compatibility hook | The provider exposes only binary thinking on or off |
supportsXHighThinking | Compatibility hook for xhigh reasoning support | The provider wants xhigh on only a subset of models |
resolveDefaultThinkingLevel | Compatibility hook for default /think level | The provider owns the default /think policy for a model family |
isModernModelRef | Match modern models for live profile filters and smoke selection | The provider owns live and smoke preferred-model matching |
prepareRuntimeAuth | Right before inference, swap a configured credential for the actual runtime token or key | The provider needs a token exchange or short-lived request credential |
resolveUsageAuth | Resolve usage and billing credentials for /usage and related status surfaces | The provider needs custom usage or quota token parsing or a different usage credential |
fetchUsageSnapshot | After auth is resolved, fetch and normalize provider-specific usage or quota snapshots | The provider needs a provider-specific usage endpoint or payload parser |
createEmbeddingProvider | Build a provider-owned embedding adapter for memory or search | Memory embedding behavior belongs with the provider plugin |
buildReplayPolicy | Return a replay policy that controls transcript handling for the provider | The provider needs a custom transcript policy (for example, stripping thinking blocks) |
sanitizeReplayHistory | After generic transcript cleanup, rewrite replay history | The provider needs provider-specific replay rewrites beyond shared compaction helpers |
validateReplayTurns | Before the embedded runner, perform final replay-turn validation or reshaping | The provider transport needs stricter turn validation after generic sanitation |
onModelSelected | After selection, run provider-owned side effects | The provider needs telemetry or provider-owned state when a model becomes active |
normalizeModelId, normalizeTransport, and normalizeConfig begin by examining the matched provider plugin, then cascade through additional hook-capable provider plugins until one successfully modifies the model identifier or transport/config. This mechanism keeps alias and compatibility provider shims operational without requiring the caller to identify which bundled plugin performed the rewrite. When no provider hook modifies a supported Google-family configuration entry, the bundled Google config normalizer still applies that compatibility cleanup.
A fully custom wire protocol or custom request executor belongs to a different extension category. These hooks handle provider behavior that still operates within OpenClaw's standard inference loop.
resolveUsageAuth determines whether OpenClaw should invoke fetchUsageSnapshot or revert to default credential resolution for usage and status surfaces. Return { token, accountId?, subscriptionType?, rateLimitTier? } when the provider possesses a usage credential (optional plan metadata flows into fetchUsageSnapshot), return { handled: true } when provider-owned usage authentication has already processed the request and must prevent generic API-key or OAuth fallback, and return null or undefined when the provider did not handle usage authentication.
Declare organization or billing credentials in manifest providerUsageAuthEnvVars. This allows generic discovery and secret-scrubbing surfaces to recognize them without treating them as inference authentication 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, authentication, reasoning, replay, and usage requirements. The authoritative hook set resides with each plugin under extensions/; this page illustrates the patterns rather than duplicating the list.
Pass-through catalog providers
OpenRouter, Kilocode, Z.AI, and xAI register catalog together with resolveDynamicModel or prepareDynamicModel so they can expose upstream model identifiers ahead of OpenClaw's static catalog.
OAuth and usage endpoint providers
GitHub Copilot, Gemini CLI, ChatGPT Codex, MiniMax, Xiaomi, and 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) let providers opt into transcript policy through buildReplayPolicy instead of each plugin reimplementing 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 or serviceTier, and context1m reside within the Anthropic plugin's public api.ts or contract-api.ts seam (wrapAnthropicProviderStream, resolveAnthropicBetas, resolveAnthropicFastMode, resolveAnthropicServiceTier) rather than in the generic SDK.
Runtime helpers
Plugins can access selected core helpers through 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:
textToSpeechdelivers the standard core TTS output payload intended for file and voice note surfaces.- Relies on the core
ttsconfiguration and the chosen provider. - Outputs a PCM audio buffer together with its sample rate. Plugins must handle resampling and encoding for their specific provider.
listVoicesis not mandatory for every provider. It should be employed for vendor-managed voice selection interfaces or setup workflows.- The core sends a resolved request deadline to provider
listVoiceshooks; provider-specific timeout settings can take precedence over it. - Voice listings may carry additional metadata like locale, gender, and personality tags to support provider-aware pickers.
- Currently, OpenAI and ElevenLabs support telephony, while Microsoft does not.
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 TSS policy, fallback logic, and reply delivery managed by the core.
- Use speech providers to encapsulate vendor-specific synthesis behavior.
- Legacy Microsoft
edgeinput gets normalized to themicrosoftprovider identifier. - The recommended ownership model is company oriented: a single vendor plugin can own text, speech, image, and future media providers as OpenClaw adds those capability contracts.
For image, audio, and video understanding, plugins register a single 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, configuration, and channel wiring within the core.
- Keep vendor behavior inside the provider plugin.
- Additive expansion should remain typed: new optional methods, new optional result fields, and new optional capabilities.
- Video generation already follows this same pattern:
- the core owns 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 can invoke:
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 may use 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.*is the recommended shared surface for image, audio, and video understanding.extractStructuredWithModel(...)is the plugin facing seam for bounded provider owned image first extraction. Include at least one image input; text inputs serve as supplemental 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 example when input is skipped or unsupported).
Plugins can also launch background subagent runs through 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:
providerandmodelare optional per run overrides, not persistent session changes.toolsAlsoAllowaccepts exact, uniquely owned tool names registered by the calling plugin. Core and ambiguous names are rejected. It adds to the normal profile, but operator allowlists and denies remain authoritative.- OpenClaw only respects those override fields 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 restrict trusted plugins to specific canonicalprovider/modeltargets, or"*"to allow any target explicitly. - Untrusted plugin subagent runs still work, but override requests are rejected instead of silently falling back.
- Plugin created subagent sessions are tagged with the creating plugin id. Fallback
api.runtime.subagent.deleteSession(...)may delete only those owned sessions; arbitrary session deletion still requires an admin scoped Gateway request.
For web search, plugins can consume the shared runtime helper instead of reaching 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,
},
});
Plugins can also register web search providers via
api.registerWebSearchProvider(...).
Notes:
- Keep provider selection, credential resolution, and shared request semantics in the core.
- Use web search providers for vendor specific search transports.
api.runtime.webSearch.*is the recommended 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(...): generates an image using the configured image generation provider chain.listProviders(...): lists available image generation providers and their capabilities.
Gateway HTTP routes
Plugins can expose HTTP endpoints with 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: the route path under the gateway HTTP server.auth: required,"gateway"or"plugin". Use"gateway"to require normal gateway authentication, 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. Allows the same plugin to replace its own existing route registration.handler: returntruewhen the route handled the request.
Notes:
api.registerHttpHandler(...)has been removed and triggers a plugin-load error. Replace it withapi.registerHttpRoute(...).- Plugin routes are required to explicitly declare
auth. - Exact
path + matchconflicts are disallowed unlessreplaceExisting: true, and one plugin cannot override another plugin's route. - Overlapping routes with differing
authlevels are disallowed. Maintainexact/prefixfallthrough chains exclusively at the same auth level. auth: "plugin"routes do not automatically receive operator runtime scopes. They are intended for plugin-managed webhooks and signature verification, not for privileged Gateway helper calls.auth: "gateway"routes execute within a Gateway request runtime scope. The default surface (gatewayRuntimeScopeSurface: "write-default") is deliberately limited:- shared-secret bearer auth (
gateway.auth.mode = "token"/"password") and any non-trusted-proxy auth method are granted a singleoperator.writescope, even if the caller providesx-openclaw-scopes trusted-proxycallers without an explicitx-openclaw-scopesheader also retain the legacyoperator.write-only surfacetrusted-proxycallers that do includex-openclaw-scopesreceive the declared scopes instead- a route can choose
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 missing)
- shared-secret bearer auth (
- Sandboxed external Control UI tabs backed by
auth: "gateway"routes use a short-lived signed cookie grant created only during authenticated bootstrap; 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 when browser privacy policy blocks the cookie. The grant is bound to the owning plugin, matched route root, and current auth generation; its process-random cookie name prevents trusted same-host Gateways from overwriting each other, but cookies never isolate TCP ports. The Gateway hostname therefore forms one credential boundary: do not cohost mutually untrusted services on that hostname, including other ports. Route dispatch rejects reuse against a nested route owned by another plugin. Since sandbox descendants are cross-site for cookie purposes, the grant accepts onlyGETandHEADwithoperator.read; mutations and WebSocket upgrades remain on explicit Gateway-authenticated surfaces. The cookie intentionally cannot use CHIPS: current browsers include a cross-site-ancestor bit in the partition key, so nested opaque sandbox frames would lose access to same-route assets. The cookie requires a secure context and browser permission for cross-site cookies, so gateway-auth external tabs are unavailable 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 prevents Gateway bearer-token disclosure and accidental route or scope reuse; it does not establish a security boundary between native plugins. Native plugin code and the UI content it serves remain within the same trusted in-process plugin boundary.
- Practical rule: do not assume a gateway-auth plugin route is an implicit admin surface. If your route needs admin-only behavior, opt into
trusted-operatorscope surface, require an identity-bearing auth mode, and document the explicitx-openclaw-scopesheader contract. - After route matching and authentication, standard handlers participate in Gateway root-work admission. A prepared or restarting Gateway returns
503before invoking the handler. The narrow exception is a manifest-entitledauth: "gateway"route that also opts into the route-specifictrusted-operatorsurface; it remains reachable so suspension control dispatch cannot be stranded, while ordinary sibling routes from the same plugin remain behind the admission boundary. WebSockethandleUpgradeownership uses the same atomic admission boundary; once the handler accepts a socket, the socket's later lifetime is plugin-owned and is not tracked by this boundary.
Plugin SDK import paths
When authoring new plugins, use narrow SDK subpaths instead of 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 select from a family of narrow 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 behavior should consolidate
on one approvalCapability contract rather than mixing across unrelated
plugin fields. See Channel plugins.
Runtime and configuration helpers are located under dedicated *-runtime subpaths
(approval-runtime, agent-runtime, lazy-runtime, directory-runtime,
text-runtime, runtime-store, system-event-runtime, heartbeat-runtime,
channel-activity-runtime, and others). Use config-contracts,
plugin-config-runtime, runtime-config-snapshot, and config-mutation
rather than the general config-runtime compatibility barrel.
Info
openclaw/plugin-sdk/channel-lifecycle, small channel helper facades,openclaw/plugin-sdk/config-runtime, andopenclaw/plugin-sdk/infra-runtimeare deprecated compatibility shims intended for older plugins. New code should import more specific generic primitives instead.
Entry points internal to the repository (per bundled plugin package root):
index.js, entry point for a bundled pluginapi.js, barrel for helpers and typesruntime-api.js, barrel limited to runtimesetup-entry.js, entry point for setup plugins
External plugins must import only openclaw/plugin-sdk/* subpaths. Never
import another plugin package's src/* from core or from another plugin.
Entry points loaded through facades prefer the active runtime config snapshot when one
exists, then fall back to the resolved config file on disk.
Capability-specific subpaths like image-generation, media-understanding,
and speech exist because bundled plugins currently use them. They are not
automatically long-term frozen external contracts. Check the relevant SDK
reference page before depending 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 for the contract,
fallback rules, provider mapping, and plugin author checklist.
Send-capable plugins declare what they can render through message capabilities:
presentationfor semantic presentation blocks (text,context,divider,chart,table,buttons,select)delivery-pinfor 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 asdirect,group, orchannelbefore 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.reservedLiteralslists bare words that are channel or session references for that provider. Resolution preserves configured directory entries before rejecting reserved literals, then fails closed on a directory miss.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
inferTargetChatTypefor category decisions that should happen before searching peers or groups. - Use
looksLikeIdfor checks that treat an input as an explicit or native target id. - Use
resolveTargetfor provider-specific normalization fallback, not for broad directory search. - Keep provider-native ids like chat ids, thread ids, JIDs, handles, and room
ids inside
targetvalues 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 or groups such as:
- allowlist-driven DM peers
- configured channel or group maps
- account-scoped static directory fallbacks
The shared helpers in directory-runtime only handle generic operations:
- query filtering
- limit application
- deduping and 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 that OpenClaw writes into
models.providers:
{ provider }applies to a single provider entry{ providers }applies when multiple provider entries exist
Choose catalog when the plugin manages provider-specific model identifiers,
base URL defaults, or model metadata behind authentication.
catalog.order determines the merge order of a plugin's catalog relative to
OpenClaw's built-in implicit providers:
simple: providers driven solely by API keys or environment variablesprofile: providers that surface only when authentication profiles are presentpaired: providers that combine several related provider entries into onelate: runs last, after all other implicit providers
When keys collide, later providers take precedence, so plugins can deliberately replace a built-in provider entry that shares the same provider id.
Plugins can also expose read-only model rows through
api.registerModelCatalogProvider({ provider, kinds, staticCatalog, liveCatalog }). This is the recommended approach for list, help, and picker
interfaces, and supports
text, voice, image_generation, video_generation, and music_generation
rows. Provider plugins remain responsible for live endpoint calls, token
exchange, and vendor response mapping; core handles the common row format,
source labels, and media tool help formatting. Media-generation provider
registrations automatically generate static catalog rows from defaultModel,
models, and capabilities.
Compatibility:
discoverycontinues to function as a legacy alias but issues a deprecation warning- When both
cataloganddiscoveryare registered, OpenClaw usescatalogand logs a warning augmentModelCatalogis deprecated; bundled providers should instead publish supplemental rows throughregisterModelCatalogProvider
Read-only channel inspection
If your plugin registers a channel, prefer implementing
plugin.config.inspectAccount(cfg, accountId) together with resolveAccount(...).
Rationale:
resolveAccount(...)is the execution path at runtime. It can assume credentials are fully resolved and may fail quickly when required secrets are absent.- Read-only command paths like
openclaw status,openclaw status --all,openclaw channels status,openclaw channels resolve, and doctor or config repair flows should not need to materialize runtime credentials solely to describe configuration.
Recommended behavior for inspectAccount(...):
- Only return descriptive account state.
- Keep
enabledandconfiguredintact. - Include credential source or status fields when applicable, for example:
tokenSource,tokenStatusbotTokenSource,botTokenStatusappTokenSource,appTokenStatussigningSecretSource,signingSecretStatus
- You do not have to return raw token values just to indicate read-only
availability. Returning
tokenStatus: "available"(and the corresponding source field) is sufficient for status-oriented commands. - Use
configured_unavailablewhen a credential is set via SecretRef but is inaccessible in the current command path.
This approach allows read-only commands to report "configured but unavailable in this command path" instead of crashing or incorrectly marking the account as unconfigured.
Package packs
A plugin directory may contain a package.json with openclaw.extensions:
{
"name": "my-pack",
"openclaw": {
"extensions": ["./src/safety.ts", "./src/tools.ts"],
"setupEntry": "./src/setup-entry.ts"
}
}
Each entry becomes a plugin. When the pack lists multiple extensions, the plugin
id becomes <manifestOrPackageName>/<fileBase> (the manifest id takes precedence if present;
otherwise the unscoped package.json name is used).
If your plugin imports npm dependencies, install them inside that directory so
node_modules is available (npm install / pnpm install).
Security guardrail: every openclaw.extensions entry must remain within the plugin
directory after symlink resolution. Any entry that escapes the package directory
is 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), ignoring any inherited global npm install
settings. Keep plugin dependency trees "pure JS/TS" and avoid packages that
require postinstall builds.
Optionally, openclaw.setupEntry can reference a lightweight module that only handles setup.
When a channel plugin is disabled yet still requires setup surfaces, or when an enabled channel plugin remains unconfigured, OpenClaw loads setupEntry instead of the complete plugin entry. This approach reduces startup and setup overhead when the main plugin entry also wires tools, hooks, or other runtime-only code.
Optionally, openclaw.startup.deferConfiguredChannelFullLoadUntilAfterListen can enable a channel plugin to use the same setupEntry path during the gateway's pre-listen startup phase, even if the channel is already configured.
Only activate this when setupEntry fully covers all startup surfaces that must be available before the gateway begins listening. In practice, this means the setup entry must register every channel-owned capability that startup depends on, including:
- the channel registration itself
- any HTTP routes that must be ready before the gateway starts listening
- any gateway methods, tools, or services that must exist during that same window
If your full entry still controls any required startup capability, do not enable this flag. Leave the plugin on the default behavior and allow OpenClaw to load the full entry during startup.
Bundled channels can also publish setup-only contract-surface helpers that core can inspect before the full channel runtime loads. The current setup promotion surface includes:
singleAccountKeysToMovenamedAccountPromotionKeysresolveSingleAccountPromotionTarget(...)
Core uses that surface when it needs to promote a legacy single-account channel configuration into channels.<id>.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.
These setup patch adapters keep bundled contract-surface discovery lazy. Import time remains light; the promotion surface loads only on first use rather than 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.*) stay reserved and always resolve to operator.admin, even if a plugin requests a narrower scope.
Example:
{
"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 through openclaw.channel and install hints through openclaw.install. This keeps the core catalog data-free.
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: hides the channel from configured-channel listing surfaces when set tofalseexposure.setup: hides the channel from interactive setup/configure pickers when set tofalseexposure.docs: marks the channel as internal/private for docs navigation surfacesquickstartAllowFrom: opts the channel into the standard quickstartallowFromflowforceAccountBinding: requires explicit account binding even when only one account existspreferSessionLookupForAnnounceTarget: prefers 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.
Generated channel catalog entries and provider install catalog entries expose normalized install-source facts alongside the raw openclaw.install block. The normalized facts identify whether the npm spec is an exact version or floating selector, whether expected integrity metadata is present, and whether a local source path is also available. When the catalog/package identity is known, the normalized facts warn if the parsed npm package name drifts from that identity. They also warn when defaultChoice is invalid or points at an unavailable source, and when npm integrity metadata is present without a valid npm source. Consumers should treat installSource as an additive optional field so hand-built entries and catalog shims do not have to synthesize it. This lets onboarding and diagnostics explain source-plane state without importing plugin runtime.
Official external npm entries should prefer an exact npmSpec plus expectedIntegrity. Bare package names and dist-tags still work for compatibility, but they surface source-plane warnings so the catalog can move toward pinned, integrity-checked installs without breaking existing plugins. When onboarding installs from a local catalog path, it records a managed plugin index entry with source: "path" and a workspace-relative sourcePath when possible. The absolute operational load path stays in plugins.load.paths; the install record avoids duplicating local workstation paths into long-lived config. This keeps local development installs visible to source-plane diagnostics without adding a second raw filesystem-path disclosure surface. The persisted installed_plugin_index SQLite table is the install source of truth and can be refreshed without loading plugin runtime modules. Its installRecords map is durable even when a plugin manifest is missing or invalid; its plugins payload is a rebuildable manifest view.
Context engine plugins
Context engine plugins handle session context orchestration for ingestion, assembly, and compaction. To register one from your plugin, use api.registerContextEngine(id, factory), then pick the active engine with plugins.slots.contextEngine.
Choose this approach when your plugin must replace or extend the standard 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 },
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 };
},
}));
}
The factory ctx exposes optional values for config, agentDir, and workspaceDir to use during construction-time initialization.
Before calling a non-legacy engine's assemble(), the host finishes registered async memory prompt preparation. While assemble() is active, buildMemorySystemPromptAddition(...) stays synchronous and reads that immutable run snapshot. Pass the supplied tool and citation context through unchanged so the snapshot cannot cross run boundaries.
When the active harness has a persistent backend thread, assemble() may return contextProjection. Omit it for legacy per-turn projection. Return { mode: "thread_bootstrap", epoch } when the assembled context should be injected once into a backend thread and reused until the epoch changes. Change the epoch after the engine's semantic context changes, for example after an engine-owned compaction pass. Hosts may preserve tool-call metadata, input shape, and redacted tool results in a thread-bootstrap projection so fresh backend threads retain 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 does not fit the current API, do not bypass the plugin system with a private reach-in. Add the missing capability instead.
Recommended sequence:
- Define the core contract. Decide what 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 yet fully integrated.
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, public capability model and shapes
- Plugin SDK subpaths
- Plugin SDK setup
- Building plugins