Plugin internals

Plugin internals

This is the deep architecture reference for the OpenClaw plugin system. For practical guides, start with one of the focused pages below.

Public capability model

Capabilities are the public native plugin model inside OpenClaw. Native plugins can register one or more capability types:

Capability Registration method Example plugins
Text inference api.registerProvider(...) anthropic, openai
CLI inference backend api.registerCliBackend(...) anthropic, openai
Embeddings api.registerEmbeddingProvider(...) Provider-owned vector plugins
Speech api.registerSpeechProvider(...) elevenlabs, microsoft
Realtime transcription api.registerRealtimeTranscriptionProvider(...) openai
Realtime voice api.registerRealtimeVoiceProvider(...) google, openai
Media understanding api.registerMediaUnderstandingProvider(...) google, openai
Transcripts source api.registerTranscriptSourceProvider(...) discord, google-meet, teams-meetings, zoom-meetings
Image generation api.registerImageGenerationProvider(...) fal, google, openai
Music generation api.registerMusicGenerationProvider(...) fal, google, minimax
Video generation api.registerVideoGenerationProvider(...) fal, google, qwen
Web fetch api.registerWebFetchProvider(...) firecrawl
Web search api.registerWebSearchProvider(...) brave, firecrawl, google
Channel / messaging api.registerChannel(...) matrix, msteams
Gateway discovery api.registerGatewayDiscoveryService(...) bonjour
Migration api.registerMigrationProvider(...) migrate-claude, migrate-hermes

External compatibility stance

The capability model is landed in core and used by bundled/native plugins today, but external plugin compatibility still needs a tighter bar than "it is exported, therefore it is frozen."

Plugin situation Guidance
Existing external plugins Keep hook-based integrations working; this is the compatibility baseline.
New bundled/native plugins Prefer explicit capability registration over vendor-specific reach-ins or new hook-only designs.
External plugins adopting capability registration Allowed, but treat capability-specific helper surfaces as evolving unless docs mark them stable.

Capability registration is the intended direction. Legacy hooks remain the safest no-breakage path for external plugins during the transition. Exported helper subpaths are not all equal — prefer narrow documented contracts over incidental helper exports.

Plugin shapes

OpenClaw classifies every loaded plugin into a shape based on its actual registration behavior (not just static metadata):

plain-capability

Registers exactly one capability type (for example a provider-only plugin like arcee or chutes).

hybrid-capability

Registers multiple capability types (for example openai owns text inference, speech, media understanding, and image generation).

hook-only

Registers only hooks (typed or custom), no capabilities, tools, commands, or services.

non-capability

Registers tools, commands, services, or routes but no capabilities.

Use openclaw plugins inspect <id> to see a plugin's shape and capability breakdown. See CLI reference for details.

Compatibility signals

openclaw doctor, openclaw plugins inspect <id>, openclaw status --all, and openclaw plugins doctor surface these compatibility notices:

Signal Meaning
config valid Config parses fine and plugins resolve
hook-only (info) Plugin registers only hooks; a supported path, but not migrated to capability registration yet
deprecated memory-embedding API (warn) Non-bundled plugin uses the old memory-specific embedding provider API instead of registerEmbeddingProvider
hard error Config is invalid or plugin failed to load

None of the advisory/warn signals break your plugin today. These signals also appear in openclaw status --all and openclaw plugins doctor.

Architecture overview

