docs(plugins): add SDK reference and how-to guide pages (#52366)

* docs(plugins): add SDK reference and how-to guide pages

Create 7 new plugin SDK documentation pages:
- sdk-overview: import map, registration API reference
- sdk-entrypoints: definePluginEntry/defineChannelPluginEntry reference
- sdk-runtime: api.runtime namespace reference
- sdk-setup: packaging, manifests, config schemas reference
- sdk-channel-plugins: step-by-step channel plugin how-to
- sdk-provider-plugins: step-by-step provider plugin how-to
- sdk-testing: test utilities and patterns reference

Restructure plugin docs navigation with nested groups:
- Top-level: user-facing pages (Install, Community, Bundles)
- Building Plugins: Getting Started, Channel, Provider
- SDK Reference: Overview, Entry Points, Runtime, Setup, Testing, Migration, Manifest, Internals

Revise existing pages for new IA:
- building-plugins.md: tightened as quick-start, routes to detailed guides
- architecture.md: updated info box with links to new guides
- sdk-migration.md: expanded Related section

* docs(plugins): add Mintlify components (Steps, CodeGroup, Tabs, Accordion, CardGroup)

- Channel plugin guide: wrap walkthrough in Steps, use CodeGroup for
  package.json/manifest, Accordion for createChatChannelPlugin details,
  CardGroup for advanced topics
- Provider plugin guide: wrap walkthrough in Steps, use CodeGroup for
  package files, Tabs for hook examples, Accordion for all-hooks reference
- Getting started: use CardGroup for plugin-type picker and next steps,
  CodeGroup for package/manifest
- SDK Overview: wrap subpath tables in AccordionGroup for scannability

* fix(docs): address PR review feedback on plugin SDK pages

- Remove nonexistent api.runtime.channel.handleInboundMessage call,
  replace with realistic webhook pattern and note about channel-specific
  inbound handling (issue a)
- Fix registrationMode values: 'setup' -> 'setup-only' and 'setup-runtime'
  matching actual PluginRegistrationMode type (issue b)
- Fix createOptionalChannelSetupSurface params: channelId -> channel,
  add required label field (issue c)
- Fix broken anchor links: #multi-capability-providers ->
  #step-5-add-extra-capabilities, #plugin-kinds -> #registration-api (issue d)
- Add missing acmeChatApi import in channel plugin example (issue e)
- Fix undefined provider variable in provider test example (issue f)

* fix(docs): use correct createProviderApiKeyAuthMethod options

Replace incorrect params (provider, validate) with actual required fields
(providerId, methodId, optionKey, flagName, promptMessage) matching
src/plugins/provider-api-key-auth.ts.

* fix(docs): address second round of PR review feedback

- Add required model fields (reasoning, input, cost, contextWindow,
  maxTokens) to catalog example (issue b)
- Fix buildChannelConfigSchema to take a Zod schema argument (issue c)
- Replace fabricated setupWizard steps/run with real ChannelSetupWizard
  contract (channel, status, credentials) (issue d)
- Add required sessionFile/workspaceDir to runEmbeddedPiAgent (issue e)
- Fix wrapStreamFn to return StreamFn from ctx.streamFn (issue f)
This commit is contained in:
Vincent Koc 2026-03-22 11:35:53 -07:00 committed by GitHub
parent 1ed40cdf54
commit 28838802d4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 1982 additions and 1170 deletions

View File

@ -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"
]
}
]
},
{

View File

@ -12,9 +12,12 @@ sidebarTitle: "Internals"
# Plugin Internals
<Info>
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
</Info>
This page covers the internal architecture of the OpenClaw plugin system.

View File

@ -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 <npm-spec>`. 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 <npm-spec>`.
## 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:
<CardGroup cols={3}>
<Card title="Channel plugin" icon="message" href="/plugins/sdk-channel-plugins">
Connect OpenClaw to a messaging platform (Discord, IRC, etc.)
</Card>
<Card title="Provider plugin" icon="microchip" href="/plugins/sdk-provider-plugins">
Add a model provider (LLM, proxy, or custom endpoint)
</Card>
<Card title="Tool / hook plugin" icon="wrench">
Register agent tools, event hooks, or services — continue below
</Card>
</CardGroup>
| 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.
<Steps>
<Step title="Create the package">
Create `package.json` with the `openclaw` metadata block. The structure
depends on what capabilities your plugin provides.
**Channel plugin example:**
```json
<Step title="Create the package and manifest">
<CodeGroup>
```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
}
}
```
</CodeGroup>
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.
</Step>
<Step title="Define the entry point">
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:**
<Step title="Write the entry point">
```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).
</Step>
<Step title="Import from focused SDK subpaths">
Always import from specific `openclaw/plugin-sdk/\<subpath\>` paths. The old
monolithic import is deprecated (see [SDK Migration](/plugins/sdk-migration)).
<Step title="Test and publish">
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";
```
<Accordion title="Common subpaths reference">
| 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 |
</Accordion>
Use the narrowest subpath that matches the job. For the curated map and
examples, see [Plugin SDK Overview](/plugins/sdk-overview).
</Step>
<Step title="Use local modules for internal imports">
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";
```
<Warning>
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.
</Warning>
</Step>
<Step title="Add a plugin manifest">
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.
</Step>
<Step title="Test your plugin">
**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";
```
</Step>
<Step title="Publish and install">
**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 <query>
openclaw plugins install <npm-spec>
pnpm test -- extensions/my-plugin/
```
</Step>
</Steps>
## 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/<subpath>` 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/\<name\>` 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
<Check>**package.json** has correct `openclaw` metadata</Check>
<Check>**openclaw.plugin.json** manifest is present and valid</Check>
<Check>Entry point uses `defineChannelPluginEntry` or `definePluginEntry`</Check>
<Check>All imports use focused `plugin-sdk/\<subpath\>` paths</Check>
<Check>All imports use focused `plugin-sdk/<subpath>` paths</Check>
<Check>Internal imports use local modules, not SDK self-imports</Check>
<Check>`openclaw.plugin.json` manifest is present and valid</Check>
<Check>Tests pass</Check>
<Check>Tests pass (`pnpm test -- extensions/my-plugin/`)</Check>
<Check>`pnpm check` passes (in-repo plugins)</Check>
## 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
<CardGroup cols={2}>
<Card title="Channel Plugins" icon="message" href="/plugins/sdk-channel-plugins">
Build a messaging channel plugin
</Card>
<Card title="Provider Plugins" icon="microchip" href="/plugins/sdk-provider-plugins">
Build a model provider plugin
</Card>
<Card title="SDK Overview" icon="book" href="/plugins/sdk-overview">
Import map and registration API reference
</Card>
<Card title="Runtime Helpers" icon="gear" href="/plugins/sdk-runtime">
TTS, search, subagent via api.runtime
</Card>
<Card title="Testing" icon="flask" href="/plugins/sdk-testing">
Test utilities and patterns
</Card>
<Card title="Plugin Manifest" icon="file-code" href="/plugins/manifest">
Full manifest schema reference
</Card>
</CardGroup>

