Files
openclaw/docs/tools/plugin.md
2026-03-12 22:24:35 +00:00

41 KiB
Raw Blame History

summary, read_when, title
summary read_when title
OpenClaw plugins/extensions: discovery, config, and safety
Adding or modifying plugins/extensions
Documenting plugin install or load rules
Plugins

Plugins (Extensions)

Quick start (new to plugins?)

A plugin is just a small code module that extends OpenClaw with extra features (commands, tools, and Gateway RPC).

Most of the time, youll use plugins when you want a feature thats not built into core OpenClaw yet (or you want to keep optional features out of your main install).

Fast path:

  1. See whats already loaded:
openclaw plugins list
  1. Install an official plugin (example: Voice Call):
openclaw plugins install @openclaw/voice-call

Npm specs are registry-only (package name + optional exact version or dist-tag). Git/URL/file specs and semver ranges are rejected.

Bare specs and @latest stay on the stable track. If npm resolves either of those to a prerelease, OpenClaw stops and asks you to opt in explicitly with a prerelease tag such as @beta/@rc or an exact prerelease version.

  1. Restart the Gateway, then configure under plugins.entries.<id>.config.

See Voice Call for a concrete example plugin. Looking for third-party listings? See Community plugins.

Architecture

OpenClaw's plugin system has four layers:

  1. Manifest + discovery OpenClaw finds candidate plugins from configured paths, workspace roots, global extension roots, and bundled extensions. Discovery reads openclaw.plugin.json plus package metadata first.
  2. Enablement + validation Core decides whether a discovered plugin is enabled, disabled, blocked, or selected for an exclusive slot such as memory.
  3. Runtime loading Enabled plugins are loaded in-process via jiti and register capabilities into a central registry.
  4. Surface consumption The rest of OpenClaw reads the registry to expose tools, channels, provider setup, hooks, HTTP routes, CLI commands, and services.

The important design boundary:

  • discovery + config validation should work from manifest/schema metadata without executing plugin code
  • runtime behavior comes from the plugin module's register(api) path

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

Execution model

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

Implications:

  • a plugin can register tools, network handlers, hooks, and services
  • a plugin bug can crash or destabilize the gateway
  • a malicious plugin is equivalent to arbitrary code execution inside the OpenClaw process

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

Available plugins (official)

  • Microsoft Teams is plugin-only as of 2026.1.15; install @openclaw/msteams if you use Teams.
  • Memory (Core) — bundled memory search plugin (enabled by default via plugins.slots.memory)
  • Memory (LanceDB) — bundled long-term memory plugin (auto-recall/capture; set plugins.slots.memory = "memory-lancedb")
  • Voice Call@openclaw/voice-call
  • Zalo Personal@openclaw/zalouser
  • Matrix@openclaw/matrix
  • Nostr@openclaw/nostr
  • Zalo@openclaw/zalo
  • Microsoft Teams@openclaw/msteams
  • Google Antigravity OAuth (provider auth) — bundled as google-antigravity-auth (disabled by default)
  • Gemini CLI OAuth (provider auth) — bundled as google-gemini-cli-auth (disabled by default)
  • Qwen OAuth (provider auth) — bundled as qwen-portal-auth (disabled by default)
  • Copilot Proxy (provider auth) — local VS Code Copilot Proxy bridge; distinct from built-in github-copilot device login (bundled, disabled by default)

OpenClaw plugins are TypeScript modules loaded at runtime via jiti. Config validation does not execute plugin code; it uses the plugin manifest and JSON Schema instead. See Plugin manifest.

Plugins can register:

  • Gateway RPC methods
  • Gateway HTTP routes
  • Agent tools
  • CLI commands
  • Background services
  • Context engines
  • Optional config validation
  • Skills (by listing skills directories in the plugin manifest)
  • Auto-reply commands (execute without invoking the AI agent)

Plugins run inprocess with the Gateway, so treat them as trusted code. Tool authoring guide: Plugin agent tools.

Load pipeline

At startup, OpenClaw does roughly this:

  1. discover candidate plugin roots
  2. read openclaw.plugin.json and package metadata
  3. reject unsafe candidates
  4. normalize plugin config (plugins.enabled, allow, deny, entries, slots, load.paths)
  5. decide enablement for each candidate
  6. load enabled modules via jiti
  7. call register(api) and collect registrations into the plugin registry
  8. expose the registry to commands/runtime surfaces

