Plugin Architecture Internals: Load Pipeline, Registry, Hooks, Routes

This page details OpenClaw's internal plugin mechanics: load pipeline, registry, runtime hooks, HTTP routes, import paths, and schema tables. It is intended for developers extending or debugging the plugin system.

Read this when

  • Implementing provider runtime hooks, channel lifecycle, or package packs
  • Debugging plugin load order or registry state
  • Adding a new plugin capability or context engine plugin

For the public capability model, plugin shapes, and ownership/execution contracts, see Plugin architecture. This page covers the internal mechanics: load pipeline, registry, runtime hooks, Gateway HTTP routes, import paths, and schema tables.

Load pipeline

At startup, OpenClaw performs these steps:

  1. locate candidate plugin roots
  2. read native or compatible bundle manifests and package metadata
  3. filter out unsafe candidates
  4. normalize plugin config (plugins.enabled, allow, deny, entries, slots, load.paths)
  5. determine which candidates are enabled
  6. load enabled native modules: built bundled modules use a native loader; third-party local source TypeScript uses the emergency Jiti fallback
  7. invoke native register(api) hooks and gather registrations into the plugin registry
  8. expose the registry to commands/runtime surfaces

Safety gates run before runtime execution. Discovery rejects a candidate when:

  • its resolved entry escapes the plugin root
  • its path (or its root directory) is world-writable
  • for non-bundled plugins, path ownership does not match the current uid (or root)

World-writable bundled directories get an in-place chmod repair attempt first (npm/global installs can ship package dirs at 0777) before the gate re-checks; ownership checks are skipped for bundled origin entirely.

Blocked candidates still carry their plugin id in the emitted diagnostic when one is known (including ids resolved from a manifest inside an otherwise-rejected directory), so config referencing that id sees a blocked plugin tied to a path-safety warning instead of an unrelated "unknown plugin" error.

Manifest-first behavior

The manifest is the control-plane source of truth. OpenClaw uses it to:

  • identify the plugin
  • discover declared channels/skills/config schema or bundle capabilities
  • validate plugins.entries.<id>.config
  • augment Control UI labels/placeholders
  • show install/catalog metadata
  • preserve cheap activation and setup descriptors without loading plugin runtime

For native plugins, the runtime module is the data-plane part. It registers actual behavior such as hooks, tools, commands, or provider flows.

Optional manifest activation and setup blocks stay on the control plane. They are metadata-only descriptors for activation planning and setup discovery; they do not replace runtime registration, register(...), or setupEntry. Live activation consumers use manifest command, channel, and provider hints to narrow plugin loading before broader registry materialization:

  • CLI loading narrows to plugins that own the requested primary command
  • channel setup/plugin resolution narrows to plugins that own the requested channel id
  • explicit provider setup/runtime resolution narrows to plugins that own the requested provider id
  • Gateway startup planning uses activation.onStartup for explicit startup imports; plugins without startup metadata load only through narrower activation triggers

The activation planner exposes both an ids-only API for existing callers and a plan API for diagnostics. Plan entries report why a plugin was selected, separating explicit activation.* hints from manifest-ownership fallback:

Reason (from activation.* hints)Reason (from manifest ownership)
activation-agent-harness-hint,
activation-capability-hint,
activation-channel-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)

That reason split is the compatibility boundary: existing plugin metadata keeps working, while new code can detect broad hints or fallback behavior without changing runtime loading semantics.

Request-time runtime preloads that ask for the broad all scope still derive an explicit effective plugin id set from config, startup planning, configured channels, slots, and auto-enable rules (resolveEffectivePluginIds in src/plugins/effective-plugin-ids.ts). If that derived set is empty, OpenClaw keeps the scope empty instead of widening to every discoverable plugin.

Setup discovery prefers descriptor-owned ids such as setup.providers and setup.cliBackends to narrow candidate plugins before falling back to setup-api for plugins that still need setup-time runtime hooks. Provider setup lists use manifest providerAuthChoices, descriptor-derived setup choices, and install-catalog metadata without loading provider runtime. Explicit setup.requiresRuntime: false is a descriptor-only cutoff; omitted requiresRuntime keeps the legacy setup-api fallback for compatibility. If more than one discovered plugin claims the same normalized setup provider or CLI backend id, setup lookup refuses the ambiguous owner instead of relying on discovery order. When setup runtime executes, registry diagnostics reject undeclared provider and CLI backend registrations. CLI backend descriptors also report missing runtime registrations; provider descriptors may stay metadata-only while the setup module contributes other setup hooks.

Plugin cache boundary

OpenClaw does not cache plugin discovery results or direct manifest registry data behind wall-clock windows. Installs, manifest edits, and load-path changes must become visible on the next explicit metadata read or snapshot rebuild. The manifest file parser keeps a bounded file-signature cache keyed by the opened manifest path plus device/inode, size, and mtime/ctime; that cache only avoids re-parsing unchanged bytes and must not cache discovery, registry, owner, or policy answers.

The safe metadata fast path is explicit object ownership, not a hidden cache. Gateway startup hot paths should pass the current PluginMetadataSnapshot, the derived PluginLookUpTable, or an explicit manifest registry through the call chain. Config validation, startup auto-enable, plugin bootstrap, and provider selection can reuse those objects while they represent the current config and plugin inventory. Setup lookup still reconstructs manifest metadata on demand unless the specific setup path receives an explicit manifest registry; keep that as a cold-path fallback rather than adding hidden lookup caches. When the input changes, rebuild and replace the snapshot instead of mutating it or keeping historical copies. Views over the active plugin registry and bundled channel bootstrap helpers should be recomputed from the current registry/root. Short-lived maps are fine inside one call to dedupe work or guard reentry; they must not become process metadata caches.

For plugin loading, the persistent cache layer is runtime loading. It may reuse loader state when code or installed artifacts are actually loaded, such as:

  • PluginLoaderCacheState and compatible active runtime registries
  • jiti/module caches and public-surface loader caches used to avoid importing the same runtime surface repeatedly
  • filesystem caches for installed plugin artifacts
  • short-lived per-call maps for path normalization or duplicate resolution

Those caches are data-plane implementation details. They must not answer control-plane questions such as "which plugin owns this provider?" unless the caller deliberately asked for runtime loading.

