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