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:

  1. Locate potential plugin roots.
  2. Parse native or compatible bundle manifests and package metadata.
  3. Filter out unsafe candidates.
  4. Standardize plugin configuration (plugins.enabled, allow, deny, entries, slots, load.paths).
  5. Determine which candidates should be enabled.
  6. Load enabled native modules: bundled modules use a native loader; third-party local TypeScript relies on the emergency Jiti fallback.
  7. Execute native register(api) hooks and store registrations in the plugin registry.
  8. 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.onStartup for 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-hintmanifest-channel-owner (channels)
activation-command-hintmanifest-command-alias (commandAliases)
activation-provider-hintmanifest-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:

  • PluginLoaderCacheState and 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 requests
  • request: 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, and channelConfigs.
  • Config-time hooks: catalog together with applyConfigDefaults.
  • 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.

HookWhat it doesWhen to use
catalogDuring models.json generation, inject provider config into models.providersThe provider manages a catalog or sets base URL defaults
applyConfigDefaultsWhen config materializes, enforce provider-level global defaultsDefaults 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)
normalizeModelIdBefore looking up a model, clean up legacy or preview model-id aliasesThe provider is responsible for alias normalization prior to canonical model resolution
normalizeTransportBefore generic model assembly, normalize provider-family api and baseUrlThe provider manages transport cleanup for custom provider IDs within the same transport family
normalizeConfigBefore 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
applyNativeStreamingUsageCompatApply native streaming-usage compatibility rewrites to config providersThe provider requires endpoint-driven fixes for native streaming usage metadata
resolveConfigApiKeyBefore runtime auth loading, resolve env-marker authentication for config providersProviders expose their own hooks for env-marker API-key resolution
resolveSyntheticAuthExpose local, self-hosted, or config-backed authentication without storing plaintextThe provider can function with a synthetic or local credential marker
resolveExternalAuthProfilesOverlay provider-owned external authentication profiles; the default for persistence is runtime-only for CLI or app-owned credentialsThe provider reuses external auth credentials without persisting copied refresh tokens; declare contracts.externalAuthProviders in the manifest
shouldDeferSyntheticProfileAuthBehind env or config-backed auth, demote stored synthetic placeholder profilesThe provider stores synthetic placeholder profiles that should not take precedence
resolveDynamicModelAs a fallback, sync provider-owned model IDs not yet in the local registryThe provider accepts arbitrary upstream model IDs
prepareDynamicModelWarm up asynchronously, then rerun resolveDynamicModelThe provider needs network metadata before resolving unknown IDs
normalizeResolvedModelBefore the embedded runner uses the resolved model, perform a final rewriteThe provider needs transport rewrites but still relies on a core transport
normalizeToolSchemasBefore the embedded runner sees tool schemas, normalize themThe provider requires transport-family schema cleanup
inspectToolSchemasAfter normalization, expose provider-owned schema diagnosticsThe provider wants keyword warnings without embedding provider-specific rules in the core
resolveReasoningOutputModeChoose between native and tagged reasoning-output contractsThe provider needs tagged reasoning or final output instead of native fields
prepareExtraParamsBefore generic stream option wrappers, normalize request parametersThe provider needs default request parameters or per-provider parameter cleanup
createStreamFnCompletely replace the normal stream path with a custom transportThe provider requires a custom wire protocol, not merely a wrapper
wrapStreamFnAfter generic wrappers are applied, wrap the streamThe provider needs request headers, body, or model compatibility wrappers without a custom transport
resolveTransportTurnStateAttach native per-turn transport headers or metadataThe provider wants generic transports to send provider-native turn identity
resolveWebSocketSessionPolicyAttach native WebSocket headers or a session cool-down policyThe provider wants generic WS transports to adjust session headers or fallback policy
formatApiKeyFormat an auth profile so the stored profile becomes the runtime apiKey stringThe provider stores extra auth metadata and requires a custom runtime token shape
refreshOAuthOverride OAuth refresh for custom refresh endpoints or refresh-failure policyThe provider does not fit the shared OpenClaw refreshers
buildAuthDoctorHintWhen OAuth refresh fails, append a repair hintThe provider needs provider-owned auth repair guidance after a refresh failure
matchesContextOverflowErrorMatch provider-owned context-window overflow errorsThe provider has raw overflow errors that generic heuristics would miss
classifyFailoverReasonClassify provider-owned failover reasonsThe provider can map raw API or transport errors to rate-limit, overload, or similar
isCacheTtlEligibleSet a prompt-cache policy for proxy or backhaul providersThe provider needs proxy-specific cache TTL gating
buildMissingAuthMessageReplace the generic missing-auth recovery messageThe provider needs a provider-specific missing-auth recovery hint
augmentModelCatalogAfter discovery, append synthetic or final catalog rows (deprecated, see below)The provider needs synthetic forward-compat rows in models list and pickers
resolveThinkingProfileSet model-specific /think levels, display labels, and defaultsThe provider exposes a custom thinking ladder or binary label for selected models
isBinaryThinkingToggle reasoning on or off compatibility hookThe provider exposes only binary thinking on or off
supportsXHighThinkingCompatibility hook for xhigh reasoning supportThe provider wants xhigh on only a subset of models
resolveDefaultThinkingLevelCompatibility hook for default /think levelThe provider owns the default /think policy for a model family
isModernModelRefMatch modern models for live profile filters and smoke selectionThe provider owns live and smoke preferred-model matching
prepareRuntimeAuthRight before inference, swap a configured credential for the actual runtime token or keyThe provider needs a token exchange or short-lived request credential
resolveUsageAuthResolve usage and billing credentials for /usage and related status surfacesThe provider needs custom usage or quota token parsing or a different usage credential
fetchUsageSnapshotAfter auth is resolved, fetch and normalize provider-specific usage or quota snapshotsThe provider needs a provider-specific usage endpoint or payload parser
createEmbeddingProviderBuild a provider-owned embedding adapter for memory or searchMemory embedding behavior belongs with the provider plugin
buildReplayPolicyReturn a replay policy that controls transcript handling for the providerThe provider needs a custom transcript policy (for example, stripping thinking blocks)
sanitizeReplayHistoryAfter generic transcript cleanup, rewrite replay historyThe provider needs provider-specific replay rewrites beyond shared compaction helpers
validateReplayTurnsBefore the embedded runner, perform final replay-turn validation or reshapingThe provider transport needs stricter turn validation after generic sanitation
onModelSelectedAfter selection, run provider-owned side effectsThe 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:

  • textToSpeech delivers the standard core TTS output payload intended for file and voice note surfaces.
  • Relies on the core tts configuration and the chosen provider.
  • Outputs a PCM audio buffer together with its sample rate. Plugins must handle resampling and encoding for their specific provider.
  • listVoices is 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 listVoices hooks; 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 edge input gets normalized to the microsoft provider 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:

  • provider and model are optional per run overrides, not persistent session changes.
  • toolsAlsoAllow accepts 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.allowedModels to restrict trusted plugins to specific canonical provider/model targets, or "*" to allow any target explicitly.
  • Untrusted plugin subagent runs still work, but override requests are rejected instead of silently falling back.
  • 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: return true when the route handled the request.