Do not add persistent or wall-clock caches for:

  • discovery results
  • direct manifest registries
  • manifest registries reconstructed from the installed plugin index
  • provider owner lookup, model suppression, provider policy, or public-artifact metadata
  • any other manifest-derived answer where a changed manifest, installed index, or load path should be visible on the next metadata read

Callers that rebuild manifest metadata from the persisted installed plugin index reconstruct that registry on demand. The installed index is durable source-plane state; it is not a hidden in-process metadata cache.

Registry model

Loaded plugins do not directly mutate random core globals. They register into a central plugin registry (PluginRegistry in src/plugins/registry-types.ts), which tracks plugin records (identity, source, origin, status, diagnostics) plus arrays for every capability: tools, legacy hooks and typed hooks, channels, providers, gateway RPC handlers, HTTP routes, CLI registrars, background services, plugin-owned commands, and dozens more typed provider families (speech, embeddings, image/video/music generation, web fetch/search, agent harnesses, session actions, and so on).

Core features then read from that registry instead of talking to plugin modules directly. This keeps loading one-way:

  • plugin module -> registry registration
  • core runtime -> registry consumption

That separation matters for maintainability. It means most core surfaces only need one integration point: "read the registry", not "special-case every plugin module".

Conversation binding callbacks

When a conversation-bound plugin is active, it can respond to the outcome of an approval decision.

To get notified once a bind request has been either accepted or rejected, call api.onConversationBindingResolved(...):

export default {
  id: "my-plugin",
  register(api) {
    api.onConversationBindingResolved(async (event) => {
      if (event.status === "approved") {
        // A binding now exists for this plugin + conversation.
        console.log(event.binding?.conversationId);
        return;
      }

      // The request was denied; clear any local pending state.
      console.log(event.request.conversation.conversationId);
    });
  },
};

Fields carried by the callback payload:

  • status: either "approved" or "denied"
  • decision: one of "allow-once", "allow-always", or "deny"
  • binding: the binding that was settled, present only when the request was approved
  • request: the original request details, including the detach hint, sender id, and conversation metadata

This callback serves purely as a notification. It has no effect on binding permissions, and it fires only after the core approval process has completed.

Provider runtime hooks

Provider plugins are organized into three tiers:

  • Manifest metadata, which enables inexpensive lookup before runtime starts: setup.providers[].envVars, providerAuthAliases, providerAuthChoices, and channelConfigs.
  • Config-time hooks: catalog together with applyConfigDefaults.
  • Runtime hooks: more than 40 optional hooks that span auth, model resolution, stream wrapping, thinking levels, replay policy, and usage endpoints. Refer to Hook order and usage for details.

OpenClaw retains control over the generic agent loop, failover, transcript handling, and tool policy. These hooks give providers a way to extend behavior without building a fully custom inference transport.

When a provider relies on environment-based credentials that the generic auth, status, and model-picker paths should recognize without loading the plugin runtime, use manifest setup.providers[].envVars. If one provider id needs to share another provider id's env vars, auth profiles, config-backed auth, and API-key onboarding selection, use manifest providerAuthAliases. When the onboarding or auth-choice CLI surfaces need to know the provider's choice id, group labels, and a straightforward one-flag auth setup without loading provider runtime, use manifest providerAuthChoices. Keep provider runtime envVars for operator-facing hints like onboarding labels or OAuth client-id and client-secret setup variables.

Use the owning channelConfigs.<id>.schema and setup descriptors to describe env-driven channel configuration and authentication.

Hook order and usage

For model and provider plugins, OpenClaw invokes hooks in approximately this sequence. The "When to use" column acts as a quick reference for deciding which hook fits. Compatibility-only provider fields that OpenClaw no longer invokes, including ProviderPlugin.capabilities and suppressBuiltInModel, are deliberately omitted from this list.