View File

@ -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
<Info>
If you have not built any OpenClaw plugin before, read
[Getting Started](/plugins/building-plugins) first for the basic package
structure and manifest setup.
</Info>
```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`.
<Steps>
<Step title="Package and manifest">
Create the standard plugin files. The `channel` field in `package.json` is
what makes this a channel plugin:
## Shared `message` tool
<CodeGroup>
```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" }
}
}
}
}
}
}
```
</CodeGroup>
```ts
import { Type } from "@sinclair/typebox";
import { createMessageToolButtonsSchema } from "openclaw/plugin-sdk/channel-actions";
</Step>
export const exampleActions = {
describeMessageTool() {
return {
actions: ["send", "edit"],
capabilities: ["buttons"],
schema: {
visibility: "current-channel",
properties: {
buttons: createMessageToolButtonsSchema(),
threadId: Type.String(),
},
},
<Step title="Build the channel plugin object">
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<string, any>)?.["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<ResolvedAccount>({
base: createChannelPluginBase({
id: "acme-chat",
setup: {
resolveAccount,
inspectAccount(cfg, accountId) {
const section =
(cfg.channels as Record<string, any>)?.["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);
},
},
},
});
},
};
```
<Accordion title="What createChatChannelPlugin does for you">
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.
</Accordion>
</Step>
<Step title="Wire the entry point">
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.
</Step>
<Step title="Add a setup entry">
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.
</Step>
<Step title="Handle inbound messages">
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;
},
});
}
```
<Note>
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.
</Note>
</Step>
<Step title="Test">
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).
</Step>
</Steps>
## 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:
<CardGroup cols={2}>
<Card title="Threading options" href="/plugins/sdk-entrypoints#registration-mode">
Fixed, account-scoped, or custom reply modes
</Card>
<Card title="Message tool integration" href="/plugins/architecture#channel-plugins-and-the-shared-message-tool">
describeMessageTool and action discovery
</Card>
<Card title="Target resolution" href="/plugins/architecture#channel-target-resolution">
inferTargetChatType, looksLikeId, resolveTarget
</Card>
<Card title="Runtime helpers" href="/plugins/sdk-runtime">
TTS, STT, media, subagent via api.runtime
</Card>
</CardGroup>
```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