The safety gates happen before runtime execution. Candidates are blocked when the entry escapes the plugin root, the path is world-writable, or path ownership looks suspicious for non-bundled plugins.

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
  • validate plugins.entries.<id>.config
  • augment Control UI labels/placeholders
  • show install/catalog metadata

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

What the loader caches

OpenClaw keeps short in-process caches for:

  • discovery results
  • manifest registry data
  • loaded plugin registries

These caches reduce bursty startup and repeated command overhead. They are safe to think of as short-lived performance caches, not persistence.

Runtime helpers

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

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

Notes:

  • Uses core messages.tts configuration (OpenAI or ElevenLabs).
  • Returns PCM audio buffer + sample rate. Plugins must resample/encode for providers.
  • Edge TTS is not supported for telephony.

For STT/transcription, plugins can call:

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

Notes:

  • Uses core media-understanding audio configuration (tools.media.audio) and provider fallback order.
  • Returns { text: undefined } when no transcription output is produced (for example skipped/unsupported input).

Gateway HTTP routes

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

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

Route fields:

  • path: route path under the gateway HTTP server.
  • auth: required. Use "gateway" to require normal gateway auth, or "plugin" for plugin-managed auth/webhook verification.
  • match: optional. "exact" (default) or "prefix".
  • replaceExisting: optional. Allows the same plugin to replace its own existing route registration.
  • handler: return true when the route handled the request.

Notes:

  • api.registerHttpHandler(...) is obsolete. Use api.registerHttpRoute(...).
  • Plugin routes must declare auth explicitly.
  • Exact path + match conflicts are rejected unless replaceExisting: true, and one plugin cannot replace another plugin's route.
  • Overlapping routes with different auth levels are rejected. Keep exact/prefix fallthrough chains on the same auth level only.

Plugin SDK import paths

Use SDK subpaths instead of the monolithic openclaw/plugin-sdk import when authoring plugins:

  • openclaw/plugin-sdk/core for generic plugin APIs, provider auth types, and shared helpers.
  • openclaw/plugin-sdk/compat for bundled/internal plugin code that needs broader shared runtime helpers than core.
  • openclaw/plugin-sdk/telegram for Telegram channel plugins.
  • openclaw/plugin-sdk/discord for Discord channel plugins.
  • openclaw/plugin-sdk/slack for Slack channel plugins.
  • openclaw/plugin-sdk/signal for Signal channel plugins.
  • openclaw/plugin-sdk/imessage for iMessage channel plugins.
  • openclaw/plugin-sdk/whatsapp for WhatsApp channel plugins.
  • openclaw/plugin-sdk/line for LINE channel plugins.
  • openclaw/plugin-sdk/msteams for the bundled Microsoft Teams plugin surface.
  • Bundled extension-specific subpaths are also available: openclaw/plugin-sdk/acpx, openclaw/plugin-sdk/bluebubbles, openclaw/plugin-sdk/copilot-proxy, openclaw/plugin-sdk/device-pair, openclaw/plugin-sdk/diagnostics-otel, openclaw/plugin-sdk/diffs, openclaw/plugin-sdk/feishu, openclaw/plugin-sdk/google-gemini-cli-auth, openclaw/plugin-sdk/googlechat, openclaw/plugin-sdk/irc, openclaw/plugin-sdk/llm-task, openclaw/plugin-sdk/lobster, openclaw/plugin-sdk/matrix, openclaw/plugin-sdk/mattermost, openclaw/plugin-sdk/memory-core, openclaw/plugin-sdk/memory-lancedb, openclaw/plugin-sdk/minimax-portal-auth, openclaw/plugin-sdk/nextcloud-talk, openclaw/plugin-sdk/nostr, openclaw/plugin-sdk/open-prose, openclaw/plugin-sdk/phone-control, openclaw/plugin-sdk/qwen-portal-auth, openclaw/plugin-sdk/synology-chat, openclaw/plugin-sdk/talk-voice, openclaw/plugin-sdk/test-utils, openclaw/plugin-sdk/thread-ownership, openclaw/plugin-sdk/tlon, openclaw/plugin-sdk/twitch, openclaw/plugin-sdk/voice-call, openclaw/plugin-sdk/zalo, and openclaw/plugin-sdk/zalouser.

Compatibility note:

  • openclaw/plugin-sdk remains supported for existing external plugins.
  • New and migrated bundled plugins should use channel or extension-specific subpaths; use core for generic surfaces and compat only when broader shared helpers are required.

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 is allowed to 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/config repair flows should not need to materialize runtime credentials just to describe configuration.