HookWhat it doesWhen to use
catalogInject provider configuration into models.providers as part of models.json generationProvider supplies catalog or base URL defaults
applyConfigDefaultsSet provider-level global config defaults when materializing configurationDefaults hinge on auth mode, environment, or provider model-family semantics
(built-in model lookup)OpenClaw attempts the standard registry/catalog route first(not a plugin hook)
normalizeModelIdClean up legacy or preview model-id aliases prior to lookupProvider handles alias cleanup before canonical model resolution
normalizeTransportStandardize provider-family api / baseUrl ahead of generic model assemblyProvider manages transport cleanup for custom provider ids within the same transport family
normalizeConfigStandardize models.providers.<id> before runtime/provider resolutionProvider requires config cleanup tied to the plugin; bundled Google-family helpers also backstop supported Google config entries
applyNativeStreamingUsageCompatApply native streaming-usage compat rewrites to config providersProvider needs endpoint-driven native streaming usage metadata fixes
resolveConfigApiKeyHandle env-marker auth for config providers prior to runtime auth loadingProviders expose their own env-marker API-key resolution hooks
resolveSyntheticAuthExpose local/self-hosted or config-backed auth without storing plaintextProvider can operate with a synthetic/local credential marker
resolveExternalAuthProfilesLayer provider-owned external auth profiles; default persistence is runtime-only for CLI/app-owned credsProvider reuses external auth credentials without persisting copied refresh tokens; declare contracts.externalAuthProviders in the manifest
shouldDeferSyntheticProfileAuthDemote stored synthetic profile placeholders behind env/config-backed authProvider stores synthetic placeholder profiles that should not win precedence
resolveDynamicModelFallback sync for provider-owned model ids absent from the local registry yetProvider accepts arbitrary upstream model ids
prepareDynamicModelHand back an asynchronously prepared model, or warm reusable metadata before retrying resolveDynamicModelProvider needs network metadata before resolving unknown ids
normalizeResolvedModelFinal rewrite before the embedded runner consumes the resolved modelProvider needs transport rewrites but still uses a core transport
normalizeToolSchemasStandardize tool schemas before the embedded runner sees themProvider needs transport-family schema cleanup
inspectToolSchemasExpose provider-owned schema diagnostics after normalizationProvider wants keyword warnings without teaching core provider-specific rules
resolveReasoningOutputModeChoose native vs tagged reasoning-output contractProvider needs tagged reasoning/final output instead of native fields
prepareExtraParamsRequest-param normalization before generic stream option wrappersProvider needs default request params or per-provider param cleanup
createStreamFnCompletely swap the normal stream path for a custom transportProvider needs a custom wire protocol, not just a wrapper
wrapStreamFnStream wrapper applied after generic wrappersProvider needs request headers/body/model compat wrappers without a custom transport
resolveTransportTurnStateAdd native per-turn headers, metadata, or WebSocket policyProvider wants generic transports to send provider-native turn identity or tune WebSocket headers and fallback cool-down
resolveWebSocketSessionPolicyDeprecated compatibility hook for WebSocket policyExisting plugins migrate WebSocket fields into resolveTransportTurnState
formatApiKeyAuth-profile formatter: stored profile becomes the runtime apiKey stringProvider stores extra auth metadata and needs a custom runtime token shape
refreshOAuthOAuth refresh override for custom refresh endpoints or refresh-failure policyProvider does not fit the shared OpenClaw refreshers
buildAuthDoctorHintRepair hint appended when OAuth refresh failsProvider needs provider-owned auth repair guidance after refresh failure
matchesContextOverflowErrorProvider-owned context-window overflow matcherProvider has raw overflow errors generic heuristics would miss
classifyFailoverReasonProvider-owned failover reason classificationProvider can map raw API/transport errors to rate-limit/overload/etc
isCacheTtlEligiblePrompt-cache policy for proxy/backhaul providersProvider needs proxy-specific cache TTL gating
buildMissingAuthMessageReplacement for the generic missing-auth recovery messageProvider needs a provider-specific missing-auth recovery hint
augmentModelCatalogSynthetic/final catalog rows appended after discovery (deprecated, see below)Provider needs synthetic forward-compat rows in models list and pickers
resolveThinkingProfileModel-specific /think level set, display labels, and defaultProvider exposes a custom thinking ladder or binary label for selected models
isBinaryThinkingOn/off reasoning toggle compatibility hookProvider exposes only binary thinking on/off
supportsXHighThinkingxhigh reasoning support compatibility hookProvider wants xhigh on only a subset of models
resolveDefaultThinkingLevelDefault /think level compatibility hookProvider owns default /think policy for a model family
isModernModelRefModern-model matcher for live profile filters and smoke selectionProvider owns live/smoke preferred-model matching
prepareRuntimeAuthSwap a configured credential for the actual runtime token/key just before inferenceProvider needs a token exchange or short-lived request credential
resolveUsageAuthResolve usage/billing credentials for /usage and related status surfacesProvider needs custom usage/quota token parsing or a different usage credential
fetchUsageSnapshotFetch and normalize provider-specific usage/quota snapshots after auth is resolvedProvider needs a provider-specific usage endpoint or payload parser
createEmbeddingProviderBuild a provider-owned embedding adapter for memory/searchMemory embedding behavior belongs with the provider plugin
buildReplayPolicyReturn a replay policy controlling transcript handling for the providerProvider needs custom transcript policy (for example, thinking-block stripping)
sanitizeReplayHistoryRewrite replay history after generic transcript cleanupProvider needs provider-specific replay rewrites beyond shared compaction helpers
validateReplayTurnsFinal replay-turn validation or reshaping before the embedded runnerProvider transport needs stricter turn validation after generic sanitation
onModelSelectedRun provider-owned post-selection side effectsProvider needs telemetry or provider-owned state when a model becomes active

normalizeModelId, normalizeTransport, and normalizeConfig begin by examining the matched provider plugin, then proceed through other hook-capable provider plugins until one actually modifies the model id or transport/config. This mechanism ensures alias/compat provider shims function correctly without the caller needing to identify which bundled plugin owns the rewrite. In cases where no provider hook rewrites a supported Google-family config entry, the bundled Google config normalizer still performs that compatibility cleanup.

A fully custom wire protocol or custom request executor falls into a separate extension category. These hooks apply only to provider behavior that continues to operate on OpenClaw's standard inference loop.

resolveUsageAuth determines whether OpenClaw invokes fetchUsageSnapshot or defaults to generic credential resolution for usage/status surfaces. When the provider has a usage credential, return { token, accountId?, subscriptionType?, rateLimitTier? } (the optional plan metadata flows into fetchUsageSnapshot); when provider-owned usage auth has handled the request and must block generic API-key/OAuth fallback, return { handled: true }; and return null or undefined when the provider did not handle usage auth.

Organization or billing credentials are declared in manifest providerUsageAuthEnvVars. This allows generic discovery and secret-scrubbing surfaces to recognize them without treating them as inference auth candidates.

Provider example

api.registerProvider({
  id: "example-proxy",
  label: "Example Proxy",
  auth: [],
  catalog: {
    order: "simple",
    run: async (ctx) => {
      const apiKey = ctx.resolveProviderApiKey("example-proxy").apiKey;
      if (!apiKey) {
        return null;
      }
      return {
        provider: {
          baseUrl: "https://proxy.example.com/v1",
          apiKey,
          api: "openai-completions",
          models: [{ id: "auto", name: "Auto" }],
        },
      };
    },
  },
  resolveDynamicModel: (ctx) => ({
    id: ctx.modelId,
    name: ctx.modelId,
    provider: "example-proxy",
    api: "openai-completions",
    baseUrl: "https://proxy.example.com/v1",
    reasoning: false,
    input: ["text"],
    cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
    contextWindow: 128000,
    maxTokens: 8192,
  }),
  prepareRuntimeAuth: async (ctx) => {
    const exchanged = await exchangeToken(ctx.apiKey);
    return {
      apiKey: exchanged.token,
      baseUrl: exchanged.baseUrl,
      expiresAt: exchanged.expiresAt,
    };
  },
  resolveUsageAuth: async (ctx) => {
    const auth = await ctx.resolveOAuthToken();
    return auth ? { token: auth.token } : null;
  },
  fetchUsageSnapshot: async (ctx) => {
    return await fetchExampleProxyUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn);
  },
});

Built-in examples

Bundled provider plugins combine the hooks described above to accommodate each vendor's catalog, auth, thinking, replay, and usage requirements. The authoritative hook set resides with each plugin under extensions/; this page demonstrates the shapes rather than reproducing the full list.