Notes:

  • api.registerHttpHandler(...) has been removed and triggers a plugin-load error. Replace it with api.registerHttpRoute(...).
  • Plugin routes are required to explicitly declare auth.
  • Exact path + match conflicts are disallowed unless replaceExisting: true, and one plugin cannot override another plugin's route.
  • Overlapping routes with differing auth levels are disallowed. Maintain exact/prefix fallthrough 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 single operator.write scope, even if the caller provides x-openclaw-scopes
    • trusted-proxy callers without an explicit x-openclaw-scopes header also retain the legacy operator.write-only surface
    • trusted-proxy callers that do include x-openclaw-scopes receive the declared scopes instead
    • a route can choose gatewayRuntimeScopeSurface: "trusted-operator" to always respect x-openclaw-scopes for identity-bearing auth modes (falling back to the full CLI default scope set when the header is missing)
  • 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 only GET and HEAD with operator.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-operator scope surface, require an identity-bearing auth mode, and document the explicit x-openclaw-scopes header contract.
  • After route matching and authentication, standard handlers participate in Gateway root-work admission. A prepared or restarting Gateway returns 503 before invoking the handler. The narrow exception is a manifest-entitled auth: "gateway" route that also opts into the route-specific trusted-operator surface; it remains reachable so suspension control dispatch cannot be stranded, while ordinary sibling routes from the same plugin remain behind the admission boundary. WebSocket handleUpgrade ownership 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:

