diff --git a/docs/docs.json b/docs/docs.json
index a176d2a180a..301a81d9381 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -1037,24 +1037,29 @@
"group": "Plugins",
"pages": [
"tools/plugin",
- "plugins/building-plugins",
"plugins/community",
"plugins/bundles",
- "plugins/manifest",
- "plugins/sdk-migration",
- "plugins/architecture"
- ]
- },
- {
- "group": "Plugin SDK",
- "pages": [
- "plugins/sdk-overview",
- "plugins/sdk-entrypoints",
- "plugins/sdk-runtime",
- "plugins/sdk-setup",
- "plugins/sdk-channel-plugins",
- "plugins/sdk-provider-plugins",
- "plugins/sdk-testing"
+ {
+ "group": "Building Plugins",
+ "pages": [
+ "plugins/building-plugins",
+ "plugins/sdk-channel-plugins",
+ "plugins/sdk-provider-plugins"
+ ]
+ },
+ {
+ "group": "SDK Reference",
+ "pages": [
+ "plugins/sdk-overview",
+ "plugins/sdk-entrypoints",
+ "plugins/sdk-runtime",
+ "plugins/sdk-setup",
+ "plugins/sdk-testing",
+ "plugins/sdk-migration",
+ "plugins/manifest",
+ "plugins/architecture"
+ ]
+ }
]
},
{
diff --git a/docs/plugins/architecture.md b/docs/plugins/architecture.md
index 49aa6344ca9..73ba14f0726 100644
--- a/docs/plugins/architecture.md
+++ b/docs/plugins/architecture.md
@@ -12,9 +12,12 @@ sidebarTitle: "Internals"
# Plugin Internals
- This page is for **plugin developers and contributors**. If you just want to
- install and use plugins, see [Plugins](/tools/plugin). If you want to build
- a plugin, see [Building Plugins](/plugins/building-plugins).
+ This is the **deep architecture reference**. For practical guides, see:
+ - [Install and use plugins](/tools/plugin) — user guide
+ - [Getting Started](/plugins/building-plugins) — first plugin tutorial
+ - [Channel Plugins](/plugins/sdk-channel-plugins) — build a messaging channel
+ - [Provider Plugins](/plugins/sdk-provider-plugins) — build a model provider
+ - [SDK Overview](/plugins/sdk-overview) — import map and registration API
This page covers the internal architecture of the OpenClaw plugin system.
diff --git a/docs/plugins/building-plugins.md b/docs/plugins/building-plugins.md
index 7e645c2079f..95403dfd87a 100644
--- a/docs/plugins/building-plugins.md
+++ b/docs/plugins/building-plugins.md
@@ -1,337 +1,179 @@
---
title: "Building Plugins"
-sidebarTitle: "Building Plugins"
-summary: "Step-by-step guide for creating OpenClaw plugins with any combination of capabilities"
+sidebarTitle: "Getting Started"
+summary: "Create your first OpenClaw plugin in minutes"
read_when:
- You want to create a new OpenClaw plugin
- - You need to understand the plugin SDK import patterns
+ - You need a quick-start for plugin development
- You are adding a new channel, provider, tool, or other capability to OpenClaw
---
# Building Plugins
Plugins extend OpenClaw with new capabilities: channels, model providers, speech,
-image generation, web search, agent tools, or any combination. A single plugin
-can register multiple capabilities.
+image generation, web search, agent tools, or any combination.
-OpenClaw encourages **external plugin development**. You do not need to add your
-plugin to the OpenClaw repository. Publish your plugin on npm, and users install
-it with `openclaw plugins install `. OpenClaw also maintains a set of
-core plugins in-repo, but the plugin system is designed for independent ownership
-and distribution.
+You do not need to add your plugin to the OpenClaw repository. Publish on npm
+and users install with `openclaw plugins install `.
## Prerequisites
- Node >= 22 and a package manager (npm or pnpm)
- Familiarity with TypeScript (ESM)
-- For in-repo plugins: OpenClaw repository cloned and `pnpm install` done
+- For in-repo plugins: repository cloned and `pnpm install` done
-## Plugin capabilities
+## What kind of plugin?
-A plugin can register one or more capabilities. The capability you register
-determines what your plugin provides to OpenClaw:
+
+
+ Connect OpenClaw to a messaging platform (Discord, IRC, etc.)
+
+
+ Add a model provider (LLM, proxy, or custom endpoint)
+
+
+ Register agent tools, event hooks, or services — continue below
+
+
-| Capability | Registration method | What it adds |
-| ------------------- | --------------------------------------------- | ------------------------------ |
-| Text inference | `api.registerProvider(...)` | Model provider (LLM) |
-| Channel / messaging | `api.registerChannel(...)` | Chat channel (e.g. Slack, IRC) |
-| Speech | `api.registerSpeechProvider(...)` | Text-to-speech / STT |
-| Media understanding | `api.registerMediaUnderstandingProvider(...)` | Image/audio/video analysis |
-| Image generation | `api.registerImageGenerationProvider(...)` | Image generation |
-| Web search | `api.registerWebSearchProvider(...)` | Web search provider |
-| Agent tools | `api.registerTool(...)` | Tools callable by the agent |
+## Quick start: tool plugin
-A plugin that registers zero capabilities but provides hooks or services is a
-**hook-only** plugin. That pattern is still supported.
-
-## Plugin structure
-
-Plugins follow this layout (whether in-repo or standalone):
-
-```
-my-plugin/
-├── package.json # npm metadata + openclaw config
-├── openclaw.plugin.json # Plugin manifest
-├── index.ts # Entry point
-├── setup-entry.ts # Setup wizard (optional)
-├── api.ts # Public exports (optional)
-├── runtime-api.ts # Internal exports (optional)
-└── src/
- ├── provider.ts # Capability implementation
- ├── runtime.ts # Runtime wiring
- └── *.test.ts # Colocated tests
-```
-
-## Create a plugin
+This walkthrough creates a minimal plugin that registers an agent tool. Channel
+and provider plugins have dedicated guides linked above.
-
- Create `package.json` with the `openclaw` metadata block. The structure
- depends on what capabilities your plugin provides.
-
- **Channel plugin example:**
-
- ```json
+
+
+ ```json package.json
{
- "name": "@myorg/openclaw-my-channel",
+ "name": "@myorg/openclaw-my-plugin",
"version": "1.0.0",
"type": "module",
"openclaw": {
- "extensions": ["./index.ts"],
- "channel": {
- "id": "my-channel",
- "label": "My Channel",
- "blurb": "Short description of the channel."
- }
+ "extensions": ["./index.ts"]
}
}
```
- **Provider plugin example:**
-
- ```json
+ ```json openclaw.plugin.json
{
- "name": "@myorg/openclaw-my-provider",
- "version": "1.0.0",
- "type": "module",
- "openclaw": {
- "extensions": ["./index.ts"],
- "providers": ["my-provider"]
+ "id": "my-plugin",
+ "name": "My Plugin",
+ "description": "Adds a custom tool to OpenClaw",
+ "configSchema": {
+ "type": "object",
+ "additionalProperties": false
}
}
```
+
- The `openclaw` field tells the plugin system what your plugin provides.
- A plugin can declare both `channel` and `providers` if it provides multiple
- capabilities.
+ Every plugin needs a manifest, even with no config. See
+ [Manifest](/plugins/manifest) for the full schema.
-
- The entry point registers your capabilities with the plugin API.
-
- **Channel plugin:**
-
- ```typescript
- import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core";
-
- export default defineChannelPluginEntry({
- id: "my-channel",
- name: "My Channel",
- description: "Connects OpenClaw to My Channel",
- plugin: {
- // Channel adapter implementation
- },
- });
- ```
-
- **Provider plugin:**
+
```typescript
+ // index.ts
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
+ import { Type } from "@sinclair/typebox";
export default definePluginEntry({
- id: "my-provider",
- name: "My Provider",
+ id: "my-plugin",
+ name: "My Plugin",
+ description: "Adds a custom tool to OpenClaw",
register(api) {
- api.registerProvider({
- // Provider implementation
+ api.registerTool({
+ name: "my_tool",
+ description: "Do a thing",
+ parameters: Type.Object({ input: Type.String() }),
+ async execute(_id, params) {
+ return { content: [{ type: "text", text: `Got: ${params.input}` }] };
+ },
});
},
});
```
- **Multi-capability plugin** (provider + tool):
-
- ```typescript
- import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
-
- export default definePluginEntry({
- id: "my-plugin",
- name: "My Plugin",
- register(api) {
- api.registerProvider({ /* ... */ });
- api.registerTool({ /* ... */ });
- api.registerImageGenerationProvider({ /* ... */ });
- },
- });
- ```
-
- Use `defineChannelPluginEntry` from `plugin-sdk/core` for channel plugins
- and `definePluginEntry` from `plugin-sdk/plugin-entry` for everything else.
- A single plugin can register as many capabilities as needed.
-
- For chat-style channels, `plugin-sdk/core` also exposes
- `createChatChannelPlugin(...)` so you can compose common DM security,
- text pairing, reply threading, and attached outbound send results without
- wiring each adapter separately.
+ `definePluginEntry` is for non-channel plugins. For channels, use
+ `defineChannelPluginEntry` — see [Channel Plugins](/plugins/sdk-channel-plugins).
+ For full entry point options, see [Entry Points](/plugins/sdk-entrypoints).
-
- Always import from specific `openclaw/plugin-sdk/\` paths. The old
- monolithic import is deprecated (see [SDK Migration](/plugins/sdk-migration)).
+
- If older plugin code still imports `openclaw/extension-api`, treat that as a
- temporary compatibility bridge only. New code should use injected runtime
- helpers such as `api.runtime.agent.*` instead of importing host-side agent
- helpers directly.
-
- ```typescript
- // Correct: focused subpaths
- import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
- import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
- import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-auth";
-
- // Wrong: monolithic root (lint will reject this)
- import { ... } from "openclaw/plugin-sdk";
-
- // Deprecated: legacy host bridge
- import { runEmbeddedPiAgent } from "openclaw/extension-api";
- ```
-
-
- | Subpath | Purpose |
- | --- | --- |
- | `plugin-sdk/plugin-entry` | Canonical `definePluginEntry` helper + provider/plugin entry types |
- | `plugin-sdk/core` | Channel entry helpers, channel builders, and shared base types |
- | `plugin-sdk/runtime-store` | Safe module-level runtime storage |
- | `plugin-sdk/setup` | Shared setup-wizard helpers |
- | `plugin-sdk/channel-setup` | Channel setup adapters |
- | `plugin-sdk/channel-pairing` | DM pairing primitives |
- | `plugin-sdk/channel-actions` | Shared `message` tool schema helpers |
- | `plugin-sdk/channel-contract` | Pure channel types |
- | `plugin-sdk/secret-input` | Secret input parsing/helpers |
- | `plugin-sdk/webhook-ingress` | Webhook request/target helpers |
- | `plugin-sdk/reply-payload` | Message reply types |
- | `plugin-sdk/provider-auth` | Provider auth and OAuth helpers |
- | `plugin-sdk/provider-onboard` | Provider onboarding config patches |
- | `plugin-sdk/provider-models` | Model catalog helpers |
- | `plugin-sdk/testing` | Test utilities |
-
-
- Use the narrowest subpath that matches the job. For the curated map and
- examples, see [Plugin SDK Overview](/plugins/sdk-overview).
-
-
-
-
- Within your plugin, create local module files for internal code sharing
- instead of re-importing through the plugin SDK:
-
- ```typescript
- // api.ts — public exports for this plugin
- export { MyConfig } from "./src/config.js";
- export { MyRuntime } from "./src/runtime.js";
-
- // runtime-api.ts — internal-only exports
- export { internalHelper } from "./src/helpers.js";
- ```
-
-
- Never import your own plugin back through its published SDK path from
- production files. Route internal imports through local files like `./api.ts`
- or `./runtime-api.ts`. The SDK path is for external consumers only.
-
-
-
-
-
- Create `openclaw.plugin.json` in your plugin root:
-
- ```json
- {
- "id": "my-plugin",
- "kind": "provider",
- "name": "My Plugin",
- "description": "Adds My Provider to OpenClaw"
- }
- ```
-
- For channel plugins, set `"kind": "channel"` and add `"channels": ["my-channel"]`.
-
- See [Plugin Manifest](/plugins/manifest) for the full schema.
-
-
-
-
- **External plugins:** run your own test suite against the plugin SDK contracts.
-
- **In-repo plugins:** OpenClaw runs contract tests against all registered plugins:
-
- ```bash
- pnpm test:contracts:channels # channel plugins
- pnpm test:contracts:plugins # provider plugins
- ```
-
- For unit tests, import test helpers from the testing surface:
-
- ```typescript
- import { createWindowsCmdShimFixture } from "openclaw/plugin-sdk/testing";
- ```
-
-
-
-
- **External plugins:** publish to npm, then install:
+ **External plugins:**
```bash
npm publish
openclaw plugins install @myorg/openclaw-my-plugin
```
- **In-repo plugins:** place the plugin under `extensions/` and it is
- automatically discovered during build.
-
- Users can browse and install community plugins with:
+ **In-repo plugins:** place under `extensions/` — automatically discovered.
```bash
- openclaw plugins search
- openclaw plugins install
+ pnpm test -- extensions/my-plugin/
```
+## Plugin capabilities
+
+A single plugin can register any number of capabilities via the `api` object:
+
+| Capability | Registration method | Detailed guide |
+| -------------------- | --------------------------------------------- | ------------------------------------------------------------------------------- |
+| Text inference (LLM) | `api.registerProvider(...)` | [Provider Plugins](/plugins/sdk-provider-plugins) |
+| Channel / messaging | `api.registerChannel(...)` | [Channel Plugins](/plugins/sdk-channel-plugins) |
+| Speech (TTS/STT) | `api.registerSpeechProvider(...)` | [Provider Plugins](/plugins/sdk-provider-plugins#step-5-add-extra-capabilities) |
+| Media understanding | `api.registerMediaUnderstandingProvider(...)` | [Provider Plugins](/plugins/sdk-provider-plugins#step-5-add-extra-capabilities) |
+| Image generation | `api.registerImageGenerationProvider(...)` | [Provider Plugins](/plugins/sdk-provider-plugins#step-5-add-extra-capabilities) |
+| Web search | `api.registerWebSearchProvider(...)` | [Provider Plugins](/plugins/sdk-provider-plugins#step-5-add-extra-capabilities) |
+| Agent tools | `api.registerTool(...)` | Below |
+| Custom commands | `api.registerCommand(...)` | [Entry Points](/plugins/sdk-entrypoints) |
+| Event hooks | `api.registerHook(...)` | [Entry Points](/plugins/sdk-entrypoints) |
+| HTTP routes | `api.registerHttpRoute(...)` | [Internals](/plugins/architecture#gateway-http-routes) |
+| CLI subcommands | `api.registerCli(...)` | [Entry Points](/plugins/sdk-entrypoints) |
+
+For the full registration API, see [SDK Overview](/plugins/sdk-overview#registration-api).
+
## Registering agent tools
-Plugins can register **agent tools** — typed functions the LLM can call. Tools
-can be required (always available) or optional (users opt in via allowlists).
+Tools are typed functions the LLM can call. They can be required (always
+available) or optional (user opt-in):
```typescript
-import { Type } from "@sinclair/typebox";
+register(api) {
+ // Required tool — always available
+ api.registerTool({
+ name: "my_tool",
+ description: "Do a thing",
+ parameters: Type.Object({ input: Type.String() }),
+ async execute(_id, params) {
+ return { content: [{ type: "text", text: params.input }] };
+ },
+ });
-export default definePluginEntry({
- id: "my-plugin",
- name: "My Plugin",
- register(api) {
- // Required tool (always available)
- api.registerTool({
- name: "my_tool",
- description: "Do a thing",
- parameters: Type.Object({ input: Type.String() }),
+ // Optional tool — user must add to allowlist
+ api.registerTool(
+ {
+ name: "workflow_tool",
+ description: "Run a workflow",
+ parameters: Type.Object({ pipeline: Type.String() }),
async execute(_id, params) {
- return { content: [{ type: "text", text: params.input }] };
+ return { content: [{ type: "text", text: params.pipeline }] };
},
- });
-
- // Optional tool (user must add to allowlist)
- api.registerTool(
- {
- name: "workflow_tool",
- description: "Run a workflow",
- parameters: Type.Object({ pipeline: Type.String() }),
- async execute(_id, params) {
- return { content: [{ type: "text", text: params.pipeline }] };
- },
- },
- { optional: true },
- );
- },
-});
+ },
+ { optional: true },
+ );
+}
```
-Enable optional tools in config:
+Users enable optional tools in config:
```json5
{
@@ -339,46 +181,56 @@ Enable optional tools in config:
}
```
-Tips:
-
-- Tool names must not clash with core tool names (conflicts are skipped)
-- Use `optional: true` for tools that trigger side effects or require extra binaries
+- Tool names must not clash with core tools (conflicts are skipped)
+- Use `optional: true` for tools with side effects or extra binary requirements
- Users can enable all tools from a plugin by adding the plugin id to `tools.allow`
-## Lint enforcement (in-repo plugins)
+## Import conventions
-Three scripts enforce SDK boundaries for plugins in the OpenClaw repository:
+Always import from focused `openclaw/plugin-sdk/` paths:
-1. **No monolithic root imports** — `openclaw/plugin-sdk` root is rejected
-2. **No direct src/ imports** — plugins cannot import `../../src/` directly
-3. **No self-imports** — plugins cannot import their own `plugin-sdk/\` subpath
+```typescript
+import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
+import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
-Run `pnpm check` to verify all boundaries before committing.
+// Wrong: monolithic root (deprecated, will be removed)
+import { ... } from "openclaw/plugin-sdk";
+```
-External plugins are not subject to these lint rules, but following the same
-patterns is strongly recommended.
+For the full subpath reference, see [SDK Overview](/plugins/sdk-overview).
+
+Within your plugin, use local barrel files (`api.ts`, `runtime-api.ts`) for
+internal imports — never import your own plugin through its SDK path.
## Pre-submission checklist
**package.json** has correct `openclaw` metadata
+**openclaw.plugin.json** manifest is present and valid
Entry point uses `defineChannelPluginEntry` or `definePluginEntry`
-All imports use focused `plugin-sdk/\` paths
+All imports use focused `plugin-sdk/` paths
Internal imports use local modules, not SDK self-imports
-`openclaw.plugin.json` manifest is present and valid
-Tests pass
+Tests pass (`pnpm test -- extensions/my-plugin/`)
`pnpm check` passes (in-repo plugins)
-## Related
+## Next steps
-- [Plugin SDK Migration](/plugins/sdk-migration) — migrating from deprecated compat surfaces
-- [Plugin SDK Overview](/plugins/sdk-overview) — public SDK map and subpath guidance
-- [Plugin Entry Points](/plugins/sdk-entrypoints) — `definePluginEntry` and `defineChannelPluginEntry`
-- [Plugin Runtime](/plugins/sdk-runtime) — injected runtime and runtime-store
-- [Plugin Setup](/plugins/sdk-setup) — setup, channel setup, and secret input helpers
-- [Channel Plugin SDK](/plugins/sdk-channel-plugins) — channel contracts and actions
-- [Provider Plugin SDK](/plugins/sdk-provider-plugins) — provider auth, onboarding, and catalogs
-- [Plugin SDK Testing](/plugins/sdk-testing) — public test helpers
-- [Plugin Architecture](/plugins/architecture) — internals and capability model
-- [Plugin Manifest](/plugins/manifest) — full manifest schema
-- [Plugin Agent Tools](/plugins/building-plugins#registering-agent-tools) — adding agent tools in a plugin
-- [Community Plugins](/plugins/community) — listing and quality bar
+
+
+ Build a messaging channel plugin
+
+
+ Build a model provider plugin
+
+
+ Import map and registration API reference
+
+
+ TTS, search, subagent via api.runtime
+
+
+ Test utilities and patterns
+
+
+ Full manifest schema reference
+
+
diff --git a/docs/plugins/sdk-channel-plugins.md b/docs/plugins/sdk-channel-plugins.md
index b185d6df2af..29c1008ecb7 100644
--- a/docs/plugins/sdk-channel-plugins.md
+++ b/docs/plugins/sdk-channel-plugins.md
@@ -1,161 +1,370 @@
---
-title: "Channel Plugin SDK"
+title: "Building Channel Plugins"
sidebarTitle: "Channel Plugins"
-summary: "Contracts and helpers for native messaging channel plugins, including actions, routing, pairing, and setup"
+summary: "Step-by-step guide to building a messaging channel plugin for OpenClaw"
read_when:
- - You are building a native channel plugin
- - You need to implement the shared `message` tool for a channel
- - You need pairing, setup, or routing helpers for a channel
+ - You are building a new messaging channel plugin
+ - You want to connect OpenClaw to a messaging platform
+ - You need to understand the ChannelPlugin adapter surface
---
-# Channel Plugin SDK
+# Building Channel Plugins
-Channel plugins use `defineChannelPluginEntry(...)` from
-`openclaw/plugin-sdk/core` and implement the `ChannelPlugin` contract.
+This guide walks through building a channel plugin that connects OpenClaw to a
+messaging platform. By the end you will have a working channel with DM security,
+pairing, reply threading, and outbound messaging.
-## Minimal channel entry
+
+ If you have not built any OpenClaw plugin before, read
+ [Getting Started](/plugins/building-plugins) first for the basic package
+ structure and manifest setup.
+
-```ts
-import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core";
-import { exampleChannelPlugin } from "./src/channel.js";
-import { setExampleRuntime } from "./src/runtime.js";
+## How channel plugins work
-export default defineChannelPluginEntry({
- id: "example-channel",
- name: "Example Channel",
- description: "Example native channel plugin",
- plugin: exampleChannelPlugin,
- setRuntime: setExampleRuntime,
-});
-```
+Channel plugins do not need their own send/edit/react tools. OpenClaw keeps one
+shared `message` tool in core. Your plugin owns:
-## `ChannelPlugin` shape
+- **Config** — account resolution and setup wizard
+- **Security** — DM policy and allowlists
+- **Pairing** — DM approval flow
+- **Outbound** — sending text, media, and polls to the platform
+- **Threading** — how replies are threaded
-Important sections of the contract:
+Core owns the shared message tool, prompt wiring, session bookkeeping, and
+dispatch.
-- `meta`: docs, labels, and picker metadata
-- `capabilities`: replies, polls, reactions, threads, media, and chat types
-- `config` and `configSchema`: account resolution and config parsing
-- `setup` and `setupWizard`: onboarding/setup flow
-- `security`: DM policy and allowlist behavior
-- `messaging`: target parsing and outbound session routing
-- `actions`: shared `message` tool discovery and execution
-- `pairing`, `threading`, `status`, `lifecycle`, `groups`, `directory`
+## Walkthrough
-For pure types, import from `openclaw/plugin-sdk/channel-contract`.
+
+
+ Create the standard plugin files. The `channel` field in `package.json` is
+ what makes this a channel plugin:
-## Shared `message` tool
+
+ ```json package.json
+ {
+ "name": "@myorg/openclaw-acme-chat",
+ "version": "1.0.0",
+ "type": "module",
+ "openclaw": {
+ "extensions": ["./index.ts"],
+ "setupEntry": "./setup-entry.ts",
+ "channel": {
+ "id": "acme-chat",
+ "label": "Acme Chat",
+ "blurb": "Connect OpenClaw to Acme Chat."
+ }
+ }
+ }
+ ```
-Channel plugins own their channel-specific part of the shared `message` tool
-through `ChannelMessageActionAdapter`.
+ ```json openclaw.plugin.json
+ {
+ "id": "acme-chat",
+ "kind": "channel",
+ "channels": ["acme-chat"],
+ "name": "Acme Chat",
+ "description": "Acme Chat channel plugin",
+ "configSchema": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "acme-chat": {
+ "type": "object",
+ "properties": {
+ "token": { "type": "string" },
+ "allowFrom": {
+ "type": "array",
+ "items": { "type": "string" }
+ }
+ }
+ }
+ }
+ }
+ }
+ ```
+
-```ts
-import { Type } from "@sinclair/typebox";
-import { createMessageToolButtonsSchema } from "openclaw/plugin-sdk/channel-actions";
+
-export const exampleActions = {
- describeMessageTool() {
- return {
- actions: ["send", "edit"],
- capabilities: ["buttons"],
- schema: {
- visibility: "current-channel",
- properties: {
- buttons: createMessageToolButtonsSchema(),
- threadId: Type.String(),
- },
- },
+
+ The `ChannelPlugin` interface has many optional adapter surfaces. Start with
+ the minimum — `id` and `setup` — and add adapters as you need them.
+
+ Create `src/channel.ts`:
+
+ ```typescript src/channel.ts
+ import {
+ createChatChannelPlugin,
+ createChannelPluginBase,
+ } from "openclaw/plugin-sdk/core";
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
+ import { acmeChatApi } from "./client.js"; // your platform API client
+
+ type ResolvedAccount = {
+ accountId: string | null;
+ token: string;
+ allowFrom: string[];
+ dmPolicy: string | undefined;
};
- },
- async handleAction(ctx) {
- if (ctx.action === "send") {
+
+ function resolveAccount(
+ cfg: OpenClawConfig,
+ accountId?: string | null,
+ ): ResolvedAccount {
+ const section = (cfg.channels as Record)?.["acme-chat"];
+ const token = section?.token;
+ if (!token) throw new Error("acme-chat: token is required");
return {
- content: [{ type: "text", text: `send to ${String(ctx.params.to)}` }],
+ accountId: accountId ?? null,
+ token,
+ allowFrom: section?.allowFrom ?? [],
+ dmPolicy: section?.dmSecurity,
};
}
- return {
- content: [{ type: "text", text: `unsupported action: ${ctx.action}` }],
- };
- },
-};
-```
+ export const acmeChatPlugin = createChatChannelPlugin({
+ base: createChannelPluginBase({
+ id: "acme-chat",
+ setup: {
+ resolveAccount,
+ inspectAccount(cfg, accountId) {
+ const section =
+ (cfg.channels as Record)?.["acme-chat"];
+ return {
+ enabled: Boolean(section?.token),
+ configured: Boolean(section?.token),
+ tokenStatus: section?.token ? "available" : "missing",
+ };
+ },
+ },
+ }),
-Key types:
+ // DM security: who can message the bot
+ security: {
+ dm: {
+ channelKey: "acme-chat",
+ resolvePolicy: (account) => account.dmPolicy,
+ resolveAllowFrom: (account) => account.allowFrom,
+ defaultPolicy: "allowlist",
+ },
+ },
-- `ChannelMessageActionAdapter`
-- `ChannelMessageActionContext`
-- `ChannelMessageActionDiscoveryContext`
-- `ChannelMessageToolDiscovery`
+ // Pairing: approval flow for new DM contacts
+ pairing: {
+ text: {
+ idLabel: "Acme Chat username",
+ message: "Send this code to verify your identity:",
+ notify: async ({ target, code }) => {
+ await acmeChatApi.sendDm(target, `Pairing code: ${code}`);
+ },
+ },
+ },
-## Outbound routing helpers
+ // Threading: how replies are delivered
+ threading: { topLevelReplyToMode: "reply" },
-When a channel plugin needs custom outbound routing, implement
-`messaging.resolveOutboundSessionRoute(...)`.
-
-Use `buildChannelOutboundSessionRoute(...)` from `plugin-sdk/core` to return the
-standard route payload:
-
-```ts
-import { buildChannelOutboundSessionRoute } from "openclaw/plugin-sdk/core";
-
-const messaging = {
- resolveOutboundSessionRoute({ cfg, agentId, accountId, target }) {
- return buildChannelOutboundSessionRoute({
- cfg,
- agentId,
- channel: "example-channel",
- accountId,
- peer: { kind: "direct", id: target },
- chatType: "direct",
- from: accountId ?? "default",
- to: target,
+ // Outbound: send messages to the platform
+ outbound: {
+ attachedResults: {
+ sendText: async (params) => {
+ const result = await acmeChatApi.sendMessage(
+ params.to,
+ params.text,
+ );
+ return { messageId: result.id };
+ },
+ },
+ base: {
+ sendMedia: async (params) => {
+ await acmeChatApi.sendFile(params.to, params.filePath);
+ },
+ },
+ },
});
- },
-};
+ ```
+
+
+ Instead of implementing low-level adapter interfaces manually, you pass
+ declarative options and the builder composes them:
+
+ | Option | What it wires |
+ | --- | --- |
+ | `security.dm` | Scoped DM security resolver from config fields |
+ | `pairing.text` | Text-based DM pairing flow with code exchange |
+ | `threading` | Reply-to-mode resolver (fixed, account-scoped, or custom) |
+ | `outbound.attachedResults` | Send functions that return result metadata (message IDs) |
+
+ You can also pass raw adapter objects instead of the declarative options
+ if you need full control.
+
+
+
+
+
+ Create `index.ts`:
+
+ ```typescript index.ts
+ import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core";
+ import { acmeChatPlugin } from "./src/channel.js";
+
+ export default defineChannelPluginEntry({
+ id: "acme-chat",
+ name: "Acme Chat",
+ description: "Acme Chat channel plugin",
+ plugin: acmeChatPlugin,
+ registerFull(api) {
+ api.registerCli(
+ ({ program }) => {
+ program
+ .command("acme-chat")
+ .description("Acme Chat management");
+ },
+ { commands: ["acme-chat"] },
+ );
+ },
+ });
+ ```
+
+ `defineChannelPluginEntry` handles the setup/full registration split
+ automatically. See
+ [Entry Points](/plugins/sdk-entrypoints#definechannelpluginentry) for all
+ options.
+
+
+
+
+ Create `setup-entry.ts` for lightweight loading during onboarding:
+
+ ```typescript setup-entry.ts
+ import { defineSetupPluginEntry } from "openclaw/plugin-sdk/core";
+ import { acmeChatPlugin } from "./src/channel.js";
+
+ export default defineSetupPluginEntry(acmeChatPlugin);
+ ```
+
+ OpenClaw loads this instead of the full entry when the channel is disabled
+ or unconfigured. It avoids pulling in heavy runtime code during setup flows.
+ See [Setup and Config](/plugins/sdk-setup#setup-entry) for details.
+
+
+
+
+ Your plugin needs to receive messages from the platform and forward them to
+ OpenClaw. The typical pattern is a webhook that verifies the request and
+ dispatches it through your channel's inbound handler:
+
+ ```typescript
+ registerFull(api) {
+ api.registerHttpRoute({
+ path: "/acme-chat/webhook",
+ auth: "plugin", // plugin-managed auth (verify signatures yourself)
+ handler: async (req, res) => {
+ const event = parseWebhookPayload(req);
+
+ // Your inbound handler dispatches the message to OpenClaw.
+ // The exact wiring depends on your platform SDK —
+ // see a real example in extensions/msteams or extensions/googlechat.
+ await handleAcmeChatInbound(api, event);
+
+ res.statusCode = 200;
+ res.end("ok");
+ return true;
+ },
+ });
+ }
+ ```
+
+
+ Inbound message handling is channel-specific. Each channel plugin owns
+ its own inbound pipeline. Look at bundled channel plugins
+ (e.g. `extensions/msteams`, `extensions/googlechat`) for real patterns.
+
+
+
+
+
+ Write colocated tests in `src/channel.test.ts`:
+
+ ```typescript src/channel.test.ts
+ import { describe, it, expect } from "vitest";
+ import { acmeChatPlugin } from "./channel.js";
+
+ describe("acme-chat plugin", () => {
+ it("resolves account from config", () => {
+ const cfg = {
+ channels: {
+ "acme-chat": { token: "test-token", allowFrom: ["user1"] },
+ },
+ } as any;
+ const account = acmeChatPlugin.setup!.resolveAccount(cfg, undefined);
+ expect(account.token).toBe("test-token");
+ });
+
+ it("inspects account without materializing secrets", () => {
+ const cfg = {
+ channels: { "acme-chat": { token: "test-token" } },
+ } as any;
+ const result = acmeChatPlugin.setup!.inspectAccount!(cfg, undefined);
+ expect(result.configured).toBe(true);
+ expect(result.tokenStatus).toBe("available");
+ });
+
+ it("reports missing config", () => {
+ const cfg = { channels: {} } as any;
+ const result = acmeChatPlugin.setup!.inspectAccount!(cfg, undefined);
+ expect(result.configured).toBe(false);
+ });
+ });
+ ```
+
+ ```bash
+ pnpm test -- extensions/acme-chat/
+ ```
+
+ For shared test helpers, see [Testing](/plugins/sdk-testing).
+
+
+
+
+## File structure
+
+```
+extensions/acme-chat/
+├── package.json # openclaw.channel metadata
+├── openclaw.plugin.json # Manifest with config schema
+├── index.ts # defineChannelPluginEntry
+├── setup-entry.ts # defineSetupPluginEntry
+├── api.ts # Public exports (optional)
+├── runtime-api.ts # Internal runtime exports (optional)
+└── src/
+ ├── channel.ts # ChannelPlugin via createChatChannelPlugin
+ ├── channel.test.ts # Tests
+ ├── client.ts # Platform API client
+ └── runtime.ts # Runtime store (if needed)
```
-## Pairing helpers
+## Advanced topics
-Use `plugin-sdk/channel-pairing` for DM approval flows:
+
+
+ Fixed, account-scoped, or custom reply modes
+
+
+ describeMessageTool and action discovery
+
+
+ inferTargetChatType, looksLikeId, resolveTarget
+
+
+ TTS, STT, media, subagent via api.runtime
+
+
-```ts
-import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
+## Next steps
-const pairing = createChannelPairingController({
- core: runtime,
- channel: "example-channel",
- accountId: "default",
-});
-
-const result = pairing.issueChallenge({
- agentId: "assistant",
- requesterId: "user-123",
-});
-```
-
-That surface also gives you scoped access to pairing storage helpers such as
-allowlist reads and request upserts.
-
-## Channel setup helpers
-
-Use:
-
-- `plugin-sdk/channel-setup` for optional or installable channels
-- `plugin-sdk/setup` for setup adapters, DM policy, and allowlist prompts
-- `plugin-sdk/webhook-ingress` for plugin-owned webhook routes
-
-## Channel plugin guidance
-
-- Keep transport-specific execution inside the channel package.
-- Use `channel-contract` types in tests and local helpers.
-- Keep `describeMessageTool(...)` and `handleAction(...)` aligned.
-- Keep session routing in `messaging`, not in ad-hoc command handlers.
-- Prefer focused subpaths over broad runtime coupling.
-
-## Related
-
-- [Plugin SDK Overview](/plugins/sdk-overview)
-- [Plugin Entry Points](/plugins/sdk-entrypoints)
-- [Plugin Setup](/plugins/sdk-setup)
-- [Plugin Internals](/plugins/architecture)
+- [Provider Plugins](/plugins/sdk-provider-plugins) — if your plugin also provides models
+- [SDK Overview](/plugins/sdk-overview) — full subpath import reference
+- [SDK Testing](/plugins/sdk-testing) — test utilities and contract tests
+- [Plugin Manifest](/plugins/manifest) — full manifest schema
diff --git a/docs/plugins/sdk-entrypoints.md b/docs/plugins/sdk-entrypoints.md
index be3f36ef62c..25ebc050e2e 100644
--- a/docs/plugins/sdk-entrypoints.md
+++ b/docs/plugins/sdk-entrypoints.md
@@ -1,159 +1,161 @@
---
title: "Plugin Entry Points"
sidebarTitle: "Entry Points"
-summary: "How to define plugin entry files for provider, tool, channel, and setup plugins"
+summary: "Reference for definePluginEntry, defineChannelPluginEntry, and defineSetupPluginEntry"
read_when:
- - You are writing a plugin `index.ts`
- - You need to choose between `definePluginEntry` and `defineChannelPluginEntry`
- - You are adding a separate `setup-entry.ts`
+ - You need the exact type signature of definePluginEntry or defineChannelPluginEntry
+ - You want to understand registration mode (full vs setup)
+ - You are looking up entry point options
---
# Plugin Entry Points
-OpenClaw has two main entry helpers:
+Every plugin exports a default entry object. The SDK provides three helpers for
+creating them.
-- `definePluginEntry(...)` for general plugins
-- `defineChannelPluginEntry(...)` for native messaging channels
+
+ **Looking for a walkthrough?** See [Channel Plugins](/plugins/sdk-channel-plugins)
+ or [Provider Plugins](/plugins/sdk-provider-plugins) for step-by-step guides.
+
-There is also `defineSetupPluginEntry(...)` for a separate setup-only module.
+## `definePluginEntry`
-## `definePluginEntry(...)`
+**Import:** `openclaw/plugin-sdk/plugin-entry`
-Use this for providers, tools, commands, services, memory plugins, and context
-engines.
+For provider plugins, tool plugins, hook plugins, and anything that is **not**
+a messaging channel.
-```ts
-import { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
+```typescript
+import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
export default definePluginEntry({
- id: "example-tools",
- name: "Example Tools",
- description: "Adds a command and a tool",
- register(api: OpenClawPluginApi) {
- api.registerCommand({
- name: "example",
- description: "Show plugin status",
- handler: async () => ({ text: "example ok" }),
+ id: "my-plugin",
+ name: "My Plugin",
+ description: "Short summary",
+ register(api) {
+ api.registerProvider({
+ /* ... */
});
-
api.registerTool({
- name: "example_lookup",
- description: "Look up Example data",
- parameters: {
- type: "object",
- properties: {
- query: { type: "string" },
- },
- required: ["query"],
- },
- async execute(_callId, params) {
- return {
- content: [{ type: "text", text: `lookup: ${String(params.query)}` }],
- };
- },
+ /* ... */
});
},
});
```
-## `defineChannelPluginEntry(...)`
+### Options
-Use this for a plugin that registers a `ChannelPlugin`.
+| Field | Type | Required | Default |
+| -------------- | ---------------------------------------------------------------- | -------- | ------------------- |
+| `id` | `string` | Yes | — |
+| `name` | `string` | Yes | — |
+| `description` | `string` | Yes | — |
+| `kind` | `string` | No | — |
+| `configSchema` | `OpenClawPluginConfigSchema \| () => OpenClawPluginConfigSchema` | No | Empty object schema |
+| `register` | `(api: OpenClawPluginApi) => void` | Yes | — |
-```ts
+- `id` must match your `openclaw.plugin.json` manifest.
+- `kind` is for exclusive slots: `"memory"` or `"context-engine"`.
+- `configSchema` can be a function for lazy evaluation.
+
+## `defineChannelPluginEntry`
+
+**Import:** `openclaw/plugin-sdk/core`
+
+Wraps `definePluginEntry` with channel-specific wiring. Automatically calls
+`api.registerChannel({ plugin })` and gates `registerFull` on registration mode.
+
+```typescript
import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core";
-import { channelPlugin } from "./src/channel.js";
-import { setRuntime } from "./src/runtime.js";
export default defineChannelPluginEntry({
- id: "example-channel",
- name: "Example Channel",
- description: "Example messaging plugin",
- plugin: channelPlugin,
- setRuntime,
+ id: "my-channel",
+ name: "My Channel",
+ description: "Short summary",
+ plugin: myChannelPlugin,
+ setRuntime: setMyRuntime,
registerFull(api) {
- api.registerTool({
- name: "example_channel_status",
- description: "Inspect Example Channel state",
- parameters: { type: "object", properties: {} },
- async execute() {
- return { content: [{ type: "text", text: "ok" }] };
- },
- });
+ api.registerCli(/* ... */);
+ api.registerGatewayMethod(/* ... */);
},
});
```
-### Why `registerFull(...)` exists
+### Options
-OpenClaw can load plugins in setup-focused registration modes. `registerFull`
-lets a channel plugin skip extra runtime-only registrations such as tools while
-still registering the channel capability itself.
+| Field | Type | Required | Default |
+| -------------- | ---------------------------------------------------------------- | -------- | ------------------- |
+| `id` | `string` | Yes | — |
+| `name` | `string` | Yes | — |
+| `description` | `string` | Yes | — |
+| `plugin` | `ChannelPlugin` | Yes | — |
+| `configSchema` | `OpenClawPluginConfigSchema \| () => OpenClawPluginConfigSchema` | No | Empty object schema |
+| `setRuntime` | `(runtime: PluginRuntime) => void` | No | — |
+| `registerFull` | `(api: OpenClawPluginApi) => void` | No | — |
-Use it for:
+- `setRuntime` is called during registration so you can store the runtime reference
+ (typically via `createPluginRuntimeStore`).
+- `registerFull` only runs when `api.registrationMode === "full"`. It is skipped
+ during setup-only loading.
-- agent tools
-- gateway-only routes
-- runtime-only commands
+## `defineSetupPluginEntry`
-Do not use it for the actual `ChannelPlugin`; that belongs in `plugin: ...`.
+**Import:** `openclaw/plugin-sdk/core`
-## `defineSetupPluginEntry(...)`
+For the lightweight `setup-entry.ts` file. Returns just `{ plugin }` with no
+runtime or CLI wiring.
-Use this when a channel ships a second module for setup flows.
-
-```ts
+```typescript
import { defineSetupPluginEntry } from "openclaw/plugin-sdk/core";
-import { exampleSetupPlugin } from "./src/channel.setup.js";
-export default defineSetupPluginEntry(exampleSetupPlugin);
+export default defineSetupPluginEntry(myChannelPlugin);
```
-This keeps the setup entry shape explicit and matches the bundled channel
-pattern used in OpenClaw.
+OpenClaw loads this instead of the full entry when a channel is disabled,
+unconfigured, or when deferred loading is enabled. See
+[Setup and Config](/plugins/sdk-setup#setup-entry) for when this matters.
-## One plugin, many capabilities
+## Registration mode
-A single entry file can register multiple capabilities:
+`api.registrationMode` tells your plugin how it was loaded:
-```ts
-import { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
+| Mode | When | What to register |
+| ----------------- | --------------------------------- | ----------------------------- |
+| `"full"` | Normal gateway startup | Everything |
+| `"setup-only"` | Disabled/unconfigured channel | Channel registration only |
+| `"setup-runtime"` | Setup flow with runtime available | Channel + lightweight runtime |
-export default definePluginEntry({
- id: "example-hybrid",
- name: "Example Hybrid",
- description: "Provider plus tools",
- register(api: OpenClawPluginApi) {
- api.registerProvider({
- id: "example",
- label: "Example",
- auth: [],
- });
+`defineChannelPluginEntry` handles this split automatically. If you use
+`definePluginEntry` directly for a channel, check mode yourself:
- api.registerTool({
- name: "example_ping",
- description: "Simple health check",
- parameters: { type: "object", properties: {} },
- async execute() {
- return { content: [{ type: "text", text: "pong" }] };
- },
- });
- },
-});
+```typescript
+register(api) {
+ api.registerChannel({ plugin: myPlugin });
+ if (api.registrationMode !== "full") return;
+
+ // Heavy runtime-only registrations
+ api.registerCli(/* ... */);
+ api.registerService(/* ... */);
+}
```
-## Entry-file checklist
+## Plugin shapes
-- Give the plugin a stable `id`.
-- Keep `name` and `description` human-readable.
-- Put schema at the entry level when the plugin has config.
-- Register only public capabilities inside `register(api)`.
-- Keep channel plugins on `plugin-sdk/core`.
-- Keep non-channel plugins on `plugin-sdk/plugin-entry`.
+OpenClaw classifies loaded plugins by their registration behavior:
+
+| Shape | Description |
+| --------------------- | -------------------------------------------------- |
+| **plain-capability** | One capability type (e.g. provider-only) |
+| **hybrid-capability** | Multiple capability types (e.g. provider + speech) |
+| **hook-only** | Only hooks, no capabilities |
+| **non-capability** | Tools/commands/services but no capabilities |
+
+Use `openclaw plugins inspect ` to see a plugin's shape.
## Related
-- [Plugin SDK Overview](/plugins/sdk-overview)
-- [Plugin Runtime](/plugins/sdk-runtime)
-- [Channel Plugin SDK](/plugins/sdk-channel-plugins)
-- [Provider Plugin SDK](/plugins/sdk-provider-plugins)
+- [SDK Overview](/plugins/sdk-overview) — registration API and subpath reference
+- [Runtime Helpers](/plugins/sdk-runtime) — `api.runtime` and `createPluginRuntimeStore`
+- [Setup and Config](/plugins/sdk-setup) — manifest, setup entry, deferred loading
+- [Channel Plugins](/plugins/sdk-channel-plugins) — building the `ChannelPlugin` object
+- [Provider Plugins](/plugins/sdk-provider-plugins) — provider registration and hooks
diff --git a/docs/plugins/sdk-migration.md b/docs/plugins/sdk-migration.md
index a5b69049b55..2e51c682867 100644
--- a/docs/plugins/sdk-migration.md
+++ b/docs/plugins/sdk-migration.md
@@ -164,7 +164,9 @@ This is a temporary escape hatch, not a permanent solution.
## Related
-- [Building Plugins](/plugins/building-plugins)
-- [Plugin SDK Overview](/plugins/sdk-overview)
-- [Plugin Internals](/plugins/architecture)
-- [Plugin Manifest](/plugins/manifest)
+- [Getting Started](/plugins/building-plugins) — build your first plugin
+- [SDK Overview](/plugins/sdk-overview) — full subpath import reference
+- [Channel Plugins](/plugins/sdk-channel-plugins) — building channel plugins
+- [Provider Plugins](/plugins/sdk-provider-plugins) — building provider plugins
+- [Plugin Internals](/plugins/architecture) — architecture deep dive
+- [Plugin Manifest](/plugins/manifest) — manifest schema reference
diff --git a/docs/plugins/sdk-overview.md b/docs/plugins/sdk-overview.md
index a3c6faf468c..9e194d22ef0 100644
--- a/docs/plugins/sdk-overview.md
+++ b/docs/plugins/sdk-overview.md
@@ -1,175 +1,196 @@
---
title: "Plugin SDK Overview"
sidebarTitle: "SDK Overview"
-summary: "How the OpenClaw plugin SDK is organized, which subpaths are stable, and how to choose the right import"
+summary: "Import map, registration API reference, and SDK architecture"
read_when:
- - You are starting a new OpenClaw plugin
- - You need to choose the right plugin-sdk subpath
- - You are replacing deprecated compat imports
+ - You need to know which SDK subpath to import from
+ - You want a reference for all registration methods on OpenClawPluginApi
+ - You are looking up a specific SDK export
---
# Plugin SDK Overview
-The OpenClaw plugin SDK is split into **small public subpaths** under
-`openclaw/plugin-sdk/`.
+The plugin SDK is the typed contract between plugins and core. This page is the
+reference for **what to import** and **what you can register**.
-Use the narrowest import that matches the job. That keeps plugin dependencies
-small, avoids circular imports, and makes it clear which contract you depend on.
+
+ **Looking for a how-to guide?**
+ - First plugin? Start with [Getting Started](/plugins/building-plugins)
+ - Channel plugin? See [Channel Plugins](/plugins/sdk-channel-plugins)
+ - Provider plugin? See [Provider Plugins](/plugins/sdk-provider-plugins)
+
-## Rules first
+## Import convention
-- Use focused imports such as `openclaw/plugin-sdk/plugin-entry`.
-- Do not import the root `openclaw/plugin-sdk` barrel in new code.
-- Do not import `openclaw/extension-api` in new code.
-- Do not import `src/**` from plugin packages.
-- Inside a plugin package, route internal imports through local files such as
- `./api.ts` or `./runtime-api.ts`, not through the published SDK path for that
- same plugin.
+Always import from a specific subpath:
-## SDK map
+```typescript
+import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
+import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core";
-| Job | Subpath | Next page |
-| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
-| Define plugin entry modules | `plugin-sdk/plugin-entry`, `plugin-sdk/core` | [Plugin Entry Points](/plugins/sdk-entrypoints) |
-| Use injected runtime helpers | `plugin-sdk/runtime`, `plugin-sdk/runtime-store` | [Plugin Runtime](/plugins/sdk-runtime) |
-| Build setup/configure flows | `plugin-sdk/setup`, `plugin-sdk/channel-setup`, `plugin-sdk/secret-input` | [Plugin Setup](/plugins/sdk-setup) |
-| Build channel plugins | `plugin-sdk/core`, `plugin-sdk/channel-contract`, `plugin-sdk/channel-actions`, `plugin-sdk/channel-pairing` | [Channel Plugin SDK](/plugins/sdk-channel-plugins) |
-| Build provider plugins | `plugin-sdk/plugin-entry`, `plugin-sdk/provider-auth`, `plugin-sdk/provider-onboard`, `plugin-sdk/provider-models`, `plugin-sdk/provider-usage` | [Provider Plugin SDK](/plugins/sdk-provider-plugins) |
-| Test plugin code | `plugin-sdk/testing` | [Plugin SDK Testing](/plugins/sdk-testing) |
+// Deprecated — will be removed in the next major release
+import { definePluginEntry } from "openclaw/plugin-sdk";
+```
-## Typical plugin layout
+Each subpath is a small, self-contained module. This keeps startup fast and
+prevents circular dependency issues.
-```text
+## Subpath reference
+
+The most commonly used subpaths, grouped by purpose. The full list of 100+
+subpaths is in `scripts/lib/plugin-sdk-entrypoints.json`.
+
+### Plugin entry
+
+| Subpath | Key exports |
+| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
+| `plugin-sdk/plugin-entry` | `definePluginEntry` |
+| `plugin-sdk/core` | `defineChannelPluginEntry`, `createChatChannelPlugin`, `createChannelPluginBase`, `defineSetupPluginEntry`, `buildChannelConfigSchema` |
+
+
+
+ | Subpath | Key exports |
+ | --- | --- |
+ | `plugin-sdk/channel-setup` | `createOptionalChannelSetupSurface` |
+ | `plugin-sdk/channel-pairing` | `createChannelPairingController` |
+ | `plugin-sdk/channel-reply-pipeline` | `createChannelReplyPipeline` |
+ | `plugin-sdk/channel-config-helpers` | `createHybridChannelConfigAdapter` |
+ | `plugin-sdk/channel-config-schema` | Channel config schema types |
+ | `plugin-sdk/channel-policy` | `resolveChannelGroupRequireMention` |
+ | `plugin-sdk/channel-lifecycle` | `createAccountStatusSink` |
+ | `plugin-sdk/channel-inbound` | Debounce, mention matching, envelope helpers |
+ | `plugin-sdk/channel-send-result` | Reply result types |
+ | `plugin-sdk/channel-actions` | `createMessageToolButtonsSchema`, `createMessageToolCardSchema` |
+ | `plugin-sdk/channel-targets` | Target parsing/matching helpers |
+ | `plugin-sdk/channel-contract` | Channel contract types |
+ | `plugin-sdk/channel-feedback` | Feedback/reaction wiring |
+
+
+
+ | Subpath | Key exports |
+ | --- | --- |
+ | `plugin-sdk/provider-auth` | `createProviderApiKeyAuthMethod`, `ensureApiKeyFromOptionEnvOrPrompt`, `upsertAuthProfile` |
+ | `plugin-sdk/provider-models` | `normalizeModelCompat` |
+ | `plugin-sdk/provider-catalog` | Catalog type re-exports |
+ | `plugin-sdk/provider-usage` | `fetchClaudeUsage` and similar |
+ | `plugin-sdk/provider-stream` | Stream wrapper types |
+ | `plugin-sdk/provider-onboard` | Onboarding config patch helpers |
+
+
+
+ | Subpath | Key exports |
+ | --- | --- |
+ | `plugin-sdk/command-auth` | `resolveControlCommandGate` |
+ | `plugin-sdk/allow-from` | `formatAllowFromLowercase` |
+ | `plugin-sdk/secret-input` | Secret input parsing helpers |
+ | `plugin-sdk/webhook-ingress` | Webhook request/target helpers |
+
+
+
+ | Subpath | Key exports |
+ | --- | --- |
+ | `plugin-sdk/runtime-store` | `createPluginRuntimeStore` |
+ | `plugin-sdk/config-runtime` | Config load/write helpers |
+ | `plugin-sdk/infra-runtime` | System event/heartbeat helpers |
+ | `plugin-sdk/agent-runtime` | Agent dir/identity/workspace helpers |
+ | `plugin-sdk/directory-runtime` | Config-backed directory query/dedup |
+ | `plugin-sdk/keyed-async-queue` | `KeyedAsyncQueue` |
+
+
+
+ | Subpath | Key exports |
+ | --- | --- |
+ | `plugin-sdk/image-generation` | Image generation provider types |
+ | `plugin-sdk/media-understanding` | Media understanding provider types |
+ | `plugin-sdk/speech` | Speech provider types |
+ | `plugin-sdk/testing` | `installCommonResolveTargetErrorCases`, `shouldAckReaction` |
+
+
+
+## Registration API
+
+The `register(api)` callback receives an `OpenClawPluginApi` object with these
+methods:
+
+### Capability registration
+
+| Method | What it registers |
+| --------------------------------------------- | ------------------------------ |
+| `api.registerProvider(...)` | Text inference (LLM) |
+| `api.registerChannel(...)` | Messaging channel |
+| `api.registerSpeechProvider(...)` | Text-to-speech / STT synthesis |
+| `api.registerMediaUnderstandingProvider(...)` | Image/audio/video analysis |
+| `api.registerImageGenerationProvider(...)` | Image generation |
+| `api.registerWebSearchProvider(...)` | Web search |
+
+### Tools and commands
+
+| Method | What it registers |
+| ------------------------------- | --------------------------------------------- |
+| `api.registerTool(tool, opts?)` | Agent tool (required or `{ optional: true }`) |
+| `api.registerCommand(def)` | Custom command (bypasses the LLM) |
+
+### Infrastructure
+
+| Method | What it registers |
+| ---------------------------------------------- | --------------------- |
+| `api.registerHook(events, handler, opts?)` | Event hook |
+| `api.registerHttpRoute(params)` | Gateway HTTP endpoint |
+| `api.registerGatewayMethod(name, handler)` | Gateway RPC method |
+| `api.registerCli(registrar, opts?)` | CLI subcommand |
+| `api.registerService(service)` | Background service |
+| `api.registerInteractiveHandler(registration)` | Interactive handler |
+
+### Exclusive slots
+
+| Method | What it registers |
+| ------------------------------------------ | ------------------------------------- |
+| `api.registerContextEngine(id, factory)` | Context engine (one active at a time) |
+| `api.registerMemoryPromptSection(builder)` | Memory prompt section builder |
+
+### Events and lifecycle
+
+| Method | What it does |
+| -------------------------------------------- | ----------------------------- |
+| `api.on(hookName, handler, opts?)` | Typed lifecycle hook |
+| `api.onConversationBindingResolved(handler)` | Conversation binding callback |
+
+### API object fields
+
+| Field | Type | Description |
+| ------------------------ | ------------------------- | --------------------------------------------------------- |
+| `api.id` | `string` | Plugin id |
+| `api.name` | `string` | Display name |
+| `api.config` | `OpenClawConfig` | Current config snapshot |
+| `api.pluginConfig` | `Record` | Plugin-specific config from `plugins.entries..config` |
+| `api.runtime` | `PluginRuntime` | [Runtime helpers](/plugins/sdk-runtime) |
+| `api.logger` | `PluginLogger` | Scoped logger (`debug`, `info`, `warn`, `error`) |
+| `api.registrationMode` | `PluginRegistrationMode` | `"full"`, `"setup-only"`, or `"setup-runtime"` |
+| `api.resolvePath(input)` | `(string) => string` | Resolve path relative to plugin root |
+
+## Internal module convention
+
+Within your plugin, use local barrel files for internal imports:
+
+```
my-plugin/
-├── package.json
-├── openclaw.plugin.json
-├── index.ts
-├── setup-entry.ts
-├── api.ts
-├── runtime-api.ts
-└── src/
- ├── provider.ts
- ├── setup.ts
- └── provider.test.ts
+ api.ts # Public exports for external consumers
+ runtime-api.ts # Internal-only runtime exports
+ index.ts # Plugin entry point
+ setup-entry.ts # Lightweight setup-only entry (optional)
```
-```ts
-// api.ts
-export {
- definePluginEntry,
- type OpenClawPluginApi,
- type ProviderAuthContext,
- type ProviderAuthResult,
-} from "openclaw/plugin-sdk/plugin-entry";
-```
-
-## What belongs where
-
-### Entry helpers
-
-- `plugin-sdk/plugin-entry` is the default entry surface for providers, tools,
- commands, services, memory plugins, and context engines.
-- `plugin-sdk/core` adds channel-focused helpers such as
- `defineChannelPluginEntry(...)`.
-
-### Runtime helpers
-
-- Use `api.runtime.*` for trusted in-process helpers that OpenClaw injects at
- registration time.
-- Use `plugin-sdk/runtime-store` when plugin modules need a mutable runtime slot
- that is initialized later.
-
-### Setup helpers
-
-- `plugin-sdk/setup` contains shared setup-wizard helpers and config patch
- helpers.
-- `plugin-sdk/channel-setup` contains channel-specific setup adapters.
-- `plugin-sdk/secret-input` exposes the shared secret-input schema helpers.
-
-### Channel helpers
-
-- `plugin-sdk/channel-contract` exports pure channel types.
-- `plugin-sdk/channel-actions` covers shared `message` tool schema helpers.
-- `plugin-sdk/channel-pairing` covers pairing approval flows.
-- `plugin-sdk/webhook-ingress` covers plugin-owned webhook routes.
-
-### Provider helpers
-
-- `plugin-sdk/provider-auth` covers auth flows and credential helpers.
-- `plugin-sdk/provider-onboard` covers config patches after auth/setup.
-- `plugin-sdk/provider-models` covers catalog and model-definition helpers.
-- `plugin-sdk/provider-usage` covers usage snapshot helpers.
-- `plugin-sdk/provider-setup` and `plugin-sdk/self-hosted-provider-setup`
- cover self-hosted and local-model onboarding.
-
-## Example: mixing subpaths in one plugin
-
-```ts
-import { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
-import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth";
-import { applyProviderConfigWithDefaultModel } from "openclaw/plugin-sdk/provider-onboard";
-import { buildSecretInputSchema } from "openclaw/plugin-sdk/secret-input";
-
-export default definePluginEntry({
- id: "example-provider",
- name: "Example Provider",
- description: "Small provider plugin example",
- configSchema: {
- jsonSchema: {
- type: "object",
- additionalProperties: false,
- properties: {
- apiKey: { type: "string" },
- },
- },
- safeParse(value) {
- return buildSecretInputSchema().safeParse((value as { apiKey?: unknown })?.apiKey);
- },
- },
- register(api: OpenClawPluginApi) {
- api.registerProvider({
- id: "example",
- label: "Example",
- auth: [
- createProviderApiKeyAuthMethod({
- providerId: "example",
- methodId: "api-key",
- label: "Example API key",
- optionKey: "exampleApiKey",
- flagName: "--example-api-key",
- envVar: "EXAMPLE_API_KEY",
- promptMessage: "Enter Example API key",
- profileId: "example:default",
- defaultModel: "example/default",
- applyConfig: (cfg) =>
- applyProviderConfigWithDefaultModel(cfg, "example", {
- id: "default",
- name: "Default",
- }),
- }),
- ],
- });
- },
-});
-```
-
-## Choose the smallest public seam
-
-If a helper exists on a focused subpath, prefer that over a broader runtime
-surface.
-
-- Prefer `plugin-sdk/provider-auth` over reaching into unrelated provider files.
-- Prefer `plugin-sdk/channel-contract` for types in tests and helper modules.
-- Prefer `plugin-sdk/runtime-store` over custom mutable globals.
-- Prefer `plugin-sdk/testing` for shared test fixtures.
+
+ Never import your own plugin through `openclaw/plugin-sdk/`
+ from production code. Route internal imports through `./api.ts` or
+ `./runtime-api.ts`. The SDK path is the external contract only.
+
## Related
-- [Building Plugins](/plugins/building-plugins)
-- [Plugin Entry Points](/plugins/sdk-entrypoints)
-- [Plugin Runtime](/plugins/sdk-runtime)
-- [Plugin Setup](/plugins/sdk-setup)
-- [Channel Plugin SDK](/plugins/sdk-channel-plugins)
-- [Provider Plugin SDK](/plugins/sdk-provider-plugins)
-- [Plugin SDK Testing](/plugins/sdk-testing)
-- [Plugin SDK Migration](/plugins/sdk-migration)
+- [Entry Points](/plugins/sdk-entrypoints) — `definePluginEntry` and `defineChannelPluginEntry` options
+- [Runtime Helpers](/plugins/sdk-runtime) — full `api.runtime` namespace reference
+- [Setup and Config](/plugins/sdk-setup) — packaging, manifests, config schemas
+- [Testing](/plugins/sdk-testing) — test utilities and lint rules
+- [SDK Migration](/plugins/sdk-migration) — migrating from deprecated surfaces
+- [Plugin Internals](/plugins/architecture) — deep architecture and capability model
diff --git a/docs/plugins/sdk-provider-plugins.md b/docs/plugins/sdk-provider-plugins.md
index d54564b373f..7b22264b8e9 100644
--- a/docs/plugins/sdk-provider-plugins.md
+++ b/docs/plugins/sdk-provider-plugins.md
@@ -1,184 +1,370 @@
---
-title: "Provider Plugin SDK"
+title: "Building Provider Plugins"
sidebarTitle: "Provider Plugins"
-summary: "Contracts and helper subpaths for model-provider plugins, including auth, onboarding, catalogs, and usage"
+summary: "Step-by-step guide to building a model provider plugin for OpenClaw"
read_when:
- - You are building a model provider plugin
- - You need auth helpers for API keys or OAuth
- - You need onboarding config patches or catalog helpers
+ - You are building a new model provider plugin
+ - You want to add an OpenAI-compatible proxy or custom LLM to OpenClaw
+ - You need to understand provider auth, catalogs, and runtime hooks
---
-# Provider Plugin SDK
+# Building Provider Plugins
-Provider plugins use `definePluginEntry(...)` and call `api.registerProvider(...)`
-with a `ProviderPlugin` definition.
+This guide walks through building a provider plugin that adds a model provider
+(LLM) to OpenClaw. By the end you will have a provider with a model catalog,
+API key auth, and dynamic model resolution.
-## Minimal provider entry
+
+ If you have not built any OpenClaw plugin before, read
+ [Getting Started](/plugins/building-plugins) first for the basic package
+ structure and manifest setup.
+
-```ts
-import { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
+## Walkthrough
-export default definePluginEntry({
- id: "example-provider",
- name: "Example Provider",
- description: "Example text-inference provider plugin",
- register(api: OpenClawPluginApi) {
- api.registerProvider({
- id: "example",
- label: "Example",
- auth: [],
+
+
+
+ ```json package.json
+ {
+ "name": "@myorg/openclaw-acme-ai",
+ "version": "1.0.0",
+ "type": "module",
+ "openclaw": {
+ "extensions": ["./index.ts"],
+ "providers": ["acme-ai"]
+ }
+ }
+ ```
+
+ ```json openclaw.plugin.json
+ {
+ "id": "acme-ai",
+ "name": "Acme AI",
+ "description": "Acme AI model provider",
+ "providers": ["acme-ai"],
+ "providerAuthEnvVars": {
+ "acme-ai": ["ACME_AI_API_KEY"]
+ },
+ "providerAuthChoices": [
+ {
+ "provider": "acme-ai",
+ "method": "api-key",
+ "choiceId": "acme-ai-api-key",
+ "choiceLabel": "Acme AI API key",
+ "groupId": "acme-ai",
+ "groupLabel": "Acme AI",
+ "cliFlag": "--acme-ai-api-key",
+ "cliOption": "--acme-ai-api-key ",
+ "cliDescription": "Acme AI API key"
+ }
+ ],
+ "configSchema": {
+ "type": "object",
+ "additionalProperties": false
+ }
+ }
+ ```
+
+
+ The manifest declares `providerAuthEnvVars` so OpenClaw can detect
+ credentials without loading your plugin runtime.
+
+
+
+
+ A minimal provider needs an `id`, `label`, `auth`, and `catalog`:
+
+ ```typescript index.ts
+ import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
+ import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth";
+
+ export default definePluginEntry({
+ id: "acme-ai",
+ name: "Acme AI",
+ description: "Acme AI model provider",
+ register(api) {
+ api.registerProvider({
+ id: "acme-ai",
+ label: "Acme AI",
+ docsPath: "/providers/acme-ai",
+ envVars: ["ACME_AI_API_KEY"],
+
+ auth: [
+ createProviderApiKeyAuthMethod({
+ providerId: "acme-ai",
+ methodId: "api-key",
+ label: "Acme AI API key",
+ hint: "API key from your Acme AI dashboard",
+ optionKey: "acmeAiApiKey",
+ flagName: "--acme-ai-api-key",
+ envVar: "ACME_AI_API_KEY",
+ promptMessage: "Enter your Acme AI API key",
+ defaultModel: "acme-ai/acme-large",
+ }),
+ ],
+
+ catalog: {
+ order: "simple",
+ run: async (ctx) => {
+ const apiKey =
+ ctx.resolveProviderApiKey("acme-ai").apiKey;
+ if (!apiKey) return null;
+ return {
+ provider: {
+ baseUrl: "https://api.acme-ai.com/v1",
+ apiKey,
+ api: "openai-completions",
+ models: [
+ {
+ id: "acme-large",
+ name: "Acme Large",
+ reasoning: true,
+ input: ["text", "image"],
+ cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
+ contextWindow: 200000,
+ maxTokens: 32768,
+ },
+ {
+ id: "acme-small",
+ name: "Acme Small",
+ reasoning: false,
+ input: ["text"],
+ cost: { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 },
+ contextWindow: 128000,
+ maxTokens: 8192,
+ },
+ ],
+ },
+ };
+ },
+ },
+ });
+ },
});
- },
-});
+ ```
+
+ That is a working provider. Users can now
+ `openclaw onboard --acme-ai-api-key ` and select
+ `acme-ai/acme-large` as their model.
+
+
+
+
+ If your provider accepts arbitrary model IDs (like a proxy or router),
+ add `resolveDynamicModel`:
+
+ ```typescript
+ api.registerProvider({
+ // ... id, label, auth, catalog from above
+
+ resolveDynamicModel: (ctx) => ({
+ id: ctx.modelId,
+ name: ctx.modelId,
+ provider: "acme-ai",
+ api: "openai-completions",
+ baseUrl: "https://api.acme-ai.com/v1",
+ reasoning: false,
+ input: ["text"],
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+ contextWindow: 128000,
+ maxTokens: 8192,
+ }),
+ });
+ ```
+
+ If resolving requires a network call, use `prepareDynamicModel` for async
+ warm-up — `resolveDynamicModel` runs again after it completes.
+
+
+
+
+ Most providers only need `catalog` + `resolveDynamicModel`. Add hooks
+ incrementally as your provider requires them.
+
+
+
+ For providers that need a token exchange before each inference call:
+
+ ```typescript
+ prepareRuntimeAuth: async (ctx) => {
+ const exchanged = await exchangeToken(ctx.apiKey);
+ return {
+ apiKey: exchanged.token,
+ baseUrl: exchanged.baseUrl,
+ expiresAt: exchanged.expiresAt,
+ };
+ },
+ ```
+
+
+ For providers that need custom request headers or body modifications:
+
+ ```typescript
+ // wrapStreamFn returns a StreamFn derived from ctx.streamFn
+ wrapStreamFn: (ctx) => {
+ if (!ctx.streamFn) return undefined;
+ const inner = ctx.streamFn;
+ return async (params) => {
+ params.headers = {
+ ...params.headers,
+ "X-Acme-Version": "2",
+ };
+ return inner(params);
+ };
+ },
+ ```
+
+
+ For providers that expose usage/billing data:
+
+ ```typescript
+ resolveUsageAuth: async (ctx) => {
+ const auth = await ctx.resolveOAuthToken();
+ return auth ? { token: auth.token } : null;
+ },
+ fetchUsageSnapshot: async (ctx) => {
+ return await fetchAcmeUsage(ctx.token, ctx.timeoutMs);
+ },
+ ```
+
+
+
+
+ OpenClaw calls hooks in this order. Most providers only use 2-3:
+
+ | # | Hook | When to use |
+ | --- | --- | --- |
+ | 1 | `catalog` | Model catalog or base URL defaults |
+ | 2 | `resolveDynamicModel` | Accept arbitrary upstream model IDs |
+ | 3 | `prepareDynamicModel` | Async metadata fetch before resolving |
+ | 4 | `normalizeResolvedModel` | Transport rewrites before the runner |
+ | 5 | `capabilities` | Transcript/tooling metadata |
+ | 6 | `prepareExtraParams` | Default request params |
+ | 7 | `wrapStreamFn` | Custom headers/body wrappers |
+ | 8 | `formatApiKey` | Custom runtime token shape |
+ | 9 | `refreshOAuth` | Custom OAuth refresh |
+ | 10 | `buildAuthDoctorHint` | Auth repair guidance |
+ | 11 | `isCacheTtlEligible` | Prompt cache TTL gating |
+ | 12 | `buildMissingAuthMessage` | Custom missing-auth hint |
+ | 13 | `suppressBuiltInModel` | Hide stale upstream rows |
+ | 14 | `augmentModelCatalog` | Synthetic forward-compat rows |
+ | 15 | `isBinaryThinking` | Binary thinking on/off |
+ | 16 | `supportsXHighThinking` | `xhigh` reasoning support |
+ | 17 | `resolveDefaultThinkingLevel` | Default `/think` policy |
+ | 18 | `isModernModelRef` | Live/smoke model matching |
+ | 19 | `prepareRuntimeAuth` | Token exchange before inference |
+ | 20 | `resolveUsageAuth` | Custom usage credential parsing |
+ | 21 | `fetchUsageSnapshot` | Custom usage endpoint |
+
+ For detailed descriptions and real-world examples, see
+ [Internals: Provider Runtime Hooks](/plugins/architecture#provider-runtime-hooks).
+
+
+
+
+
+ A provider plugin can register speech, media understanding, image
+ generation, and web search alongside text inference:
+
+ ```typescript
+ register(api) {
+ api.registerProvider({ id: "acme-ai", /* ... */ });
+
+ api.registerSpeechProvider({
+ id: "acme-ai",
+ label: "Acme Speech",
+ isConfigured: ({ config }) => Boolean(config.messages?.tts),
+ synthesize: async (req) => ({
+ audioBuffer: Buffer.from(/* PCM data */),
+ outputFormat: "mp3",
+ fileExtension: ".mp3",
+ voiceCompatible: false,
+ }),
+ });
+
+ api.registerMediaUnderstandingProvider({
+ id: "acme-ai",
+ capabilities: ["image", "audio"],
+ describeImage: async (req) => ({ text: "A photo of..." }),
+ transcribeAudio: async (req) => ({ text: "Transcript..." }),
+ });
+
+ api.registerImageGenerationProvider({
+ id: "acme-ai",
+ label: "Acme Images",
+ generate: async (req) => ({ /* image result */ }),
+ });
+ }
+ ```
+
+ OpenClaw classifies this as a **hybrid-capability** plugin. This is the
+ recommended pattern for company plugins (one plugin per vendor). See
+ [Internals: Capability Ownership](/plugins/architecture#capability-ownership-model).
+
+
+
+
+ ```typescript src/provider.test.ts
+ import { describe, it, expect } from "vitest";
+ // Export your provider config object from index.ts or a dedicated file
+ import { acmeProvider } from "./provider.js";
+
+ describe("acme-ai provider", () => {
+ it("resolves dynamic models", () => {
+ const model = acmeProvider.resolveDynamicModel!({
+ modelId: "acme-beta-v3",
+ } as any);
+ expect(model.id).toBe("acme-beta-v3");
+ expect(model.provider).toBe("acme-ai");
+ });
+
+ it("returns catalog when key is available", async () => {
+ const result = await acmeProvider.catalog!.run({
+ resolveProviderApiKey: () => ({ apiKey: "test-key" }),
+ } as any);
+ expect(result?.provider?.models).toHaveLength(2);
+ });
+
+ it("returns null catalog when no key", async () => {
+ const result = await acmeProvider.catalog!.run({
+ resolveProviderApiKey: () => ({ apiKey: undefined }),
+ } as any);
+ expect(result).toBeNull();
+ });
+ });
+ ```
+
+
+
+
+## File structure
+
+```
+extensions/acme-ai/
+├── package.json # openclaw.providers metadata
+├── openclaw.plugin.json # Manifest with providerAuthEnvVars
+├── index.ts # definePluginEntry + registerProvider
+└── src/
+ ├── provider.test.ts # Tests
+ └── usage.ts # Usage endpoint (optional)
```
-## Provider subpaths
+## Catalog order reference
-| Subpath | Use it for |
-| --------------------------------------- | ---------------------------------------------- |
-| `plugin-sdk/provider-auth` | API key, OAuth, auth-profile, and PKCE helpers |
-| `plugin-sdk/provider-onboard` | Config patches after setup/auth |
-| `plugin-sdk/provider-models` | Model-definition and catalog helpers |
-| `plugin-sdk/provider-setup` | Shared local/self-hosted setup flows |
-| `plugin-sdk/self-hosted-provider-setup` | OpenAI-compatible self-hosted providers |
-| `plugin-sdk/provider-usage` | Usage snapshot fetch helpers |
-
-## API key auth
-
-`createProviderApiKeyAuthMethod(...)` is the standard helper for API-key
+`catalog.order` controls when your catalog merges relative to built-in
providers:
-```ts
-import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth";
-import { applyProviderConfigWithDefaultModel } from "openclaw/plugin-sdk/provider-onboard";
+| Order | When | Use case |
+| --------- | ------------- | ----------------------------------------------- |
+| `simple` | First pass | Plain API-key providers |
+| `profile` | After simple | Providers gated on auth profiles |
+| `paired` | After profile | Synthesize multiple related entries |
+| `late` | Last pass | Override existing providers (wins on collision) |
-const auth = [
- createProviderApiKeyAuthMethod({
- providerId: "example",
- methodId: "api-key",
- label: "Example API key",
- optionKey: "exampleApiKey",
- flagName: "--example-api-key",
- envVar: "EXAMPLE_API_KEY",
- promptMessage: "Enter Example API key",
- profileId: "example:default",
- defaultModel: "example/default",
- applyConfig: (cfg) =>
- applyProviderConfigWithDefaultModel(cfg, "example", {
- id: "default",
- name: "Default",
- }),
- }),
-];
-```
+## Next steps
-## OAuth auth
-
-`buildOauthProviderAuthResult(...)` builds the standard auth result payload for
-OAuth-style providers:
-
-```ts
-import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-auth";
-
-async function runOAuthLogin() {
- return buildOauthProviderAuthResult({
- providerId: "example-portal",
- defaultModel: "example-portal/default",
- access: "access-token",
- refresh: "refresh-token",
- email: "user@example.com",
- notes: ["Tokens auto-refresh when the provider supports refresh tokens."],
- });
-}
-```
-
-## Catalog and discovery hooks
-
-Provider plugins usually implement either `catalog` or the legacy `discovery`
-alias. `catalog` is preferred.
-
-```ts
-api.registerProvider({
- id: "example",
- label: "Example",
- auth,
- catalog: {
- order: "simple",
- async run(ctx) {
- const apiKey = ctx.resolveProviderApiKey("example").apiKey;
- if (!apiKey) {
- return null;
- }
- return {
- provider: {
- api: "openai",
- baseUrl: "https://api.example.com/v1",
- apiKey,
- models: [
- {
- id: "default",
- name: "Default",
- input: ["text"],
- },
- ],
- },
- };
- },
- },
-});
-```
-
-## Onboarding config patches
-
-`plugin-sdk/provider-onboard` keeps post-auth config writes consistent.
-
-Common helpers:
-
-- `applyProviderConfigWithDefaultModel(...)`
-- `applyProviderConfigWithDefaultModels(...)`
-- `applyProviderConfigWithModelCatalog(...)`
-- `applyAgentDefaultModelPrimary(...)`
-- `ensureModelAllowlistEntry(...)`
-
-## Self-hosted and local model setup
-
-Use `plugin-sdk/provider-setup` or
-`plugin-sdk/self-hosted-provider-setup` when the provider is an OpenAI-style
-backend, Ollama, SGLang, or vLLM.
-
-Examples from the shared setup surfaces:
-
-- `promptAndConfigureOllama(...)`
-- `configureOllamaNonInteractive(...)`
-- `promptAndConfigureOpenAICompatibleSelfHostedProvider(...)`
-- `discoverOpenAICompatibleSelfHostedProvider(...)`
-
-These helpers keep setup behavior aligned with built-in provider flows.
-
-## Usage snapshots
-
-If the provider owns quota or usage endpoints, use `resolveUsageAuth(...)` and
-`fetchUsageSnapshot(...)`.
-
-`plugin-sdk/provider-usage` includes shared fetch helpers such as:
-
-- `fetchClaudeUsage(...)`
-- `fetchCodexUsage(...)`
-- `fetchGeminiUsage(...)`
-- `fetchMinimaxUsage(...)`
-- `fetchZaiUsage(...)`
-
-## Provider guidance
-
-- Keep auth logic in `provider-auth`.
-- Keep config mutation in `provider-onboard`.
-- Keep catalog/model helpers in `provider-models`.
-- Keep usage logic in `provider-usage`.
-- Use `catalog`, not `discovery`, in new plugins.
-
-## Related
-
-- [Plugin SDK Overview](/plugins/sdk-overview)
-- [Plugin Entry Points](/plugins/sdk-entrypoints)
-- [Plugin Setup](/plugins/sdk-setup)
-- [Plugin Internals](/plugins/architecture#provider-runtime-hooks)
+- [Channel Plugins](/plugins/sdk-channel-plugins) — if your plugin also provides a channel
+- [SDK Runtime](/plugins/sdk-runtime) — `api.runtime` helpers (TTS, search, subagent)
+- [SDK Overview](/plugins/sdk-overview) — full subpath import reference
+- [Plugin Internals](/plugins/architecture#provider-runtime-hooks) — hook details and bundled examples
diff --git a/docs/plugins/sdk-runtime.md b/docs/plugins/sdk-runtime.md
index 09d741fce39..ca73ca7b7aa 100644
--- a/docs/plugins/sdk-runtime.md
+++ b/docs/plugins/sdk-runtime.md
@@ -1,156 +1,345 @@
---
-title: "Plugin Runtime"
-sidebarTitle: "Runtime"
-summary: "How `api.runtime` works, when to use it, and how to manage plugin runtime state safely"
+title: "Plugin SDK Runtime"
+sidebarTitle: "Runtime Helpers"
+summary: "api.runtime -- the injected runtime helpers available to plugins"
read_when:
- - You need to call runtime helpers from a plugin
- - You are deciding between hooks and injected runtime
- - You need a safe module-level runtime store
+ - You need to call core helpers from a plugin (TTS, STT, image gen, web search, subagent)
+ - You want to understand what api.runtime exposes
+ - You are accessing config, agent, or media helpers from plugin code
---
-# Plugin Runtime
+# Plugin Runtime Helpers
-Native OpenClaw plugins receive a trusted runtime through `api.runtime`.
+Reference for the `api.runtime` object injected into every plugin during
+registration. Use these helpers instead of importing host internals directly.
-Use it for **host-owned operations** that should stay inside OpenClaw’s runtime:
+
+ **Looking for a walkthrough?** See [Channel Plugins](/plugins/sdk-channel-plugins)
+ or [Provider Plugins](/plugins/sdk-provider-plugins) for step-by-step guides
+ that show these helpers in context.
+
-- reading and writing config
-- agent/session helpers
-- system commands with OpenClaw timeouts
-- media, speech, image-generation, and web-search runtime calls
-- channel-owned helpers for bundled channel plugins
-
-## When to use runtime vs focused SDK helpers
-
-- Use focused SDK helpers when a public subpath already models the job.
-- Use `api.runtime.*` when the host owns the operation or state.
-- Prefer hooks for loose integrations that do not need tight in-process access.
+```typescript
+register(api) {
+ const runtime = api.runtime;
+}
+```
## Runtime namespaces
-| Namespace | What it covers |
-| -------------------------------- | -------------------------------------------------- |
-| `api.runtime.config` | Load and persist OpenClaw config |
-| `api.runtime.agent` | Agent workspace, identity, timeouts, session store |
-| `api.runtime.system` | System events, heartbeats, command execution |
-| `api.runtime.media` | File/media loading and transforms |
-| `api.runtime.tts` | Speech synthesis and voice listing |
-| `api.runtime.mediaUnderstanding` | Image/audio/video understanding |
-| `api.runtime.imageGeneration` | Image generation providers |
-| `api.runtime.webSearch` | Runtime web-search execution |
-| `api.runtime.modelAuth` | Resolve model/provider credentials |
-| `api.runtime.subagent` | Spawn, wait, inspect, and delete subagent sessions |
-| `api.runtime.channel` | Channel-heavy helpers for native channel plugins |
+### `api.runtime.agent`
-## Example: read and persist config
+Agent identity, directories, and session management.
-```ts
-import { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
+```typescript
+// Resolve the agent's working directory
+const agentDir = api.runtime.agent.resolveAgentDir(cfg);
-export default definePluginEntry({
- id: "talk-settings",
- name: "Talk Settings",
- description: "Example runtime config write",
- register(api: OpenClawPluginApi) {
- api.registerCommand({
- name: "talk-mode",
- description: "Enable talk mode",
- handler: async () => {
- const cfg = api.runtime.config.loadConfig();
- const nextConfig = {
- ...cfg,
- talk: {
- ...cfg.talk,
- enabled: true,
- },
- };
- await api.runtime.config.writeConfigFile(nextConfig);
- return { text: "talk mode enabled" };
- },
- });
- },
+// Resolve agent workspace
+const workspaceDir = api.runtime.agent.resolveAgentWorkspaceDir(cfg);
+
+// Get agent identity
+const identity = api.runtime.agent.resolveAgentIdentity(cfg);
+
+// Get default thinking level
+const thinking = api.runtime.agent.resolveThinkingDefault(cfg, provider, model);
+
+// Get agent timeout
+const timeoutMs = api.runtime.agent.resolveAgentTimeoutMs(cfg);
+
+// Ensure workspace exists
+await api.runtime.agent.ensureAgentWorkspace(cfg);
+
+// Run an embedded Pi agent (requires sessionFile + workspaceDir at minimum)
+const agentDir = api.runtime.agent.resolveAgentDir(cfg);
+const result = await api.runtime.agent.runEmbeddedPiAgent({
+ sessionId: "my-plugin:task-1",
+ sessionFile: path.join(agentDir, "sessions", "my-plugin-task-1.jsonl"),
+ workspaceDir: api.runtime.agent.resolveAgentWorkspaceDir(cfg),
+ prompt: "Summarize the latest changes",
});
```
-## Example: use a runtime service owned by OpenClaw
+**Session store helpers** are under `api.runtime.agent.session`:
-```ts
-const cfg = api.runtime.config.loadConfig();
+```typescript
+const storePath = api.runtime.agent.session.resolveStorePath(cfg);
+const store = api.runtime.agent.session.loadSessionStore(cfg);
+await api.runtime.agent.session.saveSessionStore(cfg, store);
+const filePath = api.runtime.agent.session.resolveSessionFilePath(cfg, sessionId);
+```
+
+### `api.runtime.agent.defaults`
+
+Default model and provider constants:
+
+```typescript
+const model = api.runtime.agent.defaults.model; // e.g. "anthropic/claude-sonnet-4-6"
+const provider = api.runtime.agent.defaults.provider; // e.g. "anthropic"
+```
+
+### `api.runtime.subagent`
+
+Launch and manage background subagent runs.
+
+```typescript
+// Start a subagent run
+const { runId } = await api.runtime.subagent.run({
+ sessionKey: "agent:main:subagent:search-helper",
+ message: "Expand this query into focused follow-up searches.",
+ provider: "openai", // optional override
+ model: "gpt-4.1-mini", // optional override
+ deliver: false,
+});
+
+// Wait for completion
+const result = await api.runtime.subagent.waitForRun({ runId, timeoutMs: 30000 });
+
+// Read session messages
+const { messages } = await api.runtime.subagent.getSessionMessages({
+ sessionKey: "agent:main:subagent:search-helper",
+ limit: 10,
+});
+
+// Delete a session
+await api.runtime.subagent.deleteSession({
+ sessionKey: "agent:main:subagent:search-helper",
+});
+```
+
+
+ Model overrides (`provider`/`model`) require operator opt-in via
+ `plugins.entries..subagent.allowModelOverride: true` in config.
+ Untrusted plugins can still run subagents, but override requests are rejected.
+
+
+### `api.runtime.tts`
+
+Text-to-speech synthesis.
+
+```typescript
+// Standard TTS
+const clip = await api.runtime.tts.textToSpeech({
+ text: "Hello from OpenClaw",
+ cfg: api.config,
+});
+
+// Telephony-optimized TTS
+const telephonyClip = await api.runtime.tts.textToSpeechTelephony({
+ text: "Hello from OpenClaw",
+ cfg: api.config,
+});
+
+// List available voices
const voices = await api.runtime.tts.listVoices({
+ provider: "elevenlabs",
+ cfg: api.config,
+});
+```
+
+Uses core `messages.tts` configuration and provider selection. Returns PCM audio
+buffer + sample rate.
+
+### `api.runtime.mediaUnderstanding`
+
+Image, audio, and video analysis.
+
+```typescript
+// Describe an image
+const image = await api.runtime.mediaUnderstanding.describeImageFile({
+ filePath: "/tmp/inbound-photo.jpg",
+ cfg: api.config,
+ agentDir: "/tmp/agent",
+});
+
+// Transcribe audio
+const { text } = await api.runtime.mediaUnderstanding.transcribeAudioFile({
+ filePath: "/tmp/inbound-audio.ogg",
+ cfg: api.config,
+ mime: "audio/ogg", // optional, for when MIME cannot be inferred
+});
+
+// Describe a video
+const video = await api.runtime.mediaUnderstanding.describeVideoFile({
+ filePath: "/tmp/inbound-video.mp4",
+ cfg: api.config,
+});
+
+// Generic file analysis
+const result = await api.runtime.mediaUnderstanding.runFile({
+ filePath: "/tmp/inbound-file.pdf",
+ cfg: api.config,
+});
+```
+
+Returns `{ text: undefined }` when no output is produced (e.g. skipped input).
+
+
+ `api.runtime.stt.transcribeAudioFile(...)` remains as a compatibility alias
+ for `api.runtime.mediaUnderstanding.transcribeAudioFile(...)`.
+
+
+### `api.runtime.imageGeneration`
+
+Image generation.
+
+```typescript
+const result = await api.runtime.imageGeneration.generate({
+ prompt: "A robot painting a sunset",
+ cfg: api.config,
+});
+
+const providers = api.runtime.imageGeneration.listProviders({ cfg: api.config });
+```
+
+### `api.runtime.webSearch`
+
+Web search.
+
+```typescript
+const providers = api.runtime.webSearch.listProviders({ config: api.config });
+
+const result = await api.runtime.webSearch.search({
+ config: api.config,
+ args: { query: "OpenClaw plugin SDK", count: 5 },
+});
+```
+
+### `api.runtime.media`
+
+Low-level media utilities.
+
+```typescript
+const webMedia = await api.runtime.media.loadWebMedia(url);
+const mime = await api.runtime.media.detectMime(buffer);
+const kind = api.runtime.media.mediaKindFromMime("image/jpeg"); // "image"
+const isVoice = api.runtime.media.isVoiceCompatibleAudio(filePath);
+const metadata = await api.runtime.media.getImageMetadata(filePath);
+const resized = await api.runtime.media.resizeToJpeg(buffer, { maxWidth: 800 });
+```
+
+### `api.runtime.config`
+
+Config load and write.
+
+```typescript
+const cfg = await api.runtime.config.loadConfig();
+await api.runtime.config.writeConfigFile(cfg);
+```
+
+### `api.runtime.system`
+
+System-level utilities.
+
+```typescript
+await api.runtime.system.enqueueSystemEvent(event);
+api.runtime.system.requestHeartbeatNow();
+const output = await api.runtime.system.runCommandWithTimeout(cmd, args, opts);
+const hint = api.runtime.system.formatNativeDependencyHint(pkg);
+```
+
+### `api.runtime.events`
+
+Event subscriptions.
+
+```typescript
+api.runtime.events.onAgentEvent((event) => {
+ /* ... */
+});
+api.runtime.events.onSessionTranscriptUpdate((update) => {
+ /* ... */
+});
+```
+
+### `api.runtime.logging`
+
+Logging.
+
+```typescript
+const verbose = api.runtime.logging.shouldLogVerbose();
+const childLogger = api.runtime.logging.getChildLogger({ plugin: "my-plugin" }, { level: "debug" });
+```
+
+### `api.runtime.modelAuth`
+
+Model and provider auth resolution.
+
+```typescript
+const auth = await api.runtime.modelAuth.getApiKeyForModel({ model, cfg });
+const providerAuth = await api.runtime.modelAuth.resolveApiKeyForProvider({
provider: "openai",
cfg,
});
-
-return {
- text: voices.map((voice) => `${voice.name ?? voice.id}: ${voice.id}`).join("\n"),
-};
```
-## `createPluginRuntimeStore(...)`
+### `api.runtime.state`
-Plugin modules often need a small mutable slot for runtime-backed helpers. Use
-`plugin-sdk/runtime-store` instead of an unguarded `let runtime`.
+State directory resolution.
-```ts
-import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core";
+```typescript
+const stateDir = api.runtime.state.resolveStateDir();
+```
+
+### `api.runtime.tools`
+
+Memory tool factories and CLI.
+
+```typescript
+const getTool = api.runtime.tools.createMemoryGetTool(/* ... */);
+const searchTool = api.runtime.tools.createMemorySearchTool(/* ... */);
+api.runtime.tools.registerMemoryCli(/* ... */);
+```
+
+### `api.runtime.channel`
+
+Channel-specific runtime helpers (available when a channel plugin is loaded).
+
+## Storing runtime references
+
+Use `createPluginRuntimeStore` to store the runtime reference for use outside
+the `register` callback:
+
+```typescript
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
-import { channelPlugin } from "./src/channel.js";
+import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
-const runtimeStore = createPluginRuntimeStore<{
- logger: { info(message: string): void };
-}>("Example Channel runtime not initialized");
-
-export function setExampleRuntime(runtime: { logger: { info(message: string): void } }) {
- runtimeStore.setRuntime(runtime);
-}
-
-export function getExampleRuntime() {
- return runtimeStore.getRuntime();
-}
+const store = createPluginRuntimeStore("my-plugin runtime not initialized");
+// In your entry point
export default defineChannelPluginEntry({
- id: "example-channel",
- name: "Example Channel",
- description: "Example runtime store usage",
- plugin: channelPlugin,
- setRuntime: setExampleRuntime,
+ id: "my-plugin",
+ name: "My Plugin",
+ description: "Example",
+ plugin: myPlugin,
+ setRuntime: store.setRuntime,
});
+
+// In other files
+export function getRuntime() {
+ return store.getRuntime(); // throws if not initialized
+}
+
+export function tryGetRuntime() {
+ return store.tryGetRuntime(); // returns null if not initialized
+}
```
-`createPluginRuntimeStore(...)` gives you:
+## Other top-level `api` fields
-- `setRuntime(next)`
-- `clearRuntime()`
-- `tryGetRuntime()`
-- `getRuntime()`
+Beyond `api.runtime`, the API object also provides:
-`getRuntime()` throws with your custom message if the runtime was never set.
-
-## Channel runtime note
-
-`api.runtime.channel.*` is the heaviest namespace. It exists for native channel
-plugins that need tight coupling with the OpenClaw messaging stack.
-
-Prefer narrower subpaths such as:
-
-- `plugin-sdk/channel-pairing`
-- `plugin-sdk/channel-actions`
-- `plugin-sdk/channel-feedback`
-- `plugin-sdk/channel-lifecycle`
-
-Use `api.runtime.channel.*` when the operation is clearly host-owned and there
-is no smaller public seam.
-
-## Runtime safety guidelines
-
-- Do not cache config snapshots longer than needed.
-- Prefer `createPluginRuntimeStore(...)` for shared module state.
-- Keep runtime-backed code behind small local helpers.
-- Avoid reaching into runtime namespaces you do not need.
+| Field | Type | Description |
+| ------------------------ | ------------------------- | --------------------------------------------------------- |
+| `api.id` | `string` | Plugin id |
+| `api.name` | `string` | Plugin display name |
+| `api.config` | `OpenClawConfig` | Current config snapshot |
+| `api.pluginConfig` | `Record` | Plugin-specific config from `plugins.entries..config` |
+| `api.logger` | `PluginLogger` | Scoped logger (`debug`, `info`, `warn`, `error`) |
+| `api.registrationMode` | `PluginRegistrationMode` | `"full"`, `"setup-only"`, or `"setup-runtime"` |
+| `api.resolvePath(input)` | `(string) => string` | Resolve a path relative to the plugin root |
## Related
-- [Plugin SDK Overview](/plugins/sdk-overview)
-- [Plugin Entry Points](/plugins/sdk-entrypoints)
-- [Plugin Setup](/plugins/sdk-setup)
-- [Channel Plugin SDK](/plugins/sdk-channel-plugins)
+- [SDK Overview](/plugins/sdk-overview) -- subpath reference
+- [SDK Entry Points](/plugins/sdk-entrypoints) -- `definePluginEntry` options
+- [Plugin Internals](/plugins/architecture) -- capability model and registry
diff --git a/docs/plugins/sdk-setup.md b/docs/plugins/sdk-setup.md
index 21df88117ab..e9df3e86141 100644
--- a/docs/plugins/sdk-setup.md
+++ b/docs/plugins/sdk-setup.md
@@ -1,132 +1,324 @@
---
-title: "Plugin Setup"
-sidebarTitle: "Setup"
-summary: "Shared setup-wizard helpers for channel plugins, provider plugins, and secret inputs"
+title: "Plugin SDK Setup"
+sidebarTitle: "Setup and Config"
+summary: "Setup wizards, setup-entry.ts, config schemas, and package.json metadata"
read_when:
- - You are building a setup or onboarding flow
- - You need shared allowlist or DM policy setup helpers
- - You need the shared secret-input schema
+ - You are adding a setup wizard to a plugin
+ - You need to understand setup-entry.ts vs index.ts
+ - You are defining plugin config schemas or package.json openclaw metadata
---
-# Plugin Setup
+# Plugin Setup and Config
-OpenClaw exposes shared setup helpers so plugin setup flows behave like the
-built-in ones.
+Reference for plugin packaging (`package.json` metadata), manifests
+(`openclaw.plugin.json`), setup entries, and config schemas.
-Main subpaths:
+
+ **Looking for a walkthrough?** The how-to guides cover packaging in context:
+ [Channel Plugins](/plugins/sdk-channel-plugins#step-1-package-and-manifest) and
+ [Provider Plugins](/plugins/sdk-provider-plugins#step-1-package-and-manifest).
+
-- `openclaw/plugin-sdk/setup`
-- `openclaw/plugin-sdk/channel-setup`
-- `openclaw/plugin-sdk/secret-input`
+## Package metadata
-## Channel setup helpers
+Your `package.json` needs an `openclaw` field that tells the plugin system what
+your plugin provides:
-Use `plugin-sdk/channel-setup` when a channel plugin needs the standard setup
-adapter and setup wizard shapes.
+**Channel plugin:**
-### Optional channel plugins
-
-If a channel is installable but not always present, use
-`createOptionalChannelSetupSurface(...)`:
-
-```ts
-import { createOptionalChannelSetupSurface } from "openclaw/plugin-sdk/channel-setup";
-
-export const optionalExampleSetup = createOptionalChannelSetupSurface({
- channel: "example",
- label: "Example Channel",
- npmSpec: "@openclaw/example-channel",
- docsPath: "/channels/example",
-});
-```
-
-That returns:
-
-- `setupAdapter`
-- `setupWizard`
-
-Both surfaces produce a consistent “install this plugin first” experience.
-
-## Shared setup helpers
-
-`plugin-sdk/setup` re-exports the setup primitives used by bundled channels.
-
-Common helpers:
-
-- `applySetupAccountConfigPatch(...)`
-- `createPatchedAccountSetupAdapter(...)`
-- `createEnvPatchedAccountSetupAdapter(...)`
-- `createTopLevelChannelDmPolicy(...)`
-- `setSetupChannelEnabled(...)`
-- `promptResolvedAllowFrom(...)`
-- `promptSingleChannelSecretInput(...)`
-
-### Example: patch channel config in setup
-
-```ts
-import {
- DEFAULT_ACCOUNT_ID,
- createPatchedAccountSetupAdapter,
- setSetupChannelEnabled,
-} from "openclaw/plugin-sdk/setup";
-
-export const exampleSetupAdapter = createPatchedAccountSetupAdapter({
- resolveAccountId: ({ accountId }) => accountId ?? DEFAULT_ACCOUNT_ID,
- applyPatch: ({ nextConfig, accountId }) => {
- const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
- return setSetupChannelEnabled({
- nextConfig,
- channel: "example",
- accountId: resolvedAccountId,
- enabled: true,
- });
- },
-});
-```
-
-## Secret input schema
-
-Use `plugin-sdk/secret-input` instead of rolling your own secret-input parser.
-
-```ts
-import {
- buildOptionalSecretInputSchema,
- buildSecretInputArraySchema,
- buildSecretInputSchema,
- hasConfiguredSecretInput,
-} from "openclaw/plugin-sdk/secret-input";
-
-const ApiKeySchema = buildSecretInputSchema();
-const OptionalApiKeySchema = buildOptionalSecretInputSchema();
-const ExtraKeysSchema = buildSecretInputArraySchema();
-
-const parsed = OptionalApiKeySchema.safeParse(process.env.EXAMPLE_API_KEY);
-if (parsed.success && hasConfiguredSecretInput(parsed.data)) {
- // ...
+```json
+{
+ "name": "@myorg/openclaw-my-channel",
+ "version": "1.0.0",
+ "type": "module",
+ "openclaw": {
+ "extensions": ["./index.ts"],
+ "setupEntry": "./setup-entry.ts",
+ "channel": {
+ "id": "my-channel",
+ "label": "My Channel",
+ "blurb": "Short description of the channel."
+ }
+ }
}
```
-## Provider setup note
+**Provider plugin:**
-Provider-specific onboarding helpers live on provider-focused subpaths:
+```json
+{
+ "name": "@myorg/openclaw-my-provider",
+ "version": "1.0.0",
+ "type": "module",
+ "openclaw": {
+ "extensions": ["./index.ts"],
+ "providers": ["my-provider"]
+ }
+}
+```
-- `plugin-sdk/provider-auth`
-- `plugin-sdk/provider-onboard`
-- `plugin-sdk/provider-setup`
-- `plugin-sdk/self-hosted-provider-setup`
+### `openclaw` fields
-See [Provider Plugin SDK](/plugins/sdk-provider-plugins).
+| Field | Type | Description |
+| ------------ | ---------- | ------------------------------------------------------------------------------------------ |
+| `extensions` | `string[]` | Entry point files (relative to package root) |
+| `setupEntry` | `string` | Lightweight setup-only entry (optional) |
+| `channel` | `object` | Channel metadata: `id`, `label`, `blurb`, `selectionLabel`, `docsPath`, `order`, `aliases` |
+| `providers` | `string[]` | Provider ids registered by this plugin |
+| `install` | `object` | Install hints: `npmSpec`, `localPath`, `defaultChoice` |
+| `startup` | `object` | Startup behavior flags |
-## Setup guidance
+### Deferred full load
-- Keep setup input schemas strict and small.
-- Reuse OpenClaw’s allowlist, DM-policy, and secret-input helpers.
-- Keep setup-entry modules thin; move behavior into `src/`.
-- Link docs from setup flows when install or auth steps are manual.
+Channel plugins can opt into deferred loading with:
+
+```json
+{
+ "openclaw": {
+ "extensions": ["./index.ts"],
+ "setupEntry": "./setup-entry.ts",
+ "startup": {
+ "deferConfiguredChannelFullLoadUntilAfterListen": true
+ }
+ }
+}
+```
+
+When enabled, OpenClaw loads only `setupEntry` during the pre-listen startup
+phase, even for already-configured channels. The full entry loads after the
+gateway starts listening.
+
+
+ Only enable deferred loading when your `setupEntry` registers everything the
+ gateway needs before it starts listening (channel registration, HTTP routes,
+ gateway methods). If the full entry owns required startup capabilities, keep
+ the default behavior.
+
+
+## Plugin manifest
+
+Every native plugin must ship an `openclaw.plugin.json` in the package root.
+OpenClaw uses this to validate config without executing plugin code.
+
+```json
+{
+ "id": "my-plugin",
+ "name": "My Plugin",
+ "description": "Adds My Plugin capabilities to OpenClaw",
+ "configSchema": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "webhookSecret": {
+ "type": "string",
+ "description": "Webhook verification secret"
+ }
+ }
+ }
+}
+```
+
+For channel plugins, add `kind` and `channels`:
+
+```json
+{
+ "id": "my-channel",
+ "kind": "channel",
+ "channels": ["my-channel"],
+ "configSchema": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {}
+ }
+}
+```
+
+Even plugins with no config must ship a schema. An empty schema is valid:
+
+```json
+{
+ "id": "my-plugin",
+ "configSchema": {
+ "type": "object",
+ "additionalProperties": false
+ }
+}
+```
+
+See [Plugin Manifest](/plugins/manifest) for the full schema reference.
+
+## Setup entry
+
+The `setup-entry.ts` file is a lightweight alternative to `index.ts` that
+OpenClaw loads when it only needs setup surfaces (onboarding, config repair,
+disabled channel inspection).
+
+```typescript
+// setup-entry.ts
+import { defineSetupPluginEntry } from "openclaw/plugin-sdk/core";
+import { myChannelPlugin } from "./src/channel.js";
+
+export default defineSetupPluginEntry(myChannelPlugin);
+```
+
+This avoids loading heavy runtime code (crypto libraries, CLI registrations,
+background services) during setup flows.
+
+**When OpenClaw uses `setupEntry` instead of the full entry:**
+
+- The channel is disabled but needs setup/onboarding surfaces
+- The channel is enabled but unconfigured
+- Deferred loading is enabled (`deferConfiguredChannelFullLoadUntilAfterListen`)
+
+**What `setupEntry` must register:**
+
+- The channel plugin object (via `defineSetupPluginEntry`)
+- Any HTTP routes required before gateway listen
+- Any gateway methods needed during startup
+
+**What `setupEntry` should NOT include:**
+
+- CLI registrations
+- Background services
+- Heavy runtime imports (crypto, SDKs)
+- Gateway methods only needed after startup
+
+## Config schema
+
+Plugin config is validated against the JSON Schema in your manifest. Users
+configure plugins via:
+
+```json5
+{
+ plugins: {
+ entries: {
+ "my-plugin": {
+ config: {
+ webhookSecret: "abc123",
+ },
+ },
+ },
+ },
+}
+```
+
+Your plugin receives this config as `api.pluginConfig` during registration.
+
+For channel-specific config, use the channel config section instead:
+
+```json5
+{
+ channels: {
+ "my-channel": {
+ token: "bot-token",
+ allowFrom: ["user1", "user2"],
+ },
+ },
+}
+```
+
+### Building channel config schemas
+
+Use `buildChannelConfigSchema` from `openclaw/plugin-sdk/core` to convert a
+Zod schema into the `ChannelConfigSchema` wrapper that OpenClaw validates:
+
+```typescript
+import { z } from "zod";
+import { buildChannelConfigSchema } from "openclaw/plugin-sdk/core";
+
+const accountSchema = z.object({
+ token: z.string().optional(),
+ allowFrom: z.array(z.string()).optional(),
+ accounts: z.object({}).catchall(z.any()).optional(),
+ defaultAccount: z.string().optional(),
+});
+
+const configSchema = buildChannelConfigSchema(accountSchema);
+```
+
+## Setup wizards
+
+Channel plugins can provide interactive setup wizards for `openclaw onboard`.
+The wizard is a `ChannelSetupWizard` object on the `ChannelPlugin`:
+
+```typescript
+import type { ChannelSetupWizard } from "openclaw/plugin-sdk/channel-setup";
+
+const setupWizard: ChannelSetupWizard = {
+ channel: "my-channel",
+ status: {
+ configuredLabel: "Connected",
+ unconfiguredLabel: "Not configured",
+ resolveConfigured: ({ cfg }) => Boolean((cfg.channels as any)?.["my-channel"]?.token),
+ },
+ credentials: [
+ {
+ inputKey: "token",
+ providerHint: "my-channel",
+ credentialLabel: "Bot token",
+ preferredEnvVar: "MY_CHANNEL_BOT_TOKEN",
+ envPrompt: "Use MY_CHANNEL_BOT_TOKEN from environment?",
+ keepPrompt: "Keep current token?",
+ inputPrompt: "Enter your bot token:",
+ inspect: ({ cfg, accountId }) => {
+ const token = (cfg.channels as any)?.["my-channel"]?.token;
+ return {
+ accountConfigured: Boolean(token),
+ hasConfiguredValue: Boolean(token),
+ };
+ },
+ },
+ ],
+};
+```
+
+The `ChannelSetupWizard` type supports `credentials`, `textInputs`,
+`dmPolicy`, `allowFrom`, `groupAccess`, `prepare`, `finalize`, and more.
+See bundled plugins (e.g. `extensions/discord/src/channel.setup.ts`) for
+full examples.
+
+For optional setup surfaces that should only appear in certain contexts, use
+`createOptionalChannelSetupSurface` from `openclaw/plugin-sdk/channel-setup`:
+
+```typescript
+import { createOptionalChannelSetupSurface } from "openclaw/plugin-sdk/channel-setup";
+
+const setupSurface = createOptionalChannelSetupSurface({
+ channel: "my-channel",
+ label: "My Channel",
+ npmSpec: "@myorg/openclaw-my-channel",
+ docsPath: "/channels/my-channel",
+});
+// Returns { setupAdapter, setupWizard }
+```
+
+## Publishing and installing
+
+**External plugins:**
+
+```bash
+npm publish
+openclaw plugins install @myorg/openclaw-my-plugin
+```
+
+**In-repo plugins:** place under `extensions/` and they are automatically
+discovered during build.
+
+**Users can browse and install:**
+
+```bash
+openclaw plugins search
+openclaw plugins install
+```
+
+
+ `openclaw plugins install` runs `npm install --ignore-scripts` (no lifecycle
+ scripts). Keep plugin dependency trees pure JS/TS and avoid packages that
+ require `postinstall` builds.
+
## Related
-- [Plugin SDK Overview](/plugins/sdk-overview)
-- [Plugin Entry Points](/plugins/sdk-entrypoints)
-- [Provider Plugin SDK](/plugins/sdk-provider-plugins)
-- [Plugin Manifest](/plugins/manifest)
+- [SDK Entry Points](/plugins/sdk-entrypoints) -- `definePluginEntry` and `defineChannelPluginEntry`
+- [Plugin Manifest](/plugins/manifest) -- full manifest schema reference
+- [Building Plugins](/plugins/building-plugins) -- step-by-step getting started guide
diff --git a/docs/plugins/sdk-testing.md b/docs/plugins/sdk-testing.md
index 90373eca25d..d174978bf23 100644
--- a/docs/plugins/sdk-testing.md
+++ b/docs/plugins/sdk-testing.md
@@ -1,112 +1,263 @@
---
-title: "Plugin SDK Testing"
+title: "SDK Testing"
sidebarTitle: "Testing"
-summary: "How to test plugin code with the public testing helpers and small local test doubles"
+summary: "Testing utilities and patterns for OpenClaw plugins"
read_when:
- You are writing tests for a plugin
- - You need fixtures for Windows command shims or shared routing failures
- - You want to know what the public testing surface includes
+ - You need test utilities from the plugin SDK
+ - You want to understand contract tests for bundled plugins
---
-# Plugin SDK Testing
+# Plugin Testing
-OpenClaw keeps the public testing surface intentionally small.
+Reference for test utilities, patterns, and lint enforcement for OpenClaw
+plugins.
-Use `openclaw/plugin-sdk/testing` for helpers that are stable enough to support
-for plugin authors, and build small plugin-local doubles for everything else.
+
+ **Looking for test examples?** The how-to guides include worked test examples:
+ [Channel plugin tests](/plugins/sdk-channel-plugins#step-6-test) and
+ [Provider plugin tests](/plugins/sdk-provider-plugins#step-6-test).
+
-## Public testing helpers
+## Test utilities
-Current helpers include:
+**Import:** `openclaw/plugin-sdk/testing`
-- `createWindowsCmdShimFixture(...)`
-- `installCommonResolveTargetErrorCases(...)`
-- `shouldAckReaction(...)`
-- `removeAckReactionAfterReply(...)`
+The testing subpath exports a narrow set of helpers for plugin authors:
-The testing surface also re-exports some shared types:
+```typescript
+import {
+ installCommonResolveTargetErrorCases,
+ shouldAckReaction,
+ removeAckReactionAfterReply,
+} from "openclaw/plugin-sdk/testing";
+```
-- `OpenClawConfig`
-- `PluginRuntime`
-- `RuntimeEnv`
-- `ChannelAccountSnapshot`
-- `ChannelGatewayContext`
+### Available exports
-## Example: Windows command shim fixture
+| Export | Purpose |
+| -------------------------------------- | ------------------------------------------------------ |
+| `installCommonResolveTargetErrorCases` | Shared test cases for target resolution error handling |
+| `shouldAckReaction` | Check whether a channel should add an ack reaction |
+| `removeAckReactionAfterReply` | Remove ack reaction after reply delivery |
-```ts
-import { createWindowsCmdShimFixture } from "openclaw/plugin-sdk/testing";
-import { describe, expect, it } from "vitest";
+### Types
-describe("example CLI integration", () => {
- it("creates a command shim", async () => {
- await createWindowsCmdShimFixture({
- shimPath: "/tmp/example.cmd",
- scriptPath: "/tmp/example.js",
- shimLine: 'node "%~dp0\\example.js" %*',
- });
+The testing subpath also re-exports types useful in test files:
- expect(true).toBe(true);
+```typescript
+import type {
+ ChannelAccountSnapshot,
+ ChannelGatewayContext,
+ OpenClawConfig,
+ PluginRuntime,
+ RuntimeEnv,
+ MockFn,
+} from "openclaw/plugin-sdk/testing";
+```
+
+## Testing target resolution
+
+Use `installCommonResolveTargetErrorCases` to add standard error cases for
+channel target resolution:
+
+```typescript
+import { describe } from "vitest";
+import { installCommonResolveTargetErrorCases } from "openclaw/plugin-sdk/testing";
+
+describe("my-channel target resolution", () => {
+ installCommonResolveTargetErrorCases({
+ resolveTarget: ({ to, mode, allowFrom }) => {
+ // Your channel's target resolution logic
+ return myChannelResolveTarget({ to, mode, allowFrom });
+ },
+ implicitAllowFrom: ["user1", "user2"],
+ });
+
+ // Add channel-specific test cases
+ it("should resolve @username targets", () => {
+ // ...
});
});
```
-## Example: shared target-resolution failures
+## Testing patterns
-```ts
-import { installCommonResolveTargetErrorCases } from "openclaw/plugin-sdk/testing";
+### Unit testing a channel plugin
-installCommonResolveTargetErrorCases({
- implicitAllowFrom: ["user-1"],
- resolveTarget({ to, mode, allowFrom }) {
- if (!to?.trim()) {
- return { ok: false, error: new Error("missing target") };
- }
- if (mode === "implicit" && allowFrom.length > 0 && to === "invalid-target") {
- return { ok: false, error: new Error("invalid target") };
- }
- return { ok: true, to };
- },
+```typescript
+import { describe, it, expect, vi } from "vitest";
+
+describe("my-channel plugin", () => {
+ it("should resolve account from config", () => {
+ const cfg = {
+ channels: {
+ "my-channel": {
+ token: "test-token",
+ allowFrom: ["user1"],
+ },
+ },
+ };
+
+ const account = myPlugin.setup.resolveAccount(cfg, undefined);
+ expect(account.token).toBe("test-token");
+ });
+
+ it("should inspect account without materializing secrets", () => {
+ const cfg = {
+ channels: {
+ "my-channel": { token: "test-token" },
+ },
+ };
+
+ const inspection = myPlugin.setup.inspectAccount(cfg, undefined);
+ expect(inspection.configured).toBe(true);
+ expect(inspection.tokenStatus).toBe("available");
+ // No token value exposed
+ expect(inspection).not.toHaveProperty("token");
+ });
});
```
-## Runtime doubles
+### Unit testing a provider plugin
-There is no catch-all `createTestRuntime()` export on the public SDK today.
-Instead:
+```typescript
+import { describe, it, expect } from "vitest";
-- use the public testing helpers where they fit
-- use `plugin-sdk/runtime` for small runtime adapters
-- build tiny plugin-local runtime doubles for the rest
+describe("my-provider plugin", () => {
+ it("should resolve dynamic models", () => {
+ const model = myProvider.resolveDynamicModel({
+ modelId: "custom-model-v2",
+ // ... context
+ });
-Example:
+ expect(model.id).toBe("custom-model-v2");
+ expect(model.provider).toBe("my-provider");
+ expect(model.api).toBe("openai-completions");
+ });
-```ts
-import { createLoggerBackedRuntime } from "openclaw/plugin-sdk/runtime";
+ it("should return catalog when API key is available", async () => {
+ const result = await myProvider.catalog.run({
+ resolveProviderApiKey: () => ({ apiKey: "test-key" }),
+ // ... context
+ });
-const logs: string[] = [];
-
-const runtime = createLoggerBackedRuntime({
- logger: {
- info(message) {
- logs.push(`info:${message}`);
- },
- error(message) {
- logs.push(`error:${message}`);
- },
- },
+ expect(result?.provider?.models).toHaveLength(2);
+ });
});
```
-## Test guidance
+### Mocking the plugin runtime
-- Prefer focused unit tests over giant end-to-end harnesses.
-- Import pure types from focused SDK subpaths in tests.
-- Keep plugin-local test doubles small and explicit.
-- Avoid depending on non-exported OpenClaw test internals.
+For code that uses `createPluginRuntimeStore`, mock the runtime in tests:
+
+```typescript
+import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
+import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
+
+const store = createPluginRuntimeStore("test runtime not set");
+
+// In test setup
+const mockRuntime = {
+ agent: {
+ resolveAgentDir: vi.fn().mockReturnValue("/tmp/agent"),
+ // ... other mocks
+ },
+ config: {
+ loadConfig: vi.fn(),
+ writeConfigFile: vi.fn(),
+ },
+ // ... other namespaces
+} as unknown as PluginRuntime;
+
+store.setRuntime(mockRuntime);
+
+// After tests
+store.clearRuntime();
+```
+
+### Testing with per-instance stubs
+
+Prefer per-instance stubs over prototype mutation:
+
+```typescript
+// Preferred: per-instance stub
+const client = new MyChannelClient();
+client.sendMessage = vi.fn().mockResolvedValue({ id: "msg-1" });
+
+// Avoid: prototype mutation
+// MyChannelClient.prototype.sendMessage = vi.fn();
+```
+
+## Contract tests (in-repo plugins)
+
+Bundled plugins have contract tests that verify registration ownership:
+
+```bash
+pnpm test -- src/plugins/contracts/
+```
+
+These tests assert:
+
+- Which plugins register which providers
+- Which plugins register which speech providers
+- Registration shape correctness
+- Runtime contract compliance
+
+### Running scoped tests
+
+For a specific plugin:
+
+```bash
+pnpm test -- extensions/my-channel/
+```
+
+For contract tests only:
+
+```bash
+pnpm test -- src/plugins/contracts/shape.contract.test.ts
+pnpm test -- src/plugins/contracts/auth.contract.test.ts
+pnpm test -- src/plugins/contracts/runtime.contract.test.ts
+```
+
+## Lint enforcement (in-repo plugins)
+
+Three rules are enforced by `pnpm check` for in-repo plugins:
+
+1. **No monolithic root imports** -- `openclaw/plugin-sdk` root barrel is rejected
+2. **No direct `src/` imports** -- plugins cannot import `../../src/` directly
+3. **No self-imports** -- plugins cannot import their own `plugin-sdk/` subpath
+
+External plugins are not subject to these lint rules, but following the same
+patterns is recommended.
+
+## Test configuration
+
+OpenClaw uses Vitest with V8 coverage thresholds. For plugin tests:
+
+```bash
+# Run all tests
+pnpm test
+
+# Run specific plugin tests
+pnpm test -- extensions/my-channel/src/channel.test.ts
+
+# Run with a specific test name filter
+pnpm test -- extensions/my-channel/ -t "resolves account"
+
+# Run with coverage
+pnpm test:coverage
+```
+
+If local runs cause memory pressure:
+
+```bash
+OPENCLAW_TEST_PROFILE=low OPENCLAW_TEST_SERIAL_GATEWAY=1 pnpm test
+```
## Related
-- [Building Plugins](/plugins/building-plugins)
-- [Plugin SDK Overview](/plugins/sdk-overview)
-- [Plugin Runtime](/plugins/sdk-runtime)
+- [SDK Overview](/plugins/sdk-overview) -- import conventions
+- [SDK Channel Plugins](/plugins/sdk-channel-plugins) -- channel plugin interface
+- [SDK Provider Plugins](/plugins/sdk-provider-plugins) -- provider plugin hooks
+- [Building Plugins](/plugins/building-plugins) -- getting started guide