Pass-through catalog providers

OpenRouter, Kilocode, Z.AI, xAI register catalog along with resolveDynamicModel / prepareDynamicModel so they can present upstream model ids ahead of OpenClaw's static catalog.

OAuth and usage endpoint providers

GitHub Copilot, Gemini CLI, ChatGPT Codex, MiniMax, Xiaomi, z.ai pair prepareRuntimeAuth or formatApiKey with resolveUsageAuth plus fetchUsageSnapshot to manage token exchange and /usage integration.

Replay and transcript cleanup families

Shared named families (google-gemini, passthrough-gemini, anthropic-by-model, hybrid-anthropic-openai) enable providers to opt into transcript policy through buildReplayPolicy instead of each plugin re-implementing cleanup.

Catalog-only providers

byteplus, cloudflare-ai-gateway, huggingface, kimi-coding, nvidia, qianfan, synthetic, together, venice, vercel-ai-gateway, and volcengine register only catalog and rely on the shared inference loop.

Anthropic-specific stream helpers

Beta headers, /fast / serviceTier, and context1m reside within the Anthropic plugin's public api.ts / contract-api.ts seam (wrapAnthropicProviderStream, resolveAnthropicBetas, resolveAnthropicFastMode, resolveAnthropicServiceTier) rather than in the generic SDK.

Runtime helpers

Plugins can access selected core helpers via api.runtime. For TTS:

const clip = await api.runtime.tts.textToSpeech({
  text: "Hello from OpenClaw",
  cfg: api.config,
});

const result = await api.runtime.tts.textToSpeechTelephony({
  text: "Hello from OpenClaw",
  cfg: api.config,
});

const voices = await api.runtime.tts.listVoices({
  provider: "elevenlabs",
  cfg: api.config,
});

Notes:

  • textToSpeech hands back the standard core TTS output payload intended for file and voice-note surfaces.
  • Relies on the core tts configuration along with provider selection.
  • Delivers a PCM audio buffer plus sample rate. It falls to plugins to resample or encode for the target providers.
  • listVoices is provider-optional. Turn to it for vendor-controlled voice pickers or setup workflows.
  • The core forwards a resolved request deadline into provider listVoices hooks; provider-specific timeout settings can take precedence.
  • Voice listings may carry richer metadata, including locale, gender, and personality tags, which helps provider-aware pickers.
  • Telephony is currently supported by OpenAI and ElevenLabs. Microsoft has no such support.

Speech providers can also be registered by plugins through api.registerSpeechProvider(...).

api.registerSpeechProvider({
  id: "acme-speech",
  label: "Acme Speech",
  isConfigured: ({ config }) => Boolean(config.messages?.tts),
  synthesize: async (req) => {
    return {
      audioBuffer: Buffer.from([]),
      outputFormat: "mp3",
      fileExtension: ".mp3",
      voiceCompatible: false,
    };
  },
});

Notes:

  • Keep TTS policy, fallback behavior, and reply delivery inside core.
  • Rely on speech providers when synthesis behavior is vendor-owned.
  • Legacy Microsoft edge input gets normalized to the microsoft provider id.
  • The preferred ownership model is company-oriented: a single vendor plugin can own text, speech, image, and future media providers as OpenClaw introduces those capability contracts.

For image, audio, and video understanding, plugins register one typed media-understanding provider rather than a generic key/value bag:

api.registerMediaUnderstandingProvider({
  id: "google",
  capabilities: ["image", "audio", "video"],
  describeImage: async (req) => ({ text: "..." }),
  transcribeAudio: async (req) => ({ text: "..." }),
  describeVideo: async (req) => ({ text: "..." }),
});

Notes:

  • Keep orchestration, fallback, config, and channel wiring in core.
  • Keep vendor behavior inside the provider plugin.
  • Additive expansion must remain typed: new optional methods, new optional result fields, new optional capabilities.
  • Video generation already mirrors this pattern:
    • core holds the capability contract and runtime helper
    • vendor plugins register api.registerVideoGenerationProvider(...)
    • feature and channel plugins consume api.runtime.videoGeneration.*

For media-understanding runtime helpers, plugins may call:

const image = await api.runtime.mediaUnderstanding.describeImageFile({
  filePath: "/tmp/inbound-photo.jpg",
  cfg: api.config,
  agentDir: "/tmp/agent",
});

const video = await api.runtime.mediaUnderstanding.describeVideoFile({
  filePath: "/tmp/inbound-video.mp4",
  cfg: api.config,
});

const extraction = await api.runtime.mediaUnderstanding.extractStructuredWithModel({
  provider: "codex",
  model: "gpt-5.6-sol",
  input: [
    {
      type: "image",
      buffer: receiptImageBuffer,
      fileName: "receipt.png",
      mime: "image/png",
    },
    { type: "text", text: "Use the printed fields as the source of truth." },
  ],
  instructions: "Return entities and searchable tags.",
  schemaName: "example.evidence",
  jsonSchema: {
    type: "object",
    properties: {
      entities: { type: "array", items: { type: "string" } },
      tags: { type: "array", items: { type: "string" } },
    },
  },
  cfg: api.config,
});

For audio transcription, plugins can go through either the media-understanding runtime or the older STT alias:

const { text } = await api.runtime.mediaUnderstanding.transcribeAudioFile({
  filePath: "/tmp/inbound-audio.ogg",
  cfg: api.config,
  // Optional when MIME cannot be inferred reliably:
  mime: "audio/ogg",
});

Notes:

  • api.runtime.mediaUnderstanding.* stands as the preferred shared surface for image, audio, and video understanding.
  • extractStructuredWithModel(...) is the plugin-facing seam for bounded provider-owned image-first extraction. At least one image input is required; text inputs only add context. Product plugins own their routes and schemas while OpenClaw owns the provider and runtime boundary.
  • Uses core media-understanding audio configuration (tools.media.audio) and provider fallback order.
  • Returns { text: undefined } when no transcription output is produced, for instance when input is skipped or unsupported.

Plugins can also kick off background subagent runs via api.runtime.subagent:

const result = await api.runtime.subagent.run({
  sessionKey: "agent:main:subagent:search-helper",
  message: "Expand this query into focused follow-up searches.",
  toolsAlsoAllow: ["my_plugin_progress"],
  provider: "openai",
  model: "gpt-4.1-mini",
  deliver: false,
});