Recommended inspectAccount(...) behavior:

  • Return descriptive account state only.
  • Preserve enabled and configured.
  • Include credential source/status fields when relevant, such as:
    • tokenSource, tokenStatus
    • botTokenSource, botTokenStatus
    • appTokenSource, appTokenStatus
    • signingSecretSource, signingSecretStatus
  • You do not need to return raw token values just to report read-only availability. Returning tokenStatus: "available" (and the matching source field) is enough for status-style commands.
  • Use configured_unavailable when a credential is configured via SecretRef but unavailable in the current command path.

This lets read-only commands report “configured but unavailable in this command path” instead of crashing or misreporting the account as not configured.

Performance note:

  • Plugin discovery and manifest metadata use short in-process caches to reduce bursty startup/reload work.
  • Set OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE=1 or OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE=1 to disable these caches.
  • Tune cache windows with OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS and OPENCLAW_PLUGIN_MANIFEST_CACHE_MS.

Discovery & precedence

OpenClaw scans, in order:

  1. Config paths
  • plugins.load.paths (file or directory)
  1. Workspace extensions
  • <workspace>/.openclaw/extensions/*.ts
  • <workspace>/.openclaw/extensions/*/index.ts
  1. Global extensions
  • ~/.openclaw/extensions/*.ts
  • ~/.openclaw/extensions/*/index.ts
  1. Bundled extensions (shipped with OpenClaw, mostly disabled by default)
  • <openclaw>/extensions/*

Most bundled plugins must be enabled explicitly via plugins.entries.<id>.enabled or openclaw plugins enable <id>.

Default-on bundled plugin exceptions:

  • device-pair
  • phone-control
  • talk-voice
  • active memory slot plugin (default slot: memory-core)

Installed plugins are enabled by default, but can be disabled the same way.

Workspace plugins are disabled by default unless you explicitly enable them or allowlist them. This is intentional: a checked-out repo should not silently become production gateway code.

Hardening notes:

  • If plugins.allow is empty and non-bundled plugins are discoverable, OpenClaw logs a startup warning with plugin ids and sources.
  • Candidate paths are safety-checked before discovery admission. OpenClaw blocks candidates when:
    • extension entry resolves outside plugin root (including symlink/path traversal escapes),
    • plugin root/source path is world-writable,
    • path ownership is suspicious for non-bundled plugins (POSIX owner is neither current uid nor root).
  • Loaded non-bundled plugins without install/load-path provenance emit a warning so you can pin trust (plugins.allow) or install tracking (plugins.installs).

Each plugin must include a openclaw.plugin.json file in its root. If a path points at a file, the plugin root is the file's directory and must contain the manifest.

If multiple plugins resolve to the same id, the first match in the order above wins and lower-precedence copies are ignored.

Enablement rules

Enablement is resolved after discovery:

  • plugins.enabled: false disables all plugins
  • plugins.deny always wins
  • plugins.entries.<id>.enabled: false disables that plugin
  • workspace-origin plugins are disabled by default
  • allowlists restrict the active set when plugins.allow is non-empty
  • bundled plugins are disabled by default unless:
    • the bundled id is in the built-in default-on set, or
    • you explicitly enable it, or
    • channel config implicitly enables the bundled channel plugin
  • exclusive slots can force-enable the selected plugin for that slot

In current core, bundled default-on ids include local/provider helpers such as ollama, sglang, vllm, plus device-pair, phone-control, and talk-voice.

Package packs

A plugin directory may include a package.json with openclaw.extensions:

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

Each entry becomes a plugin. If the pack lists multiple extensions, the plugin id becomes name/<fileBase>.

If your plugin imports npm deps, install them in that directory so node_modules is available (npm install / pnpm install).

Security guardrail: every openclaw.extensions entry must stay inside the plugin directory after symlink resolution. Entries that escape the package directory are rejected.

Security note: openclaw plugins install installs plugin dependencies with npm install --ignore-scripts (no lifecycle scripts). Keep plugin dependency trees "pure JS/TS" and avoid packages that require postinstall builds.

Channel catalog metadata

Channel plugins can advertise onboarding metadata via openclaw.channel and install hints via openclaw.install. This keeps the core catalog data-free.

Example:

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

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

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

Or point OPENCLAW_PLUGIN_CATALOG_PATHS (or OPENCLAW_MPM_CATALOG_PATHS) at one or more JSON files (comma/semicolon/PATH-delimited). Each file should contain { "entries": [ { "name": "@scope/pkg", "openclaw": { "channel": {...}, "install": {...} } } ] }.

Plugin IDs

Default plugin ids:

  • Package packs: package.json name
  • Standalone file: file base name (~/.../voice-call.tsvoice-call)

If a plugin exports id, OpenClaw uses it but warns when it doesnt match the configured id.

Registry model

Loaded plugins do not directly mutate random core globals. They register into a central plugin registry.

The registry tracks:

  • plugin records (identity, source, origin, status, diagnostics)
  • tools
  • legacy hooks and typed hooks
  • channels
  • providers
  • gateway RPC handlers
  • HTTP routes
  • CLI registrars
  • background services
  • plugin-owned commands

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".

Config

{
  plugins: {
    enabled: true,
    allow: ["voice-call"],
    deny: ["untrusted-plugin"],
    load: { paths: ["~/Projects/oss/voice-call-extension"] },
    entries: {
      "voice-call": { enabled: true, config: { provider: "twilio" } },
    },
  },
}

Fields:

  • enabled: master toggle (default: true)
  • allow: allowlist (optional)
  • deny: denylist (optional; deny wins)
  • load.paths: extra plugin files/dirs
  • slots: exclusive slot selectors such as memory and contextEngine
  • entries.<id>: perplugin toggles + config

Config changes require a gateway restart.

Validation rules (strict):

  • Unknown plugin ids in entries, allow, deny, or slots are errors.
  • Unknown channels.<id> keys are errors unless a plugin manifest declares the channel id.
  • Plugin config is validated using the JSON Schema embedded in openclaw.plugin.json (configSchema).
  • If a plugin is disabled, its config is preserved and a warning is emitted.

Disabled vs missing vs invalid

These states are intentionally different:

  • disabled: plugin exists, but enablement rules turned it off
  • missing: config references a plugin id that discovery did not find
  • invalid: plugin exists, but its config does not match the declared schema

OpenClaw preserves config for disabled plugins so toggling them back on is not destructive.

Plugin slots (exclusive categories)

Some plugin categories are exclusive (only one active at a time). Use plugins.slots to select which plugin owns the slot:

{
  plugins: {
    slots: {
      memory: "memory-core", // or "none" to disable memory plugins
      contextEngine: "legacy", // or a plugin id such as "lossless-claw"
    },
  },
}

Supported exclusive slots:

  • memory: active memory plugin ("none" disables memory plugins)
  • contextEngine: active context engine plugin ("legacy" is the built-in default)

If multiple plugins declare kind: "memory" or kind: "context-engine", only the selected plugin loads for that slot. Others are disabled with diagnostics.

Context engine plugins

Context engine plugins own session context orchestration for ingest, assembly, and compaction. Register them from your plugin with api.registerContextEngine(id, factory), then select the active engine with plugins.slots.contextEngine.

Use this when your plugin needs to replace or extend the default context pipeline rather than just add memory search or hooks.

Control UI (schema + labels)

The Control UI uses config.schema (JSON Schema + uiHints) to render better forms.

OpenClaw augments uiHints at runtime based on discovered plugins:

  • Adds per-plugin labels for plugins.entries.<id> / .enabled / .config
  • Merges optional plugin-provided config field hints under: plugins.entries.<id>.config.<field>

If you want your plugin config fields to show good labels/placeholders (and mark secrets as sensitive), provide uiHints alongside your JSON Schema in the plugin manifest.

Example:

{
  "id": "my-plugin",
  "configSchema": {
    "type": "object",
    "additionalProperties": false,
    "properties": {
      "apiKey": { "type": "string" },
      "region": { "type": "string" }
    }
  },
  "uiHints": {
    "apiKey": { "label": "API Key", "sensitive": true },
    "region": { "label": "Region", "placeholder": "us-east-1" }
  }
}

CLI

openclaw plugins list
openclaw plugins info <id>
openclaw plugins install <path>                 # copy a local file/dir into ~/.openclaw/extensions/<id>
openclaw plugins install ./extensions/voice-call # relative path ok
openclaw plugins install ./plugin.tgz           # install from a local tarball
openclaw plugins install ./plugin.zip           # install from a local zip
openclaw plugins install -l ./extensions/voice-call # link (no copy) for dev
openclaw plugins install @openclaw/voice-call # install from npm
openclaw plugins install @openclaw/voice-call --pin # store exact resolved name@version
openclaw plugins update <id>
openclaw plugins update --all
openclaw plugins enable <id>
openclaw plugins disable <id>
openclaw plugins doctor

plugins update only works for npm installs tracked under plugins.installs. If stored integrity metadata changes between updates, OpenClaw warns and asks for confirmation (use global --yes to bypass prompts).

Plugins may also register their own toplevel commands (example: openclaw voicecall).

Plugin API (overview)

Plugins export either:

  • A function: (api) => { ... }
  • An object: { id, name, configSchema, register(api) { ... } }

register(api) is where plugins attach behavior. Common registrations include:

  • registerTool
  • registerHook
  • on(...) for typed lifecycle hooks
  • registerChannel
  • registerProvider
  • registerHttpRoute
  • registerCommand
  • registerCli
  • registerContextEngine
  • registerService

Context engine plugins can also register a runtime-owned context manager:

export default function (api) {
  api.registerContextEngine("lossless-claw", () => ({
    info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true },
    async ingest() {
      return { ingested: true };
    },
    async assemble({ messages }) {
      return { messages, estimatedTokens: 0 };
    },
    async compact() {
      return { ok: true, compacted: false };
    },
  }));
}

Then enable it in config:

{
  plugins: {
    slots: {
      contextEngine: "lossless-claw",
    },
  },
}

Plugin hooks

Plugins can register hooks at runtime. This lets a plugin bundle event-driven automation without a separate hook pack install.

Example

export default function register(api) {
  api.registerHook(
    "command:new",
    async () => {
      // Hook logic here.
    },
    {
      name: "my-plugin.command-new",
      description: "Runs when /new is invoked",
    },
  );
}

Notes:

  • Register hooks explicitly via api.registerHook(...).
  • Hook eligibility rules still apply (OS/bins/env/config requirements).
  • Plugin-managed hooks show up in openclaw hooks list with plugin:<id>.
  • You cannot enable/disable plugin-managed hooks via openclaw hooks; enable/disable the plugin instead.

Agent lifecycle hooks (api.on)

For typed runtime lifecycle hooks, use api.on(...):

export default function register(api) {
  api.on(
    "before_prompt_build",
    (event, ctx) => {
      return {
        prependSystemContext: "Follow company style guide.",
      };
    },
    { priority: 10 },
  );
}

Important hooks for prompt construction:

  • before_model_resolve: runs before session load (messages are not available). Use this to deterministically override modelOverride or providerOverride.
  • before_prompt_build: runs after session load (messages are available). Use this to shape prompt input.
  • before_agent_start: legacy compatibility hook. Prefer the two explicit hooks above.

Core-enforced hook policy:

  • Operators can disable prompt mutation hooks per plugin via plugins.entries.<id>.hooks.allowPromptInjection: false.
  • When disabled, OpenClaw blocks before_prompt_build and ignores prompt-mutating fields returned from legacy before_agent_start while preserving legacy modelOverride and providerOverride.

before_prompt_build result fields:

  • prependContext: prepends text to the user prompt for this run. Best for turn-specific or dynamic content.
  • systemPrompt: full system prompt override.
  • prependSystemContext: prepends text to the current system prompt.
  • appendSystemContext: appends text to the current system prompt.

Prompt build order in embedded runtime:

  1. Apply prependContext to the user prompt.
  2. Apply systemPrompt override when provided.
  3. Apply prependSystemContext + current system prompt + appendSystemContext.

Merge and precedence notes:

  • Hook handlers run by priority (higher first).
  • For merged context fields, values are concatenated in execution order.
  • before_prompt_build values are applied before legacy before_agent_start fallback values.

Migration guidance:

  • Move static guidance from prependContext to prependSystemContext (or appendSystemContext) so providers can cache stable system-prefix content.
  • Keep prependContext for per-turn dynamic context that should stay tied to the user message.

Provider plugins (model auth)

Plugins can register model providers so users can run OAuth or API-key setup inside OpenClaw, surface provider setup in onboarding/model-pickers, and contribute implicit provider discovery.

Provider plugins are the modular extension seam for model-provider setup. They are not just "OAuth helpers" anymore.

Provider plugin lifecycle

A provider plugin can participate in four distinct phases:

  1. Auth auth[].run(ctx) performs OAuth, API-key capture, device code, or custom setup and returns auth profiles plus optional config patches.
  2. Wizard integration wizard.onboarding adds an entry to openclaw onboard. wizard.modelPicker adds a setup entry to the model picker.
  3. Implicit discovery discovery.run(ctx) can contribute provider config automatically during model resolution/listing.
  4. Post-selection follow-up onModelSelected(ctx) runs after a model is chosen. Use this for provider- specific work such as downloading a local model.

This is the recommended split because these phases have different lifecycle requirements:

  • auth is interactive and writes credentials/config
  • wizard metadata is static and UI-facing
  • discovery should be safe, quick, and failure-tolerant
  • post-select hooks are side effects tied to the chosen model

Provider auth contract

auth[].run(ctx) returns:

  • profiles: auth profiles to write
  • configPatch: optional openclaw.json changes
  • defaultModel: optional provider/model ref
  • notes: optional user-facing notes

Core then:

  1. writes the returned auth profiles
  2. applies auth-profile config wiring
  3. merges the config patch
  4. optionally applies the default model
  5. runs the provider's onModelSelected hook when appropriate

That means a provider plugin owns the provider-specific setup logic, while core owns the generic persistence and config-merge path.

Provider wizard metadata

wizard.onboarding controls how the provider appears in grouped onboarding:

  • choiceId: auth-choice value
  • choiceLabel: option label
  • choiceHint: short hint
  • groupId: group bucket id
  • groupLabel: group label
  • groupHint: group hint
  • methodId: auth method to run

wizard.modelPicker controls how a provider appears as a "set this up now" entry in model selection:

  • label
  • hint
  • methodId

When a provider has multiple auth methods, the wizard can either point at one explicit method or let OpenClaw synthesize per-method choices.

Provider discovery contract

discovery.run(ctx) returns one of:

  • { provider }
  • { providers }
  • null

Use { provider } for the common case where the plugin owns one provider id. Use { providers } when a plugin discovers multiple provider entries.

The discovery context includes:

  • the current config
  • agent/workspace dirs
  • process env
  • a helper to resolve the provider API key and a discovery-safe API key value

Discovery should be:

  • fast
  • best-effort
  • safe to skip on failure
  • careful about side effects

It should not depend on prompts or long-running setup.

Discovery ordering

Provider discovery runs in ordered phases:

  • simple
  • profile
  • paired
  • late

Use:

  • simple for cheap environment-only discovery
  • profile when discovery depends on auth profiles
  • paired for providers that need to coordinate with another discovery step
  • late for expensive or local-network probing

Most self-hosted providers should use late.

Good provider-plugin boundaries

Good fit for provider plugins:

  • local/self-hosted providers with custom setup flows
  • provider-specific OAuth/device-code login
  • implicit discovery of local model servers
  • post-selection side effects such as model pulls

Less compelling fit:

  • trivial API-key-only providers that differ only by env var, base URL, and one default model

Those can still become plugins, but the main modularity payoff comes from extracting behavior-rich providers first.

Register a provider via api.registerProvider(...). Each provider exposes one or more auth methods (OAuth, API key, device code, etc.). Those methods can power:

  • openclaw models auth login --provider <id> [--method <id>]
  • openclaw onboard
  • model-picker “custom provider” setup entries
  • implicit provider discovery during model resolution/listing

Example:

api.registerProvider({
  id: "acme",
  label: "AcmeAI",
  auth: [
    {
      id: "oauth",
      label: "OAuth",
      kind: "oauth",
      run: async (ctx) => {
        // Run OAuth flow and return auth profiles.
        return {
          profiles: [
            {
              profileId: "acme:default",
              credential: {
                type: "oauth",
                provider: "acme",
                access: "...",
                refresh: "...",
                expires: Date.now() + 3600 * 1000,
              },
            },
          ],
          defaultModel: "acme/opus-1",
        };
      },
    },
  ],
  wizard: {
    onboarding: {
      choiceId: "acme",
      choiceLabel: "AcmeAI",
      groupId: "acme",
      groupLabel: "AcmeAI",
      methodId: "oauth",
    },
    modelPicker: {
      label: "AcmeAI (custom)",
      hint: "Connect a self-hosted AcmeAI endpoint",
      methodId: "oauth",
    },
  },
  discovery: {
    order: "late",
    run: async () => ({
      provider: {
        baseUrl: "https://acme.example/v1",
        api: "openai-completions",
        apiKey: "${ACME_API_KEY}",
        models: [],
      },
    }),
  },
});

Notes:

  • run receives a ProviderAuthContext with prompter, runtime, openUrl, and oauth.createVpsAwareHandlers helpers.
  • Return configPatch when you need to add default models or provider config.
  • Return defaultModel so --set-default can update agent defaults.
  • wizard.onboarding adds a provider choice to openclaw onboard.
  • wizard.modelPicker adds a “setup this provider” entry to the model picker.
  • discovery.run returns either { provider } for the plugins own provider id or { providers } for multi-provider discovery.
  • discovery.order controls when the provider runs relative to built-in discovery phases: simple, profile, paired, or late.
  • onModelSelected is the post-selection hook for provider-specific follow-up work such as pulling a local model.

Register a messaging channel

Plugins can register channel plugins that behave like builtin channels (WhatsApp, Telegram, etc.). Channel config lives under channels.<id> and is validated by your channel plugin code.

const myChannel = {
  id: "acmechat",
  meta: {
    id: "acmechat",
    label: "AcmeChat",
    selectionLabel: "AcmeChat (API)",
    docsPath: "/channels/acmechat",
    blurb: "demo channel plugin.",
    aliases: ["acme"],
  },
  capabilities: { chatTypes: ["direct"] },
  config: {
    listAccountIds: (cfg) => Object.keys(cfg.channels?.acmechat?.accounts ?? {}),
    resolveAccount: (cfg, accountId) =>
      cfg.channels?.acmechat?.accounts?.[accountId ?? "default"] ?? {
        accountId,
      },
  },
  outbound: {
    deliveryMode: "direct",
    sendText: async () => ({ ok: true }),
  },
};

export default function (api) {
  api.registerChannel({ plugin: myChannel });
}

Notes:

  • Put config under channels.<id> (not plugins.entries).
  • meta.label is used for labels in CLI/UI lists.
  • meta.aliases adds alternate ids for normalization and CLI inputs.
  • meta.preferOver lists channel ids to skip auto-enable when both are configured.
  • meta.detailLabel and meta.systemImage let UIs show richer channel labels/icons.

Channel onboarding hooks

Channel plugins can define optional onboarding hooks on plugin.onboarding:

  • configure(ctx) is the baseline setup flow.
  • configureInteractive(ctx) can fully own interactive setup for both configured and unconfigured states.
  • configureWhenConfigured(ctx) can override behavior only for already configured channels.

Hook precedence in the wizard:

  1. configureInteractive (if present)
  2. configureWhenConfigured (only when channel status is already configured)
  3. fallback to configure

Context details:

  • configureInteractive and configureWhenConfigured receive:
    • configured (true or false)
    • label (user-facing channel name used by prompts)
    • plus the shared config/runtime/prompter/options fields
  • Returning "skip" leaves selection and account tracking unchanged.
  • Returning { cfg, accountId? } applies config updates and records account selection.

Write a new messaging channel (stepbystep)

Use this when you want a new chat surface (a "messaging channel"), not a model provider. Model provider docs live under /providers/*.

  1. Pick an id + config shape
  • All channel config lives under channels.<id>.
  • Prefer channels.<id>.accounts.<accountId> for multiaccount setups.
  1. Define the channel metadata
  • meta.label, meta.selectionLabel, meta.docsPath, meta.blurb control CLI/UI lists.
  • meta.docsPath should point at a docs page like /channels/<id>.
  • meta.preferOver lets a plugin replace another channel (auto-enable prefers it).
  • meta.detailLabel and meta.systemImage are used by UIs for detail text/icons.
  1. Implement the required adapters
  • config.listAccountIds + config.resolveAccount
  • capabilities (chat types, media, threads, etc.)
  • outbound.deliveryMode + outbound.sendText (for basic send)
  1. Add optional adapters as needed
  • setup (wizard), security (DM policy), status (health/diagnostics)
  • gateway (start/stop/login), mentions, threading, streaming
  • actions (message actions), commands (native command behavior)
  1. Register the channel in your plugin
  • api.registerChannel({ plugin })

Minimal config example:

{
  channels: {
    acmechat: {
      accounts: {
        default: { token: "ACME_TOKEN", enabled: true },
      },
    },
  },
}

Minimal channel plugin (outboundonly):

const plugin = {
  id: "acmechat",
  meta: {
    id: "acmechat",
    label: "AcmeChat",
    selectionLabel: "AcmeChat (API)",
    docsPath: "/channels/acmechat",
    blurb: "AcmeChat messaging channel.",
    aliases: ["acme"],
  },
  capabilities: { chatTypes: ["direct"] },
  config: {
    listAccountIds: (cfg) => Object.keys(cfg.channels?.acmechat?.accounts ?? {}),
    resolveAccount: (cfg, accountId) =>
      cfg.channels?.acmechat?.accounts?.[accountId ?? "default"] ?? {
        accountId,
      },
  },
  outbound: {
    deliveryMode: "direct",
    sendText: async ({ text }) => {
      // deliver `text` to your channel here
      return { ok: true };
    },
  },
};

export default function (api) {
  api.registerChannel({ plugin });
}

Load the plugin (extensions dir or plugins.load.paths), restart the gateway, then configure channels.<id> in your config.

Agent tools

See the dedicated guide: Plugin agent tools.

Register a gateway RPC method

export default function (api) {
  api.registerGatewayMethod("myplugin.status", ({ respond }) => {
    respond(true, { ok: true });
  });
}

Register CLI commands

export default function (api) {
  api.registerCli(
    ({ program }) => {
      program.command("mycmd").action(() => {
        console.log("Hello");
      });
    },
    { commands: ["mycmd"] },
  );
}

Register auto-reply commands

Plugins can register custom slash commands that execute without invoking the AI agent. This is useful for toggle commands, status checks, or quick actions that don't need LLM processing.

export default function (api) {
  api.registerCommand({
    name: "mystatus",
    description: "Show plugin status",
    handler: (ctx) => ({
      text: `Plugin is running! Channel: ${ctx.channel}`,
    }),
  });
}

Command handler context:

  • senderId: The sender's ID (if available)
  • channel: The channel where the command was sent
  • isAuthorizedSender: Whether the sender is an authorized user
  • args: Arguments passed after the command (if acceptsArgs: true)
  • commandBody: The full command text
  • config: The current OpenClaw config

Command options:

  • name: Command name (without the leading /)
  • nativeNames: Optional native-command aliases for slash/menu surfaces. Use default for all native providers, or provider-specific keys like discord
  • description: Help text shown in command lists
  • acceptsArgs: Whether the command accepts arguments (default: false). If false and arguments are provided, the command won't match and the message falls through to other handlers
  • requireAuth: Whether to require authorized sender (default: true)
  • handler: Function that returns { text: string } (can be async)

Example with authorization and arguments:

api.registerCommand({
  name: "setmode",
  description: "Set plugin mode",
  acceptsArgs: true,
  requireAuth: true,
  handler: async (ctx) => {
    const mode = ctx.args?.trim() || "default";
    await saveMode(mode);
    return { text: `Mode set to: ${mode}` };
  },
});

Notes:

  • Plugin commands are processed before built-in commands and the AI agent
  • Commands are registered globally and work across all channels
  • Command names are case-insensitive (/MyStatus matches /mystatus)
  • Command names must start with a letter and contain only letters, numbers, hyphens, and underscores
  • Reserved command names (like help, status, reset, etc.) cannot be overridden by plugins
  • Duplicate command registration across plugins will fail with a diagnostic error

Register background services

export default function (api) {
  api.registerService({
    id: "my-service",
    start: () => api.logger.info("ready"),
    stop: () => api.logger.info("bye"),
  });
}

Naming conventions

  • Gateway methods: pluginId.action (example: voicecall.status)
  • Tools: snake_case (example: voice_call)
  • CLI commands: kebab or camel, but avoid clashing with core commands

Skills

Plugins can ship a skill in the repo (skills/<name>/SKILL.md). Enable it with plugins.entries.<id>.enabled (or other config gates) and ensure its present in your workspace/managed skills locations.

Distribution (npm)

Recommended packaging:

  • Main package: openclaw (this repo)
  • Plugins: separate npm packages under @openclaw/* (example: @openclaw/voice-call)

Publishing contract:

  • Plugin package.json must include openclaw.extensions with one or more entry files.
  • Entry files can be .js or .ts (jiti loads TS at runtime).
  • openclaw plugins install <npm-spec> uses npm pack, extracts into ~/.openclaw/extensions/<id>/, and enables it in config.
  • Config key stability: scoped packages are normalized to the unscoped id for plugins.entries.*.

Example plugin: Voice Call

This repo includes a voicecall plugin (Twilio or log fallback):

  • Source: extensions/voice-call
  • Skill: skills/voice-call
  • CLI: openclaw voicecall start|status
  • Tool: voice_call
  • RPC: voicecall.start, voicecall.status
  • Config (twilio): provider: "twilio" + twilio.accountSid/authToken/from (optional statusCallbackUrl, twimlUrl)
  • Config (dev): provider: "log" (no network)

See Voice Call and extensions/voice-call/README.md for setup and usage.

Safety notes

Plugins run in-process with the Gateway. Treat them as trusted code:

  • Only install plugins you trust.
  • Prefer plugins.allow allowlists.
  • Restart the Gateway after changes.

Testing plugins

Plugins can (and should) ship tests:

  • In-repo plugins can keep Vitest tests under src/** (example: src/plugins/voice-call.plugin.test.ts).
  • Separately published plugins should run their own CI (lint/build/test) and validate openclaw.extensions points at the built entrypoint (dist/index.js).