mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-23 07:51:33 +00:00
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:
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,156 +1,345 @@
|
||||
---
|
||||
title: "Plugin Runtime"
|
||||
sidebarTitle: "Runtime"
|
||||
summary: "How `api.runtime` works, when to use it, and how to manage plugin runtime state safely"
|
||||
title: "Plugin SDK Runtime"
|
||||
sidebarTitle: "Runtime Helpers"
|
||||
summary: "api.runtime -- the injected runtime helpers available to plugins"
|
||||
read_when:
|
||||
- You need to call runtime helpers from a plugin
|
||||
- You are deciding between hooks and injected runtime
|
||||
- You need a safe module-level runtime store
|
||||
- You need to call core helpers from a plugin (TTS, STT, image gen, web search, subagent)
|
||||
- You want to understand what api.runtime exposes
|
||||
- You are accessing config, agent, or media helpers from plugin code
|
||||
---
|
||||
|
||||
# Plugin Runtime
|
||||
# Plugin Runtime Helpers
|
||||
|
||||
Native OpenClaw plugins receive a trusted runtime through `api.runtime`.
|
||||
Reference for the `api.runtime` object injected into every plugin during
|
||||
registration. Use these helpers instead of importing host internals directly.
|
||||
|
||||
Use it for **host-owned operations** that should stay inside OpenClaw’s runtime:
|
||||
<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
|
||||
|
||||
@@ -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 OpenClaw’s allowlist, DM-policy, and secret-input helpers.
|
||||
- Keep setup-entry modules thin; move behavior into `src/`.
|
||||
- Link docs from setup flows when install or auth steps are manual.
|
||||
Channel plugins can opt into deferred loading with:
|
||||
|
||||
```json
|
||||
{
|
||||
"openclaw": {
|
||||
"extensions": ["./index.ts"],
|
||||
"setupEntry": "./setup-entry.ts",
|
||||
"startup": {
|
||||
"deferConfiguredChannelFullLoadUntilAfterListen": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When enabled, OpenClaw loads only `setupEntry` during the pre-listen startup
|
||||
phase, even for already-configured channels. The full entry loads after the
|
||||
gateway starts listening.
|
||||
|
||||
<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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user