Notes:

  • provider and model act as optional per-run overrides, not persistent session changes.
  • toolsAlsoAllow accepts exact, uniquely owned tool names registered by the calling plugin. Core and ambiguous names get rejected. It stacks on top of the normal profile, but operator allowlists and denies still hold authority.
  • OpenClaw honors those override fields only for trusted callers.
  • For plugin-owned fallback runs, operators must opt in with plugins.entries.<id>.subagent.allowModelOverride: true.
  • Use plugins.entries.<id>.subagent.allowedModels to confine trusted plugins to specific canonical provider/model targets, or "*" to permit any target explicitly.
  • Untrusted plugin subagent runs still function, but override requests are refused instead of silently degrading.
  • Plugin-created subagent sessions carry the creating plugin id as a tag. Fallback api.runtime.subagent.deleteSession(...) may remove only those owned sessions; arbitrary session removal still demands an admin-scoped Gateway request.

For web search, plugins can use the shared runtime helper instead of tapping into the agent tool wiring:

const providers = api.runtime.webSearch.listProviders({
  config: api.config,
});

const result = await api.runtime.webSearch.search({
  config: api.config,
  args: {
    query: "OpenClaw plugin runtime helpers",
    count: 5,
  },
});

Web-search providers can also be registered by plugins through api.registerWebSearchProvider(...).

Notes:

  • Keep provider selection, credential resolution, and shared request semantics in core.
  • Use web-search providers for vendor-specific search transports.
  • api.runtime.webSearch.* is the preferred shared surface for feature and channel plugins that need search behavior without depending on the agent tool wrapper.

api.runtime.imageGeneration

const result = await api.runtime.imageGeneration.generate({
  config: api.config,
  args: { prompt: "A friendly lobster mascot", size: "1024x1024" },
});

const providers = api.runtime.imageGeneration.listProviders({
  config: api.config,
});
  • generate(...): produce an image through the configured image-generation provider chain.
  • listProviders(...): enumerate available image-generation providers along with their capabilities.

Gateway HTTP routes

Plugins can expose HTTP endpoints using api.registerHttpRoute(...).

api.registerHttpRoute({
  path: "/acme/webhook",
  auth: "plugin",
  match: "exact",
  handler: async (_req, res) => {
    res.statusCode = 200;
    res.end("ok");
    return true;
  },
});

Route fields:

  • path: route path under the gateway HTTP server.
  • auth: required, "gateway" or "plugin". Pick "gateway" to demand normal gateway auth, or "plugin" for plugin-managed auth or webhook verification.
  • match: optional. "exact" (default) or "prefix".
  • handleUpgrade: optional handler for WebSocket upgrade requests on the same route.
  • replaceExisting: optional. Needed only for dynamic lifecycle registration to replace its own existing route.
  • handler: return true when the route handled the request.

Notes:

  • api.registerHttpHandler(...) has been dropped, and attempting to load it now triggers a plugin-load error. Switch to api.registerHttpRoute(...).
  • Every plugin route needs an explicit declaration of auth.
  • Paths that are canonically equivalent and share the same match mode collapse into a single route. When the same plugin issues static api.registerHttpRoute(...) calls, that route gets replaced; no other plugin can perform the replacement.
  • Routes that overlap but differ in their auth levels get rejected outright. Restrict exact/prefix fallthrough chains so they only operate within one auth level.
  • Lifecycle code that runs dynamically and pulls in registerPluginHttpRoute(...) from openclaw/plugin-sdk/webhook-ingress must assign replaceExisting: true to refresh its own canonical route. Named registrations are limited to replacing the same nonempty pluginId; if either side sets a route source, both have to set the same nonempty source. For shipped SDK callers, same-plugin refreshes from source-less to source-less and from anonymous to anonymous are still allowed, but a named route and an anonymous route can never swap with each other.
  • View route source as a durable sub-owner tag for the same plugin, not as a diagnostic string. Existing source-less callers can continue leaving it out; source-aware callers must preserve it unchanged through every refresh.
  • When dynamic lifecycle registration is rejected, the default behavior logs the event and hands back a no-op unregister callback. If readiness hinges on that route, set throwOnFailure: true; required bundled webhook transports enforce strict registration, so they cannot declare readiness while live ingress is missing.
  • Operator runtime scopes are not handed out automatically to auth: "plugin" routes. Those routes exist for plugin-managed webhooks and signature verification, not for privileged Gateway helper calls.
  • auth: "gateway" routes execute inside a Gateway request runtime scope. The default surface (gatewayRuntimeScopeSurface: "write-default") stays deliberately minimal:
    • shared-secret bearer auth (gateway.auth.mode = "token" / "password") and every non-trusted-proxy auth method receive a single operator.write scope, even when the caller supplies x-openclaw-scopes
    • trusted-proxy callers that omit an explicit x-openclaw-scopes header continue to get the legacy operator.write-only surface
    • trusted-proxy callers that do include x-openclaw-scopes receive the declared scopes instead
    • a route can enable 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 absent
  • Sandboxed external Control UI tabs backed by auth: "gateway" routes rely on a short-lived signed cookie grant that only authenticated bootstrap can mint; plugin-auth tabs keep their direct iframe path. Before mounting, the parent runs a route-owned probe inside the same opaque sandbox and fails closed if browser privacy policy blocks the cookie. The grant ties itself to the owning plugin, the matched route root, and the current auth generation; its process-random cookie name stops trusted same-host Gateways from overwriting each other, though cookies never isolate TCP ports. That makes the Gateway hostname a single credential boundary: do not host mutually untrusted services together on that hostname, other ports included. Route dispatch refuses reuse against a nested route owned by a different plugin. Since sandbox descendants count as cross-site for cookies, the grant only accepts GET and HEAD with operator.read; mutations and WebSocket upgrades stay on explicit Gateway-authenticated surfaces. The cookie deliberately avoids CHIPS: current browsers fold a cross-site-ancestor bit into the partition key, so nested opaque sandbox frames would lose access to same-route assets. A secure context and browser permission for cross-site cookies are both required, which means gateway-auth external tabs do not work on plain-HTTP LAN origins or under full third-party-cookie blocking; use HTTPS/Tailscale Serve or browser-trusted loopback with a compatible cookie policy.
  • The grant stops Gateway bearer-token leakage and accidental route/scope reuse; it does not establish a security boundary between native plugins. Native plugin code and the UI content it serves stay inside the same trusted in-process plugin boundary.
  • Working rule: never treat a gateway-auth plugin route as an implicit admin surface. For admin-only behavior, opt into the trusted-operator scope surface, demand an identity-bearing auth mode, and spell out the explicit x-openclaw-scopes header contract.
  • Startup plugins register HTTP routes with their full runtime only after the Gateway begins listening. Until startup sidecars are ready, an otherwise-unclaimed HTTP request gets 503 with Retry-After: 1; core routes keep dispatching normally. This generic fallback covers plugin routes before the runtime registry can identify their owners.
  • After route matching and authentication, ordinary handlers join Gateway root-work admission. A prepared or restarting Gateway returns 503 before the handler runs. The narrow exception is a manifest-entitled auth: "gateway" route that also opts into the route-specific trusted-operator surface; it stays reachable so suspension control dispatch is never stranded, while ordinary sibling routes from the same plugin remain behind the admission boundary. WebSocket handleUpgrade ownership follows the same atomic admission boundary; once the handler accepts a socket, the socket's later lifetime belongs to the plugin and is not tracked by this boundary.