SubpathPurpose
openclaw/plugin-sdk/plugin-entryPlugin registration primitives
openclaw/plugin-sdk/channel-coreChannel entry/build helpers
openclaw/plugin-sdk/coreGeneric 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, and openclaw/plugin-sdk/infra-runtime are 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 plugin
  • api.js, barrel for helpers and types
  • runtime-api.js, barrel limited to runtime
  • setup-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:

  • presentation for semantic presentation blocks (text, context, divider, chart, table, buttons, select)
  • delivery-pin for pinned-delivery requests

Core decides whether to render the presentation natively or degrade it to text. Do not expose provider-native UI escape hatches from the generic message tool. Deprecated SDK helpers for legacy native schemas remain exported for existing third-party plugins, but new plugins should not use them.

Channel target resolution

Channel plugins should own channel-specific target semantics. Keep the shared outbound host generic and use the messaging adapter surface for provider rules:

  • messaging.inferTargetChatType({ to }) decides whether a normalized target should be treated as direct, group, or channel before directory lookup.
  • messaging.targetResolver.looksLikeId(raw, normalized) tells core whether an input should skip straight to id-like resolution instead of directory search.
  • messaging.targetResolver.reservedLiterals lists 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 inferTargetChatType for category decisions that should happen before searching peers or groups.
  • Use looksLikeId for checks that treat an input as an explicit or native target id.
  • Use resolveTarget for provider-specific normalization fallback, not for broad directory search.
  • Keep provider-native ids like chat ids, thread ids, JIDs, handles, and room ids inside target values or provider-specific params, not in generic SDK fields.

Config-backed directories

Plugins that derive directory entries from config should keep that logic in the plugin and reuse the shared helpers from openclaw/plugin-sdk/directory-runtime.

Use this when a channel needs config-backed peers 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 variables
  • profile: providers that surface only when authentication profiles are present
  • paired: providers that combine several related provider entries into one
  • late: 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:

  • discovery continues to function as a legacy alias but issues a deprecation warning
  • When both catalog and discovery are registered, OpenClaw uses catalog and logs a warning
  • augmentModelCatalog is deprecated; bundled providers should instead publish supplemental rows through registerModelCatalogProvider

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 enabled and configured intact.
  • Include credential source or status fields when applicable, for example:
    • tokenSource, tokenStatus
    • botTokenSource, botTokenStatus
    • appTokenSource, appTokenStatus
    • signingSecretSource, 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_unavailable when 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:

  • singleAccountKeysToMove
  • namedAccountPromotionKeys
  • resolveSingleAccountPromotionTarget(...)

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 surfaces
  • docsLabel: override link text for the docs link
  • preferOver: lower-priority plugin/channel IDs this catalog entry should outrank
  • selectionDocsPrefix, selectionDocsOmitLabel, selectionExtras: selection-surface copy controls
  • markdownCapable: marks the channel as markdown-capable for outbound formatting decisions
  • exposure.configured: hides the channel from configured-channel listing surfaces when set to false
  • exposure.setup: hides the channel from interactive setup/configure pickers when set to false
  • exposure.docs: marks the channel as internal/private for docs navigation surfaces
  • quickstartAllowFrom: opts the channel into the standard quickstart allowFrom flow
  • forceAccountBinding: requires explicit account binding even when only one account exists
  • preferSessionLookupForAnnounceTarget: 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:

  1. Define the core contract. Decide what shared behavior core should own: policy, fallback, config merge, lifecycle, channel-facing semantics, and runtime helper shape.
  2. Add typed plugin registration/runtime surfaces. Extend OpenClawPluginApi and/or api.runtime with the smallest useful typed capability surface.
  3. Wire core + channel/feature consumers. Channels and feature plugins should consume the new capability through core, not by importing a vendor implementation directly.
  4. Register vendor implementations. Vendor plugins then register their backends against the capability.
  5. Add contract coverage. Add tests so ownership and registration shape stay explicit over time.

This is how OpenClaw stays opinionated without becoming hardcoded to one provider's worldview. See the Capability Cookbook 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