OpenClaw's plugin system has four layers:

  • Manifest + discovery

    OpenClaw finds candidate plugins from configured paths, workspace roots, global plugin roots, and bundled plugins. Discovery reads native openclaw.plugin.json manifests plus supported bundle manifests first.

  • Enablement + validation

    Core decides whether a discovered plugin is enabled, disabled, blocked, or selected for an exclusive slot such as memory.

  • Runtime loading

    Native OpenClaw plugins are loaded in-process and register capabilities into a central registry. Managed instances load JavaScript through Node and compile TypeScript source when needed. Compatible bundles are normalized into registry records without importing runtime code.

  • Surface consumption

    The rest of OpenClaw reads the registry to expose tools, channels, provider setup, hooks, HTTP routes, CLI commands, and services.

  • For plugin CLI specifically, root command discovery is split in two phases:

    • parse-time metadata comes from registerCli(..., { descriptors: [...] })
    • the real plugin CLI module can stay lazy and register on first invocation

    That keeps plugin-owned CLI code inside the plugin while still letting OpenClaw reserve root command names before parsing.

    The important design boundary:

    • manifest/config validation should work from manifest/schema metadata without executing plugin code
    • native capability discovery may load trusted plugin entry code to build a non-activating registry snapshot
    • native runtime behavior comes from the plugin module's register(api) path with api.registrationMode === "full"

    That split lets OpenClaw validate config, explain missing/disabled plugins, and build UI/schema hints before the full runtime is active.

    Plugin metadata snapshot and lookup table

    One PluginCache starts on the first plugin metadata access, including CLI preflight before Gateway startup, and fills progressively as metadata and artifacts are needed. Gateway startup retains that owner and builds its immutable PluginMetadataSnapshot. The snapshot includes plugin metadata from all configured agent workspaces, including disabled plugins, with source precedence and workspace provenance preserved. It stores the installed plugin index, manifest registry, manifest diagnostics, owner maps, and a plugin id normalizer. Package contents and lazily loaded module exports belong to other typed views of the same cache, not the snapshot itself.

    Plugin-aware config validation, startup auto-enable, and Gateway plugin bootstrap consume that snapshot instead of rebuilding manifest/index metadata independently. PluginLookUpTable is derived from the same snapshot and adds the startup plugin plan for the current runtime config.

    Channel setup catalogs retain the requested workspace and load-path scope, including raw plugin shadows, so trust filtering can select the appropriate installed alternative.

    After startup, runtime readers reuse that inventory without filesystem discovery, manifest rereads, or freshness checks. Narrow plugin selections are in-memory views of the same inventory. Changing an account or an agent's run workspace does not invalidate it. Explicit plugin lifecycle operations prepare a new inventory for installs, updates, removals, source or manifest edits, and discovery-root changes before publishing it to the running Gateway.

    Model-id normalization policies are prepared with each snapshot or narrowed view. Model selection, catalogs, and runtime normalization carry that view forward instead of rebuilding policies from its plugin list. An empty view remains authoritative and cannot inherit policies from a broader process snapshot.

    The snapshot and lookup table keep repeated startup decisions on the fast path:

    • channel ownership
    • startup plugin planning
    • startup plugin ids
    • provider and CLI backend ownership
    • setup provider, command alias, model catalog provider, and manifest contract ownership
    • plugin config schema and channel config schema validation
    • startup auto-enable decisions

    Startup and hot replacement share one prepared registry publisher. Replacement retains unchanged plugin instances, validates the candidate, drains affected services and channels, then publishes runtime methods and metadata together. Connected clients refresh their plugin capabilities after publication. A rejected candidate leaves the old generation selected when cleanup succeeds; a failure after publication reports the committed generation. Plugin runtime imports remain lazy; retaining metadata does not activate every discovered plugin.

    A provider or harness plugin load failure remains recorded in its runtime generation. It makes that plugin unavailable without superseding the generation or blocking models that use healthy plugins. Inspect the failing owner with openclaw plugins inspect <id> --runtime --json. Use openclaw doctor --fix for supported installation repairs, or fix the reported problem in plugin code, then request plugins.reload through the admin Gateway API to load the repaired plugin.

    Read-only model validation, effective tool inventory, and isolated model probes acquire their own registrations when they need executable provider or harness hooks. Concurrent callers share the prepared generation, and its lifecycle disposers run after the final borrower and any unfinished preparation or catalog work settle. Cancellation does not close a registration while its callback is still running. Process shutdown revokes these registry views before joining their remaining work and disposal. Catalog reads that need only metadata do not acquire these executable registrations.

    Each plugin service startup attempt owns one cleanup operation, including failed starts. Hot replacement observes candidate startup and service cleanup with five-second deadlines. Candidate startup failure rejects the replacement. Cleanup failures and deadlines produce warnings while replacement can proceed. A pending startup retains its resources until it finishes and its one stop operation settles. Replacement or rejection can report deferred cleanup; Gateway shutdown joins that work before releasing the plugin's resources. Disposal stops new registered calls and attempts explicit cleanup within its own bounded wait; other native work may finish later. A later reload can create a fresh instance without waiting for all old resources to disappear. Service cleanup is not invoked a second time merely because an observer timed out.

    Gateway shutdown also joins actual harness, MCP, LSP, embedding, and media cleanup after their initial grace periods. When clearing the active registry, plugin host cleanup can advance to later hooks after a timeout, but registry resets and shared database closure wait for its actual completion. These waits preserve resources for cleanup; they do not restore a retired plugin's runtime authority.

    Executable CLI cleanup reports each disposer that exceeds five seconds and proceeds with later cleanup without canceling the pending work. On macOS with Node's system CA support enabled, automatic exit after command completion waits for this pending cleanup to finish. Explicit command exit requests and the update exit watchdog retain their bounded behavior.

    Standalone plugin and Codex supervision MCP stdio services retain their discovered registrations through accepted tool work, harness cleanup, and nested SDK provider lookups. Terminal shutdown cancels and joins handlers before releasing these registrations and awaiting their resource disposers. Transport-close and registration-disposal failures reach the serving caller. Programmatic servers created from supplied tools leave those resources with the caller; closing and reconnecting the same server does not dispose them.

    Hot registry publication does not wait for retired host cleanup; terminal shutdown also joins cleanup already started by earlier registry replacements before resetting shared state. Activation and rollback apply only to their captured registry version. An activation superseded by a lifecycle callback reports an error.

    The cache rule is documented in Plugin architecture internals: Gateway retains one cache generation, while explicit management operations use isolated generations of the same cache. There are no wall-clock TTLs for Gateway metadata.

    Install, update, registry refresh, and doctor flows may read fresh package metadata to validate their changes. A management snapshot or installed-index write alone does not replace the running Gateway's inventory: the Gateway lifecycle owner must prepare and publish it. Runtime flows use their selected snapshot or lookup table instead of falling back to cold management paths.

    Runtime instance and source lifetime

    A managed runtime instance owns its module results, registered callables, and runtime-store slots. With Node's synchronous module hooks, it also owns a captured source artifact. Package plugins capture their package inputs when the instance is created. Standalone files and compiled setup artifacts capture their entry and statically known inputs without copying the surrounding workspace. Conditional package aliases retain their package metadata, and native Node conditions select the target from that captured metadata. Legacy packages without an exports map also prefetch their existing main or index entry as raw bytes; this can read a large native entry, but does not execute unselected code. The selected package's remaining body is captured before execution. Dependency links retain existing nested installation locations; hoisted dependencies link at the captured package root. Capture does not add node_modules beside individual source files, so native-addon loaders can still locate their package root and its build assets.

    After the existing runtime, setup, or executable-discovery checks admit an entry, its instance captures imported shared files and dependency modules on demand. Relative, absolute, and file-URL imports use captured files; TypeScript dependency entries compile in their own package scope. Metadata and install inspection remain confined and do not acquire executable shared inputs.

    Executable loading can follow a shared-module link by capturing its selected module separately. Cold source snapshots still reject links outside the plugin root, so deferred install batches and install-digest settlement require those inputs to be packaged as dependencies. Linked non-module resources outside the plugin root are not captured by this module-loading path.

    A first-demand import() or require() can observe later source edits; captured metadata and entry bytes remain unchanged. The initial source digest covers the creation-time capture; later inputs extend explicit source-current checks without changing that digest. Invalid optional package metadata fails only when selected. Module acquisition uses the instance's current admission, and disposal closes further capture. Runtime and setup retirement remove captured artifacts asynchronously and wait for removal to finish. Plugin callback deadlines do not end custody of those files; synchronous source inspection and failed capture still clean up before returning.

    Model-catalog workers keep their captured plugin files in a directory owned by one worker. The parent removes any remaining captures after that worker exits, including cancellation and crashes. Files remain available while the worker is running, and retiring one worker does not remove another generation's captures. Cancellation releases compute capacity after the worker exits; terminal shutdown also waits for file cleanup. Failed file removal is reported as a cleanup warning.

    Loading metadata alone does not execute every plugin, and registration remains synchronous. Synchronously loaded TypeScript entries and their synchronous TypeScript imports retain Jiti's CommonJS compilation behavior, including .mts and .mtsx entries. Node evaluates the captured output. Keep top-level await out of synchronous entrypoints; start asynchronous work through lifecycle callbacks or a later dynamic import.

    Dynamic TypeScript imports preserve asynchronous CommonJS execution, including top-level await and module.exports, while source loaded from native JavaScript follows Node's module format. The first evaluation fixes that mode for the instance; resolving a module alone does not evaluate it. Source import.meta.resolve retains Jiti's optional parent URL and resolution options, including custom conditions and try. The one-argument resolver uses the source's directory and package scope. Entries loaded from captured source retain evaluation failures for their instance instead of retrying through another loader. Core-shipped JavaScript and libraries loaded outside a captured plugin instance keep their existing native/Jiti loading behavior.

    Managed TypeScript filename metadata (import.meta.url, import.meta.filename, import.meta.dirname, __filename, and __dirname) identifies the captured source so relative asset reads stay within that generation. Node executes compiled JavaScript from a separate directory; its module URLs and CommonJS cache keys can differ from the source filenames.

    Bun 1.4.2 uses its native/Jiti loader with a separate captured source artifact for each managed instance. Reload prepares fresh TypeScript entries and helpers while existing consumers retain their old instance. Disposal removes that instance's captured cache records and files without evicting its replacement or the host SDK. Native imports and Jiti imports retain their respective package conditions.

    Bun needs local package import/export targets to exist before native resolution. Selective captures therefore acquire existing files matched by those declarations, including conditional branches and wildcard targets, before evaluation. Unselected source remains raw bytes; its code and TypeScript configuration are not evaluated. This can read more files at startup than Node's demand-driven capture. Other deferred imports still acquire source on first use through the instance's current admission; already prepared modules need no new acquisition.

    When using Jiti's TypeScript path settings, keep the original tsconfig files and configuration dependencies available while the plugin is active. Loaded modules retain their selected path mappings; previously unvisited modules may read those configuration files on first use. Newly loaded instances select the current path settings.

    Registry retirement revokes managed execution separately from physical resource release. An acquired inspection can release its execution authority while a borrower still holds the underlying registration resources; the last physical claim owns their disposal. Bare SDK provider results retain their own instance consumer, so their callbacks remain usable until the owning SDK host closes. That host joins admitted callback work before releasing consumers and resources; releasing the inspection still prevents new borrows. Gateway shutdown keeps shared dependencies until the owners that still need them have joined. These ownership rules do not make native plugins a sandbox or automatically close plugin-created resources. See Plugin lifecycle and cleanup for the plugin author's cleanup contract.

    An admitted agent turn keeps its original context engine through accepted commit and engine disposal. Reload can report deferred cleanup while that turn finishes. Starting engine disposal closes its normal callbacks immediately; the engine's cleanup remains owned until it settles.

    Activation planning

    Activation planning is part of the control plane. Callers can ask which plugins are relevant to a concrete command, provider, channel, route, agent harness, or capability before loading broader runtime registries.

    The planner keeps current manifest behavior compatible:

    • activation.* fields are explicit planner hints
    • providers, channels, commandAliases, setup.providers, contracts.tools, and hooks remain manifest ownership fallback
    • the ids-only planner API stays available for existing callers
    • the plan API reports reason labels so diagnostics can distinguish explicit hints from ownership fallback

    Channel plugins and the shared message tool

    Channel plugins do not need to register a separate send/edit/react tool for normal chat actions. OpenClaw keeps one shared message tool in core, and channel plugins own the channel-specific discovery and execution behind it.

    The current boundary is:

    • core owns the shared message tool host, prompt wiring, session/thread bookkeeping, and execution dispatch
    • channel plugins own scoped action discovery, capability discovery, and any channel-specific schema fragments
    • channel plugins own provider-specific session conversation grammar, such as how conversation ids encode thread ids or inherit from parent conversations
    • channel plugins execute the final action through their action adapter

    For channel plugins, the SDK surface is ChannelMessageActionAdapter.describeMessageTool(...). That unified discovery call lets a plugin return its visible actions, capabilities, and schema contributions together so those pieces do not drift apart.

    Message action names use a deliberately closed, core-owned vocabulary so every transport can render every action. Plugins add action names through a core PR; runtime registration is intentionally unsupported.

    When a channel-specific message-tool param carries a media source such as a local path or remote media URL, the plugin should also return mediaSourceParams from describeMessageTool(...). Core uses that explicit list to apply sandbox path normalization and outbound media-access hints without hardcoding plugin-owned param names. Prefer action-scoped maps there, not one channel-wide flat list, so a profile-only media param does not get normalized on unrelated actions like send.

    Core passes runtime scope into that discovery step. Important fields include:

    • accountId
    • currentChannelId
    • chatType (direct, group, or channel when the inbound route establishes it)
    • currentThreadTs
    • currentMessageId
    • sessionKey
    • sessionId
    • agentId
    • trusted inbound requesterSenderId

    That matters for context-sensitive plugins. A channel can hide or expose message actions based on the active account, current room/thread/message, authoritative conversation type, or trusted requester identity without hardcoding channel-specific branches in the core message tool. Treat chatType as discovery scope supplied by the current inbound route, not something to infer again from an opaque channel id; it is absent when that route did not establish the conversation type.

    This is why embedded-runner routing changes are still plugin work: the runner is responsible for forwarding the current chat/session identity into the plugin discovery boundary so the shared message tool exposes the right channel-owned surface for the current turn.

    For channel-owned execution helpers, channel plugins should keep the execution runtime inside their own plugin modules. Core no longer owns the Discord, Slack, Telegram, or WhatsApp message-action runtimes under src/agents/tools. We do not publish separate plugin-sdk/*-action-runtime subpaths, and those plugins should import their own local runtime code directly from their plugin-owned modules.

    The same boundary applies to provider-named SDK seams in general: core should not import channel-specific convenience barrels for Discord, Signal, Slack, WhatsApp, or similar plugins. If core needs a behavior, either consume the bundled plugin's own api.ts / runtime-api.ts barrel or promote the need into a narrow generic capability in the shared SDK.

    Bundled plugins follow the same rule. A bundled plugin's runtime-api.ts should not re-export its own branded openclaw/plugin-sdk/<plugin-id> facade. Those branded facades remain compatibility shims for external plugins and older consumers, but bundled plugins should use local exports plus narrow generic SDK subpaths such as openclaw/plugin-sdk/channel-policy, openclaw/plugin-sdk/runtime-store, or openclaw/plugin-sdk/webhook-ingress. New code should not add plugin-id-specific SDK facades unless the compatibility boundary for an existing external ecosystem requires it.

    For polls specifically, there are two execution paths:

    • outbound.sendPoll is the shared baseline for channels that fit the common poll model
    • actions.handleAction("poll") is the preferred path for channel-specific poll semantics or extra poll parameters

    Core now defers shared poll parsing until after plugin poll dispatch declines the action, so plugin-owned poll handlers can accept channel-specific poll fields without being blocked by the generic poll parser first.

    See Plugin architecture internals for the full startup sequence.

    Capability ownership model

    OpenClaw treats a native plugin as the ownership boundary for a company or a feature, not as a grab bag of unrelated integrations.

    That means:

    • a company plugin should usually own all of that company's OpenClaw-facing surfaces
    • a feature plugin should usually own the full feature surface it introduces
    • channels should consume shared core capabilities instead of re-implementing provider behavior ad hoc
    Vendor multi-capability

    google owns text inference, CLI backend, embeddings, speech, realtime voice, media understanding, image/music/video generation, and web search. openai owns text inference, embeddings, speech, realtime transcription, realtime voice, media understanding, image/video generation. minimax owns text inference plus media understanding, speech, image/music/video generation, and web search.

    Vendor single-capability

    arcee and chutes own text inference only; microsoft owns speech only. A vendor plugin can stay this narrow until it needs to cover more of that vendor's surface.

    Feature plugin

    voice-call owns call transport, tools, CLI, routes, and Twilio media-stream bridging, but consumes shared speech, realtime transcription, and realtime voice capabilities instead of importing vendor plugins directly.

    The intended end state is:

    • a vendor's OpenClaw-facing surface lives in one plugin even if it spans text models, speech, images, and video
    • other vendors can do the same for their own surface area
    • channels do not care which vendor plugin owns the provider; they consume the shared capability contract exposed by core

    This is the key distinction:

    • plugin = ownership boundary
    • capability = core contract that multiple plugins can implement or consume

    So if OpenClaw adds a new domain such as video, the first question is not "which provider should hardcode video handling?" The first question is "what is the core video capability contract?" Once that contract exists, vendor plugins can register against it and channel/feature plugins can consume it.

    If the capability does not exist yet, the right move is usually:

  • Define the capability

    Define the missing capability in core.

  • Expose through the SDK

    Expose it through the plugin API/runtime in a typed way.

  • Wire consumers

    Wire channels/features against that capability.

  • Vendor implementations

    Let vendor plugins register implementations.

  • This keeps ownership explicit while avoiding core behavior that depends on a single vendor or a one-off plugin-specific code path.

    Capability layering

    Use this mental model when deciding where code belongs:

    Core capability layer

    Shared orchestration, policy, fallback, config merge rules, delivery semantics, and typed contracts.

    Vendor plugin layer

    Vendor-specific APIs, auth, model catalogs, speech synthesis, image generation, video backends, usage endpoints.

    Channel/feature plugin layer

    Discord/Slack/voice-call/etc. integration that consumes core capabilities and presents them on a surface.

    For example, TTS follows this shape:

    • core owns reply-time TTS policy, fallback order, prefs, and channel delivery
    • elevenlabs, google, microsoft, and openai own synthesis implementations
    • voice-call consumes the telephony TTS runtime helper

    That same pattern should be preferred for future capabilities.

    Multi-capability company plugin example

    A company plugin should feel cohesive from the outside. If OpenClaw has shared contracts for models, speech, realtime transcription, realtime voice, media understanding, image generation, video generation, web fetch, and web search, a vendor can own all of its surfaces in one place:

    ts
    import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";import { exampleAiMedia } from "./exampleai-media.js"; export default definePluginEntry({  id: "exampleai",  name: "ExampleAI",  description: "ExampleAI models and media capabilities.",  register(api) {    api.registerProvider({      id: "exampleai",      // auth/model catalog/runtime hooks    });     api.registerSpeechProvider({      id: "exampleai",      // vendor speech config — implement the SpeechProviderPlugin interface directly    });     api.registerMediaUnderstandingProvider({      id: "exampleai",      capabilities: ["image", "audio", "video"],      describeImage: (req) => exampleAiMedia.describeImage(req),      transcribeAudio: (req) => exampleAiMedia.transcribeAudio(req),      describeVideo: (req) => exampleAiMedia.describeVideo(req),    });     api.registerWebSearchProvider({      id: "exampleai-search",      createTool() {        // Return the vendor-owned web search tool.      },    });  },});

    What matters is not the exact helper names. The shape matters:

    • one plugin owns the vendor surface
    • core still owns the capability contracts
    • provider request translation and HTTP helpers stay in the vendor plugin
    • channels and feature plugins consume api.runtime.* helpers, not vendor code
    • contract tests can assert that the plugin registered the capabilities it claims to own

    Capability example: video understanding

    OpenClaw already treats image/audio/video understanding as one shared capability. The same ownership model applies there:

  • Core defines the contract

    Core defines the media-understanding contract.

  • Vendor plugins register

    Vendor plugins register describeImage, transcribeAudio, and describeVideo as applicable.

  • Consumers use the shared behavior

    Channels and feature plugins consume the shared core behavior instead of wiring directly to vendor code.

  • That avoids baking one provider's video assumptions into core. The plugin owns the vendor surface; core owns the capability contract and fallback behavior.

    Video generation already uses that same sequence: core owns the typed capability contract and runtime helper, and vendor plugins register api.registerVideoGenerationProvider(...) implementations against it.

    Need a concrete rollout checklist? See Adding capabilities.

    Contracts and enforcement

    The plugin API surface is intentionally typed and centralized in OpenClawPluginApi. That contract defines the supported registration points and the runtime helpers a plugin may rely on.

    Why this matters:

    • plugin authors get one stable internal standard
    • core can reject duplicate ownership such as two plugins registering the same provider id
    • startup can surface actionable diagnostics for malformed registration
    • contract tests can enforce bundled-plugin ownership and prevent silent drift

    There are two layers of enforcement:

    Runtime registration enforcement

    The plugin registry validates registrations as plugins load. Examples: duplicate provider ids, duplicate speech provider ids, and malformed registrations produce plugin diagnostics instead of undefined behavior.

    Contract tests

    Bundled plugins are captured in contract registries during test runs so OpenClaw can assert ownership explicitly. Today this is used for model providers, speech providers, web search providers, and bundled registration ownership.

    The practical effect is that OpenClaw knows, up front, which plugin owns which surface. That lets core and channels compose seamlessly because ownership is declared, typed, and testable rather than implicit.

    What belongs in a contract

    Good contracts

    • typed
    • small
    • capability-specific
    • owned by core
    • reusable by multiple plugins
    • consumable by channels/features without vendor knowledge

    Bad contracts

    • vendor-specific policy hidden in core
    • one-off plugin escape hatches that bypass the registry
    • channel code reaching straight into a vendor implementation
    • ad hoc runtime objects that are not part of OpenClawPluginApi or api.runtime

    When in doubt, raise the abstraction level: define the capability first, then let plugins plug into it.

    Execution model

    Native OpenClaw plugins run in-process with the Gateway. They are not sandboxed. A loaded native plugin has the same process-level trust boundary as core code.

    Compatible bundles are safer by default because OpenClaw currently treats them as metadata/content packs. In current releases, that mostly means bundled skills.

    Use allowlists and explicit install/load paths for non-bundled plugins. Treat workspace plugins as development-time code, not production defaults.

    For bundled workspace package names, keep the plugin id anchored in the npm name: @openclaw/<id> by default, or an approved typed suffix such as -provider, -plugin, -speech, -sandbox, or -media-understanding when the package intentionally exposes a narrower plugin role.

    Export boundary

    OpenClaw exports capabilities, not implementation convenience.

    Keep capability registration public. Trim non-contract helper exports:

    • bundled-plugin-specific helper subpaths
    • runtime plumbing subpaths not intended as public API
    • vendor-specific convenience helpers
    • setup/onboarding helpers that are implementation details

    Reserved bundled-plugin helper subpaths have been retired from the generated SDK export map. Keep owner-specific helpers inside the owning plugin package; promote only reusable host behavior to generic SDK contracts such as plugin-sdk/gateway-runtime, plugin-sdk/security-runtime, and injected plugin API capabilities.

    Internals and reference

    For the load pipeline, registry model, provider runtime hooks, Gateway HTTP routes, message tool schemas, channel target resolution, provider catalogs, context engine plugins, and the guide to adding a new capability, see Plugin architecture internals.

    Was this useful?
    On this page

    On this page