Plugin SDK import paths

When writing new plugins, prefer narrow SDK subpaths over the monolithic openclaw/plugin-sdk root barrel. Core subpaths:

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 draw from a narrow set of integration seams: channel-setup, setup-runtime, setup-tools, channel-pairing, channel-contract, channel-feedback, channel-inbound, channel-outbound, command-auth, secret-input, webhook-ingress, channel-targets, and channel-actions. Approval logic should converge on a single approvalCapability contract instead of spreading across unrelated plugin fields. Refer to Channel plugins.

Focused *-runtime subpaths house the runtime and configuration helpers (approval-runtime, agent-runtime, lazy-runtime, directory-runtime, text-utility-runtime, runtime-store, system-event-runtime, heartbeat-runtime, channel-activity-runtime, and others). Choose config-contracts, plugin-config-runtime, runtime-config-snapshot, and config-mutation over the wide config-runtime compatibility barrel.

Info

openclaw/plugin-sdk/channel-lifecycle, compact channel helper wrappers, openclaw/plugin-sdk/config-runtime, and openclaw/plugin-sdk/infra-runtime serve as deprecated compatibility shims for legacy plugins. Fresh code should pull in narrower generic primitives instead.

Entry points internal to the repo, per bundled plugin package root:

  • index.js, entry for bundled plugins
  • api.js, barrel for helpers and types
  • runtime-api.js, barrel limited to runtime
  • setup-entry.js, entry for setup plugins