View File

@ -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
<Tip>
**Looking for a walkthrough?** See [Channel Plugins](/plugins/sdk-channel-plugins)
or [Provider Plugins](/plugins/sdk-provider-plugins) for step-by-step guides.
</Tip>
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 <id>` 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

View File

@ -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

View File

@ -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/<subpath>`.
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.
<Tip>
**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)
</Tip>
## 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` |
<AccordionGroup>
<Accordion title="Channel subpaths">
| 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 |
</Accordion>
<Accordion title="Provider subpaths">
| 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 |
</Accordion>
<Accordion title="Auth and security subpaths">
| 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 |
</Accordion>
<Accordion title="Runtime and storage subpaths">
| 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` |
</Accordion>
<Accordion title="Capability and testing subpaths">
| 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` |
</Accordion>
</AccordionGroup>
## 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<string, unknown>` | Plugin-specific config from `plugins.entries.<id>.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.
<Warning>
Never import your own plugin through `openclaw/plugin-sdk/<your-plugin>`
from production code. Route internal imports through `./api.ts` or
`./runtime-api.ts`. The SDK path is the external contract only.
</Warning>
## 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

View File

@ -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
<Info>
If you have not built any OpenClaw plugin before, read
[Getting Started](/plugins/building-plugins) first for the basic package
structure and manifest setup.
</Info>
```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: [],
<Steps>
<Step title="Package and manifest">
<CodeGroup>
```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 <key>",
"cliDescription": "Acme AI API key"
}
],
"configSchema": {
"type": "object",
"additionalProperties": false
}
}
```
</CodeGroup>
The manifest declares `providerAuthEnvVars` so OpenClaw can detect
credentials without loading your plugin runtime.
</Step>
<Step title="Register the provider">
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 <key>` and select
`acme-ai/acme-large` as their model.
</Step>
<Step title="Add dynamic model resolution">
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.
</Step>
<Step title="Add runtime hooks (as needed)">
Most providers only need `catalog` + `resolveDynamicModel`. Add hooks
incrementally as your provider requires them.
<Tabs>
<Tab title="Token exchange">
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,
};
},
```
</Tab>
<Tab title="Custom headers">
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);
};
},
```
</Tab>
<Tab title="Usage and billing">
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);
},
```
</Tab>
</Tabs>
<Accordion title="All 21 available hooks">
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).
</Accordion>
</Step>
<Step title="Add extra capabilities (optional)">
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).
</Step>
<Step title="Test">
```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();
});
});
```
</Step>
</Steps>
## 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

View File

@ -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 OpenClaws runtime:
<Tip>
**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.
</Tip>
- 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",
});
```
<Warning>
Model overrides (`provider`/`model`) require operator opt-in via
`plugins.entries.<id>.subagent.allowModelOverride: true` in config.
Untrusted plugins can still run subagents, but override requests are rejected.
</Warning>
### `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).
<Info>
`api.runtime.stt.transcribeAudioFile(...)` remains as a compatibility alias
for `api.runtime.mediaUnderstanding.transcribeAudioFile(...)`.
</Info>
### `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<PluginRuntime>("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<string, unknown>` | Plugin-specific config from `plugins.entries.<id>.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

View File

@ -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:
<Tip>
**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).
</Tip>
- `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 OpenClaws 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.
<Warning>
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.
</Warning>
## 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 <query>
openclaw plugins install <npm-spec>
```
<Info>
`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.
</Info>
## 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

View File

@ -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.
<Tip>
**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).
</Tip>
## 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<PluginRuntime>("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/<name>` 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