External plugins must import only openclaw/plugin-sdk/* subpaths. Importing another plugin's src/* from core or from a different plugin is forbidden. Facade-loaded entry points use the active runtime config snapshot when available, otherwise they fall back to the resolved config file on disk.

Capability-specific subpaths like image-generation, media-understanding, and speech exist because bundled plugins rely on them today. They are not guaranteed long-term external contracts, check the relevant SDK reference page before depending on them.

Message tool schemas

Channel plugins should supply channel-specific describeMessageTool(...) schema contributions for non-message primitives such as reactions, reads, and polls. Shared send presentation should rely on the generic MessagePresentation contract rather than provider-native button, component, block, or card fields. See Message Presentation for the contract, fallback rules, provider mapping, and plugin author checklist.

Send-capable plugins advertise their rendering capabilities via message capabilities:

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

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

Channel target resolution

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

  • messaging.inferTargetChatType({ to }) determines whether a normalized target gets classified as direct, group, or channel prior to any directory lookup. That direct classification is a prerequisite for implicit owner heartbeat delivery; without it, Gateway status reports come back as waiting for route.
  • messaging.targetResolver.looksLikeId(raw, normalized) signals to core that an input should bypass directory search and go straight to id-like resolution.
  • messaging.targetResolver.reservedLiterals enumerates bare words that serve as channel or session references for that provider. Resolution honors configured directory entries before rejecting reserved literals, then closes with a failure on a directory miss.
  • messaging.targetResolver.resolveTarget(...) acts as the plugin fallback when core needs a final provider-owned resolution after normalization or a directory miss.
  • messaging.resolveOutboundSessionRoute(...) handles provider-specific session route construction once a target has been resolved.

Suggested division:

  • Apply inferTargetChatType for category decisions that must occur before peer or group searches.
  • Apply looksLikeId for checks that say "treat this as an explicit or native target id".
  • Apply resolveTarget for provider-specific normalization fallback, not for general directory searching.
  • Keep provider-native ids, including chat ids, thread ids, JIDs, handles, and room ids, inside target values or provider-specific params rather than generic SDK fields.

Config-backed directories

When a plugin derives directory entries from config, that logic should live in the plugin and reuse the shared helpers from openclaw/plugin-sdk/directory-runtime.

Use this when a channel needs config-backed peers or groups, for example:

  • allowlist-driven DM peers
  • configured channel or group maps
  • account-scoped static directory fallbacks

The shared helpers in directory-runtime cover only generic operations:

  • query filtering
  • limit application
  • deduping and normalization helpers
  • building ChannelDirectoryEntry[]

Channel-specific account inspection and id normalization remain in the plugin implementation.

Provider catalogs

Provider plugins can define model catalogs for inference using registerProvider({ catalog: { run(...) { ... } } }).

catalog.run(...) returns the same shape OpenClaw writes into models.providers:

  • { provider } for a single provider entry
  • { providers } for multiple provider entries

Use catalog when the plugin owns provider-specific model ids, base URL defaults, or auth-gated model metadata.

catalog.order governs when a plugin's catalog merges relative to OpenClaw's built-in implicit providers:

  • simple: plain API-key or env-driven providers
  • profile: providers that surface when auth profiles exist
  • paired: providers that combine several related provider entries
  • late: final pass, after other implicit providers

Later providers win on key collision, so plugins can intentionally override a built-in provider entry that shares the same provider id.

Plugins can also publish read-only model rows through api.registerModelCatalogProvider({ provider, kinds, staticCatalog, liveCatalog }). This is the forward path for list, help, and picker surfaces and supports text, voice, image_generation, video_generation, and music_generation rows. Provider plugins still own live endpoint calls, token exchange, and vendor response mapping; core owns the common row shape, source labels, and media tool help formatting. Media-generation provider registrations synthesize static catalog rows automatically from defaultModel, models, and capabilities.

Compatibility:

  • discovery still works as a legacy alias, but emits a deprecation warning
  • if both catalog and discovery are registered, OpenClaw uses catalog and emits a warning
  • augmentModelCatalog is deprecated; bundled providers should publish supplemental rows through registerModelCatalogProvider

Read-only channel inspection

If your plugin registers a channel, prefer implementing plugin.config.inspectAccount(cfg, accountId) alongside resolveAccount(...).

Why:

  • resolveAccount(...) is the runtime path. It can assume credentials are fully materialized and can fail fast when required secrets are missing.
  • Read-only command paths such as openclaw status, openclaw status --all, openclaw channels status, openclaw channels resolve, and doctor or config repair flows should not need to materialize runtime credentials just to describe configuration.

Recommended inspectAccount(...) behavior:

  • Hand back only the descriptive account state.
  • Keep enabled and configured intact.
  • When applicable, add credential source and status fields, for instance:
    • tokenSource, tokenStatus
    • botTokenSource, botTokenStatus
    • appTokenSource, appTokenStatus
    • signingSecretSource, signingSecretStatus
  • Raw token values are not required merely to indicate read-only presence. Supplying tokenStatus: "available" along with the corresponding source field suffices for status-oriented commands.
  • Use configured_unavailable when a credential is set through SecretRef but cannot be accessed in the current command context.

This approach lets read-only commands say "configured but unavailable in this command path" rather than failing or incorrectly labeling the account as unconfigured.

Package packs

A plugin directory can hold a package.json containing openclaw.extensions:

{
  "name": "my-pack",
  "openclaw": {
    "extensions": ["./src/safety.ts", "./src/tools.ts"],
    "setupEntry": "./src/setup-entry.ts"
  }
}

Each entry becomes its own plugin. When the pack lists multiple extensions, the plugin id turns into <manifestOrPackageName>/<fileBase> (the manifest id takes precedence if present; otherwise the unscoped package.json name applies).

If your plugin depends on npm packages, place them in that directory so node_modules is accessible (npm install / pnpm install).

Security guardrail: after symlink resolution, every openclaw.extensions entry must remain within the plugin directory. Any entry that leaves the package directory gets rejected.

Security note: openclaw plugins install installs plugin dependencies using a project-local npm install --omit=dev --ignore-scripts (no lifecycle scripts, no dev dependencies at runtime), overriding any inherited global npm install settings. Keep plugin dependency trees "pure JS/TS" and steer clear of packages needing postinstall builds.

Optional: openclaw.setupEntry can reference a lightweight setup-only module. When OpenClaw requires setup surfaces for a disabled channel plugin, or when a channel plugin is enabled but not yet configured, it loads setupEntry instead of the full plugin entry. This keeps startup and setup lighter when your main plugin entry also wires tools, hooks, or other runtime-only code.

Bundled channels can also expose setup-only contract-surface helpers that core can consult before the full channel runtime loads. The current setup promotion surface consists of:

  • singleAccountKeysToMove
  • namedAccountPromotionKeys
  • resolveSingleAccountPromotionTarget(...)

Core uses that surface when it must promote a legacy single-account channel config into channels.<id>.accounts.* without loading the full plugin entry. Matrix serves as the current bundled example: it moves only auth/bootstrap keys into a named promoted account when named accounts already exist, and it can keep a configured non-canonical default-account key instead of always creating accounts.default.

These setup patch adapters keep bundled contract-surface discovery lazy. Import time stays light; the promotion surface loads only on first use rather than re-entering bundled channel startup at module import.

When setup surfaces include gateway RPC methods, place them on a plugin-specific prefix. Core admin namespaces (config.*, exec.approvals.*, wizard.*, update.*) stay reserved and always resolve to operator.admin, even if a plugin asks for a narrower scope.

Channel catalog metadata

Channel plugins can advertise setup/discovery metadata via openclaw.channel and install hints via openclaw.install. This keeps the core catalog free of data.

Example:

{
  "name": "@openclaw/nextcloud-talk",
  "openclaw": {
    "extensions": ["./index.ts"],
    "channel": {
      "id": "nextcloud-talk",
      "label": "Nextcloud Talk",
      "selectionLabel": "Nextcloud Talk (self-hosted)",
      "docsPath": "/channels/nextcloud-talk",
      "docsLabel": "nextcloud-talk",
      "blurb": "Self-hosted chat via Nextcloud Talk webhook bots.",
      "order": 65,
      "aliases": ["nc-talk", "nc"]
    },
    "install": {
      "npmSpec": "@openclaw/nextcloud-talk",
      "localPath": "<bundled-plugin-local-path>",
      "defaultChoice": "npm"
    }
  }
}

Useful openclaw.channel fields beyond the minimal example:

  • detailLabel: secondary label for richer catalog/status surfaces
  • docsLabel: override link text for the docs link
  • preferOver: lower-priority plugin/channel ids this catalog entry should outrank
  • selectionDocsPrefix, selectionDocsOmitLabel, selectionExtras: selection-surface copy controls
  • markdownCapable: marks the channel as markdown-capable for outbound formatting decisions
  • exposure.configured: hide the channel from configured-channel listing surfaces when set to false
  • exposure.setup: hide the channel from interactive setup/configure pickers when set to false
  • exposure.docs: mark the channel as internal/private for docs navigation surfaces
  • quickstartAllowFrom: opt the channel into the standard quickstart allowFrom flow
  • forceAccountBinding: require explicit account binding even when only one account exists
  • preferSessionLookupForAnnounceTarget: prefer session lookup when resolving announce targets

OpenClaw can also merge external channel catalogs (for example, an MPM registry export). Drop a JSON file at one of:

  • ~/.openclaw/mpm/plugins.json
  • ~/.openclaw/mpm/catalog.json
  • ~/.openclaw/plugins/catalog.json

Alternatively, set OPENCLAW_PLUGIN_CATALOG_PATHS (or OPENCLAW_MPM_CATALOG_PATHS) to reference one or more JSON files, using commas, semicolons, or PATH as delimiters. Every file is expected to hold { "entries": [ { "name": "@scope/pkg", "openclaw": { "channel": {...}, "install": {...} } } ] }. For backward compatibility, the parser recognizes "packages" and "plugins" as older names for the "entries" key.

Both generated channel catalog entries and provider install catalog entries present normalized install-source details alongside the unprocessed openclaw.install block. These normalized details indicate whether the npm spec is pinned to an exact version or uses a floating selector, whether the expected integrity metadata is included, and whether a local source path is also available. When the catalog or package identity is known, the normalized details flag any mismatch between the parsed npm package name and that identity. They also flag an invalid defaultChoice or one that references an unavailable source, as well as npm integrity metadata that lacks a valid npm source. Consumers should view installSource as an optional additive field, so manually constructed entries and catalog shims are not forced to generate it. This approach lets onboarding and diagnostics describe source-plane state without pulling in the plugin runtime.

For official external npm entries, an exact npmSpec paired with expectedIntegrity is the preferred approach. Bare package names and dist-tags remain functional for compatibility, but they trigger source-plane warnings, allowing the catalog to shift toward pinned, integrity-checked installs without disrupting existing plugins. When onboarding installs from a local catalog path, it creates a managed plugin index entry that includes source: "path" and, when feasible, a workspace-relative sourcePath. The absolute operational load path is stored in plugins.load.paths; the install record avoids embedding local workstation paths in long-lived configuration. This keeps local development installs visible to source-plane diagnostics without introducing a second raw filesystem-path disclosure surface. The persisted installed_plugin_index SQLite table serves as the authoritative install source and can be refreshed without loading plugin runtime modules. Its installRecords map remains durable even when a plugin manifest is missing or invalid, while its plugins payload offers a rebuildable view of the manifest.

Context engine plugins

Session context orchestration for ingest, assembly, and compaction is handled by context engine plugins. To register one from your plugin, use api.registerContextEngine(id, factory), then pick the active engine with plugins.slots.contextEngine.

This is the right choice when your plugin must replace or extend the default context pipeline, rather than simply adding memory search or hooks.

import { buildMemorySystemPromptAddition } from "openclaw/plugin-sdk/core";

export default function (api) {
  api.registerContextEngine("lossless-claw", (ctx) => ({
    info: {
      id: "lossless-claw",
      name: "Lossless Claw",
      ownsCompaction: true,
      acceptedHostParams: ["sessionKey"],
    },
    async ingest() {
      return { ingested: true };
    },
    async assemble({ messages, sessionKey, availableTools, citationsMode }) {
      return {
        messages,
        estimatedTokens: 0,
        systemPromptAddition: buildMemorySystemPromptAddition({
          availableTools: availableTools ?? new Set(),
          citationsMode,
          agentSessionKey: sessionKey,
        }),
      };
    },
    async compact() {
      return { ok: true, compacted: false };
    },
  }));
}

At construction time, the factory ctx makes optional config, agentDir, and workspaceDir values available for initialization.

Before invoking a non-legacy engine's assemble(), the host finishes preparing registered async memory prompts. While assemble() is running, buildMemorySystemPromptAddition(...) remains synchronous and reads that immutable run snapshot. Pass the supplied tool and citation context through unchanged, ensuring the snapshot cannot leak across run boundaries.

When the active harness has a persistent backend thread, assemble() may return contextProjection. For legacy per-turn projection, omit it. If the assembled context should be injected once into a backend thread and reused until the epoch changes, return { mode: "thread_bootstrap", epoch }. Update the epoch whenever the engine's semantic context shifts, such as after an engine-owned compaction pass. Hosts can preserve tool-call metadata, input shape, and redacted tool results in a thread-bootstrap projection, so fresh backend threads keep tool continuity without copying raw secret-bearing payloads.

If your engine does not own the compaction algorithm, keep compact() implemented and delegate it explicitly:

import {
  buildMemorySystemPromptAddition,
  delegateCompactionToRuntime,
} from "openclaw/plugin-sdk/core";

export default function (api) {
  api.registerContextEngine("my-memory-engine", (ctx) => ({
    info: {
      id: "my-memory-engine",
      name: "My Memory Engine",
      ownsCompaction: false,
    },
    async ingest() {
      return { ingested: true };
    },
    async assemble({ messages, sessionKey, availableTools, citationsMode }) {
      return {
        messages,
        estimatedTokens: 0,
        systemPromptAddition: buildMemorySystemPromptAddition({
          availableTools: availableTools ?? new Set(),
          citationsMode,
          agentSessionKey: sessionKey,
        }),
      };
    },
    async compact(params) {
      return await delegateCompactionToRuntime(params);
    },
  }));
}

Adding a new capability

When a plugin needs behavior that the current API cannot accommodate, do not reach around the plugin system with a private workaround. Add the missing capability instead.

Recommended sequence:

  1. Define the core contract. Decide which 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 fully integrated yet.

Capability template

Minimal pattern:

// core contract
export type VideoGenerationProviderPlugin = {
  id: string;
  label: string;
  generateVideo: (req: VideoGenerationRequest) => Promise<VideoGenerationResult>;
};

// plugin API
api.registerVideoGenerationProvider({
  id: "openai",
  label: "OpenAI",
  async generateVideo(req) {
    return await generateOpenAiVideo(req);
  },
});

// shared runtime helper for feature/channel plugins
const clip = await api.runtime.videoGeneration.generate({
  prompt: "Show the robot walking through the lab.",
  cfg,
});

Contract test pattern (src/plugins/contracts/registry.ts exposes ownership lookups such as providerContractPluginIds; tests assert a plugin's contracts.videoGenerationProviders list matches what it actually registers):

expect(pluginManifest.contracts?.videoGenerationProviders).toEqual(["openai"]);

That keeps the rule simple:

  • core owns the capability contract + orchestration
  • vendor plugins own vendor implementations
  • feature/channel plugins consume runtime helpers
  • contract tests keep ownership explicit
7,774 words · updated Aug 25, 2026