From 06311f89e03d0a5b02e4fed396b8e30282392d5e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 20 Mar 2026 10:30:30 -0700 Subject: [PATCH 01/24] docs: escape angle brackets in sdk-migration to fix Mintlify MDX build --- docs/plugins/sdk-migration.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/plugins/sdk-migration.md b/docs/plugins/sdk-migration.md index 7ae4e514c94..c02cc53cd3d 100644 --- a/docs/plugins/sdk-migration.md +++ b/docs/plugins/sdk-migration.md @@ -10,7 +10,7 @@ read_when: # Plugin SDK Migration OpenClaw is migrating from a single monolithic `openclaw/plugin-sdk/compat` barrel -to **focused subpath imports** (`openclaw/plugin-sdk/`). This page explains +to **focused subpath imports** (`openclaw/plugin-sdk/\`). This page explains what changed, why, and how to migrate. ## Why this change @@ -31,7 +31,7 @@ If your plugin imports from the compat barrel, you will see: ``` [OPENCLAW_PLUGIN_SDK_COMPAT_DEPRECATED] Warning: openclaw/plugin-sdk/compat is -deprecated for new plugins. Migrate to focused openclaw/plugin-sdk/ imports. +deprecated for new plugins. Migrate to focused openclaw/plugin-sdk/\ imports. ``` The compat barrel still works at runtime. This is a deprecation warning, not an @@ -133,7 +133,7 @@ export { MyConfig } from "./src/config.js"; export { MyRuntime } from "./src/runtime.js"; ``` -Never import your own extension back through `openclaw/plugin-sdk/` +Never import your own extension back through `openclaw/plugin-sdk/\` from production files. That path is for external consumers only. See [Building Extensions](/plugins/building-extensions#step-4-use-local-barrels-for-internal-imports). From 58889f984f59d14b00d4183f9d89192699197fb2 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 20 Mar 2026 10:32:51 -0700 Subject: [PATCH 02/24] docs: set sidebar title to SDK Migration --- docs/plugins/sdk-migration.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/plugins/sdk-migration.md b/docs/plugins/sdk-migration.md index c02cc53cd3d..45c163cd0ed 100644 --- a/docs/plugins/sdk-migration.md +++ b/docs/plugins/sdk-migration.md @@ -1,5 +1,6 @@ --- title: "Plugin SDK Migration" +sidebarTitle: "SDK Migration" summary: "Migrate from openclaw/plugin-sdk/compat to focused subpath imports" read_when: - You see the OPENCLAW_PLUGIN_SDK_COMPAT_DEPRECATED warning From fb3550ef5ece8ee02b9cb33c3d2dacf5ce1fcc15 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 20 Mar 2026 10:17:48 -0700 Subject: [PATCH 03/24] test(sessions): stabilize pruning integration setup --- src/config/sessions/store.pruning.integration.test.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/config/sessions/store.pruning.integration.test.ts b/src/config/sessions/store.pruning.integration.test.ts index 3fde5236294..cba88dda8b9 100644 --- a/src/config/sessions/store.pruning.integration.test.ts +++ b/src/config/sessions/store.pruning.integration.test.ts @@ -10,11 +10,9 @@ vi.mock("../config.js", () => ({ loadConfig: vi.fn().mockReturnValue({}), })); -type StoreModule = typeof import("./store.js"); +import { loadConfig } from "../config.js"; +import { clearSessionStoreCacheForTest, loadSessionStore, saveSessionStore } from "./store.js"; -let clearSessionStoreCacheForTest: StoreModule["clearSessionStoreCacheForTest"]; -let loadSessionStore: StoreModule["loadSessionStore"]; -let saveSessionStore: StoreModule["saveSessionStore"]; let mockLoadConfig: ReturnType; const DAY_MS = 24 * 60 * 60 * 1000; @@ -81,10 +79,6 @@ describe("Integration: saveSessionStore with pruning", () => { }); beforeEach(async () => { - vi.resetModules(); - ({ clearSessionStoreCacheForTest, loadSessionStore, saveSessionStore } = - await import("./store.js")); - const { loadConfig } = await import("../config.js"); mockLoadConfig = vi.mocked(loadConfig) as ReturnType; testDir = await createCaseDir("pruning-integ"); storePath = path.join(testDir, "sessions.json"); From a2e1991ed315968b0a57f1c60d90a05f314c4d7d Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 20 Mar 2026 10:18:20 -0700 Subject: [PATCH 04/24] refactor(plugin-sdk): route bundled runtime barrels through public subpaths --- extensions/line/api.ts | 39 +++++++++++++++++++++++++ extensions/line/runtime-api.ts | 15 +++++++--- package.json | 8 +++++ scripts/lib/plugin-sdk-entrypoints.json | 2 ++ src/plugin-sdk/core.ts | 1 + src/plugin-sdk/line-core.ts | 12 +++++++- 6 files changed, 72 insertions(+), 5 deletions(-) diff --git a/extensions/line/api.ts b/extensions/line/api.ts index 35d637bcc56..a6982e83f9f 100644 --- a/extensions/line/api.ts +++ b/extensions/line/api.ts @@ -1,2 +1,41 @@ +export type { + ChannelPlugin, + OpenClawConfig, + OpenClawPluginApi, + PluginRuntime, +} from "openclaw/plugin-sdk/core"; +export { buildChannelConfigSchema, clearAccountEntryFields } from "openclaw/plugin-sdk/core"; +export type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; +export type { ChannelAccountSnapshot, ChannelGatewayContext } from "openclaw/plugin-sdk/testing"; +export type { ChannelStatusIssue } from "openclaw/plugin-sdk/channel-runtime"; +export { + buildComputedAccountStatusSnapshot, + buildTokenChannelStatusSummary, +} from "openclaw/plugin-sdk/channel-runtime"; +export type { + CardAction, + LineChannelData, + LineConfig, + ListItem, + ResolvedLineAccount, +} from "openclaw/plugin-sdk/line-core"; +export { + createActionCard, + createImageCard, + createInfoCard, + createListCard, + createReceiptCard, + DEFAULT_ACCOUNT_ID, + formatDocsLink, + LineConfigSchema, + listLineAccountIds, + normalizeAccountId, + processLineMessage, + resolveDefaultLineAccountId, + resolveExactLineGroupConfigKey, + resolveLineAccount, + setSetupChannelEnabled, + splitSetupEntries, +} from "openclaw/plugin-sdk/line-core"; export * from "./runtime-api.js"; export * from "./setup-api.js"; diff --git a/extensions/line/runtime-api.ts b/extensions/line/runtime-api.ts index b40e5c76e0e..e439c4020b0 100644 --- a/extensions/line/runtime-api.ts +++ b/extensions/line/runtime-api.ts @@ -1,12 +1,19 @@ // Private runtime barrel for the bundled LINE extension. // Keep this barrel thin and aligned with the local extension surface. -export * from "openclaw/plugin-sdk/line"; -export { resolveExactLineGroupConfigKey } from "openclaw/plugin-sdk/line-core"; +export type { OpenClawConfig } from "openclaw/plugin-sdk/line-core"; +export type { ChannelSetupDmPolicy, ChannelSetupWizard } from "openclaw/plugin-sdk/channel-setup"; +export type { LineConfig, ResolvedLineAccount } from "openclaw/plugin-sdk/line-core"; +export { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema"; export { + DEFAULT_ACCOUNT_ID, formatDocsLink, + LineConfigSchema, + listLineAccountIds, + normalizeAccountId, + resolveDefaultLineAccountId, + resolveExactLineGroupConfigKey, + resolveLineAccount, setSetupChannelEnabled, splitSetupEntries, - type ChannelSetupDmPolicy, - type ChannelSetupWizard, } from "openclaw/plugin-sdk/line-core"; diff --git a/package.json b/package.json index 91abc6172a7..0a560e9a933 100644 --- a/package.json +++ b/package.json @@ -501,6 +501,14 @@ "types": "./dist/plugin-sdk/web-media.d.ts", "default": "./dist/plugin-sdk/web-media.js" }, + "./plugin-sdk/zalo": { + "types": "./dist/plugin-sdk/zalo.d.ts", + "default": "./dist/plugin-sdk/zalo.js" + }, + "./plugin-sdk/zalouser": { + "types": "./dist/plugin-sdk/zalouser.d.ts", + "default": "./dist/plugin-sdk/zalouser.js" + }, "./plugin-sdk/speech": { "types": "./dist/plugin-sdk/speech.d.ts", "default": "./dist/plugin-sdk/speech.js" diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index 1dc306bd9b7..0b322853e02 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -115,6 +115,8 @@ "secret-input", "thread-ownership", "web-media", + "zalo", + "zalouser", "speech", "state-paths", "tool-send" diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index c8c7980fbd2..6ed9704cfa8 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -85,6 +85,7 @@ export { migrateBaseNameToDefaultAccount, } from "../channels/plugins/setup-helpers.js"; export { + clearAccountEntryFields, deleteAccountFromConfigSection, setAccountEnabledInConfigSection, } from "../channels/plugins/config-helpers.js"; diff --git a/src/plugin-sdk/line-core.ts b/src/plugin-sdk/line-core.ts index 596593fc8f4..083e9ddcbe0 100644 --- a/src/plugin-sdk/line-core.ts +++ b/src/plugin-sdk/line-core.ts @@ -1,5 +1,5 @@ export type { OpenClawConfig } from "../config/config.js"; -export type { LineConfig } from "../line/types.js"; +export type { LineChannelData, LineConfig } from "../line/types.js"; export { createTopLevelChannelDmPolicy, DEFAULT_ACCOUNT_ID, @@ -18,3 +18,13 @@ export { export { resolveExactLineGroupConfigKey } from "../line/group-keys.js"; export type { ResolvedLineAccount } from "../line/types.js"; export { LineConfigSchema } from "../line/config-schema.js"; +export { + createActionCard, + createImageCard, + createInfoCard, + createListCard, + createReceiptCard, + type CardAction, + type ListItem, +} from "../line/flex-templates.js"; +export { processLineMessage } from "../line/markdown-to-line.js"; From 5b7ae24e301e13be11ab01edfb90b228a6b08470 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 20 Mar 2026 10:18:32 -0700 Subject: [PATCH 05/24] test(msteams): align adapter doubles with interfaces --- extensions/msteams/src/graph-upload.test.ts | 2 +- extensions/msteams/src/messenger.test.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/extensions/msteams/src/graph-upload.test.ts b/extensions/msteams/src/graph-upload.test.ts index 43a66e95c3f..45c736e2e1d 100644 --- a/extensions/msteams/src/graph-upload.test.ts +++ b/extensions/msteams/src/graph-upload.test.ts @@ -141,7 +141,7 @@ describe("resolveGraphChatId", () => { }), ); // Should filter by user AAD object ID - const callUrl = (fetchFn.mock.calls[0] as unknown as [string, unknown])[0]; + const callUrl = (fetchFn.mock.calls[0] as unknown[])[0]; expect(callUrl).toContain("user-aad-object-id-123"); expect(result).toBe("19:dm-chat-id@unq.gbl.spaces"); }); diff --git a/extensions/msteams/src/messenger.test.ts b/extensions/msteams/src/messenger.test.ts index 92f161341de..719be4ab18c 100644 --- a/extensions/msteams/src/messenger.test.ts +++ b/extensions/msteams/src/messenger.test.ts @@ -375,8 +375,13 @@ describe("msteams messenger", () => { await logic({ sendActivity: createRecordedSendActivity(attempts, 503) }); }, process: async () => {}, +<<<<<<< HEAD updateActivity: noopUpdateActivity, deleteActivity: noopDeleteActivity, +======= + updateActivity: async () => {}, + deleteActivity: async () => {}, +>>>>>>> a94afa5baa (test(msteams): align adapter doubles with interfaces) }; const ids = await sendMSTeamsMessages({ From 42801f61780439e7af5b4b5c4d36ce44b49eae50 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 20 Mar 2026 10:38:21 -0700 Subject: [PATCH 06/24] fix(plugin-sdk): dedupe rebased zalo export entries --- package.json | 8 -------- scripts/lib/plugin-sdk-entrypoints.json | 2 -- 2 files changed, 10 deletions(-) diff --git a/package.json b/package.json index 0a560e9a933..646027a2cb5 100644 --- a/package.json +++ b/package.json @@ -257,14 +257,6 @@ "types": "./dist/plugin-sdk/voice-call.d.ts", "default": "./dist/plugin-sdk/voice-call.js" }, - "./plugin-sdk/zalo": { - "types": "./dist/plugin-sdk/zalo.d.ts", - "default": "./dist/plugin-sdk/zalo.js" - }, - "./plugin-sdk/zalouser": { - "types": "./dist/plugin-sdk/zalouser.d.ts", - "default": "./dist/plugin-sdk/zalouser.js" - }, "./plugin-sdk/imessage": { "types": "./dist/plugin-sdk/imessage.d.ts", "default": "./dist/plugin-sdk/imessage.js" diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index 0b322853e02..57ccd34d3a6 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -54,8 +54,6 @@ "tlon", "twitch", "voice-call", - "zalo", - "zalouser", "imessage", "imessage-core", "whatsapp", From 23a119c6ea7803b2312ff1f483d706638786494c Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 20 Mar 2026 10:38:34 -0700 Subject: [PATCH 07/24] test(msteams): clear remaining rebase conflict hunk --- extensions/msteams/src/messenger.test.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/extensions/msteams/src/messenger.test.ts b/extensions/msteams/src/messenger.test.ts index 719be4ab18c..92f161341de 100644 --- a/extensions/msteams/src/messenger.test.ts +++ b/extensions/msteams/src/messenger.test.ts @@ -375,13 +375,8 @@ describe("msteams messenger", () => { await logic({ sendActivity: createRecordedSendActivity(attempts, 503) }); }, process: async () => {}, -<<<<<<< HEAD updateActivity: noopUpdateActivity, deleteActivity: noopDeleteActivity, -======= - updateActivity: async () => {}, - deleteActivity: async () => {}, ->>>>>>> a94afa5baa (test(msteams): align adapter doubles with interfaces) }; const ids = await sendMSTeamsMessages({ From 1cabb053ad07bd20c61c73c11214ecf96415563a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 20 Mar 2026 17:43:15 +0000 Subject: [PATCH 08/24] test: lazy-load default setup registry --- test/setup.ts | 120 +++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 104 insertions(+), 16 deletions(-) diff --git a/test/setup.ts b/test/setup.ts index f0e1bdc4549..1d4429d48d7 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -29,24 +29,48 @@ import type { } from "../src/channels/plugins/types.js"; import type { OpenClawConfig } from "../src/config/config.js"; import type { OutboundSendDeps } from "../src/infra/outbound/deliver.js"; +import { installProcessWarningFilter } from "../src/infra/warning-filter.js"; +import type { PluginRegistry } from "../src/plugins/registry.js"; import { withIsolatedTestHome } from "./test-env.js"; // Set HOME/state isolation before importing any runtime OpenClaw modules. const testEnv = withIsolatedTestHome(); afterAll(() => testEnv.cleanup()); -const [ - { installProcessWarningFilter }, - { getActivePluginRegistry, setActivePluginRegistry }, - { createTestRegistry }, -] = await Promise.all([ - import("../src/infra/warning-filter.js"), - import("../src/plugins/runtime.js"), - import("../src/test-utils/channel-plugins.js"), -]); - installProcessWarningFilter(); +const REGISTRY_STATE = Symbol.for("openclaw.pluginRegistryState"); + +type RegistryState = { + registry: PluginRegistry | null; + httpRouteRegistry: PluginRegistry | null; + httpRouteRegistryPinned: boolean; + key: string | null; + version: number; +}; + +type TestChannelRegistration = { + pluginId: string; + plugin: unknown; + source: string; +}; + +const globalRegistryState = (() => { + const globalState = globalThis as typeof globalThis & { + [REGISTRY_STATE]?: RegistryState; + }; + if (!globalState[REGISTRY_STATE]) { + globalState[REGISTRY_STATE] = { + registry: null, + httpRouteRegistry: null, + httpRouteRegistryPinned: false, + key: null, + version: 0, + }; + } + return globalState[REGISTRY_STATE]; +})(); + const pickSendFn = (id: ChannelId, deps?: OutboundSendDeps) => { return deps?.[id] as ((...args: unknown[]) => Promise) | undefined; }; @@ -127,6 +151,32 @@ const createStubPlugin = (params: { outbound: createStubOutbound(params.id, params.deliveryMode), }); +const createTestRegistry = (channels: TestChannelRegistration[] = []): PluginRegistry => ({ + plugins: [], + tools: [], + hooks: [], + typedHooks: [], + channels: channels as unknown as PluginRegistry["channels"], + channelSetups: channels.map((entry) => ({ + pluginId: entry.pluginId, + plugin: entry.plugin as PluginRegistry["channelSetups"][number]["plugin"], + source: entry.source, + enabled: true, + })), + providers: [], + speechProviders: [], + mediaUnderstandingProviders: [], + imageGenerationProviders: [], + webSearchProviders: [], + gatewayHandlers: {}, + httpRoutes: [], + cliRegistrars: [], + services: [], + commands: [], + conversationBindingResolvedHandlers: [], + diagnostics: [], +}); + const createDefaultRegistry = () => createTestRegistry([ { @@ -174,17 +224,55 @@ const createDefaultRegistry = () => }, ]); -// Creating a fresh registry before every test is measurable overhead. -// The registry is immutable by default; tests that override it are restored in afterEach. -const DEFAULT_PLUGIN_REGISTRY = createDefaultRegistry(); +let materializedDefaultPluginRegistry: PluginRegistry | null = null; + +function getDefaultPluginRegistry(): PluginRegistry { + materializedDefaultPluginRegistry ??= createDefaultRegistry(); + return materializedDefaultPluginRegistry; +} + +// Most unit suites never touch the plugin registry. Keep the default test registry +// behind a lazy proxy so those files avoid allocating channel fixtures up front. +const DEFAULT_PLUGIN_REGISTRY = new Proxy({} as PluginRegistry, { + defineProperty(_target, property, attributes) { + return Reflect.defineProperty(getDefaultPluginRegistry() as object, property, attributes); + }, + deleteProperty(_target, property) { + return Reflect.deleteProperty(getDefaultPluginRegistry() as object, property); + }, + get(_target, property, receiver) { + return Reflect.get(getDefaultPluginRegistry() as object, property, receiver); + }, + getOwnPropertyDescriptor(_target, property) { + return Reflect.getOwnPropertyDescriptor(getDefaultPluginRegistry() as object, property); + }, + has(_target, property) { + return Reflect.has(getDefaultPluginRegistry() as object, property); + }, + ownKeys() { + return Reflect.ownKeys(getDefaultPluginRegistry() as object); + }, + set(_target, property, value, receiver) { + return Reflect.set(getDefaultPluginRegistry() as object, property, value, receiver); + }, +}); + +function installDefaultPluginRegistry(): void { + globalRegistryState.registry = DEFAULT_PLUGIN_REGISTRY; + if (!globalRegistryState.httpRouteRegistryPinned) { + globalRegistryState.httpRouteRegistry = DEFAULT_PLUGIN_REGISTRY; + } +} beforeAll(() => { - setActivePluginRegistry(DEFAULT_PLUGIN_REGISTRY); + installDefaultPluginRegistry(); }); afterEach(() => { - if (getActivePluginRegistry() !== DEFAULT_PLUGIN_REGISTRY) { - setActivePluginRegistry(DEFAULT_PLUGIN_REGISTRY); + if (globalRegistryState.registry !== DEFAULT_PLUGIN_REGISTRY) { + installDefaultPluginRegistry(); + globalRegistryState.key = null; + globalRegistryState.version += 1; } // Guard against leaked fake timers across test files/workers. if (vi.isFakeTimers()) { From ad4536fd7eb165b973f82c2031b2df2f7390e622 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 20 Mar 2026 10:44:11 -0700 Subject: [PATCH 09/24] docs: rename Extensions to Plugins, rewrite building guide as capability-agnostic, move voice-call to Channels --- docs/docs.json | 5 +- docs/plugins/building-extensions.md | 255 ++++++++++++++++++---------- docs/plugins/sdk-migration.md | 12 +- 3 files changed, 178 insertions(+), 94 deletions(-) diff --git a/docs/docs.json b/docs/docs.json index 65e4ed25c1b..2b8d8d84a0c 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -948,6 +948,7 @@ "channels/telegram", "channels/tlon", "channels/twitch", + "plugins/voice-call", "channels/whatsapp", "channels/zalo", "channels/zalouser" @@ -1073,15 +1074,13 @@ ] }, { - "group": "Extensions", + "group": "Plugins", "pages": [ "plugins/building-extensions", "plugins/sdk-migration", "plugins/architecture", "plugins/community", "plugins/bundles", - "plugins/voice-call", - "plugins/zalouser", "plugins/manifest", "plugins/agent-tools", "tools/capability-cookbook" diff --git a/docs/plugins/building-extensions.md b/docs/plugins/building-extensions.md index 7b4548194cd..026ac4492de 100644 --- a/docs/plugins/building-extensions.md +++ b/docs/plugins/building-extensions.md @@ -1,79 +1,116 @@ --- -title: "Building Extensions" -summary: "Step-by-step guide for creating OpenClaw channel and provider extensions" +title: "Building Plugins" +sidebarTitle: "Building Plugins" +summary: "Step-by-step guide for creating OpenClaw plugins with any combination of capabilities" read_when: - - You want to create a new OpenClaw plugin or extension + - You want to create a new OpenClaw plugin - You need to understand the plugin SDK import patterns - - You are adding a new channel or provider to OpenClaw + - You are adding a new channel, provider, tool, or other capability to OpenClaw --- -# Building Extensions +# Building Plugins -Extensions add channels, model providers, tools, or other capabilities to OpenClaw. -This guide walks through creating one from scratch. +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. + +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. ## Prerequisites -- OpenClaw repository cloned and dependencies installed (`pnpm install`) +- Node >= 22 and a package manager (npm or pnpm) - Familiarity with TypeScript (ESM) +- For in-repo plugins: OpenClaw repository cloned and `pnpm install` done -## Extension structure +## Plugin capabilities -Every extension lives under `extensions//` and follows this layout: +A plugin can register one or more capabilities. The capability you register +determines what your plugin provides to OpenClaw: + +| 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 | + +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): ``` -extensions/my-channel/ +my-plugin/ ├── package.json # npm metadata + openclaw config -├── index.ts # Entry point (defineChannelPluginEntry) +├── openclaw.plugin.json # Plugin manifest +├── index.ts # Entry point ├── setup-entry.ts # Setup wizard (optional) -├── api.ts # Public contract barrel (optional) -├── runtime-api.ts # Internal runtime barrel (optional) +├── api.ts # Public exports (optional) +├── runtime-api.ts # Internal exports (optional) └── src/ - ├── channel.ts # Channel adapter implementation + ├── provider.ts # Capability implementation ├── runtime.ts # Runtime wiring └── *.test.ts # Colocated tests ``` -## Create an extension +## Create a plugin - Create `extensions/my-channel/package.json`: + Create `package.json` with the `openclaw` metadata block. The structure + depends on what capabilities your plugin provides. + + **Channel plugin example:** ```json { - "name": "@openclaw/my-channel", - "version": "2026.1.1", - "description": "OpenClaw My Channel plugin", + "name": "@myorg/openclaw-my-channel", + "version": "1.0.0", "type": "module", - "dependencies": {}, "openclaw": { "extensions": ["./index.ts"], - "setupEntry": "./setup-entry.ts", "channel": { "id": "my-channel", "label": "My Channel", - "selectionLabel": "My Channel (plugin)", - "docsPath": "/channels/my-channel", - "docsLabel": "my-channel", - "blurb": "Short description of the channel.", - "order": 80 - }, - "install": { - "npmSpec": "@openclaw/my-channel", - "localPath": "extensions/my-channel" + "blurb": "Short description of the channel." } } } ``` - The `openclaw` field tells the plugin system what your extension provides. - For provider plugins, use `providers` instead of `channel`. + **Provider plugin example:** + + ```json + { + "name": "@myorg/openclaw-my-provider", + "version": "1.0.0", + "type": "module", + "openclaw": { + "extensions": ["./index.ts"], + "providers": ["my-provider"] + } + } + ``` + + The `openclaw` field tells the plugin system what your plugin provides. + A plugin can declare both `channel` and `providers` if it provides multiple + capabilities. - Create `extensions/my-channel/index.ts`: + The entry point registers your capabilities with the plugin API. + + **Channel plugin:** ```typescript import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core"; @@ -88,23 +125,51 @@ extensions/my-channel/ }); ``` - For provider plugins, use `definePluginEntry` instead. + **Provider plugin:** + + ```typescript + import { definePluginEntry } from "openclaw/plugin-sdk/core"; + + export default definePluginEntry({ + id: "my-provider", + name: "My Provider", + register(api) { + api.registerProvider({ + // Provider implementation + }); + }, + }); + ``` + + **Multi-capability plugin** (provider + tool): + + ```typescript + import { definePluginEntry } from "openclaw/plugin-sdk/core"; + + export default definePluginEntry({ + id: "my-plugin", + name: "My Plugin", + register(api) { + api.registerProvider({ /* ... */ }); + api.registerTool({ /* ... */ }); + api.registerImageGenerationProvider({ /* ... */ }); + }, + }); + ``` + + Use `defineChannelPluginEntry` for channel plugins and `definePluginEntry` + for everything else. A single plugin can register as many capabilities as needed. - - Always import from specific `openclaw/plugin-sdk/` paths rather than - the monolithic root. The old `openclaw/plugin-sdk/compat` barrel is deprecated - (see [SDK Migration](/plugins/sdk-migration)). + + Always import from specific `openclaw/plugin-sdk/\` paths. The old + monolithic import is deprecated (see [SDK Migration](/plugins/sdk-migration)). ```typescript // Correct: focused subpaths - import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core"; - import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; - import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing"; + import { definePluginEntry } from "openclaw/plugin-sdk/core"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; - import { createOptionalChannelSetupSurface } from "openclaw/plugin-sdk/channel-setup"; - import { resolveChannelGroupRequireMention } from "openclaw/plugin-sdk/channel-policy"; import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-oauth"; // Wrong: monolithic root (lint will reject this) @@ -114,10 +179,10 @@ extensions/my-channel/ | Subpath | Purpose | | --- | --- | - | `plugin-sdk/core` | Plugin entry definitions, base types | - | `plugin-sdk/channel-setup` | Optional setup adapters/wizards | + | `plugin-sdk/core` | Plugin entry definitions and base types | + | `plugin-sdk/channel-setup` | Setup wizard adapters | | `plugin-sdk/channel-pairing` | DM pairing primitives | - | `plugin-sdk/channel-reply-pipeline` | Prefix + typing reply wiring | + | `plugin-sdk/channel-reply-pipeline` | Reply prefix + typing wiring | | `plugin-sdk/channel-config-schema` | Config schema builders | | `plugin-sdk/channel-policy` | Group/DM policy helpers | | `plugin-sdk/secret-input` | Secret input parsing/helpers | @@ -130,95 +195,115 @@ extensions/my-channel/ | `plugin-sdk/testing` | Test utilities | - Use the narrowest primitive that matches the job. Reach for `channel-runtime` - or other larger helper barrels only when a dedicated subpath does not exist yet. + Use the narrowest subpath that matches the job. - - Within your extension, create barrel files for internal code sharing instead - of importing through the plugin SDK: + + Within your plugin, create local module files for internal code sharing + instead of re-importing through the plugin SDK: ```typescript - // api.ts — public contract for this extension - export { MyChannelConfig } from "./src/config.js"; - export { MyChannelRuntime } from "./src/runtime.js"; + // 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 (not for production consumers) + // runtime-api.ts — internal-only exports export { internalHelper } from "./src/helpers.js"; ``` - Never import your own extension back through its published SDK contract - path from production files. Route internal imports through `./api.ts` or - `./runtime-api.ts` instead. The SDK contract is for external consumers only. + 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 extension root: + Create `openclaw.plugin.json` in your plugin root: ```json { - "id": "my-channel", - "kind": "channel", - "channels": ["my-channel"], - "name": "My Channel Plugin", - "description": "Connects OpenClaw to My Channel" + "id": "my-plugin", + "kind": "provider", + "name": "My Plugin", + "description": "Adds My Provider to OpenClaw" } ``` - See [Plugin manifest](/plugins/manifest) for the full schema. + For channel plugins, set `"kind": "channel"` and add `"channels": ["my-channel"]`. + + See [Plugin Manifest](/plugins/manifest) for the full schema. - - OpenClaw runs contract tests against all registered plugins. After adding your - extension, run: + + **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 ``` - Contract tests verify your plugin conforms to the expected interface (setup - wizard, session binding, message handling, group policy, etc.). - - For unit tests, import test helpers from the public testing surface: + For unit tests, import test helpers from the testing surface: ```typescript import { createTestRuntime } from "openclaw/plugin-sdk/testing"; ``` + + + **External plugins:** publish to npm, then install: + + ```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: + + ```bash + openclaw plugins search + openclaw plugins install + ``` + + -## Lint enforcement +## Lint enforcement (in-repo plugins) -Three scripts enforce SDK boundaries: +Three scripts enforce SDK boundaries for plugins in the OpenClaw repository: 1. **No monolithic root imports** — `openclaw/plugin-sdk` root is rejected -2. **No direct src/ imports** — extensions cannot import `../../src/` directly -3. **No self-imports** — extensions cannot import their own `plugin-sdk/` subpath +2. **No direct src/ imports** — plugins cannot import `../../src/` directly +3. **No self-imports** — plugins cannot import their own `plugin-sdk/\` subpath Run `pnpm check` to verify all boundaries before committing. +External plugins are not subject to these lint rules, but following the same +patterns is strongly recommended. + ## Pre-submission checklist **package.json** has correct `openclaw` metadata Entry point uses `defineChannelPluginEntry` or `definePluginEntry` -All imports use focused `plugin-sdk/` paths -Internal imports use local barrels, not SDK self-imports +All imports use focused `plugin-sdk/\` paths +Internal imports use local modules, not SDK self-imports `openclaw.plugin.json` manifest is present and valid -Contract tests pass (`pnpm test:contracts`) -Unit tests colocated as `*.test.ts` -`pnpm check` passes (lint + format) -Doc page created under `docs/channels/` or `docs/plugins/` +Tests pass +`pnpm check` passes (in-repo plugins) ## Related -- [Plugin SDK Migration](/plugins/sdk-migration) — migrating from compat to focused subpaths +- [Plugin SDK Migration](/plugins/sdk-migration) — migrating from the deprecated compat import - [Plugin Architecture](/plugins/architecture) — internals and capability model - [Plugin Manifest](/plugins/manifest) — full manifest schema -- [Community Plugins](/plugins/community) — existing community extensions +- [Plugin Agent Tools](/plugins/agent-tools) — adding agent tools in a plugin +- [Community Plugins](/plugins/community) — listing and quality bar diff --git a/docs/plugins/sdk-migration.md b/docs/plugins/sdk-migration.md index 45c163cd0ed..fb745e46e91 100644 --- a/docs/plugins/sdk-migration.md +++ b/docs/plugins/sdk-migration.md @@ -16,7 +16,7 @@ what changed, why, and how to migrate. ## Why this change -The monolithic compat barrel re-exported everything from a single entry point. +The monolithic compat entry re-exported everything from a single entry point. This caused: - **Slow startup**: importing one helper pulled in dozens of unrelated modules. @@ -28,14 +28,14 @@ with a clear purpose. ## What triggers the warning -If your plugin imports from the compat barrel, you will see: +If your plugin imports from the compat entry, you will see: ``` [OPENCLAW_PLUGIN_SDK_COMPAT_DEPRECATED] Warning: openclaw/plugin-sdk/compat is deprecated for new plugins. Migrate to focused openclaw/plugin-sdk/\ imports. ``` -The compat barrel still works at runtime. This is a deprecation warning, not an +The compat entry still works at runtime. This is a deprecation warning, not an error. But new plugins **must not** use it, and existing plugins should migrate before compat is removed. @@ -54,7 +54,7 @@ grep -r "plugin-sdk/compat" extensions/my-plugin/ Each export from compat maps to a specific subpath. Replace the import source: ```typescript -// Before (compat barrel) +// Before (compat entry) import { createChannelReplyPipeline, createPluginRuntimeStore, @@ -106,8 +106,8 @@ check the source at `src/plugin-sdk/` or ask in Discord. ## Compat barrel removal timeline -- **Now**: compat barrel emits a deprecation warning at runtime. -- **Next major release**: compat barrel will be removed. Plugins still using it will +- **Now**: compat entry emits a deprecation warning at runtime. +- **Next major release**: compat entry will be removed. Plugins still using it will fail to import. Bundled plugins (under `extensions/`) have already been migrated. External plugins From c64893a9c2da66779a24be40c1aab0a7c31fba68 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 20 Mar 2026 10:52:40 -0700 Subject: [PATCH 10/24] fix(config): use static channel metadata in docs baseline (#51161) --- src/config/doc-baseline.integration.test.ts | 20 ++++++++++ src/config/doc-baseline.ts | 44 +++++++++++++++------ 2 files changed, 53 insertions(+), 11 deletions(-) diff --git a/src/config/doc-baseline.integration.test.ts b/src/config/doc-baseline.integration.test.ts index 1cb81623889..71c95d03f07 100644 --- a/src/config/doc-baseline.integration.test.ts +++ b/src/config/doc-baseline.integration.test.ts @@ -70,6 +70,26 @@ describe("config doc baseline integration", () => { expect(tokenEntry?.tags).toContain("security"); }); + it("uses human-readable channel metadata for top-level channel sections", async () => { + const baseline = await getSharedBaseline(); + const byPath = new Map(baseline.entries.map((entry) => [entry.path, entry])); + + expect(byPath.get("channels.discord")).toMatchObject({ + label: "Discord", + help: "very well supported right now.", + }); + expect(byPath.get("channels.msteams")).toMatchObject({ + label: "Microsoft Teams", + help: "Bot Framework; enterprise support.", + }); + expect(byPath.get("channels.matrix")).toMatchObject({ + label: "Matrix", + help: "open protocol; install the plugin to enable.", + }); + expect(byPath.get("channels.msteams")?.label).not.toContain("@openclaw/"); + expect(byPath.get("channels.matrix")?.help).not.toContain("homeserver"); + }); + it("matches array help hints that still use [] notation", async () => { const baseline = await getSharedBaseline(); const byPath = new Map(baseline.entries.map((entry) => [entry.path, entry])); diff --git a/src/config/doc-baseline.ts b/src/config/doc-baseline.ts index 1603fa3dd1b..1aa996fd5b6 100644 --- a/src/config/doc-baseline.ts +++ b/src/config/doc-baseline.ts @@ -269,7 +269,12 @@ function resolveFirstExistingPath(candidates: string[]): string | null { } async function loadBundledConfigSchemaResponse(): Promise { - const [{ loadPluginManifestRegistry }, { buildConfigSchema }] = await Promise.all([ + const [ + { listChannelPluginCatalogEntries }, + { loadPluginManifestRegistry }, + { buildConfigSchema }, + ] = await Promise.all([ + import("../channels/plugins/catalog.js"), import("../plugins/manifest-registry.js"), import("./schema.js"), ]); @@ -286,6 +291,12 @@ async function loadBundledConfigSchemaResponse(): Promise env, config: {}, }); + const channelCatalogById = new Map( + listChannelPluginCatalogEntries({ + workspaceDir: repoRoot, + env, + }).map((entry) => [entry.id, entry.meta] as const), + ); logConfigDocBaselineDebug(`loaded ${manifestRegistry.plugins.length} bundled plugin manifests`); const bundledChannelPlugins = manifestRegistry.plugins.filter( (plugin) => plugin.origin === "bundled" && plugin.channels.length > 0, @@ -295,16 +306,20 @@ async function loadBundledConfigSchemaResponse(): Promise ? await bundledChannelPlugins.reduce>( async (promise, plugin) => { const loaded = await promise; + const catalogMeta = channelCatalogById.get(plugin.id); + const label = catalogMeta?.label ?? plugin.name ?? plugin.id; + const description = catalogMeta?.blurb ?? plugin.description; loaded.push( (await loadChannelSurfaceMetadata( plugin.rootDir, plugin.id, - plugin.name ?? plugin.id, + label, + description, repoRoot, )) ?? { id: plugin.id, - label: plugin.name ?? plugin.id, - description: plugin.description, + label, + description, configSchema: plugin.configSchema, configUiHints: plugin.configUiHints, }, @@ -314,21 +329,26 @@ async function loadBundledConfigSchemaResponse(): Promise Promise.resolve([]), ) : await Promise.all( - bundledChannelPlugins.map( - async (plugin) => + bundledChannelPlugins.map(async (plugin) => { + const catalogMeta = channelCatalogById.get(plugin.id); + const label = catalogMeta?.label ?? plugin.name ?? plugin.id; + const description = catalogMeta?.blurb ?? plugin.description; + return ( (await loadChannelSurfaceMetadata( plugin.rootDir, plugin.id, - plugin.name ?? plugin.id, + label, + description, repoRoot, )) ?? { id: plugin.id, - label: plugin.name ?? plugin.id, - description: plugin.description, + label, + description, configSchema: plugin.configSchema, configUiHints: plugin.configUiHints, - }, - ), + } + ); + }), ); logConfigDocBaselineDebug( `loaded ${channelPlugins.length} bundled channel entries from channel surfaces`, @@ -359,6 +379,7 @@ async function loadChannelSurfaceMetadata( rootDir: string, id: string, label: string, + description: string | undefined, repoRoot: string, ): Promise { logConfigDocBaselineDebug(`resolve channel config surface ${rootDir}`); @@ -386,6 +407,7 @@ async function loadChannelSurfaceMetadata( return { id, label, + description, configSchema: configSurface.schema, configUiHints: configSurface.uiHints as ConfigSchemaResponse["uiHints"] | undefined, }; From 4e45a663e79c897b948de3f9f5c673d1f2cf39d5 Mon Sep 17 00:00:00 2001 From: HCL Date: Fri, 20 Mar 2026 13:13:01 +0800 Subject: [PATCH 11/24] fix(telegram): prevent silent wrong-bot routing when accountId not in config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a non-default accountId is specified but not found in the accounts config, resolveTelegramToken() falls through to channel-level defaults (botToken, tokenFile, env) — silently routing messages via the wrong bot's token. This is a cross-bot message leak with no error or warning. Root cause: extensions/telegram/src/token.ts:44-46, resolveAccountCfg() returns undefined for unknown accountIds but code continues to fallbacks. Introduced in e5bca0832f when Telegram moved to extensions/. Fix: return { token: "", source: "none" } with a diagnostic log when a non-default accountId is not found. Existing behavior for known accounts (with or without per-account tokens) preserved. Test: added "does not fall through when non-default accountId not in config" — 1/1 new, 10/10 existing unaffected. Closes #49383 Co-Authored-By: Claude Opus 4.6 Signed-off-by: HCL --- extensions/telegram/src/token.test.ts | 18 ++++++++++++++++++ extensions/telegram/src/token.ts | 11 +++++++++++ 2 files changed, 29 insertions(+) diff --git a/extensions/telegram/src/token.test.ts b/extensions/telegram/src/token.test.ts index c81e5d57b2c..74218f83ddd 100644 --- a/extensions/telegram/src/token.test.ts +++ b/extensions/telegram/src/token.test.ts @@ -188,6 +188,24 @@ describe("resolveTelegramToken", () => { expect(res.source).toBe("none"); }); + it("does not fall through to channel-level token when non-default accountId is not in config", () => { + vi.stubEnv("TELEGRAM_BOT_TOKEN", ""); + const cfg = { + channels: { + telegram: { + botToken: "wrong-bot-token", + accounts: { + knownBot: { botToken: "known-bot-token" }, + }, + }, + }, + } as OpenClawConfig; + + const res = resolveTelegramToken(cfg, { accountId: "unknownBot" }); + expect(res.token).toBe(""); + expect(res.source).toBe("none"); + }); + it("throws when botToken is an unresolved SecretRef object", () => { const cfg = { channels: { diff --git a/extensions/telegram/src/token.ts b/extensions/telegram/src/token.ts index 6727e9a7ee4..87ee2a7e11b 100644 --- a/extensions/telegram/src/token.ts +++ b/extensions/telegram/src/token.ts @@ -44,6 +44,17 @@ export function resolveTelegramToken( const accountCfg = resolveAccountCfg( accountId !== DEFAULT_ACCOUNT_ID ? accountId : DEFAULT_ACCOUNT_ID, ); + + // When a non-default accountId is explicitly specified but not found in config, + // return empty immediately — do NOT fall through to channel-level defaults, + // which would silently route the message via the wrong bot's token. + if (accountId !== DEFAULT_ACCOUNT_ID && !accountCfg) { + opts.logMissingFile?.( + `channels.telegram.accounts: unknown accountId "${accountId}" — not found in config, refusing channel-level fallback`, + ); + return { token: "", source: "none" }; + } + const accountTokenFile = accountCfg?.tokenFile?.trim(); if (accountTokenFile) { const token = tryReadSecretFileSync( From 35ac1f6e07aa6c9ac4884f6658b3ec481ad77b13 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Fri, 20 Mar 2026 23:21:53 +0530 Subject: [PATCH 12/24] fix: add changelog for telegram account routing fix (#50853) (thanks @hclsys) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e33a2d82a6..d857ac980ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -182,6 +182,7 @@ Docs: https://docs.openclaw.ai - Tests/CLI: reduce command-secret gateway test import pressure while keeping the real protocol payload validator in place, so the isolated lane no longer carries the heavier runtime-web and message-channel graphs. (#50663) Thanks @huntharo. - Gateway/plugins: share plugin interactive callback routing and plugin bind approval state across duplicate module graphs so Telegram Codex picker buttons and plugin bind approvals no longer fall through to normal inbound message routing. (#50722) Thanks @huntharo. - Agents/compaction: add an opt-in post-compaction session JSONL truncation step that drops summarized transcript entries while preserving the retained branch tail and live session metadata. (#41021) thanks @thirumaleshp. +- Telegram/routing: fail loud when `message send` targets an unknown non-default Telegram `accountId`, instead of silently falling back to the channel-level bot token and sending through the wrong bot. (#50853) Thanks @hclsys. ### Breaking From 5f600e117d2d1e9dcddfa215eb98c530d79e8fb1 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 20 Mar 2026 10:54:48 -0700 Subject: [PATCH 13/24] docs: restructure Tools & Plugins section, rename building-extensions to building-plugins, rewrite tools landing page and SDK migration --- docs/docs.json | 53 ++- docs/plugins/building-extensions.md | 305 +------------ docs/plugins/building-plugins.md | 309 +++++++++++++ docs/plugins/sdk-migration.md | 175 ++++--- docs/start/hubs.md | 2 +- docs/tools/index.md | 680 +++++----------------------- docs/tools/plugin.md | 11 +- 7 files changed, 527 insertions(+), 1008 deletions(-) create mode 100644 docs/plugins/building-plugins.md diff --git a/docs/docs.json b/docs/docs.json index 2b8d8d84a0c..e39cf3f25a8 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -64,6 +64,10 @@ "source": "/platforms/raspberry-pi", "destination": "/install/raspberry-pi" }, + { + "source": "/plugins/building-extensions", + "destination": "/plugins/building-plugins" + }, { "source": "/brave-search", "destination": "/tools/brave-search" @@ -1015,7 +1019,7 @@ ] }, { - "tab": "Tools", + "tab": "Tools & Plugins", "groups": [ { "group": "Overview", @@ -1024,23 +1028,20 @@ { "group": "Built-in tools", "pages": [ - "tools/apply-patch", - "tools/brave-search", - "tools/btw", - "tools/diffs", - "tools/elevated", "tools/exec", "tools/exec-approvals", + "tools/elevated", + "tools/apply-patch", + "tools/web", + "tools/brave-search", "tools/firecrawl", "tools/tavily", - "tools/llm-task", - "tools/lobster", - "tools/loop-detection", - "tools/pdf", "tools/perplexity-search", + "tools/pdf", "tools/reactions", "tools/thinking", - "tools/web" + "tools/loop-detection", + "tools/btw" ] }, { @@ -1061,22 +1062,11 @@ "tools/multi-agent-sandbox-tools" ] }, - { - "group": "Skills", - "pages": [ - "tools/creating-skills", - "tools/slash-commands", - "tools/skills", - "tools/skills-config", - "tools/clawhub", - "tools/plugin", - "prose" - ] - }, { "group": "Plugins", "pages": [ - "plugins/building-extensions", + "tools/plugin", + "plugins/building-plugins", "plugins/sdk-migration", "plugins/architecture", "plugins/community", @@ -1086,6 +1076,21 @@ "tools/capability-cookbook" ] }, + { + "group": "Skills", + "pages": [ + "tools/skills", + "tools/creating-skills", + "tools/skills-config", + "tools/slash-commands", + "tools/clawhub", + "prose" + ] + }, + { + "group": "Plugin tools", + "pages": ["tools/diffs", "tools/llm-task", "tools/lobster"] + }, { "group": "Automation", "pages": [ diff --git a/docs/plugins/building-extensions.md b/docs/plugins/building-extensions.md index 026ac4492de..f0db0f3173f 100644 --- a/docs/plugins/building-extensions.md +++ b/docs/plugins/building-extensions.md @@ -1,309 +1,10 @@ --- title: "Building Plugins" -sidebarTitle: "Building Plugins" -summary: "Step-by-step guide for creating OpenClaw plugins with any combination of capabilities" +summary: "Redirects to the current Building Plugins guide" read_when: - - You want to create a new OpenClaw plugin - - You need to understand the plugin SDK import patterns - - You are adding a new channel, provider, tool, or other capability to OpenClaw + - Legacy link to building-extensions --- # 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. - -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. - -## 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 - -## Plugin capabilities - -A plugin can register one or more capabilities. The capability you register -determines what your plugin provides to OpenClaw: - -| 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 | - -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 - - - - Create `package.json` with the `openclaw` metadata block. The structure - depends on what capabilities your plugin provides. - - **Channel plugin example:** - - ```json - { - "name": "@myorg/openclaw-my-channel", - "version": "1.0.0", - "type": "module", - "openclaw": { - "extensions": ["./index.ts"], - "channel": { - "id": "my-channel", - "label": "My Channel", - "blurb": "Short description of the channel." - } - } - } - ``` - - **Provider plugin example:** - - ```json - { - "name": "@myorg/openclaw-my-provider", - "version": "1.0.0", - "type": "module", - "openclaw": { - "extensions": ["./index.ts"], - "providers": ["my-provider"] - } - } - ``` - - The `openclaw` field tells the plugin system what your plugin provides. - A plugin can declare both `channel` and `providers` if it provides multiple - capabilities. - - - - - 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 - import { definePluginEntry } from "openclaw/plugin-sdk/core"; - - export default definePluginEntry({ - id: "my-provider", - name: "My Provider", - register(api) { - api.registerProvider({ - // Provider implementation - }); - }, - }); - ``` - - **Multi-capability plugin** (provider + tool): - - ```typescript - import { definePluginEntry } from "openclaw/plugin-sdk/core"; - - export default definePluginEntry({ - id: "my-plugin", - name: "My Plugin", - register(api) { - api.registerProvider({ /* ... */ }); - api.registerTool({ /* ... */ }); - api.registerImageGenerationProvider({ /* ... */ }); - }, - }); - ``` - - Use `defineChannelPluginEntry` for channel plugins and `definePluginEntry` - for everything else. A single plugin can register as many capabilities as needed. - - - - - Always import from specific `openclaw/plugin-sdk/\` paths. The old - monolithic import is deprecated (see [SDK Migration](/plugins/sdk-migration)). - - ```typescript - // Correct: focused subpaths - import { definePluginEntry } from "openclaw/plugin-sdk/core"; - import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; - import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-oauth"; - - // Wrong: monolithic root (lint will reject this) - import { ... } from "openclaw/plugin-sdk"; - ``` - - - | Subpath | Purpose | - | --- | --- | - | `plugin-sdk/core` | Plugin entry definitions and base types | - | `plugin-sdk/channel-setup` | Setup wizard adapters | - | `plugin-sdk/channel-pairing` | DM pairing primitives | - | `plugin-sdk/channel-reply-pipeline` | Reply prefix + typing wiring | - | `plugin-sdk/channel-config-schema` | Config schema builders | - | `plugin-sdk/channel-policy` | Group/DM policy helpers | - | `plugin-sdk/secret-input` | Secret input parsing/helpers | - | `plugin-sdk/webhook-ingress` | Webhook request/target helpers | - | `plugin-sdk/runtime-store` | Persistent plugin storage | - | `plugin-sdk/allow-from` | Allowlist resolution | - | `plugin-sdk/reply-payload` | Message reply types | - | `plugin-sdk/provider-oauth` | OAuth login + PKCE helpers | - | `plugin-sdk/provider-onboard` | Provider onboarding config patches | - | `plugin-sdk/testing` | Test utilities | - - - Use the narrowest subpath that matches the job. - - - - - 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 { createTestRuntime } from "openclaw/plugin-sdk/testing"; - ``` - - - - - **External plugins:** publish to npm, then install: - - ```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: - - ```bash - openclaw plugins search - openclaw plugins install - ``` - - - - -## Lint enforcement (in-repo plugins) - -Three scripts enforce SDK boundaries for plugins in the OpenClaw repository: - -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 - -Run `pnpm check` to verify all boundaries before committing. - -External plugins are not subject to these lint rules, but following the same -patterns is strongly recommended. - -## Pre-submission checklist - -**package.json** has correct `openclaw` metadata -Entry point uses `defineChannelPluginEntry` or `definePluginEntry` -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 -`pnpm check` passes (in-repo plugins) - -## Related - -- [Plugin SDK Migration](/plugins/sdk-migration) — migrating from the deprecated compat import -- [Plugin Architecture](/plugins/architecture) — internals and capability model -- [Plugin Manifest](/plugins/manifest) — full manifest schema -- [Plugin Agent Tools](/plugins/agent-tools) — adding agent tools in a plugin -- [Community Plugins](/plugins/community) — listing and quality bar +This page has moved to [Building Plugins](/plugins/building-plugins). diff --git a/docs/plugins/building-plugins.md b/docs/plugins/building-plugins.md new file mode 100644 index 00000000000..026ac4492de --- /dev/null +++ b/docs/plugins/building-plugins.md @@ -0,0 +1,309 @@ +--- +title: "Building Plugins" +sidebarTitle: "Building Plugins" +summary: "Step-by-step guide for creating OpenClaw plugins with any combination of capabilities" +read_when: + - You want to create a new OpenClaw plugin + - You need to understand the plugin SDK import patterns + - 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. + +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. + +## 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 + +## Plugin capabilities + +A plugin can register one or more capabilities. The capability you register +determines what your plugin provides to OpenClaw: + +| 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 | + +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 + + + + Create `package.json` with the `openclaw` metadata block. The structure + depends on what capabilities your plugin provides. + + **Channel plugin example:** + + ```json + { + "name": "@myorg/openclaw-my-channel", + "version": "1.0.0", + "type": "module", + "openclaw": { + "extensions": ["./index.ts"], + "channel": { + "id": "my-channel", + "label": "My Channel", + "blurb": "Short description of the channel." + } + } + } + ``` + + **Provider plugin example:** + + ```json + { + "name": "@myorg/openclaw-my-provider", + "version": "1.0.0", + "type": "module", + "openclaw": { + "extensions": ["./index.ts"], + "providers": ["my-provider"] + } + } + ``` + + The `openclaw` field tells the plugin system what your plugin provides. + A plugin can declare both `channel` and `providers` if it provides multiple + capabilities. + + + + + 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 + import { definePluginEntry } from "openclaw/plugin-sdk/core"; + + export default definePluginEntry({ + id: "my-provider", + name: "My Provider", + register(api) { + api.registerProvider({ + // Provider implementation + }); + }, + }); + ``` + + **Multi-capability plugin** (provider + tool): + + ```typescript + import { definePluginEntry } from "openclaw/plugin-sdk/core"; + + export default definePluginEntry({ + id: "my-plugin", + name: "My Plugin", + register(api) { + api.registerProvider({ /* ... */ }); + api.registerTool({ /* ... */ }); + api.registerImageGenerationProvider({ /* ... */ }); + }, + }); + ``` + + Use `defineChannelPluginEntry` for channel plugins and `definePluginEntry` + for everything else. A single plugin can register as many capabilities as needed. + + + + + Always import from specific `openclaw/plugin-sdk/\` paths. The old + monolithic import is deprecated (see [SDK Migration](/plugins/sdk-migration)). + + ```typescript + // Correct: focused subpaths + import { definePluginEntry } from "openclaw/plugin-sdk/core"; + import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; + import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-oauth"; + + // Wrong: monolithic root (lint will reject this) + import { ... } from "openclaw/plugin-sdk"; + ``` + + + | Subpath | Purpose | + | --- | --- | + | `plugin-sdk/core` | Plugin entry definitions and base types | + | `plugin-sdk/channel-setup` | Setup wizard adapters | + | `plugin-sdk/channel-pairing` | DM pairing primitives | + | `plugin-sdk/channel-reply-pipeline` | Reply prefix + typing wiring | + | `plugin-sdk/channel-config-schema` | Config schema builders | + | `plugin-sdk/channel-policy` | Group/DM policy helpers | + | `plugin-sdk/secret-input` | Secret input parsing/helpers | + | `plugin-sdk/webhook-ingress` | Webhook request/target helpers | + | `plugin-sdk/runtime-store` | Persistent plugin storage | + | `plugin-sdk/allow-from` | Allowlist resolution | + | `plugin-sdk/reply-payload` | Message reply types | + | `plugin-sdk/provider-oauth` | OAuth login + PKCE helpers | + | `plugin-sdk/provider-onboard` | Provider onboarding config patches | + | `plugin-sdk/testing` | Test utilities | + + + Use the narrowest subpath that matches the job. + + + + + 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 { createTestRuntime } from "openclaw/plugin-sdk/testing"; + ``` + + + + + **External plugins:** publish to npm, then install: + + ```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: + + ```bash + openclaw plugins search + openclaw plugins install + ``` + + + + +## Lint enforcement (in-repo plugins) + +Three scripts enforce SDK boundaries for plugins in the OpenClaw repository: + +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 + +Run `pnpm check` to verify all boundaries before committing. + +External plugins are not subject to these lint rules, but following the same +patterns is strongly recommended. + +## Pre-submission checklist + +**package.json** has correct `openclaw` metadata +Entry point uses `defineChannelPluginEntry` or `definePluginEntry` +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 +`pnpm check` passes (in-repo plugins) + +## Related + +- [Plugin SDK Migration](/plugins/sdk-migration) — migrating from the deprecated compat import +- [Plugin Architecture](/plugins/architecture) — internals and capability model +- [Plugin Manifest](/plugins/manifest) — full manifest schema +- [Plugin Agent Tools](/plugins/agent-tools) — adding agent tools in a plugin +- [Community Plugins](/plugins/community) — listing and quality bar diff --git a/docs/plugins/sdk-migration.md b/docs/plugins/sdk-migration.md index fb745e46e91..83970720578 100644 --- a/docs/plugins/sdk-migration.md +++ b/docs/plugins/sdk-migration.md @@ -1,121 +1,117 @@ --- title: "Plugin SDK Migration" sidebarTitle: "SDK Migration" -summary: "Migrate from openclaw/plugin-sdk/compat to focused subpath imports" +summary: "Migrate from the deprecated openclaw/plugin-sdk/compat import to focused subpath imports" read_when: - You see the OPENCLAW_PLUGIN_SDK_COMPAT_DEPRECATED warning - - You are updating a plugin from the monolithic plugin-sdk import to scoped subpaths + - You are updating a plugin from the monolithic import to scoped subpaths - You maintain an external OpenClaw plugin --- # Plugin SDK Migration -OpenClaw is migrating from a single monolithic `openclaw/plugin-sdk/compat` barrel -to **focused subpath imports** (`openclaw/plugin-sdk/\`). This page explains -what changed, why, and how to migrate. +The `openclaw/plugin-sdk/compat` import is deprecated. All plugins should use +**focused subpath imports** (`openclaw/plugin-sdk/\`) instead. -## Why this change + + The compat import still works at runtime. This is a deprecation warning, not + a breaking change yet. But new plugins **must not** use it, and existing + plugins should migrate before the next major release removes it. + -The monolithic compat entry re-exported everything from a single entry point. -This caused: +## Why this changed -- **Slow startup**: importing one helper pulled in dozens of unrelated modules. -- **Circular dependency risk**: broad re-exports made it easy to create import cycles. -- **Unclear API surface**: no way to tell which exports were stable vs internal. +The old monolithic `openclaw/plugin-sdk/compat` re-exported everything from one +entry point. This caused slow startup (importing one helper loaded dozens of +unrelated modules), circular dependency risk, and an unclear API surface. Focused subpaths fix all three: each subpath is a small, self-contained module with a clear purpose. -## What triggers the warning +## Migration steps -If your plugin imports from the compat entry, you will see: + + + Search your plugin for imports from the compat path: -``` -[OPENCLAW_PLUGIN_SDK_COMPAT_DEPRECATED] Warning: openclaw/plugin-sdk/compat is -deprecated for new plugins. Migrate to focused openclaw/plugin-sdk/\ imports. -``` + ```bash + grep -r "plugin-sdk/compat" my-plugin/ + ``` -The compat entry still works at runtime. This is a deprecation warning, not an -error. But new plugins **must not** use it, and existing plugins should migrate -before compat is removed. + -## How to migrate + + Each export maps to a specific subpath. Replace the import source: -### Step 1: Find compat imports + ```typescript + // Before (deprecated) + import { + createChannelReplyPipeline, + createPluginRuntimeStore, + resolveControlCommandGate, + } from "openclaw/plugin-sdk/compat"; -Search your extension for imports from the compat path: + // After (focused subpaths) + import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; + import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; + import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth"; + ``` -```bash -grep -r "plugin-sdk/compat" extensions/my-plugin/ -``` + See the [subpath reference](#subpath-reference) below for the full mapping. -### Step 2: Replace with focused subpaths + -Each export from compat maps to a specific subpath. Replace the import source: - -```typescript -// Before (compat entry) -import { - createChannelReplyPipeline, - createPluginRuntimeStore, - resolveControlCommandGate, -} from "openclaw/plugin-sdk/compat"; - -// After (focused subpaths) -import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; -import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; -import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth"; -``` - -### Step 3: Verify - -Run the build and tests: - -```bash -pnpm build -pnpm test -- extensions/my-plugin/ -``` + + ```bash + pnpm build + pnpm test -- my-plugin/ + ``` + + ## Subpath reference -| Subpath | Purpose | Key exports | -| ----------------------------------- | ------------------------------------ | ---------------------------------------------------------------------- | -| `plugin-sdk/core` | Plugin entry definitions, base types | `defineChannelPluginEntry`, `definePluginEntry` | -| `plugin-sdk/channel-setup` | Setup wizard adapters | `createOptionalChannelSetupSurface` | -| `plugin-sdk/channel-pairing` | DM pairing primitives | `createChannelPairingController` | -| `plugin-sdk/channel-reply-pipeline` | Reply prefix + typing wiring | `createChannelReplyPipeline` | -| `plugin-sdk/channel-config-helpers` | Config adapter factories | `createHybridChannelConfigAdapter`, `createScopedChannelConfigAdapter` | -| `plugin-sdk/channel-config-schema` | Config schema builders | Channel config schema types | -| `plugin-sdk/channel-policy` | Group/DM policy resolution | `resolveChannelGroupRequireMention` | -| `plugin-sdk/channel-lifecycle` | Account status tracking | `createAccountStatusSink` | -| `plugin-sdk/channel-runtime` | Runtime wiring helpers | Channel runtime utilities | -| `plugin-sdk/channel-send-result` | Send result types | Reply result types | -| `plugin-sdk/runtime-store` | Persistent plugin storage | `createPluginRuntimeStore` | -| `plugin-sdk/allow-from` | Allowlist formatting | `formatAllowFromLowercase`, `formatNormalizedAllowFromEntries` | -| `plugin-sdk/allowlist-resolution` | Allowlist input mapping | `mapAllowlistResolutionInputs` | -| `plugin-sdk/command-auth` | Command gating | `resolveControlCommandGate` | -| `plugin-sdk/secret-input` | Secret input parsing | Secret input helpers | -| `plugin-sdk/webhook-ingress` | Webhook request helpers | Webhook target utilities | -| `plugin-sdk/reply-payload` | Message reply types | Reply payload types | -| `plugin-sdk/provider-onboard` | Provider onboarding patches | Onboarding config helpers | -| `plugin-sdk/keyed-async-queue` | Ordered async queue | `KeyedAsyncQueue` | -| `plugin-sdk/testing` | Test utilities | Test helpers and mocks | + + | Subpath | Purpose | Key exports | + | --- | --- | --- | + | `plugin-sdk/core` | Plugin entry definitions, base types | `defineChannelPluginEntry`, `definePluginEntry` | + | `plugin-sdk/channel-setup` | Setup wizard adapters | `createOptionalChannelSetupSurface` | + | `plugin-sdk/channel-pairing` | DM pairing primitives | `createChannelPairingController` | + | `plugin-sdk/channel-reply-pipeline` | Reply prefix + typing wiring | `createChannelReplyPipeline` | + | `plugin-sdk/channel-config-helpers` | Config adapter factories | `createHybridChannelConfigAdapter` | + | `plugin-sdk/channel-config-schema` | Config schema builders | Channel config schema types | + | `plugin-sdk/channel-policy` | Group/DM policy resolution | `resolveChannelGroupRequireMention` | + | `plugin-sdk/channel-lifecycle` | Account status tracking | `createAccountStatusSink` | + | `plugin-sdk/channel-runtime` | Runtime wiring helpers | Channel runtime utilities | + | `plugin-sdk/channel-send-result` | Send result types | Reply result types | + | `plugin-sdk/runtime-store` | Persistent plugin storage | `createPluginRuntimeStore` | + | `plugin-sdk/allow-from` | Allowlist formatting | `formatAllowFromLowercase` | + | `plugin-sdk/allowlist-resolution` | Allowlist input mapping | `mapAllowlistResolutionInputs` | + | `plugin-sdk/command-auth` | Command gating | `resolveControlCommandGate` | + | `plugin-sdk/secret-input` | Secret input parsing | Secret input helpers | + | `plugin-sdk/webhook-ingress` | Webhook request helpers | Webhook target utilities | + | `plugin-sdk/reply-payload` | Message reply types | Reply payload types | + | `plugin-sdk/provider-onboard` | Provider onboarding patches | Onboarding config helpers | + | `plugin-sdk/keyed-async-queue` | Ordered async queue | `KeyedAsyncQueue` | + | `plugin-sdk/testing` | Test utilities | Test helpers and mocks | + -Use the narrowest subpath that has what you need. If you cannot find an export, +Use the narrowest subpath that matches the job. If you cannot find an export, check the source at `src/plugin-sdk/` or ask in Discord. -## Compat barrel removal timeline +## Removal timeline -- **Now**: compat entry emits a deprecation warning at runtime. -- **Next major release**: compat entry will be removed. Plugins still using it will - fail to import. +| When | What happens | +| ---------------------- | --------------------------------------------------------------- | +| **Now** | Compat import emits a runtime deprecation warning | +| **Next major release** | Compat import will be removed; plugins still using it will fail | -Bundled plugins (under `extensions/`) have already been migrated. External plugins -should migrate before the next major release. +All core plugins have already been migrated. External plugins should migrate +before the next major release. ## Suppressing the warning temporarily -If you need to suppress the warning while migrating: +Set this environment variable while you work on migrating: ```bash OPENCLAW_SUPPRESS_PLUGIN_SDK_COMPAT_WARNING=1 openclaw gateway run @@ -123,23 +119,8 @@ OPENCLAW_SUPPRESS_PLUGIN_SDK_COMPAT_WARNING=1 openclaw gateway run This is a temporary escape hatch, not a permanent solution. -## Internal barrel pattern - -Within your extension, use local barrel files (`api.ts`, `runtime-api.ts`) for -internal code sharing instead of importing through the plugin SDK: - -```typescript -// extensions/my-plugin/api.ts — public contract for this extension -export { MyConfig } from "./src/config.js"; -export { MyRuntime } from "./src/runtime.js"; -``` - -Never import your own extension back through `openclaw/plugin-sdk/\` -from production files. That path is for external consumers only. See -[Building Extensions](/plugins/building-extensions#step-4-use-local-barrels-for-internal-imports). - ## Related -- [Building Extensions](/plugins/building-extensions) +- [Building Plugins](/plugins/building-plugins) - [Plugin Architecture](/plugins/architecture) - [Plugin Manifest](/plugins/manifest) diff --git a/docs/start/hubs.md b/docs/start/hubs.md index 7e530f769b5..8ccb2d56c66 100644 --- a/docs/start/hubs.md +++ b/docs/start/hubs.md @@ -164,7 +164,7 @@ Use these hubs to discover every page, including deep dives and reference docs t ## Extensions + plugins - [Plugins overview](/tools/plugin) -- [Building extensions](/plugins/building-extensions) +- [Building plugins](/plugins/building-plugins) - [Plugin manifest](/plugins/manifest) - [Agent tools](/plugins/agent-tools) - [Plugin bundles](/plugins/bundles) diff --git a/docs/tools/index.md b/docs/tools/index.md index 075971d6877..15b8bb24a5f 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -1,96 +1,89 @@ --- -summary: "Agent tool surface for OpenClaw (browser, canvas, nodes, message, cron) replacing legacy `openclaw-*` skills" +summary: "OpenClaw tools and plugins overview: what the agent can do and how to extend it" read_when: - - Adding or modifying agent tools - - Retiring or changing `openclaw-*` skills -title: "Tools" + - You want to understand what tools OpenClaw provides + - You need to configure, allow, or deny tools + - You are deciding between built-in tools, skills, and plugins +title: "Tools and Plugins" --- -# Tools (OpenClaw) +# Tools and Plugins -OpenClaw exposes **first-class agent tools** for browser, canvas, nodes, and cron. -These replace the old `openclaw-*` skills: the tools are typed, no shelling, -and the agent should rely on them directly. +OpenClaw gives the agent a set of **tools** it can call during a conversation. +Tools are how the agent reads files, runs commands, browses the web, sends +messages, and interacts with devices. Everything the agent does beyond generating +text happens through tools. -## Disabling tools +## How it all fits together -You can globally allow/deny tools via `tools.allow` / `tools.deny` in `openclaw.json` -(deny wins). This prevents disallowed tools from being sent to model providers. + + + Core tools shipped with OpenClaw: exec, browser, web search, file I/O, + messaging, cron, canvas, and nodes. + + + Markdown instructions that teach the agent how and when to use tools. + Skills ship inside plugins or live in your workspace. + + + Packages that add new capabilities: channels, model providers, tools, + skills, or any combination. Published on npm and installed with the CLI. + + + Hooks, cron jobs, heartbeats, webhooks, and scheduled tasks that run + without manual messages. + + + +## Tool configuration + +### Allow and deny lists + +Control which tools the agent can call via `tools.allow` / `tools.deny` in +config. Deny always wins over allow. ```json5 { - tools: { deny: ["browser"] }, + tools: { + allow: ["group:fs", "browser", "web_search"], + deny: ["exec"], + }, } ``` -Notes: +### Tool profiles -- Matching is case-insensitive. -- `*` wildcards are supported (`"*"` means all tools). -- If `tools.allow` only references unknown or unloaded plugin tool names, OpenClaw logs a warning and ignores the allowlist so core tools stay available. - -## Tool profiles (base allowlist) - -`tools.profile` sets a **base tool allowlist** before `tools.allow`/`tools.deny`. +`tools.profile` sets a base allowlist before `allow`/`deny` is applied. Per-agent override: `agents.list[].tools.profile`. -Profiles: +| Profile | What it includes | +| ----------- | ------------------------------------------- | +| `full` | All tools (default) | +| `coding` | File I/O, runtime, sessions, memory, image | +| `messaging` | Messaging, session list/history/send/status | +| `minimal` | `session_status` only | -- `minimal`: `session_status` only -- `coding`: `group:fs`, `group:runtime`, `group:sessions`, `group:memory`, `image` -- `messaging`: `group:messaging`, `sessions_list`, `sessions_history`, `sessions_send`, `session_status` -- `full`: no restriction (same as unset) +### Tool groups -Example (messaging-only by default, allow Slack + Discord tools too): +Use `group:*` shorthands in allow/deny lists: -```json5 -{ - tools: { - profile: "messaging", - allow: ["slack", "discord"], - }, -} -``` +| Group | Tools | +| ------------------ | ------------------------------------------------------------------------------ | +| `group:runtime` | exec, bash, process | +| `group:fs` | read, write, edit, apply_patch | +| `group:sessions` | sessions_list, sessions_history, sessions_send, sessions_spawn, session_status | +| `group:memory` | memory_search, memory_get | +| `group:web` | web_search, web_fetch | +| `group:ui` | browser, canvas | +| `group:automation` | cron, gateway | +| `group:messaging` | message | +| `group:nodes` | nodes | +| `group:openclaw` | All built-in OpenClaw tools (excludes plugin tools) | -Example (coding profile, but deny exec/process everywhere): +### Provider-specific restrictions -```json5 -{ - tools: { - profile: "coding", - deny: ["group:runtime"], - }, -} -``` - -Example (global coding profile, messaging-only support agent): - -```json5 -{ - tools: { profile: "coding" }, - agents: { - list: [ - { - id: "support", - tools: { profile: "messaging", allow: ["slack"] }, - }, - ], - }, -} -``` - -## Provider-specific tool policy - -Use `tools.byProvider` to **further restrict** tools for specific providers -(or a single `provider/model`) without changing your global defaults. -Per-agent override: `agents.list[].tools.byProvider`. - -This is applied **after** the base tool profile and **before** allow/deny lists, -so it can only narrow the tool set. -Provider keys accept either `provider` (e.g. `google-antigravity`) or -`provider/model` (e.g. `openai/gpt-5.2`). - -Example (keep global coding profile, but minimal tools for Google Antigravity): +Use `tools.byProvider` to restrict tools for specific providers without +changing global defaults: ```json5 { @@ -103,514 +96,43 @@ Example (keep global coding profile, but minimal tools for Google Antigravity): } ``` -Example (provider/model-specific allowlist for a flaky endpoint): - -```json5 -{ - tools: { - allow: ["group:fs", "group:runtime", "sessions_list"], - byProvider: { - "openai/gpt-5.2": { allow: ["group:fs", "sessions_list"] }, - }, - }, -} -``` - -Example (agent-specific override for a single provider): - -```json5 -{ - agents: { - list: [ - { - id: "support", - tools: { - byProvider: { - "google-antigravity": { allow: ["message", "sessions_list"] }, - }, - }, - }, - ], - }, -} -``` - -## Tool groups (shorthands) - -Tool policies (global, agent, sandbox) support `group:*` entries that expand to multiple tools. -Use these in `tools.allow` / `tools.deny`. - -Available groups: - -- `group:runtime`: `exec`, `bash`, `process` -- `group:fs`: `read`, `write`, `edit`, `apply_patch` -- `group:sessions`: `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `session_status` -- `group:memory`: `memory_search`, `memory_get` -- `group:web`: `web_search`, `web_fetch` -- `group:ui`: `browser`, `canvas` -- `group:automation`: `cron`, `gateway` -- `group:messaging`: `message` -- `group:nodes`: `nodes` -- `group:openclaw`: all built-in OpenClaw tools (excludes provider plugins) - -Example (allow only file tools + browser): - -```json5 -{ - tools: { - allow: ["group:fs", "browser"], - }, -} -``` - -## Plugins + tools - -Plugins can register **additional tools** (and CLI commands) beyond the core set. -See [Plugins](/tools/plugin) for install + config, and [Skills](/tools/skills) for how -tool usage guidance is injected into prompts. Some plugins ship their own skills -alongside tools (for example, the voice-call plugin). - -Optional plugin tools: - -- [Lobster](/tools/lobster): typed workflow runtime with resumable approvals (requires the Lobster CLI on the gateway host). -- [LLM Task](/tools/llm-task): JSON-only LLM step for structured workflow output (optional schema validation). -- [Diffs](/tools/diffs): read-only diff viewer and PNG or PDF file renderer for before/after text or unified patches. - -## Tool inventory - -### `apply_patch` - -Apply structured patches across one or more files. Use for multi-hunk edits. -Experimental: enable via `tools.exec.applyPatch.enabled` (OpenAI models only). -`tools.exec.applyPatch.workspaceOnly` defaults to `true` (workspace-contained). Set it to `false` only if you intentionally want `apply_patch` to write/delete outside the workspace directory. - -### `exec` - -Run shell commands in the workspace. - -Core parameters: - -- `command` (required) -- `yieldMs` (auto-background after timeout, default 10000) -- `background` (immediate background) -- `timeout` (seconds; kills the process if exceeded, default 1800) -- `elevated` (bool; run on host if elevated mode is enabled/allowed; only changes behavior when the agent is sandboxed) -- `host` (`sandbox | gateway | node`) -- `security` (`deny | allowlist | full`) -- `ask` (`off | on-miss | always`) -- `node` (node id/name for `host=node`) -- Need a real TTY? Set `pty: true`. - -Notes: - -- Returns `status: "running"` with a `sessionId` when backgrounded. -- Use `process` to poll/log/write/kill/clear background sessions. -- If `process` is disallowed, `exec` runs synchronously and ignores `yieldMs`/`background`. -- `elevated` is gated by `tools.elevated` plus any `agents.list[].tools.elevated` override (both must allow) and is an alias for `host=gateway` + `security=full`. -- `elevated` only changes behavior when the agent is sandboxed (otherwise it’s a no-op). -- `host=node` can target a macOS companion app or a headless node host (`openclaw node run`). -- gateway/node approvals and allowlists: [Exec approvals](/tools/exec-approvals). - -### `process` - -Manage background exec sessions. - -Core actions: - -- `list`, `poll`, `log`, `write`, `kill`, `clear`, `remove` - -Notes: - -- `poll` returns new output and exit status when complete. -- `log` supports line-based `offset`/`limit` (omit `offset` to grab the last N lines). -- `process` is scoped per agent; sessions from other agents are not visible. - -### `loop-detection` (tool-call loop guardrails) - -OpenClaw tracks recent tool-call history and blocks or warns when it detects repetitive no-progress loops. -Enable with `tools.loopDetection.enabled: true` (default is `false`). - -```json5 -{ - tools: { - loopDetection: { - enabled: true, - warningThreshold: 10, - criticalThreshold: 20, - globalCircuitBreakerThreshold: 30, - historySize: 30, - detectors: { - genericRepeat: true, - knownPollNoProgress: true, - pingPong: true, - }, - }, - }, -} -``` - -- `genericRepeat`: repeated same tool + same params call pattern. -- `knownPollNoProgress`: repeating poll-like tools with identical outputs. -- `pingPong`: alternating `A/B/A/B` no-progress patterns. -- Per-agent override: `agents.list[].tools.loopDetection`. - -### `web_search` - -Search the web using Brave, Firecrawl, Gemini, Grok, Kimi, Perplexity, or Tavily. - -Core parameters: - -- `query` (required) -- `count` (1–10; default from `tools.web.search.maxResults`) - -Notes: - -- Requires an API key for the chosen provider (recommended: `openclaw configure --section web`). -- Enable via `tools.web.search.enabled`. -- Responses are cached (default 15 min). -- See [Web tools](/tools/web) for setup. - -### `web_fetch` - -Fetch and extract readable content from a URL (HTML → markdown/text). - -Core parameters: - -- `url` (required) -- `extractMode` (`markdown` | `text`) -- `maxChars` (truncate long pages) - -Notes: - -- Enable via `tools.web.fetch.enabled`. -- `maxChars` is clamped by `tools.web.fetch.maxCharsCap` (default 50000). -- Responses are cached (default 15 min). -- For JS-heavy sites, prefer the browser tool. -- See [Web tools](/tools/web) for setup. -- See [Firecrawl](/tools/firecrawl) for the optional anti-bot fallback. - -### `browser` - -Control the dedicated OpenClaw-managed browser. - -Core actions: - -- `status`, `start`, `stop`, `tabs`, `open`, `focus`, `close` -- `snapshot` (aria/ai) -- `screenshot` (returns image block + `MEDIA:`) -- `act` (UI actions: click/type/press/hover/drag/select/fill/resize/wait/evaluate) -- `navigate`, `console`, `pdf`, `upload`, `dialog` - -Profile management: - -- `profiles` — list all browser profiles with status -- `create-profile` — create new profile with auto-allocated port (or `cdpUrl`) -- `delete-profile` — stop browser, delete user data, remove from config (local only) -- `reset-profile` — kill orphan process on profile's port (local only) - -Common parameters: - -- `profile` (optional; defaults to `browser.defaultProfile`) -- `target` (`sandbox` | `host` | `node`) -- `node` (optional; picks a specific node id/name) - Notes: -- Requires `browser.enabled=true` (default is `true`; set `false` to disable). -- All actions accept optional `profile` parameter for multi-instance support. -- Omit `profile` for the safe default: isolated OpenClaw-managed browser (`openclaw`). -- Use `profile="user"` for the real local host browser when existing logins/cookies matter and the user is present to click/approve any attach prompt. -- `profile="user"` is host-only; do not combine it with sandbox/node targets. -- When `profile` is omitted, uses `browser.defaultProfile` (defaults to `openclaw`). -- Profile names: lowercase alphanumeric + hyphens only (max 64 chars). -- Port range: 18800-18899 (~100 profiles max). -- Remote profiles are attach-only (no start/stop/reset). -- If a browser-capable node is connected, the tool may auto-route to it (unless you pin `target`). -- `snapshot` defaults to `ai` when Playwright is installed; use `aria` for the accessibility tree. -- `snapshot` also supports role-snapshot options (`interactive`, `compact`, `depth`, `selector`) which return refs like `e12`. -- `act` requires `ref` from `snapshot` (numeric `12` from AI snapshots, or `e12` from role snapshots); use `evaluate` for rare CSS selector needs. -- Avoid `act` → `wait` by default; use it only in exceptional cases (no reliable UI state to wait on). -- `upload` can optionally pass a `ref` to auto-click after arming. -- `upload` also supports `inputRef` (aria ref) or `element` (CSS selector) to set `` directly. - -### `canvas` - -Drive the node Canvas (present, eval, snapshot, A2UI). - -Core actions: - -- `present`, `hide`, `navigate`, `eval` -- `snapshot` (returns image block + `MEDIA:`) -- `a2ui_push`, `a2ui_reset` - -Notes: - -- Uses gateway `node.invoke` under the hood. -- If no `node` is provided, the tool picks a default (single connected node or local mac node). -- A2UI is v0.8 only (no `createSurface`); the CLI rejects v0.9 JSONL with line errors. -- Quick smoke: `openclaw nodes canvas a2ui push --node --text "Hello from A2UI"`. - -### `nodes` - -Discover and target paired nodes; send notifications; capture camera/screen. - -Core actions: - -- `status`, `describe` -- `pending`, `approve`, `reject` (pairing) -- `notify` (macOS `system.notify`) -- `run` (macOS `system.run`) -- `camera_list`, `camera_snap`, `camera_clip`, `screen_record` -- `location_get`, `notifications_list`, `notifications_action` -- `device_status`, `device_info`, `device_permissions`, `device_health` - -Notes: - -- Camera/screen commands require the node app to be foregrounded. -- Images return image blocks + `MEDIA:`. -- Videos return `FILE:` (mp4). -- Location returns a JSON payload (lat/lon/accuracy/timestamp). -- `run` params: `command` argv array; optional `cwd`, `env` (`KEY=VAL`), `commandTimeoutMs`, `invokeTimeoutMs`, `needsScreenRecording`. - -Example (`run`): - -```json -{ - "action": "run", - "node": "office-mac", - "command": ["echo", "Hello"], - "env": ["FOO=bar"], - "commandTimeoutMs": 12000, - "invokeTimeoutMs": 45000, - "needsScreenRecording": false -} -``` - -### `image` - -Analyze an image with the configured image model. - -Core parameters: - -- `image` (required path or URL) -- `prompt` (optional; defaults to "Describe the image.") -- `model` (optional override) -- `maxBytesMb` (optional size cap) - -Notes: - -- Only available when `agents.defaults.imageModel` is configured (primary or fallbacks), or when an implicit image model can be inferred from your default model + configured auth (best-effort pairing). -- Uses the image model directly (independent of the main chat model). - -### `image_generate` - -Generate one or more images with the configured or inferred image-generation model. - -Core parameters: - -- `action` (optional: `generate` or `list`; default `generate`) -- `prompt` (required) -- `image` or `images` (optional reference image path/URL for edit mode) -- `model` (optional provider/model override) -- `size` (optional size hint) -- `resolution` (optional `1K|2K|4K` hint) -- `count` (optional, `1-4`, default `1`) - -Notes: - -- Available when `agents.defaults.imageGenerationModel` is configured, or when OpenClaw can infer a compatible image-generation default from your enabled providers plus available auth. -- Explicit `agents.defaults.imageGenerationModel` still wins over any inferred default. -- Use `action: "list"` to inspect registered providers, default models, supported model ids, sizes, resolutions, and edit support. -- Returns local `MEDIA:` lines so channels can deliver the generated files directly. -- Uses the image-generation model directly (independent of the main chat model). -- Google-backed flows, including `google/gemini-3-pro-image-preview` for the native Nano Banana-style path, support reference-image edits plus explicit `1K|2K|4K` resolution hints. -- When editing and `resolution` is omitted, OpenClaw infers a draft/final resolution from the input image size. -- This is the built-in replacement for the old `nano-banana-pro` skill workflow. Use `agents.defaults.imageGenerationModel`, not `skills.entries`, for stock image generation. - -Native example: - -```json5 -{ - agents: { - defaults: { - imageGenerationModel: { - primary: "google/gemini-3-pro-image-preview", // native Nano Banana path - fallbacks: ["fal/fal-ai/flux/dev"], - }, - }, - }, -} -``` - -### `pdf` - -Analyze one or more PDF documents. - -For full behavior, limits, config, and examples, see [PDF tool](/tools/pdf). - -### `message` - -Send messages and channel actions across Discord/Google Chat/Slack/Telegram/WhatsApp/Signal/iMessage/Microsoft Teams. - -Core actions: - -- `send` (text + optional media; Microsoft Teams also supports `card` for Adaptive Cards) -- `poll` (WhatsApp/Discord/Microsoft Teams polls) -- `react` / `reactions` / `read` / `edit` / `delete` -- `pin` / `unpin` / `list-pins` -- `permissions` -- `thread-create` / `thread-list` / `thread-reply` -- `search` -- `sticker` -- `member-info` / `role-info` -- `emoji-list` / `emoji-upload` / `sticker-upload` -- `role-add` / `role-remove` -- `channel-info` / `channel-list` -- `voice-status` -- `event-list` / `event-create` -- `timeout` / `kick` / `ban` - -Notes: - -- `send` routes WhatsApp via the Gateway; other channels go direct. -- `poll` uses the Gateway for WhatsApp and Microsoft Teams; Discord polls go direct. -- When a message tool call is bound to an active chat session, sends are constrained to that session’s target to avoid cross-context leaks. - -### `cron` - -Manage Gateway cron jobs and wakeups. - -Core actions: - -- `status`, `list` -- `add`, `update`, `remove`, `run`, `runs` -- `wake` (enqueue system event + optional immediate heartbeat) - -Notes: - -- `add` expects a full cron job object (same schema as `cron.add` RPC). -- `update` uses `{ jobId, patch }` (`id` accepted for compatibility). - -### `gateway` - -Restart or apply updates to the running Gateway process (in-place). - -Core actions: - -- `restart` (authorizes + sends `SIGUSR1` for in-process restart; `openclaw gateway` restart in-place) -- `config.schema.lookup` (inspect one config path at a time without loading the full schema into prompt context) -- `config.get` -- `config.apply` (validate + write config + restart + wake) -- `config.patch` (merge partial update + restart + wake) -- `update.run` (run update + restart + wake) - -Notes: - -- `config.schema.lookup` expects a targeted config path such as `gateway.auth` or `agents.list.*.heartbeat`. -- Paths may include slash-delimited plugin ids when addressing `plugins.entries.`, for example `plugins.entries.pack/one.config`. -- Use `delayMs` (defaults to 2000) to avoid interrupting an in-flight reply. -- `config.schema` remains available to internal Control UI flows and is not exposed through the agent `gateway` tool. -- `restart` is enabled by default; set `commands.restart: false` to disable it. - -### `sessions_list` / `sessions_history` / `sessions_send` / `sessions_spawn` / `session_status` - -List sessions, inspect transcript history, or send to another session. - -Core parameters: - -- `sessions_list`: `kinds?`, `limit?`, `activeMinutes?`, `messageLimit?` (0 = none) -- `sessions_history`: `sessionKey` (or `sessionId`), `limit?`, `includeTools?` -- `sessions_send`: `sessionKey` (or `sessionId`), `message`, `timeoutSeconds?` (0 = fire-and-forget) -- `sessions_spawn`: `task`, `label?`, `runtime?`, `agentId?`, `model?`, `thinking?`, `cwd?`, `runTimeoutSeconds?`, `thread?`, `mode?`, `cleanup?`, `sandbox?`, `streamTo?`, `attachments?`, `attachAs?` -- `session_status`: `sessionKey?` (default current; accepts `sessionId`), `model?` (`default` clears override) - -Notes: - -- `main` is the canonical direct-chat key; global/unknown are hidden. -- `messageLimit > 0` fetches last N messages per session (tool messages filtered). -- Session targeting is controlled by `tools.sessions.visibility` (default `tree`: current session + spawned subagent sessions). If you run a shared agent for multiple users, consider setting `tools.sessions.visibility: "self"` to prevent cross-session browsing. -- `sessions_send` waits for final completion when `timeoutSeconds > 0`. -- Delivery/announce happens after completion and is best-effort; `status: "ok"` confirms the agent run finished, not that the announce was delivered. -- `sessions_spawn` supports `runtime: "subagent" | "acp"` (`subagent` default). For ACP runtime behavior, see [ACP Agents](/tools/acp-agents). -- For ACP runtime, `streamTo: "parent"` routes initial-run progress summaries back to the requester session as system events instead of direct child delivery. -- `sessions_spawn` starts a sub-agent run and posts an announce reply back to the requester chat. - - Supports one-shot mode (`mode: "run"`) and persistent thread-bound mode (`mode: "session"` with `thread: true`). - - If `thread: true` and `mode` is omitted, mode defaults to `session`. - - `mode: "session"` requires `thread: true`. - - If `runTimeoutSeconds` is omitted, OpenClaw uses `agents.defaults.subagents.runTimeoutSeconds` when set; otherwise timeout defaults to `0` (no timeout). - - Discord thread-bound flows depend on `session.threadBindings.*` and `channels.discord.threadBindings.*`. - - Reply format includes `Status`, `Result`, and compact stats. - - `Result` is the assistant completion text; if missing, the latest `toolResult` is used as fallback. -- Manual completion-mode spawns send directly first, with queue fallback and retry on transient failures (`status: "ok"` means run finished, not that announce delivered). -- `sessions_spawn` supports inline file attachments for subagent runtime only (ACP rejects them). Each attachment has `name`, `content`, and optional `encoding` (`utf8` or `base64`) and `mimeType`. Files are materialized into the child workspace at `.openclaw/attachments//` with a `.manifest.json` metadata file. The tool returns a receipt with `count`, `totalBytes`, per file `sha256`, and `relDir`. Attachment content is automatically redacted from transcript persistence. - - Configure limits via `tools.sessions_spawn.attachments` (`enabled`, `maxTotalBytes`, `maxFiles`, `maxFileBytes`, `retainOnSessionKeep`). - - `attachAs.mountPath` is a reserved hint for future mount implementations. -- `sessions_spawn` is non-blocking and returns `status: "accepted"` immediately. -- ACP `streamTo: "parent"` responses may include `streamLogPath` (session-scoped `*.acp-stream.jsonl`) for tailing progress history. -- `sessions_send` runs a reply‑back ping‑pong (reply `REPLY_SKIP` to stop; max turns via `session.agentToAgent.maxPingPongTurns`, 0–5). -- After the ping‑pong, the target agent runs an **announce step**; reply `ANNOUNCE_SKIP` to suppress the announcement. -- Sandbox clamp: when the current session is sandboxed and `agents.defaults.sandbox.sessionToolsVisibility: "spawned"`, OpenClaw clamps `tools.sessions.visibility` to `tree`. - -### `agents_list` - -List agent ids that the current session may target with `sessions_spawn`. - -Notes: - -- Result is restricted to per-agent allowlists (`agents.list[].subagents.allowAgents`). -- When `["*"]` is configured, the tool includes all configured agents and marks `allowAny: true`. - -## Parameters (common) - -Gateway-backed tools (`canvas`, `nodes`, `cron`): - -- `gatewayUrl` (default `ws://127.0.0.1:18789`) -- `gatewayToken` (if auth enabled) -- `timeoutMs` - -Note: when `gatewayUrl` is set, include `gatewayToken` explicitly. Tools do not inherit config -or environment credentials for overrides, and missing explicit credentials is an error. - -Browser tool: - -- `profile` (optional; defaults to `browser.defaultProfile`) -- `target` (`sandbox` | `host` | `node`) -- `node` (optional; pin a specific node id/name) -- Troubleshooting guides: - - Linux startup/CDP issues: [Browser troubleshooting (Linux)](/tools/browser-linux-troubleshooting) - - WSL2 Gateway + Windows remote Chrome CDP: [WSL2 + Windows + remote Chrome CDP troubleshooting](/tools/browser-wsl2-windows-remote-cdp-troubleshooting) - -## Recommended agent flows - -Browser automation: - -1. `browser` → `status` / `start` -2. `snapshot` (ai or aria) -3. `act` (click/type/press) -4. `screenshot` if you need visual confirmation - -Canvas render: - -1. `canvas` → `present` -2. `a2ui_push` (optional) -3. `snapshot` - -Node targeting: - -1. `nodes` → `status` -2. `describe` on the chosen node -3. `notify` / `run` / `camera_snap` / `screen_record` - -## Safety - -- Avoid direct `system.run`; use `nodes` → `run` only with explicit user consent. -- Respect user consent for camera/screen capture. -- Use `status/describe` to ensure permissions before invoking media commands. - -## How tools are presented to the agent +## Built-in tool reference + +For the full tool-by-tool reference (parameters, actions, notes), see the +individual tool pages in the sidebar. Key tools: + +| Tool | What it does | Page | +| ---------------------------- | -------------------------------------------------------- | --------------------------------- | +| `exec` / `process` | Run shell commands, manage background processes | [Exec](/tools/exec) | +| `browser` | Control a Chromium browser (navigate, click, screenshot) | [Browser](/tools/browser) | +| `web_search` / `web_fetch` | Search the web, fetch page content | [Web](/tools/web) | +| `read` / `write` / `edit` | File I/O in the workspace | | +| `apply_patch` | Multi-hunk file patches | [Apply Patch](/tools/apply-patch) | +| `message` | Send messages across all channels | [Agent Send](/tools/agent-send) | +| `canvas` | Drive node Canvas (present, eval, snapshot) | | +| `nodes` | Discover and target paired devices | | +| `cron` / `gateway` | Manage scheduled jobs, restart gateway | | +| `image` / `image_generate` | Analyze or generate images | | +| `sessions_*` / `agents_list` | Session management, sub-agents | [Sub-agents](/tools/subagents) | + +## Plugins add more + +Plugins can register **additional tools** beyond the built-in set. Some examples: + +- [Lobster](/tools/lobster) — typed workflow runtime with resumable approvals +- [LLM Task](/tools/llm-task) — JSON-only LLM step for structured output +- [Diffs](/tools/diffs) — diff viewer and renderer +- [OpenProse](/prose) — markdown-first workflow orchestration + +Plugins can also ship **skills** alongside tools, so the agent gets both the +tool definition and the instructions for using it. See +[Building Plugins](/plugins/building-plugins) to create your own. + +## How tools reach the agent Tools are exposed in two parallel channels: -1. **System prompt text**: a human-readable list + guidance. -2. **Tool schema**: the structured function definitions sent to the model API. +1. **System prompt text** — a human-readable list with guidance (from skills) +2. **Tool schemas** — structured function definitions sent to the model API -That means the agent sees both “what tools exist” and “how to call them.” If a tool -doesn’t appear in the system prompt or the schema, the model cannot call it. +If a tool doesn't appear in either, the model cannot call it. diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 7f1ba0fade4..9b61c2ee015 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -1,13 +1,14 @@ --- -summary: "OpenClaw plugins/extensions: discovery, config, and safety" +summary: "OpenClaw plugins: install, configure, and manage plugins that extend the gateway" read_when: - - Adding or modifying plugins/extensions - - Documenting plugin install or load rules + - Installing or configuring plugins + - Understanding plugin discovery and load rules - Working with Codex/Claude-compatible plugin bundles title: "Plugins" +sidebarTitle: "Install and Configure" --- -# Plugins (Extensions) +# Plugins ## Quick start @@ -329,7 +330,7 @@ See [Plugin manifest](/plugins/manifest) for the manifest file format. - [Plugin architecture and internals](/plugins/architecture) -- capability model, ownership model, contracts, load pipeline, runtime helpers, and developer API reference -- [Building extensions](/plugins/building-extensions) +- [Building plugins](/plugins/building-plugins) - [Plugin bundles](/plugins/bundles) - [Plugin manifest](/plugins/manifest) - [Plugin agent tools](/plugins/agent-tools) From e18ab85f08b9d8f01ceb1c840b22f457efa273e8 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 20 Mar 2026 10:59:29 -0700 Subject: [PATCH 14/24] docs(agents): clarify plugin nomenclature --- AGENTS.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index daaa0b1ebd5..6df75f20ad2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,8 @@ - Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`). - Tests: colocated `*.test.ts`. - Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`. -- Plugins/extensions: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them. +- Nomenclature: use "plugin" / "plugins" in docs, UI, changelogs, and contributor guidance. `extensions/*` remains the internal directory/package path to avoid repo-wide churn from a rename. +- Plugins: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them. - Plugins: install runs `npm install --omit=dev` in plugin dir; runtime deps must live in `dependencies`. Avoid `workspace:*` in `dependencies` (npm install breaks); put `openclaw` in `devDependencies` or `peerDependencies` instead (runtime resolves `openclaw/plugin-sdk` via jiti alias). - Import boundaries: extension production code should treat `openclaw/plugin-sdk/*` plus local `api.ts` / `runtime-api.ts` barrels as the public surface. Do not import core `src/**`, `src/plugin-sdk-internal/**`, or another extension's `src/**` directly. - Installers served from `https://openclaw.ai/*`: live in the sibling repo `../openclaw.ai` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`). From 3d097f10527c026780cbd1eb1f0967d7796bf534 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 20 Mar 2026 11:00:53 -0700 Subject: [PATCH 15/24] docs: rewrite tools landing page with Tools/Skills/Plugins explainer using Steps --- docs/tools/index.md | 125 ++++++++++++++++++++++---------------------- 1 file changed, 62 insertions(+), 63 deletions(-) diff --git a/docs/tools/index.md b/docs/tools/index.md index 15b8bb24a5f..77f334e826c 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -9,31 +9,71 @@ title: "Tools and Plugins" # Tools and Plugins -OpenClaw gives the agent a set of **tools** it can call during a conversation. +Everything the agent does beyond generating text happens through **tools**. Tools are how the agent reads files, runs commands, browses the web, sends -messages, and interacts with devices. Everything the agent does beyond generating -text happens through tools. +messages, and interacts with devices. -## How it all fits together +## Tools, skills, and plugins - - - Core tools shipped with OpenClaw: exec, browser, web search, file I/O, - messaging, cron, canvas, and nodes. - - - Markdown instructions that teach the agent how and when to use tools. - Skills ship inside plugins or live in your workspace. - - - Packages that add new capabilities: channels, model providers, tools, - skills, or any combination. Published on npm and installed with the CLI. - - - Hooks, cron jobs, heartbeats, webhooks, and scheduled tasks that run - without manual messages. - - +OpenClaw has three layers that work together: + + + + A tool is a typed function the agent can invoke (e.g. `exec`, `browser`, + `web_search`, `message`). OpenClaw ships a set of **built-in tools** and + plugins can register additional ones. + + The agent sees tools as structured function definitions sent to the model API. + + + + + A skill is a markdown file (`SKILL.md`) injected into the system prompt. + Skills give the agent context, constraints, and step-by-step guidance for + using tools effectively. Skills live in your workspace, in shared folders, + or ship inside plugins. + + [Skills reference](/tools/skills) | [Creating skills](/tools/creating-skills) + + + + + A plugin is a package that can register any combination of capabilities: + channels, model providers, tools, skills, speech, image generation, and more. + Some plugins are **core** (shipped with OpenClaw), others are **external** + (published on npm by the community). + + [Install and configure plugins](/tools/plugin) | [Build your own](/plugins/building-plugins) + + + + +## Built-in tools + +These tools ship with OpenClaw and are available without installing any plugins: + +| Tool | What it does | Page | +| ---------------------------- | -------------------------------------------------------- | --------------------------------- | +| `exec` / `process` | Run shell commands, manage background processes | [Exec](/tools/exec) | +| `browser` | Control a Chromium browser (navigate, click, screenshot) | [Browser](/tools/browser) | +| `web_search` / `web_fetch` | Search the web, fetch page content | [Web](/tools/web) | +| `read` / `write` / `edit` | File I/O in the workspace | | +| `apply_patch` | Multi-hunk file patches | [Apply Patch](/tools/apply-patch) | +| `message` | Send messages across all channels | [Agent Send](/tools/agent-send) | +| `canvas` | Drive node Canvas (present, eval, snapshot) | | +| `nodes` | Discover and target paired devices | | +| `cron` / `gateway` | Manage scheduled jobs, restart gateway | | +| `image` / `image_generate` | Analyze or generate images | | +| `sessions_*` / `agents_list` | Session management, sub-agents | [Sub-agents](/tools/subagents) | + +### Plugin-provided tools + +Plugins can register additional tools. Some examples: + +- [Lobster](/tools/lobster) — typed workflow runtime with resumable approvals +- [LLM Task](/tools/llm-task) — JSON-only LLM step for structured output +- [Diffs](/tools/diffs) — diff viewer and renderer +- [OpenProse](/prose) — markdown-first workflow orchestration ## Tool configuration @@ -95,44 +135,3 @@ changing global defaults: }, } ``` - -## Built-in tool reference - -For the full tool-by-tool reference (parameters, actions, notes), see the -individual tool pages in the sidebar. Key tools: - -| Tool | What it does | Page | -| ---------------------------- | -------------------------------------------------------- | --------------------------------- | -| `exec` / `process` | Run shell commands, manage background processes | [Exec](/tools/exec) | -| `browser` | Control a Chromium browser (navigate, click, screenshot) | [Browser](/tools/browser) | -| `web_search` / `web_fetch` | Search the web, fetch page content | [Web](/tools/web) | -| `read` / `write` / `edit` | File I/O in the workspace | | -| `apply_patch` | Multi-hunk file patches | [Apply Patch](/tools/apply-patch) | -| `message` | Send messages across all channels | [Agent Send](/tools/agent-send) | -| `canvas` | Drive node Canvas (present, eval, snapshot) | | -| `nodes` | Discover and target paired devices | | -| `cron` / `gateway` | Manage scheduled jobs, restart gateway | | -| `image` / `image_generate` | Analyze or generate images | | -| `sessions_*` / `agents_list` | Session management, sub-agents | [Sub-agents](/tools/subagents) | - -## Plugins add more - -Plugins can register **additional tools** beyond the built-in set. Some examples: - -- [Lobster](/tools/lobster) — typed workflow runtime with resumable approvals -- [LLM Task](/tools/llm-task) — JSON-only LLM step for structured output -- [Diffs](/tools/diffs) — diff viewer and renderer -- [OpenProse](/prose) — markdown-first workflow orchestration - -Plugins can also ship **skills** alongside tools, so the agent gets both the -tool definition and the instructions for using it. See -[Building Plugins](/plugins/building-plugins) to create your own. - -## How tools reach the agent - -Tools are exposed in two parallel channels: - -1. **System prompt text** — a human-readable list with guidance (from skills) -2. **Tool schemas** — structured function definitions sent to the model API - -If a tool doesn't appear in either, the model cannot call it. From 4edab304dbdb693344b35c590277c53e59e2f9c5 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 20 Mar 2026 11:10:45 -0700 Subject: [PATCH 16/24] docs: reorder Tools & Plugins nav, move Media/devices to Gateway tab, rewrite 4 problem pages with Mintlify components --- docs/docs.json | 103 +++++++++++++------------ docs/tools/agent-send.md | 119 ++++++++++++++++++++--------- docs/tools/creating-skills.md | 119 +++++++++++++++++++++-------- docs/tools/elevated.md | 139 +++++++++++++++++++++++----------- docs/tools/reactions.md | 65 +++++++++++++--- 5 files changed, 371 insertions(+), 174 deletions(-) diff --git a/docs/docs.json b/docs/docs.json index e39cf3f25a8..121125c6b98 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1025,43 +1025,6 @@ "group": "Overview", "pages": ["tools/index"] }, - { - "group": "Built-in tools", - "pages": [ - "tools/exec", - "tools/exec-approvals", - "tools/elevated", - "tools/apply-patch", - "tools/web", - "tools/brave-search", - "tools/firecrawl", - "tools/tavily", - "tools/perplexity-search", - "tools/pdf", - "tools/reactions", - "tools/thinking", - "tools/loop-detection", - "tools/btw" - ] - }, - { - "group": "Browser", - "pages": [ - "tools/browser", - "tools/browser-login", - "tools/browser-linux-troubleshooting", - "tools/browser-wsl2-windows-remote-cdp-troubleshooting" - ] - }, - { - "group": "Agent coordination", - "pages": [ - "tools/agent-send", - "tools/subagents", - "tools/acp-agents", - "tools/multi-agent-sandbox-tools" - ] - }, { "group": "Plugins", "pages": [ @@ -1087,10 +1050,6 @@ "prose" ] }, - { - "group": "Plugin tools", - "pages": ["tools/diffs", "tools/llm-task", "tools/lobster"] - }, { "group": "Automation", "pages": [ @@ -1106,18 +1065,43 @@ ] }, { - "group": "Media and devices", + "group": "Tools", "pages": [ - "nodes/index", - "nodes/troubleshooting", - "nodes/media-understanding", - "nodes/images", - "nodes/audio", - "nodes/camera", - "nodes/talk", - "nodes/voicewake", - "nodes/location-command", - "tools/tts" + "tools/exec", + "tools/exec-approvals", + "tools/elevated", + "tools/apply-patch", + "tools/web", + "tools/brave-search", + "tools/firecrawl", + "tools/tavily", + "tools/perplexity-search", + "tools/pdf", + "tools/diffs", + "tools/llm-task", + "tools/lobster", + "tools/reactions", + "tools/thinking", + "tools/loop-detection", + "tools/btw" + ] + }, + { + "group": "Browser", + "pages": [ + "tools/browser", + "tools/browser-login", + "tools/browser-linux-troubleshooting", + "tools/browser-wsl2-windows-remote-cdp-troubleshooting" + ] + }, + { + "group": "Agent coordination", + "pages": [ + "tools/agent-send", + "tools/subagents", + "tools/acp-agents", + "tools/multi-agent-sandbox-tools" ] } ] @@ -1287,6 +1271,21 @@ "security/CONTRIBUTING-THREAT-MODEL" ] }, + { + "group": "Nodes and devices", + "pages": [ + "nodes/index", + "nodes/troubleshooting", + "nodes/media-understanding", + "nodes/images", + "nodes/audio", + "nodes/camera", + "nodes/talk", + "nodes/voicewake", + "nodes/location-command", + "tools/tts" + ] + }, { "group": "Web interfaces", "pages": ["web/index", "web/control-ui", "web/dashboard", "web/webchat", "web/tui"] diff --git a/docs/tools/agent-send.md b/docs/tools/agent-send.md index e301feeea12..153a1e9b3c6 100644 --- a/docs/tools/agent-send.md +++ b/docs/tools/agent-send.md @@ -1,53 +1,100 @@ --- -summary: "Direct `openclaw agent` CLI runs (with optional delivery)" +summary: "Run agent turns from the CLI and optionally deliver replies to channels" read_when: - - Adding or modifying the agent CLI entrypoint + - You want to trigger agent runs from scripts or the command line + - You need to deliver agent replies to a chat channel programmatically title: "Agent Send" --- -# `openclaw agent` (direct agent runs) +# Agent Send -`openclaw agent` runs a single agent turn without needing an inbound chat message. -By default it goes **through the Gateway**; add `--local` to force the embedded -runtime on the current machine. +`openclaw agent` runs a single agent turn from the command line without needing +an inbound chat message. Use it for scripted workflows, testing, and +programmatic delivery. + +## Quick start + + + + ```bash + openclaw agent --message "What is the weather today?" + ``` + + This sends the message through the Gateway and prints the reply. + + + + + ```bash + # Target a specific agent + openclaw agent --agent ops --message "Summarize logs" + + # Target a phone number (derives session key) + openclaw agent --to +15555550123 --message "Status update" + + # Reuse an existing session + openclaw agent --session-id abc123 --message "Continue the task" + ``` + + + + + ```bash + # Deliver to WhatsApp (default channel) + openclaw agent --to +15555550123 --message "Report ready" --deliver + + # Deliver to Slack + openclaw agent --agent ops --message "Generate report" \ + --deliver --reply-channel slack --reply-to "#reports" + ``` + + + + +## Flags + +| Flag | Description | +| ----------------------------- | ----------------------------------------------------------- | +| `--message \` | Message to send (required) | +| `--to \` | Derive session key from a target (phone, chat id) | +| `--agent \` | Target a configured agent (uses its `main` session) | +| `--session-id \` | Reuse an existing session by id | +| `--local` | Force local embedded runtime (skip Gateway) | +| `--deliver` | Send the reply to a chat channel | +| `--channel \` | Delivery channel (whatsapp, telegram, discord, slack, etc.) | +| `--reply-to \` | Delivery target override | +| `--reply-channel \` | Delivery channel override | +| `--reply-account \` | Delivery account id override | +| `--thinking \` | Set thinking level (off, minimal, low, medium, high, xhigh) | +| `--verbose \` | Set verbose level | +| `--timeout \` | Override agent timeout | +| `--json` | Output structured JSON | ## Behavior -- Required: `--message ` -- Session selection: - - `--to ` derives the session key (group/channel targets preserve isolation; direct chats collapse to `main`), **or** - - `--session-id ` reuses an existing session by id, **or** - - `--agent ` targets a configured agent directly (uses that agent's `main` session key) -- Runs the same embedded agent runtime as normal inbound replies. -- Thinking/verbose flags persist into the session store. -- Output: - - default: prints reply text (plus `MEDIA:` lines) - - `--json`: prints structured payload + metadata -- Optional delivery back to a channel with `--deliver` + `--channel` (target formats match `openclaw message --target`). -- Use `--reply-channel`/`--reply-to`/`--reply-account` to override delivery without changing the session. - -If the Gateway is unreachable, the CLI **falls back** to the embedded local run. +- By default, the CLI goes **through the Gateway**. Add `--local` to force the + embedded runtime on the current machine. +- If the Gateway is unreachable, the CLI **falls back** to the local embedded run. +- Session selection: `--to` derives the session key (group/channel targets + preserve isolation; direct chats collapse to `main`). +- Thinking and verbose flags persist into the session store. +- Output: plain text by default, or `--json` for structured payload + metadata. ## Examples ```bash -openclaw agent --to +15555550123 --message "status update" -openclaw agent --agent ops --message "Summarize logs" -openclaw agent --session-id 1234 --message "Summarize inbox" --thinking medium +# Simple turn with JSON output openclaw agent --to +15555550123 --message "Trace logs" --verbose on --json -openclaw agent --to +15555550123 --message "Summon reply" --deliver -openclaw agent --agent ops --message "Generate report" --deliver --reply-channel slack --reply-to "#reports" + +# Turn with thinking level +openclaw agent --session-id 1234 --message "Summarize inbox" --thinking medium + +# Deliver to a different channel than the session +openclaw agent --agent ops --message "Alert" --deliver --reply-channel telegram --reply-to "@admin" ``` -## Flags +## Related -- `--local`: run locally (requires model provider API keys in your shell) -- `--deliver`: send the reply to the chosen channel -- `--channel`: delivery channel (`whatsapp|telegram|discord|googlechat|slack|signal|imessage`, default: `whatsapp`) -- `--reply-to`: delivery target override -- `--reply-channel`: delivery channel override -- `--reply-account`: delivery account id override -- `--thinking `: persist thinking level (GPT-5.2 + Codex models only) -- `--verbose `: persist verbose level -- `--timeout `: override agent timeout -- `--json`: output structured JSON +- [Agent CLI reference](/cli/agent) +- [Sub-agents](/tools/subagents) — background sub-agent spawning +- [Sessions](/concepts/session) — how session keys work diff --git a/docs/tools/creating-skills.md b/docs/tools/creating-skills.md index 964165ad0a2..69024038efc 100644 --- a/docs/tools/creating-skills.md +++ b/docs/tools/creating-skills.md @@ -6,53 +6,112 @@ read_when: - You need a quick starter workflow for SKILL.md-based skills --- -# Creating Custom Skills 🛠 +# Creating Skills -OpenClaw is designed to be easily extensible. "Skills" are the primary way to add new capabilities to your assistant. +Skills teach the agent how and when to use tools. Each skill is a directory +containing a `SKILL.md` file with YAML frontmatter and markdown instructions. -## What is a Skill? +For how skills are loaded and prioritized, see [Skills](/tools/skills). -A skill is a directory containing a `SKILL.md` file (which provides instructions and tool definitions to the LLM) and optionally some scripts or resources. +## Create your first skill -## Step-by-Step: Your First Skill + + + Skills live in your workspace. Create a new folder: -### 1. Create the Directory + ```bash + mkdir -p ~/.openclaw/workspace/skills/hello-world + ``` -Skills live in your workspace, usually `~/.openclaw/workspace/skills/`. Create a new folder for your skill: + -```bash -mkdir -p ~/.openclaw/workspace/skills/hello-world -``` + + Create `SKILL.md` inside that directory. The frontmatter defines metadata, + and the markdown body contains instructions for the agent. -### 2. Define the `SKILL.md` + ```markdown + --- + name: hello_world + description: A simple skill that says hello. + --- -Create a `SKILL.md` file in that directory. This file uses YAML frontmatter for metadata and Markdown for instructions. + # Hello World Skill -```markdown ---- -name: hello_world -description: A simple skill that says hello. ---- + When the user asks for a greeting, use the `echo` tool to say + "Hello from your custom skill!". + ``` -# Hello World Skill + -When the user asks for a greeting, use the `echo` tool to say "Hello from your custom skill!". -``` + + You can define custom tool schemas in the frontmatter or instruct the agent + to use existing system tools (like `exec` or `browser`). Skills can also + ship inside plugins alongside the tools they document. -### 3. Add Tools (Optional) + -You can define custom tools in the frontmatter or instruct the agent to use existing system tools (like `bash` or `browser`). + + Start a new session so OpenClaw picks up the skill: -### 4. Refresh OpenClaw + ```bash + # From chat + /new -Ask your agent to "refresh skills" or restart the gateway. OpenClaw will discover the new directory and index the `SKILL.md`. + # Or restart the gateway + openclaw gateway restart + ``` -## Best Practices + Verify the skill loaded: -- **Be Concise**: Instruct the model on _what_ to do, not how to be an AI. -- **Safety First**: If your skill uses `bash`, ensure the prompts don't allow arbitrary command injection from untrusted user input. -- **Test Locally**: Use `openclaw agent --message "use my new skill"` to test. + ```bash + openclaw skills list + ``` -## Shared Skills + -You can also browse and contribute skills to [ClawHub](https://clawhub.com). + + Send a message that should trigger the skill: + + ```bash + openclaw agent --message "give me a greeting" + ``` + + Or just chat with the agent and ask for a greeting. + + + + +## Skill metadata reference + +The YAML frontmatter supports these fields: + +| Field | Required | Description | +| ----------------------------------- | -------- | ------------------------------------------- | +| `name` | Yes | Unique identifier (snake_case) | +| `description` | Yes | One-line description shown to the agent | +| `metadata.openclaw.os` | No | OS filter (`["darwin"]`, `["linux"]`, etc.) | +| `metadata.openclaw.requires.bins` | No | Required binaries on PATH | +| `metadata.openclaw.requires.config` | No | Required config keys | + +## Best practices + +- **Be concise** — instruct the model on _what_ to do, not how to be an AI +- **Safety first** — if your skill uses `exec`, ensure prompts don't allow arbitrary command injection from untrusted input +- **Test locally** — use `openclaw agent --message "..."` to test before sharing +- **Use ClawHub** — browse and contribute skills at [ClawHub](https://clawhub.com) + +## Where skills live + +| Location | Precedence | Scope | +| ------------------------------- | ---------- | --------------------- | +| `\/skills/` | Highest | Per-agent | +| `~/.openclaw/skills/` | Medium | Shared (all agents) | +| Bundled (shipped with OpenClaw) | Lowest | Global | +| `skills.load.extraDirs` | Lowest | Custom shared folders | + +## Related + +- [Skills reference](/tools/skills) — loading, precedence, and gating rules +- [Skills config](/tools/skills-config) — `skills.*` config schema +- [ClawHub](/tools/clawhub) — public skill registry +- [Building Plugins](/plugins/building-plugins) — plugins can ship skills diff --git a/docs/tools/elevated.md b/docs/tools/elevated.md index c10b955ce2d..96a574f6fc9 100644 --- a/docs/tools/elevated.md +++ b/docs/tools/elevated.md @@ -1,63 +1,114 @@ --- -summary: "Elevated exec mode and /elevated directives" +summary: "Elevated exec mode: run commands on the gateway host from a sandboxed agent" read_when: - Adjusting elevated mode defaults, allowlists, or slash command behavior + - Understanding how sandboxed agents can access the host title: "Elevated Mode" --- -# Elevated Mode (/elevated directives) +# Elevated Mode -## What it does +When an agent runs inside a sandbox, its `exec` commands are confined to the +sandbox environment. **Elevated mode** lets the agent break out and run commands +on the gateway host instead, with configurable approval gates. -- `/elevated on` runs on the gateway host and keeps exec approvals (same as `/elevated ask`). -- `/elevated full` runs on the gateway host **and** auto-approves exec (skips exec approvals). -- `/elevated ask` runs on the gateway host but keeps exec approvals (same as `/elevated on`). -- `on`/`ask` do **not** force `exec.security=full`; configured security/ask policy still applies. -- Only changes behavior when the agent is **sandboxed** (otherwise exec already runs on the host). -- Directive forms: `/elevated on|off|ask|full`, `/elev on|off|ask|full`. -- Only `on|off|ask|full` are accepted; anything else returns a hint and does not change state. + + Elevated mode only changes behavior when the agent is **sandboxed**. For + unsandboxed agents, exec already runs on the host. + -## What it controls (and what it does not) +## Directives -- **Availability gates**: `tools.elevated` is the global baseline. `agents.list[].tools.elevated` can further restrict elevated per agent (both must allow). -- **Per-session state**: `/elevated on|off|ask|full` sets the elevated level for the current session key. -- **Inline directive**: `/elevated on|ask|full` inside a message applies to that message only. -- **Groups**: In group chats, elevated directives are only honored when the agent is mentioned. Command-only messages that bypass mention requirements are treated as mentioned. -- **Host execution**: elevated forces `exec` onto the gateway host; `full` also sets `security=full`. -- **Approvals**: `full` skips exec approvals; `on`/`ask` honor them when allowlist/ask rules require. -- **Unsandboxed agents**: no-op for location; only affects gating, logging, and status. -- **Tool policy still applies**: if `exec` is denied by tool policy, elevated cannot be used. -- **Separate from `/exec`**: `/exec` adjusts per-session defaults for authorized senders and does not require elevated. +Control elevated mode per-session with slash commands: + +| Directive | What it does | +| ---------------- | --------------------------------------------------- | +| `/elevated on` | Run on the gateway host, keep exec approvals | +| `/elevated ask` | Same as `on` (alias) | +| `/elevated full` | Run on the gateway host **and** skip exec approvals | +| `/elevated off` | Return to sandbox-confined execution | + +Also available as `/elev on|off|ask|full`. + +Send `/elevated` with no argument to see the current level. + +## How it works + + + + Elevated must be enabled in config and the sender must be on the allowlist: + + ```json5 + { + tools: { + elevated: { + enabled: true, + allowFrom: { + discord: ["user-id-123"], + whatsapp: ["+15555550123"], + }, + }, + }, + } + ``` + + + + + Send a directive-only message to set the session default: + + ``` + /elevated full + ``` + + Or use it inline (applies to that message only): + + ``` + /elevated on run the deployment script + ``` + + + + + With elevated active, `exec` calls route to the gateway host instead of the + sandbox. In `full` mode, exec approvals are skipped. In `on`/`ask` mode, + configured approval rules still apply. + + ## Resolution order -1. Inline directive on the message (applies only to that message). -2. Session override (set by sending a directive-only message). -3. Global default (`agents.defaults.elevatedDefault` in config). +1. **Inline directive** on the message (applies only to that message) +2. **Session override** (set by sending a directive-only message) +3. **Global default** (`agents.defaults.elevatedDefault` in config) -## Setting a session default +## Availability and allowlists -- Send a message that is **only** the directive (whitespace allowed), e.g. `/elevated full`. -- Confirmation reply is sent (`Elevated mode set to full...` / `Elevated mode disabled.`). -- If elevated access is disabled or the sender is not on the approved allowlist, the directive replies with an actionable error and does not change session state. -- Send `/elevated` (or `/elevated:`) with no argument to see the current elevated level. +- **Global gate**: `tools.elevated.enabled` (must be `true`) +- **Sender allowlist**: `tools.elevated.allowFrom` with per-channel lists +- **Per-agent gate**: `agents.list[].tools.elevated.enabled` (can only further restrict) +- **Per-agent allowlist**: `agents.list[].tools.elevated.allowFrom` (sender must match both global + per-agent) +- **Discord fallback**: if `tools.elevated.allowFrom.discord` is omitted, `channels.discord.allowFrom` is used as fallback +- **All gates must pass**; otherwise elevated is treated as unavailable -## Availability + allowlists +Allowlist entry formats: -- Feature gate: `tools.elevated.enabled` (default can be off via config even if the code supports it). -- Sender allowlist: `tools.elevated.allowFrom` with per-provider allowlists (e.g. `discord`, `whatsapp`). -- Unprefixed allowlist entries match sender-scoped identity values only (`SenderId`, `SenderE164`, `From`); recipient routing fields are never used for elevated authorization. -- Mutable sender metadata requires explicit prefixes: - - `name:` matches `SenderName` - - `username:` matches `SenderUsername` - - `tag:` matches `SenderTag` - - `id:`, `from:`, `e164:` are available for explicit identity targeting -- Per-agent gate: `agents.list[].tools.elevated.enabled` (optional; can only further restrict). -- Per-agent allowlist: `agents.list[].tools.elevated.allowFrom` (optional; when set, the sender must match **both** global + per-agent allowlists). -- Discord fallback: if `tools.elevated.allowFrom.discord` is omitted, the `channels.discord.allowFrom` list is used as a fallback (legacy: `channels.discord.dm.allowFrom`). Set `tools.elevated.allowFrom.discord` (even `[]`) to override. Per-agent allowlists do **not** use the fallback. -- All gates must pass; otherwise elevated is treated as unavailable. +| Prefix | Matches | +| ----------------------- | ------------------------------- | +| (none) | Sender ID, E.164, or From field | +| `name:` | Sender display name | +| `username:` | Sender username | +| `tag:` | Sender tag | +| `id:`, `from:`, `e164:` | Explicit identity targeting | -## Logging + status +## What elevated does not control -- Elevated exec calls are logged at info level. -- Session status includes elevated mode (e.g. `elevated=ask`, `elevated=full`). +- **Tool policy**: if `exec` is denied by tool policy, elevated cannot override it +- **Separate from `/exec`**: the `/exec` directive adjusts per-session exec defaults for authorized senders and does not require elevated mode + +## Related + +- [Exec tool](/tools/exec) — shell command execution +- [Exec approvals](/tools/exec-approvals) — approval and allowlist system +- [Sandboxing](/gateway/sandboxing) — sandbox configuration +- [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated) diff --git a/docs/tools/reactions.md b/docs/tools/reactions.md index 17f9cfbb7f9..56d6b5942e7 100644 --- a/docs/tools/reactions.md +++ b/docs/tools/reactions.md @@ -1,23 +1,64 @@ --- -summary: "Reaction semantics shared across channels" +summary: "Reaction tool semantics across all supported channels" read_when: - Working on reactions in any channel + - Understanding how emoji reactions differ across platforms title: "Reactions" --- -# Reaction tooling +# Reactions -Shared reaction semantics across channels: +The agent can add and remove emoji reactions on messages using the `message` +tool with the `react` action. Reaction behavior varies by channel. + +## How it works + +```json +{ + "action": "react", + "messageId": "msg-123", + "emoji": "thumbsup" +} +``` - `emoji` is required when adding a reaction. -- `emoji=""` removes the bot's reaction(s) when supported. -- `remove: true` removes the specified emoji when supported (requires `emoji`). +- Set `emoji` to an empty string (`""`) to remove the bot's reaction(s). +- Set `remove: true` to remove a specific emoji (requires non-empty `emoji`). -Channel notes: +## Channel behavior -- **Discord/Slack**: empty `emoji` removes all of the bot's reactions on the message; `remove: true` removes just that emoji. -- **Google Chat**: empty `emoji` removes the app's reactions on the message; `remove: true` removes just that emoji. -- **Telegram**: empty `emoji` removes the bot's reactions; `remove: true` also removes reactions but still requires a non-empty `emoji` for tool validation. -- **WhatsApp**: empty `emoji` removes the bot reaction; `remove: true` maps to empty emoji (still requires `emoji`). -- **Zalo Personal (`zalouser`)**: requires non-empty `emoji`; `remove: true` removes that specific emoji reaction. -- **Signal**: inbound reaction notifications emit system events when `channels.signal.reactionNotifications` is enabled. + + + - Empty `emoji` removes all of the bot's reactions on the message. + - `remove: true` removes just the specified emoji. + + + + - Empty `emoji` removes the app's reactions on the message. + - `remove: true` removes just the specified emoji. + + + + - Empty `emoji` removes the bot's reactions. + - `remove: true` also removes reactions but still requires a non-empty `emoji` for tool validation. + + + + - Empty `emoji` removes the bot reaction. + - `remove: true` maps to empty emoji internally (still requires `emoji` in the tool call). + + + + - Requires non-empty `emoji`. + - `remove: true` removes that specific emoji reaction. + + + + - Inbound reaction notifications emit system events when `channels.signal.reactionNotifications` is enabled. + + + +## Related + +- [Agent Send](/tools/agent-send) — the `message` tool that includes `react` +- [Channels](/channels) — channel-specific configuration From a4a5ed8948355db015b5563dcebfad78dd5bb68c Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 20 Mar 2026 11:17:49 -0700 Subject: [PATCH 17/24] docs: retitle plugin internals/agent-tools/cookbook, collapse Browser into Tools, reorder Plugins group --- docs/docs.json | 37 ++++++++++++++++++------------- docs/plugins/agent-tools.md | 16 ++++++++----- docs/plugins/architecture.md | 16 ++++++++----- docs/tools/capability-cookbook.md | 13 ++++++++--- 4 files changed, 52 insertions(+), 30 deletions(-) diff --git a/docs/docs.json b/docs/docs.json index 121125c6b98..2ae489a38b0 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1030,12 +1030,12 @@ "pages": [ "tools/plugin", "plugins/building-plugins", - "plugins/sdk-migration", - "plugins/architecture", "plugins/community", "plugins/bundles", "plugins/manifest", "plugins/agent-tools", + "plugins/sdk-migration", + "plugins/architecture", "tools/capability-cookbook" ] }, @@ -1071,11 +1071,25 @@ "tools/exec-approvals", "tools/elevated", "tools/apply-patch", - "tools/web", - "tools/brave-search", - "tools/firecrawl", - "tools/tavily", - "tools/perplexity-search", + { + "group": "Browser", + "pages": [ + "tools/browser", + "tools/browser-login", + "tools/browser-linux-troubleshooting", + "tools/browser-wsl2-windows-remote-cdp-troubleshooting" + ] + }, + { + "group": "Web and search", + "pages": [ + "tools/web", + "tools/brave-search", + "tools/firecrawl", + "tools/tavily", + "tools/perplexity-search" + ] + }, "tools/pdf", "tools/diffs", "tools/llm-task", @@ -1086,15 +1100,6 @@ "tools/btw" ] }, - { - "group": "Browser", - "pages": [ - "tools/browser", - "tools/browser-login", - "tools/browser-linux-troubleshooting", - "tools/browser-wsl2-windows-remote-cdp-troubleshooting" - ] - }, { "group": "Agent coordination", "pages": [ diff --git a/docs/plugins/agent-tools.md b/docs/plugins/agent-tools.md index 8740fd51fa4..ea3cd7e231d 100644 --- a/docs/plugins/agent-tools.md +++ b/docs/plugins/agent-tools.md @@ -1,16 +1,20 @@ --- -summary: "Write agent tools in a plugin (schemas, optional tools, allowlists)" +summary: "Register custom agent tools in a plugin with schemas, optional opt-in, and allowlists" read_when: - You want to add a new agent tool in a plugin - You need to make a tool opt-in via allowlists -title: "Plugin Agent Tools" +title: "Registering Tools in Plugins" +sidebarTitle: "Registering Tools" --- -# Plugin agent tools +# Registering Tools in Plugins -OpenClaw plugins can register **agent tools** (JSON‑schema functions) that are exposed -to the LLM during agent runs. Tools can be **required** (always available) or -**optional** (opt‑in). +Plugins can register **agent tools** — typed functions that the LLM can call +during agent runs. Tools can be **required** (always available) or +**optional** (users opt in via allowlists). + +See [Building Plugins](/plugins/building-plugins) for the full plugin creation +guide. This page focuses on the tool registration API. Agent tools are configured under `tools` in the main config, or per‑agent under `agents.list[].tools`. The allowlist/denylist policy controls which tools the agent diff --git a/docs/plugins/architecture.md b/docs/plugins/architecture.md index 4ffdeb29125..90070dae177 100644 --- a/docs/plugins/architecture.md +++ b/docs/plugins/architecture.md @@ -1,17 +1,23 @@ --- -summary: "Plugin architecture internals: capability model, ownership, contracts, load pipeline, runtime helpers" +summary: "Plugin internals: capability model, ownership, contracts, load pipeline, and runtime helpers" read_when: - Building or debugging native OpenClaw plugins - Understanding the plugin capability model or ownership boundaries - Working on the plugin load pipeline or registry - Implementing provider runtime hooks or channel plugins -title: "Plugin Architecture" +title: "Plugin Internals" +sidebarTitle: "Internals" --- -# Plugin Architecture +# Plugin Internals -This page covers the internal architecture of the OpenClaw plugin system. For -user-facing setup, discovery, and configuration, see [Plugins](/tools/plugin). + + 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 page covers the internal architecture of the OpenClaw plugin system. ## Public capability model diff --git a/docs/tools/capability-cookbook.md b/docs/tools/capability-cookbook.md index f439c362e89..7a5ab50611a 100644 --- a/docs/tools/capability-cookbook.md +++ b/docs/tools/capability-cookbook.md @@ -1,13 +1,20 @@ --- -summary: "Cookbook for adding a new shared capability to OpenClaw" +summary: "Contributor guide for adding a new shared capability to the OpenClaw plugin system" read_when: - Adding a new core capability and plugin registration surface - Deciding whether code belongs in core, a vendor plugin, or a feature plugin - Wiring a new runtime helper for channels or tools -title: "Capability Cookbook" +title: "Adding Capabilities (Contributor Guide)" +sidebarTitle: "Adding Capabilities" --- -# Capability Cookbook +# Adding Capabilities + + + This is a **contributor guide** for OpenClaw core developers. If you are + building an external plugin, see [Building Plugins](/plugins/building-plugins) + instead. + Use this when OpenClaw needs a new domain such as image generation, video generation, or some future vendor-backed feature area. From fb293fa36fad51f844116caca385ef8824bd6bde Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 20 Mar 2026 11:20:36 -0700 Subject: [PATCH 18/24] docs: rewrite plugins install/configure page with Steps, Accordions, and clear hierarchy --- docs/tools/plugin.md | 393 +++++++++++++++++-------------------------- 1 file changed, 153 insertions(+), 240 deletions(-) diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 9b61c2ee015..b30463ce270 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -1,5 +1,5 @@ --- -summary: "OpenClaw plugins: install, configure, and manage plugins that extend the gateway" +summary: "Install, configure, and manage OpenClaw plugins" read_when: - Installing or configuring plugins - Understanding plugin discovery and load rules @@ -10,61 +10,55 @@ sidebarTitle: "Install and Configure" # Plugins +Plugins extend OpenClaw with new capabilities: channels, model providers, tools, +skills, speech, image generation, and more. Some plugins are **core** (shipped +with OpenClaw), others are **external** (published on npm by the community). + ## Quick start -A plugin is either: + + + ```bash + openclaw plugins list + ``` + -- a native **OpenClaw plugin** (`openclaw.plugin.json` + runtime module), or -- a compatible **bundle** (`.codex-plugin/plugin.json` or `.claude-plugin/plugin.json`) + + ```bash + # From npm + openclaw plugins install @openclaw/voice-call -Both show up under `openclaw plugins`, but only native OpenClaw plugins execute -runtime code in-process. + # From a local directory or archive + openclaw plugins install ./my-plugin + openclaw plugins install ./my-plugin.tgz + ``` -1. See what is already loaded: + -```bash -openclaw plugins list -``` + + ```bash + openclaw gateway restart + ``` -2. Install an official plugin (example: Voice Call): + Then configure under `plugins.entries.\.config` in your config file. -```bash -openclaw plugins install @openclaw/voice-call -``` + + -Npm specs are registry-only. See [install rules](/cli/plugins#install) for -details on pinning, prerelease gating, and supported spec formats. +## Plugin types -3. Restart the Gateway, then configure under `plugins.entries..config`. +OpenClaw recognizes two plugin formats: -See [Voice Call](/plugins/voice-call) for a concrete example plugin. -Looking for third-party listings? See [Community plugins](/plugins/community). -Need the bundle compatibility details? See [Plugin bundles](/plugins/bundles). +| Format | How it works | Examples | +| ---------- | ------------------------------------------------------------------ | ------------------------------------------------------ | +| **Native** | `openclaw.plugin.json` + runtime module; executes in-process | Official plugins, community npm packages | +| **Bundle** | Codex/Claude/Cursor-compatible layout; mapped to OpenClaw features | `.codex-plugin/`, `.claude-plugin/`, `.cursor-plugin/` | -For compatible bundles, install from a local directory or archive: +Both show up under `openclaw plugins list`. See [Plugin Bundles](/plugins/bundles) for bundle details. -```bash -openclaw plugins install ./my-bundle -openclaw plugins install ./my-bundle.tgz -``` +## Official plugins -For Claude marketplace installs, list the marketplace first, then install by -marketplace entry name: - -```bash -openclaw plugins marketplace list -openclaw plugins install @ -``` - -OpenClaw resolves known Claude marketplace names from -`~/.claude/plugins/known_marketplaces.json`. You can also pass an explicit -marketplace source with `--marketplace`. - -## Available plugins (official) - -### Installable plugins - -These are published to npm and installed with `openclaw plugins install`: +### Installable (npm) | Plugin | Package | Docs | | --------------- | ---------------------- | ------------------------------------ | @@ -75,51 +69,34 @@ These are published to npm and installed with `openclaw plugins install`: | Zalo | `@openclaw/zalo` | [Zalo](/channels/zalo) | | Zalo Personal | `@openclaw/zalouser` | [Zalo Personal](/plugins/zalouser) | -Microsoft Teams is plugin-only as of 2026.1.15. +### Core (shipped with OpenClaw) -Packaged installs also ship install-on-demand metadata for heavyweight official -plugins. Today that includes WhatsApp and `memory-lancedb`: onboarding, -`openclaw channels add`, `openclaw channels login --channel whatsapp`, and -other channel setup flows prompt to install them when first used instead of -shipping their full runtime trees inside the main npm tarball. + + + `anthropic`, `byteplus`, `cloudflare-ai-gateway`, `github-copilot`, `google`, + `huggingface`, `kilocode`, `kimi-coding`, `minimax`, `mistral`, `modelstudio`, + `moonshot`, `nvidia`, `openai`, `opencode`, `opencode-go`, `openrouter`, + `qianfan`, `qwen-portal-auth`, `synthetic`, `together`, `venice`, + `vercel-ai-gateway`, `volcengine`, `xiaomi`, `zai` + -### Bundled plugins + + - `memory-core` — bundled memory search (default via `plugins.slots.memory`) + - `memory-lancedb` — install-on-demand long-term memory with auto-recall/capture (set `plugins.slots.memory = "memory-lancedb"`) + -These ship with OpenClaw and are enabled by default unless noted. + + `elevenlabs`, `microsoft` + -**Memory:** + + - `copilot-proxy` — VS Code Copilot Proxy bridge (disabled by default) + + -- `memory-core` -- bundled memory search (default via `plugins.slots.memory`) -- `memory-lancedb` -- install-on-demand long-term memory with auto-recall/capture (set `plugins.slots.memory = "memory-lancedb"`) +Looking for third-party plugins? See [Community Plugins](/plugins/community). -**Model providers** (all enabled by default): - -`anthropic`, `byteplus`, `cloudflare-ai-gateway`, `github-copilot`, `google`, `huggingface`, `kilocode`, `kimi-coding`, `minimax`, `mistral`, `modelstudio`, `moonshot`, `nvidia`, `openai`, `opencode`, `opencode-go`, `openrouter`, `qianfan`, `qwen-portal-auth`, `synthetic`, `together`, `venice`, `vercel-ai-gateway`, `volcengine`, `xiaomi`, `zai` - -**Speech providers** (enabled by default): - -`elevenlabs`, `microsoft` - -**Other bundled:** - -- `copilot-proxy` -- VS Code Copilot Proxy bridge (disabled by default) - -## Compatible bundles - -OpenClaw also recognizes compatible external bundle layouts: - -- Codex-style bundles: `.codex-plugin/plugin.json` -- Claude-style bundles: `.claude-plugin/plugin.json` or the default Claude - component layout without a manifest -- Cursor-style bundles: `.cursor-plugin/plugin.json` - -They are shown in the plugin list as `format=bundle`, with a subtype of -`codex`, `claude`, or `cursor` in verbose/inspect output. - -See [Plugin bundles](/plugins/bundles) for the exact detection rules, mapping -behavior, and current support matrix. - -## Config +## Configuration ```json5 { @@ -135,204 +112,140 @@ behavior, and current support matrix. } ``` -Fields: +| Field | Description | +| ---------------- | --------------------------------------------------------- | +| `enabled` | Master toggle (default: `true`) | +| `allow` | Plugin allowlist (optional) | +| `deny` | Plugin denylist (optional; deny wins) | +| `load.paths` | Extra plugin files/directories | +| `slots` | Exclusive slot selectors (e.g. `memory`, `contextEngine`) | +| `entries.\` | Per-plugin toggles + config | -- `enabled`: master toggle (default: true) -- `allow`: allowlist (optional) -- `deny`: denylist (optional; deny wins) -- `load.paths`: extra plugin files/dirs -- `slots`: exclusive slot selectors such as `memory` and `contextEngine` -- `entries.`: per-plugin toggles + config +Config changes **require a gateway restart**. -Config changes **require a gateway restart**. See -[Configuration reference](/configuration) for the full config schema. - -Validation rules (strict): - -- Unknown plugin ids in `entries`, `allow`, `deny`, or `slots` are **errors**. -- Unknown `channels.` keys are **errors** unless a plugin manifest declares - the channel id. -- Native plugin config is validated using the JSON Schema embedded in - `openclaw.plugin.json` (`configSchema`). -- Compatible bundles currently do not expose native OpenClaw config schemas. -- If a plugin is disabled, its config is preserved and a **warning** is emitted. - -### Disabled vs missing vs invalid - -These states are intentionally different: - -- **disabled**: plugin exists, but enablement rules turned it off -- **missing**: config references a plugin id that discovery did not find -- **invalid**: plugin exists, but its config does not match the declared schema - -OpenClaw preserves config for disabled plugins so toggling them back on is not -destructive. + + - **Disabled**: plugin exists but enablement rules turned it off. Config is preserved. + - **Missing**: config references a plugin id that discovery did not find. + - **Invalid**: plugin exists but its config does not match the declared schema. + ## Discovery and precedence -OpenClaw scans, in order: +OpenClaw scans for plugins in this order (first match wins): -1. Config paths + + + `plugins.load.paths` — explicit file or directory paths. + -- `plugins.load.paths` (file or directory) + + `\/.openclaw/extensions/*.ts` and `\/.openclaw/extensions/*/index.ts`. + -2. Workspace extensions + + `~/.openclaw/extensions/*.ts` and `~/.openclaw/extensions/*/index.ts`. + -- `/.openclaw/extensions/*.ts` -- `/.openclaw/extensions/*/index.ts` - -3. Global extensions - -- `~/.openclaw/extensions/*.ts` -- `~/.openclaw/extensions/*/index.ts` - -4. Bundled extensions (shipped with OpenClaw; mixed default-on/default-off) - -- `/dist/extensions/*` in packaged installs -- `/dist-runtime/extensions/*` in local built checkouts -- `/extensions/*` in source/Vitest workflows - -Many bundled provider plugins are enabled by default so model catalogs/runtime -hooks stay available without extra setup. Others still require explicit -enablement via `plugins.entries..enabled` or -`openclaw plugins enable `. - -Bundled plugin runtime dependencies are owned by each plugin package. Packaged -builds stage opted-in bundled dependencies under -`dist/extensions//node_modules` instead of requiring mirrored copies in the -root package. Very large official plugins can ship as metadata-only bundled -entries and install their runtime package on demand. npm artifacts ship the -built `dist/extensions/*` tree; source `extensions/*` directories stay in source -checkouts only. - -Installed plugins are enabled by default, but can be disabled the same way. - -Workspace plugins are **disabled by default** unless you explicitly enable them -or allowlist them. This is intentional: a checked-out repo should not silently -become production gateway code. - -If multiple plugins resolve to the same id, the first match in the order above -wins and lower-precedence copies are ignored. + + Shipped with OpenClaw. Many are enabled by default (model providers, speech). + Others require explicit enablement. + + ### Enablement rules -Enablement is resolved after discovery: - - `plugins.enabled: false` disables all plugins -- `plugins.deny` always wins -- `plugins.entries..enabled: false` disables that plugin -- workspace-origin plugins are disabled by default -- allowlists restrict the active set when `plugins.allow` is non-empty -- allowlists are **id-based**, not source-based -- bundled plugins are disabled by default unless: - - the bundled id is in the built-in default-on set, or - - you explicitly enable it, or - - channel config implicitly enables the bundled channel plugin -- exclusive slots can force-enable the selected plugin for that slot +- `plugins.deny` always wins over allow +- `plugins.entries.\.enabled: false` disables that plugin +- Workspace-origin plugins are **disabled by default** (must be explicitly enabled) +- Bundled plugins follow the built-in default-on set unless overridden +- Exclusive slots can force-enable the selected plugin for that slot ## Plugin slots (exclusive categories) -Some plugin categories are **exclusive** (only one active at a time). Use -`plugins.slots` to select which plugin owns the slot: +Some categories are exclusive (only one active at a time): ```json5 { plugins: { slots: { - memory: "memory-core", // or "none" to disable memory plugins - contextEngine: "legacy", // or a plugin id such as "lossless-claw" + memory: "memory-core", // or "none" to disable + contextEngine: "legacy", // or a plugin id }, }, } ``` -Supported exclusive slots: +| Slot | What it controls | Default | +| --------------- | --------------------- | ------------------- | +| `memory` | Active memory plugin | `memory-core` | +| `contextEngine` | Active context engine | `legacy` (built-in) | -- `memory`: active memory plugin (`"none"` disables memory plugins) -- `contextEngine`: active context engine plugin (`"legacy"` is the built-in default) - -If multiple plugins declare `kind: "memory"` or `kind: "context-engine"`, only -the selected plugin loads for that slot. Others are disabled with diagnostics. -Declare `kind` in your [plugin manifest](/plugins/manifest). - -## Plugin IDs - -Default plugin ids: - -- Package packs: `package.json` `name` -- Standalone file: file base name (`~/.../voice-call.ts` -> `voice-call`) - -If a plugin exports `id`, OpenClaw uses it but warns when it does not match the -configured id. - -## Inspection +## CLI reference ```bash -openclaw plugins inspect openai # deep detail on one plugin -openclaw plugins inspect openai --json # machine-readable -openclaw plugins list # compact inventory -openclaw plugins status # operational summary -openclaw plugins doctor # issue-focused diagnostics -``` +openclaw plugins list # compact inventory +openclaw plugins inspect # deep detail +openclaw plugins inspect --json # machine-readable +openclaw plugins status # operational summary +openclaw plugins doctor # diagnostics -## CLI +openclaw plugins install # install from npm +openclaw plugins install # install from local path +openclaw plugins install -l # link (no copy) for dev +openclaw plugins update # update one plugin +openclaw plugins update --all # update all -```bash -openclaw plugins list -openclaw plugins inspect -openclaw plugins install # copy a local file/dir into ~/.openclaw/extensions/ -openclaw plugins install ./extensions/voice-call # relative path ok -openclaw plugins install ./plugin.tgz # install from a local tarball -openclaw plugins install ./plugin.zip # install from a local zip -openclaw plugins install -l ./extensions/voice-call # link (no copy) for dev -openclaw plugins install @openclaw/voice-call # install from npm -openclaw plugins install @openclaw/voice-call --pin # store exact resolved name@version -openclaw plugins update -openclaw plugins update --all openclaw plugins enable openclaw plugins disable -openclaw plugins doctor ``` -See [`openclaw plugins` CLI reference](/cli/plugins) for full details on each -command (install rules, inspect output, marketplace installs, uninstall). +See [`openclaw plugins` CLI reference](/cli/plugins) for full details. -Plugins may also register their own top-level commands (example: -`openclaw voicecall`). +## Plugin API overview -## Plugin API (overview) +Plugins export either a function or an object with `register(api)`: -Plugins export either: +```typescript +export default definePluginEntry({ + id: "my-plugin", + name: "My Plugin", + register(api) { + api.registerProvider({ + /* ... */ + }); + api.registerTool({ + /* ... */ + }); + api.registerChannel({ + /* ... */ + }); + }, +}); +``` -- A function: `(api) => { ... }` -- An object: `{ id, name, configSchema, register(api) { ... } }` +Common registration methods: -`register(api)` is where plugins attach behavior. Common registrations include: +| Method | What it registers | +| ------------------------------------ | -------------------- | +| `registerProvider` | Model provider (LLM) | +| `registerChannel` | Chat channel | +| `registerTool` | Agent tool | +| `registerHook` / `on(...)` | Lifecycle hooks | +| `registerSpeechProvider` | Text-to-speech / STT | +| `registerMediaUnderstandingProvider` | Image/audio analysis | +| `registerImageGenerationProvider` | Image generation | +| `registerWebSearchProvider` | Web search | +| `registerHttpRoute` | HTTP endpoint | +| `registerCommand` / `registerCli` | CLI commands | +| `registerContextEngine` | Context engine | +| `registerService` | Background service | -- `registerTool` -- `registerHook` -- `on(...)` for typed lifecycle hooks -- `registerChannel` -- `registerProvider` -- `registerSpeechProvider` -- `registerMediaUnderstandingProvider` -- `registerWebSearchProvider` -- `registerHttpRoute` -- `registerCommand` -- `registerCli` -- `registerContextEngine` -- `registerService` +## Related -See [Plugin manifest](/plugins/manifest) for the manifest file format. - -## Further reading - -- [Plugin architecture and internals](/plugins/architecture) -- capability model, - ownership model, contracts, load pipeline, runtime helpers, and developer API - reference -- [Building plugins](/plugins/building-plugins) -- [Plugin bundles](/plugins/bundles) -- [Plugin manifest](/plugins/manifest) -- [Plugin agent tools](/plugins/agent-tools) -- [Capability Cookbook](/tools/capability-cookbook) -- [Community plugins](/plugins/community) +- [Building Plugins](/plugins/building-plugins) — create your own plugin +- [Plugin Bundles](/plugins/bundles) — Codex/Claude/Cursor bundle compatibility +- [Plugin Manifest](/plugins/manifest) — manifest schema +- [Registering Tools](/plugins/agent-tools) — add agent tools in a plugin +- [Plugin Internals](/plugins/architecture) — capability model and load pipeline +- [Community Plugins](/plugins/community) — third-party listings From e4d0fdcc155a0c57bfd49be6131a55fb0ef98be7 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 20 Mar 2026 11:23:46 -0700 Subject: [PATCH 19/24] docs: rewrite community plugins page with Cards, Steps, and quality bar table --- docs/plugins/community.md | 135 ++++++++++++++++++++++++-------------- 1 file changed, 85 insertions(+), 50 deletions(-) diff --git a/docs/plugins/community.md b/docs/plugins/community.md index 12df6c3eee0..b7ca5c10398 100644 --- a/docs/plugins/community.md +++ b/docs/plugins/community.md @@ -1,60 +1,95 @@ --- -summary: "Community plugins: quality bar, hosting requirements, and PR submission path" +summary: "Community-maintained OpenClaw plugins: browse, install, and submit your own" read_when: - - You want to publish a third-party OpenClaw plugin - - You want to propose a plugin for docs listing -title: "Community plugins" + - You want to find third-party OpenClaw plugins + - You want to publish or list your own plugin +title: "Community Plugins" --- -# Community plugins +# Community Plugins -This page tracks high-quality **community-maintained plugins** for OpenClaw. +Community plugins are third-party packages that extend OpenClaw with new +channels, tools, providers, or other capabilities. They are built and maintained +by the community, published on npm, and installable with a single command. -We accept PRs that add community plugins here when they meet the quality bar. - -## Required for listing - -- Plugin package is published on npmjs (installable via `openclaw plugins install `). -- Source code is hosted on GitHub (public repository). -- Repository includes setup/use docs and an issue tracker. -- Plugin has a clear maintenance signal (active maintainer, recent updates, or responsive issue handling). - -## How to submit - -Open a PR that adds your plugin to this page with: - -- Plugin name -- npm package name -- GitHub repository URL -- One-line description -- Install command - -## Review bar - -We prefer plugins that are useful, documented, and safe to operate. -Low-effort wrappers, unclear ownership, or unmaintained packages may be declined. - -## Candidate format - -Use this format when adding entries: - -- **Plugin Name** — short description - npm: `@scope/package` - repo: `https://github.com/org/repo` - install: `openclaw plugins install @scope/package` +```bash +openclaw plugins install +``` ## Listed plugins -- **openclaw-dingtalk** — The OpenClaw DingTalk channel plugin enables the integration of enterprise robots using the Stream mode. It supports text, images and file messages via any DingTalk client. - npm: `@largezhou/ddingtalk` - repo: `https://github.com/largezhou/openclaw-dingtalk` - install: `openclaw plugins install @largezhou/ddingtalk` -- **QQbot** — Connect OpenClaw to QQ via the QQ Bot API. Supports private chats, group mentions, channel messages, and rich media including voice, images, videos, and files. - npm: `@sliverp/qqbot` - repo: `https://github.com/sliverp/qqbot` - install: `openclaw plugins install @sliverp/qqbot` + + + Enterprise robot integration using Stream mode. Supports text, images, and + file messages via any DingTalk client. -- **WeChat** — Connect OpenClaw to WeChat personal accounts via WeChatPadPro (iPad protocol). Supports text, image, and file exchange with keyword-triggered conversations. - npm: `@icesword760/openclaw-wechat` - repo: `https://github.com/icesword0760/openclaw-wechat` - install: `openclaw plugins install @icesword760/openclaw-wechat` + ```bash + openclaw plugins install @largezhou/ddingtalk + ``` + + + + + Connect to QQ via the QQ Bot API. Supports private chats, group mentions, + channel messages, and rich media including voice, images, videos, and files. + + ```bash + openclaw plugins install @sliverp/qqbot + ``` + + + + + Connect to WeChat personal accounts via WeChatPadPro (iPad protocol). + Supports text, image, and file exchange with keyword-triggered conversations. + + ```bash + openclaw plugins install @icesword760/openclaw-wechat + ``` + + + + +## Submit your plugin + +We welcome community plugins that are useful, documented, and safe to operate. + + + + Your plugin must be installable via `openclaw plugins install \`. + See [Building Plugins](/plugins/building-plugins) for the full guide. + + + + Source code must be in a public repository with setup docs and an issue + tracker. + + + + Add your plugin to this page with: + + - Plugin name + - npm package name + - GitHub repository URL + - One-line description + - Install command + + + + +## Quality bar + +| Requirement | Why | +| -------------------- | --------------------------------------------- | +| Published on npm | Users need `openclaw plugins install` to work | +| Public GitHub repo | Source review, issue tracking, transparency | +| Setup and usage docs | Users need to know how to configure it | +| Active maintenance | Recent updates or responsive issue handling | + +Low-effort wrappers, unclear ownership, or unmaintained packages may be declined. + +## Related + +- [Install and Configure Plugins](/tools/plugin) — how to install any plugin +- [Building Plugins](/plugins/building-plugins) — create your own +- [Plugin Manifest](/plugins/manifest) — manifest schema From 16e055c083a0a6ecec713a214e65b2cfe7e28949 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Fri, 20 Mar 2026 12:41:19 -0500 Subject: [PATCH 20/24] restore extension-api backward compatibility with migration warning --- docs/plugins/building-plugins.md | 10 +++- docs/plugins/sdk-migration.md | 84 ++++++++++++++++++++++++++------ package.json | 1 + src/extensionAPI.test.ts | 21 ++++++++ src/extensionAPI.ts | 32 ++++++++++++ src/plugins/loader.test.ts | 47 ++++++++++++++++++ src/plugins/loader.ts | 31 ++++++++++++ tsconfig.json | 1 + tsdown.config.ts | 1 + vitest.config.ts | 4 ++ 10 files changed, 217 insertions(+), 15 deletions(-) create mode 100644 src/extensionAPI.test.ts create mode 100644 src/extensionAPI.ts diff --git a/docs/plugins/building-plugins.md b/docs/plugins/building-plugins.md index 026ac4492de..eea080fb406 100644 --- a/docs/plugins/building-plugins.md +++ b/docs/plugins/building-plugins.md @@ -166,6 +166,11 @@ my-plugin/ 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/core"; @@ -174,6 +179,9 @@ my-plugin/ // Wrong: monolithic root (lint will reject this) import { ... } from "openclaw/plugin-sdk"; + + // Deprecated: legacy host bridge + import { runEmbeddedPiAgent } from "openclaw/extension-api"; ``` @@ -302,7 +310,7 @@ patterns is strongly recommended. ## Related -- [Plugin SDK Migration](/plugins/sdk-migration) — migrating from the deprecated compat import +- [Plugin SDK Migration](/plugins/sdk-migration) — migrating from deprecated compat surfaces - [Plugin Architecture](/plugins/architecture) — internals and capability model - [Plugin Manifest](/plugins/manifest) — full manifest schema - [Plugin Agent Tools](/plugins/agent-tools) — adding agent tools in a plugin diff --git a/docs/plugins/sdk-migration.md b/docs/plugins/sdk-migration.md index 83970720578..53ac7d71750 100644 --- a/docs/plugins/sdk-migration.md +++ b/docs/plugins/sdk-migration.md @@ -1,17 +1,24 @@ --- title: "Plugin SDK Migration" sidebarTitle: "SDK Migration" -summary: "Migrate from the deprecated openclaw/plugin-sdk/compat import to focused subpath imports" +summary: "Migrate from legacy compat surfaces to focused plugin-sdk subpaths and injected runtime helpers" read_when: - You see the OPENCLAW_PLUGIN_SDK_COMPAT_DEPRECATED warning - - You are updating a plugin from the monolithic import to scoped subpaths + - You see the OPENCLAW_EXTENSION_API_DEPRECATED warning + - You are updating a plugin from the monolithic plugin-sdk import to scoped subpaths + - You are updating a plugin away from openclaw/extension-api - You maintain an external OpenClaw plugin --- # Plugin SDK Migration -The `openclaw/plugin-sdk/compat` import is deprecated. All plugins should use -**focused subpath imports** (`openclaw/plugin-sdk/\`) instead. +OpenClaw is migrating from broad compatibility surfaces to narrower, documented +contracts: + +- `openclaw/plugin-sdk/compat` -> focused `openclaw/plugin-sdk/` imports +- `openclaw/extension-api` -> injected runtime helpers such as `api.runtime.agent.*` + +This page explains what changed, why, and how to migrate. The compat import still works at runtime. This is a deprecation warning, not @@ -32,19 +39,21 @@ with a clear purpose. - Search your plugin for imports from the compat path: + Search your plugin for imports from either deprecated surface: ```bash grep -r "plugin-sdk/compat" my-plugin/ + grep -r "openclaw/extension-api" extensions/my-plugin/ ``` - - Each export maps to a specific subpath. Replace the import source: + + Each export from compat maps to a specific subpath. Replace the import + source: ```typescript - // Before (deprecated) + // Before (compat entry) import { createChannelReplyPipeline, createPluginRuntimeStore, @@ -57,14 +66,60 @@ with a clear purpose. import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth"; ``` - See the [subpath reference](#subpath-reference) below for the full mapping. + If your plugin imports from `openclaw/extension-api`, you will now see: + + ```text + [OPENCLAW_EXTENSION_API_DEPRECATED] Warning: openclaw/extension-api is deprecated. + Migrate to api.runtime.agent.* or focused openclaw/plugin-sdk/ imports. + ``` + + That bridge also still works at runtime today. It exists to preserve older + plugins while they migrate to the injected plugin runtime. + + Move host-side helpers onto the injected plugin runtime instead of + importing them directly: + + ```typescript + // Before (deprecated extension-api bridge) + import { runEmbeddedPiAgent } from "openclaw/extension-api"; + + const result = await runEmbeddedPiAgent({ + sessionId, + sessionFile, + workspaceDir, + prompt, + timeoutMs, + }); + + // After (preferred injected runtime) + const result = await api.runtime.agent.runEmbeddedPiAgent({ + sessionId, + sessionFile, + workspaceDir, + prompt, + timeoutMs, + }); + ``` + + The same pattern applies to the other legacy `extension-api` helpers: + + - `resolveAgentDir` -> `api.runtime.agent.resolveAgentDir` + - `resolveAgentWorkspaceDir` -> `api.runtime.agent.resolveAgentWorkspaceDir` + - `resolveAgentIdentity` -> `api.runtime.agent.resolveAgentIdentity` + - `resolveThinkingDefault` -> `api.runtime.agent.resolveThinkingDefault` + - `resolveAgentTimeoutMs` -> `api.runtime.agent.resolveAgentTimeoutMs` + - `ensureAgentWorkspace` -> `api.runtime.agent.ensureAgentWorkspace` + - session store helpers -> `api.runtime.agent.session.*` + + See the [subpath reference](#subpath-reference) below for the scoped import + mapping. ```bash pnpm build - pnpm test -- my-plugin/ + pnpm test -- extensions/my-plugin/ ``` @@ -101,10 +156,10 @@ check the source at `src/plugin-sdk/` or ask in Discord. ## Removal timeline -| When | What happens | -| ---------------------- | --------------------------------------------------------------- | -| **Now** | Compat import emits a runtime deprecation warning | -| **Next major release** | Compat import will be removed; plugins still using it will fail | +| When | What happens | +| --- | --- | +| **Now** | Compat import and `openclaw/extension-api` emit runtime warnings | +| **Next major release** | These legacy bridges may be removed; plugins still using them will fail | All core plugins have already been migrated. External plugins should migrate before the next major release. @@ -115,6 +170,7 @@ Set this environment variable while you work on migrating: ```bash OPENCLAW_SUPPRESS_PLUGIN_SDK_COMPAT_WARNING=1 openclaw gateway run +OPENCLAW_SUPPRESS_EXTENSION_API_WARNING=1 openclaw gateway run ``` This is a temporary escape hatch, not a permanent solution. diff --git a/package.json b/package.json index 646027a2cb5..8c8572581f9 100644 --- a/package.json +++ b/package.json @@ -513,6 +513,7 @@ "types": "./dist/plugin-sdk/tool-send.d.ts", "default": "./dist/plugin-sdk/tool-send.js" }, + "./extension-api": "./dist/extensionAPI.js", "./cli-entry": "./openclaw.mjs" }, "scripts": { diff --git a/src/extensionAPI.test.ts b/src/extensionAPI.test.ts new file mode 100644 index 00000000000..d2d9bf657a0 --- /dev/null +++ b/src/extensionAPI.test.ts @@ -0,0 +1,21 @@ +import * as extensionApi from "openclaw/extension-api"; +import { describe, expect, it } from "vitest"; + +describe("extension-api compat surface", () => { + it("keeps legacy agent helpers importable", () => { + expect(typeof extensionApi.runEmbeddedPiAgent).toBe("function"); + expect(typeof extensionApi.resolveAgentDir).toBe("function"); + expect(typeof extensionApi.resolveAgentWorkspaceDir).toBe("function"); + expect(typeof extensionApi.resolveAgentTimeoutMs).toBe("function"); + expect(typeof extensionApi.ensureAgentWorkspace).toBe("function"); + }); + + it("keeps legacy defaults and session helpers importable", () => { + expect(typeof extensionApi.DEFAULT_MODEL).toBe("string"); + expect(typeof extensionApi.DEFAULT_PROVIDER).toBe("string"); + expect(typeof extensionApi.resolveStorePath).toBe("function"); + expect(typeof extensionApi.loadSessionStore).toBe("function"); + expect(typeof extensionApi.saveSessionStore).toBe("function"); + expect(typeof extensionApi.resolveSessionFilePath).toBe("function"); + }); +}); diff --git a/src/extensionAPI.ts b/src/extensionAPI.ts new file mode 100644 index 00000000000..267ba27ab3c --- /dev/null +++ b/src/extensionAPI.ts @@ -0,0 +1,32 @@ +// Legacy compat surface for plugins that still import openclaw/extension-api. +// Keep this file intentionally narrow and forward-only. + +const shouldWarnExtensionApiImport = + process.env.VITEST !== "true" && + process.env.NODE_ENV !== "test" && + process.env.OPENCLAW_SUPPRESS_EXTENSION_API_WARNING !== "1"; + +if (shouldWarnExtensionApiImport) { + process.emitWarning( + "openclaw/extension-api is deprecated. Migrate to api.runtime.agent.* or focused openclaw/plugin-sdk/ imports. See https://docs.openclaw.ai/plugins/sdk-migration", + { + code: "OPENCLAW_EXTENSION_API_DEPRECATED", + detail: + "This compatibility bridge is temporary. Bundled plugins should use the injected plugin runtime instead of importing host-side agent helpers directly. Migration guide: https://docs.openclaw.ai/plugins/sdk-migration", + }, + ); +} + +export { resolveAgentDir, resolveAgentWorkspaceDir } from "./agents/agent-scope.js"; +export { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./agents/defaults.js"; +export { resolveAgentIdentity } from "./agents/identity.js"; +export { resolveThinkingDefault } from "./agents/model-selection.js"; +export { runEmbeddedPiAgent } from "./agents/pi-embedded.js"; +export { resolveAgentTimeoutMs } from "./agents/timeout.js"; +export { ensureAgentWorkspace } from "./agents/workspace.js"; +export { + resolveStorePath, + loadSessionStore, + saveSessionStore, + resolveSessionFilePath, +} from "./config/sessions.js"; diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index a4bf12fad15..8d50d1148c8 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -358,6 +358,23 @@ function createPluginSdkAliasFixture(params?: { return { root, srcFile, distFile }; } +function createExtensionApiAliasFixture(params?: { srcBody?: string; distBody?: string }) { + const root = makeTempDir(); + const srcFile = path.join(root, "src", "extensionAPI.ts"); + const distFile = path.join(root, "dist", "extensionAPI.js"); + mkdirSafe(path.dirname(srcFile)); + mkdirSafe(path.dirname(distFile)); + fs.writeFileSync( + path.join(root, "package.json"), + JSON.stringify({ name: "openclaw", type: "module" }, null, 2), + "utf-8", + ); + fs.writeFileSync(path.join(root, "openclaw.mjs"), "export {};\n", "utf-8"); + fs.writeFileSync(srcFile, params?.srcBody ?? "export {};\n", "utf-8"); + fs.writeFileSync(distFile, params?.distBody ?? "export {};\n", "utf-8"); + return { root, srcFile, distFile }; +} + function createPluginRuntimeAliasFixture(params?: { srcBody?: string; distBody?: string }) { const root = makeTempDir(); const srcFile = path.join(root, "src", "plugins", "runtime", "index.ts"); @@ -3354,6 +3371,36 @@ module.exports = { expect(resolved).toBe(expected === "dist" ? fixture.distFile : fixture.srcFile); }); + it.each([ + { + name: "prefers dist extension-api alias when loader runs from dist", + modulePath: (root: string) => path.join(root, "dist", "plugins", "loader.js"), + expected: "dist" as const, + }, + { + name: "prefers src extension-api alias when loader runs from src in non-production", + modulePath: (root: string) => path.join(root, "src", "plugins", "loader.ts"), + env: { NODE_ENV: undefined }, + expected: "src" as const, + }, + { + name: "resolves extension-api alias from package root when loader runs from transpiler cache path", + modulePath: () => "/tmp/tsx-cache/openclaw-loader.js", + argv1: (root: string) => path.join(root, "openclaw.mjs"), + env: { NODE_ENV: undefined }, + expected: "src" as const, + }, + ])("$name", ({ modulePath, argv1, env, expected }) => { + const fixture = createExtensionApiAliasFixture(); + const resolved = withEnv(env ?? {}, () => + __testing.resolveExtensionApiAlias({ + modulePath: modulePath(fixture.root), + argv1: argv1?.(fixture.root), + }), + ); + expect(resolved).toBe(expected === "dist" ? fixture.distFile : fixture.srcFile); + }); + it.each([ { name: "prefers dist candidates first for production src runtime", diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 03a1b0810ff..6f5900f8334 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -130,12 +130,42 @@ const resolvePluginSdkAlias = (params: LoaderModuleResolveParams = {}): string | function buildPluginLoaderAliasMap(modulePath: string): Record { const pluginSdkAlias = resolvePluginSdkAlias({ modulePath }); + const extensionApiAlias = resolveExtensionApiAlias({ modulePath }); return { + ...(extensionApiAlias ? { "openclaw/extension-api": extensionApiAlias } : {}), ...(pluginSdkAlias ? { "openclaw/plugin-sdk": pluginSdkAlias } : {}), ...resolvePluginSdkScopedAliasMap({ modulePath }), }; } +const resolveExtensionApiAlias = (params: LoaderModuleResolveParams = {}): string | null => { + try { + const modulePath = resolveLoaderModulePath(params); + const packageRoot = resolveLoaderPackageRoot({ ...params, modulePath }); + if (!packageRoot) { + return null; + } + + const orderedKinds = resolvePluginSdkAliasCandidateOrder({ + modulePath, + isProduction: process.env.NODE_ENV === "production", + }); + const candidateMap = { + src: path.join(packageRoot, "src", "extensionAPI.ts"), + dist: path.join(packageRoot, "dist", "extensionAPI.js"), + } as const; + for (const kind of orderedKinds) { + const candidate = candidateMap[kind]; + if (fs.existsSync(candidate)) { + return candidate; + } + } + } catch { + // ignore + } + return null; +}; + function resolvePluginRuntimeModulePath(params: LoaderModuleResolveParams = {}): string | null { try { const modulePath = resolveLoaderModulePath(params); @@ -170,6 +200,7 @@ export const __testing = { buildPluginLoaderAliasMap, listPluginSdkAliasCandidates, listPluginSdkExportedSubpaths, + resolveExtensionApiAlias, resolvePluginSdkScopedAliasMap, resolvePluginSdkAliasCandidateOrder, resolvePluginSdkAliasFile, diff --git a/tsconfig.json b/tsconfig.json index bc6439e921f..e2f9e4ff97e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,6 +18,7 @@ "target": "es2023", "useDefineForClassFields": false, "paths": { + "openclaw/extension-api": ["./src/extensionAPI.ts"], "openclaw/plugin-sdk": ["./src/plugin-sdk/index.ts"], "openclaw/plugin-sdk/*": ["./src/plugin-sdk/*.ts"], "openclaw/plugin-sdk/account-id": ["./src/plugin-sdk/account-id.ts"] diff --git a/tsdown.config.ts b/tsdown.config.ts index 98dd9e3d341..304f781d91d 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -169,6 +169,7 @@ function buildCoreDistEntries(): Record { entry: "src/entry.ts", // Ensure this module is bundled as an entry so legacy CLI shims can resolve its exports. "cli/daemon-cli": "src/cli/daemon-cli.ts", + extensionAPI: "src/extensionAPI.ts", "infra/warning-filter": "src/infra/warning-filter.ts", "telegram/audit": "extensions/telegram/src/audit.ts", "telegram/token": "extensions/telegram/src/token.ts", diff --git a/vitest.config.ts b/vitest.config.ts index f254bcdf0a7..568f5dd03e6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -13,6 +13,10 @@ export default defineConfig({ resolve: { // Keep this ordered: the base `openclaw/plugin-sdk` alias is a prefix match. alias: [ + { + find: "openclaw/extension-api", + replacement: path.join(repoRoot, "src", "extensionAPI.ts"), + }, ...pluginSdkSubpaths.map((subpath) => ({ find: `openclaw/plugin-sdk/${subpath}`, replacement: path.join(repoRoot, "src", "plugin-sdk", `${subpath}.ts`), From 2e0b445b46d736479bbbf92b72446a1e5ae2fa08 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 20 Mar 2026 11:26:39 -0700 Subject: [PATCH 21/24] docs: use expandable Accordions for community plugins, keep A-Z order --- docs/plugins/community.md | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/docs/plugins/community.md b/docs/plugins/community.md index b7ca5c10398..0ff0ef7e69c 100644 --- a/docs/plugins/community.md +++ b/docs/plugins/community.md @@ -18,37 +18,45 @@ openclaw plugins install ## Listed plugins - - + + Enterprise robot integration using Stream mode. Supports text, images, and file messages via any DingTalk client. + - **npm:** `@largezhou/ddingtalk` + - **repo:** [github.com/largezhou/openclaw-dingtalk](https://github.com/largezhou/openclaw-dingtalk) + ```bash openclaw plugins install @largezhou/ddingtalk ``` - + - - Connect to QQ via the QQ Bot API. Supports private chats, group mentions, - channel messages, and rich media including voice, images, videos, and files. + + Supports private chats, group mentions, channel messages, and rich media + including voice, images, videos, and files. + + - **npm:** `@sliverp/qqbot` + - **repo:** [github.com/sliverp/qqbot](https://github.com/sliverp/qqbot) ```bash openclaw plugins install @sliverp/qqbot ``` - + - - Connect to WeChat personal accounts via WeChatPadPro (iPad protocol). + Supports text, image, and file exchange with keyword-triggered conversations. + - **npm:** `@icesword760/openclaw-wechat` + - **repo:** [github.com/icesword0760/openclaw-wechat](https://github.com/icesword0760/openclaw-wechat) + ```bash openclaw plugins install @icesword760/openclaw-wechat ``` - - + + ## Submit your plugin From 483926a6fb4a3d8ece8393757462993c683630c2 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 20 Mar 2026 11:32:11 -0700 Subject: [PATCH 22/24] docs: rewrite sdk-migration and bundles, fold agent-tools into building-plugins, remove cookbook from nav, remove dead WeChat listing --- docs/docs.json | 12 +- docs/plugins/agent-tools.md | 101 +-------- docs/plugins/building-plugins.md | 52 +++++ docs/plugins/bundles.md | 366 ++++++++++--------------------- docs/plugins/community.md | 11 - docs/plugins/sdk-migration.md | 138 ++++++------ 6 files changed, 247 insertions(+), 433 deletions(-) diff --git a/docs/docs.json b/docs/docs.json index 2ae489a38b0..87ae0c5a120 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -68,6 +68,14 @@ "source": "/plugins/building-extensions", "destination": "/plugins/building-plugins" }, + { + "source": "/plugins/agent-tools", + "destination": "/plugins/building-plugins#registering-agent-tools" + }, + { + "source": "/tools/capability-cookbook", + "destination": "/plugins/architecture" + }, { "source": "/brave-search", "destination": "/tools/brave-search" @@ -1033,10 +1041,8 @@ "plugins/community", "plugins/bundles", "plugins/manifest", - "plugins/agent-tools", "plugins/sdk-migration", - "plugins/architecture", - "tools/capability-cookbook" + "plugins/architecture" ] }, { diff --git a/docs/plugins/agent-tools.md b/docs/plugins/agent-tools.md index ea3cd7e231d..930bdfbe629 100644 --- a/docs/plugins/agent-tools.md +++ b/docs/plugins/agent-tools.md @@ -1,103 +1,10 @@ --- -summary: "Register custom agent tools in a plugin with schemas, optional opt-in, and allowlists" +summary: "Redirects to Building Plugins (registering tools section)" read_when: - - You want to add a new agent tool in a plugin - - You need to make a tool opt-in via allowlists -title: "Registering Tools in Plugins" -sidebarTitle: "Registering Tools" + - Legacy link to agent-tools +title: "Registering Tools" --- # Registering Tools in Plugins -Plugins can register **agent tools** — typed functions that the LLM can call -during agent runs. Tools can be **required** (always available) or -**optional** (users opt in via allowlists). - -See [Building Plugins](/plugins/building-plugins) for the full plugin creation -guide. This page focuses on the tool registration API. - -Agent tools are configured under `tools` in the main config, or per‑agent under -`agents.list[].tools`. The allowlist/denylist policy controls which tools the agent -can call. - -## Basic tool - -```ts -import { Type } from "@sinclair/typebox"; - -export default function (api) { - 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 }] }; - }, - }); -} -``` - -## Optional tool (opt-in) - -Optional tools are **never** auto‑enabled. Users must add them to an agent -allowlist. - -```ts -export default function (api) { - api.registerTool( - { - name: "workflow_tool", - description: "Run a local workflow", - parameters: { - type: "object", - properties: { - pipeline: { type: "string" }, - }, - required: ["pipeline"], - }, - async execute(_id, params) { - return { content: [{ type: "text", text: params.pipeline }] }; - }, - }, - { optional: true }, - ); -} -``` - -Enable optional tools in `agents.list[].tools.allow` (or global `tools.allow`): - -```json5 -{ - agents: { - list: [ - { - id: "main", - tools: { - allow: [ - "workflow_tool", // specific tool name - "workflow", // plugin id (enables all tools from that plugin) - "group:plugins", // all plugin tools - ], - }, - }, - ], - }, -} -``` - -Other config knobs that affect tool availability: - -- Allowlists that only name plugin tools are treated as plugin opt-ins; core tools remain - enabled unless you also include core tools or groups in the allowlist. -- `tools.profile` / `agents.list[].tools.profile` (base allowlist) -- `tools.byProvider` / `agents.list[].tools.byProvider` (provider‑specific allow/deny) -- `tools.sandbox.tools.*` (sandbox tool policy when sandboxed) - -## Rules + tips - -- Tool names must **not** clash with core tool names; conflicting tools are skipped. -- Plugin ids used in allowlists must not clash with core tool names. -- Prefer `optional: true` for tools that trigger side effects or require extra - binaries/credentials. +This page has moved. See [Building Plugins: Registering agent tools](/plugins/building-plugins#registering-agent-tools). diff --git a/docs/plugins/building-plugins.md b/docs/plugins/building-plugins.md index eea080fb406..9e6d1a71880 100644 --- a/docs/plugins/building-plugins.md +++ b/docs/plugins/building-plugins.md @@ -285,6 +285,58 @@ my-plugin/ +## 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). + +```typescript +import { Type } from "@sinclair/typebox"; + +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() }), + async execute(_id, params) { + return { content: [{ type: "text", text: params.input }] }; + }, + }); + + // 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 }, + ); + }, +}); +``` + +Enable optional tools in config: + +```json5 +{ + tools: { allow: ["workflow_tool"] }, +} +``` + +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 +- Users can enable all tools from a plugin by adding the plugin id to `tools.allow` + ## Lint enforcement (in-repo plugins) Three scripts enforce SDK boundaries for plugins in the OpenClaw repository: diff --git a/docs/plugins/bundles.md b/docs/plugins/bundles.md index 82a5605e099..b60b110e6b7 100644 --- a/docs/plugins/bundles.md +++ b/docs/plugins/bundles.md @@ -1,307 +1,181 @@ --- -summary: "Unified bundle format guide for Codex, Claude, and Cursor bundles in OpenClaw" +summary: "Install and use Codex, Claude, and Cursor bundles as OpenClaw plugins" read_when: - - You want to install or debug a Codex, Claude, or Cursor-compatible bundle + - You want to install a Codex, Claude, or Cursor-compatible bundle - You need to understand how OpenClaw maps bundle content into native features - - You are documenting bundle compatibility or current support limits + - You are debugging bundle detection or missing capabilities title: "Plugin Bundles" --- -# Plugin bundles +# Plugin Bundles -OpenClaw supports one shared class of external plugin package: **bundle -plugins**. +OpenClaw can install plugins from three external ecosystems: **Codex**, **Claude**, +and **Cursor**. These are called **bundles** — content and metadata packs that +OpenClaw maps into native features like skills, hooks, and MCP tools. -Today that means three closely related ecosystems: + + Bundles are **not** the same as native OpenClaw plugins. Native plugins run + in-process and can register any capability. Bundles are content packs with + selective feature mapping and a narrower trust boundary. + -- Codex bundles -- Claude bundles -- Cursor bundles +## Why bundles exist -OpenClaw shows all of them as `Format: bundle` in `openclaw plugins list`. -Verbose output and `openclaw plugins inspect ` also show the subtype -(`codex`, `claude`, or `cursor`). +Many useful plugins are published in Codex, Claude, or Cursor format. Instead +of requiring authors to rewrite them as native OpenClaw plugins, OpenClaw +detects these formats and maps their supported content into the native feature +set. This means you can install a Claude command pack or a Codex skill bundle +and use it immediately. -Related: +## Install a bundle -- Plugin system overview: [Plugins](/tools/plugin) -- CLI install/list flows: [plugins](/cli/plugins) -- Native manifest schema: [Plugin manifest](/plugins/manifest) + + + ```bash + # Local directory + openclaw plugins install ./my-bundle -## What a bundle is + # Archive + openclaw plugins install ./my-bundle.tgz -A bundle is a **content/metadata pack**, not a native in-process OpenClaw -plugin. + # Claude marketplace + openclaw plugins marketplace list + openclaw plugins install @ + ``` -Today, OpenClaw does **not** execute bundle runtime code in-process. Instead, -it detects known bundle files, reads the metadata, and maps supported bundle -content into native OpenClaw surfaces such as skills, hook packs, MCP config, -and embedded Pi settings. + -That is the main trust boundary: + + ```bash + openclaw plugins list + openclaw plugins inspect + ``` -- native OpenClaw plugin: runtime module executes in-process -- bundle: metadata/content pack, with selective feature mapping + Bundles show as `Format: bundle` with a subtype of `codex`, `claude`, or `cursor`. -## Shared bundle model + -Codex, Claude, and Cursor bundles are similar enough that OpenClaw treats them -as one normalized model. + + ```bash + openclaw gateway restart + ``` -Shared idea: + Mapped features (skills, hooks, MCP tools) are available in the next session. -- a small manifest file, or a default directory layout -- one or more content roots such as `skills/` or `commands/` -- optional tool/runtime metadata such as MCP, hooks, agents, or LSP -- install as a directory or archive, then enable in the normal plugin list + + -Common OpenClaw behavior: +## What OpenClaw maps from bundles -- detect the bundle subtype -- normalize it into one internal bundle record -- map supported parts into native OpenClaw features -- report unsupported parts as detected-but-not-wired capabilities - -In practice, most users do not need to think about the vendor-specific format -first. The more useful question is: which bundle surfaces does OpenClaw map -today? - -## Detection order - -OpenClaw prefers native OpenClaw plugin/package layouts before bundle handling. - -Practical effect: - -- `openclaw.plugin.json` wins over bundle detection -- package installs with valid `package.json` + `openclaw.extensions` use the - native install path -- if a directory contains both native and bundle metadata, OpenClaw treats it - as native first - -That avoids partially installing a dual-format package as a bundle and then -loading it later as a native plugin. - -## What works today - -OpenClaw normalizes bundle metadata into one internal bundle record, then maps -supported surfaces into existing native behavior. +Not every bundle feature runs in OpenClaw today. Here is what works and what +is detected but not yet wired. ### Supported now -#### Skill content - -- bundle skill roots load as normal OpenClaw skill roots -- Claude `commands` roots are treated as additional skill roots -- Cursor `.cursor/commands` roots are treated as additional skill roots - -This means Claude markdown command files work through the normal OpenClaw skill -loader. Cursor command markdown works through the same path. - -#### Hook packs - -- bundle hook roots work **only** when they use the normal OpenClaw hook-pack - layout. Today this is primarily the Codex-compatible case: - - `HOOK.md` - - `handler.ts` or `handler.js` - -#### MCP for Pi - -- enabled bundles can contribute MCP server config -- OpenClaw merges bundle MCP config into the effective embedded Pi settings as - `mcpServers` -- OpenClaw also exposes supported bundle MCP tools during embedded Pi agent - turns by launching supported stdio MCP servers as subprocesses -- project-local Pi settings still apply after bundle defaults, so workspace - settings can override bundle MCP entries when needed - -#### Embedded Pi settings - -- Claude `settings.json` is imported as default embedded Pi settings when the - bundle is enabled -- OpenClaw sanitizes shell override keys before applying them - -Sanitized keys: - -- `shellPath` -- `shellCommandPrefix` +| Feature | How it maps | Applies to | +| ------------- | ---------------------------------------------------------------------------------------------------- | -------------- | +| Skill content | Bundle skill roots load as normal OpenClaw skills | All formats | +| Commands | `commands/` and `.cursor/commands/` treated as skill roots | Claude, Cursor | +| Hook packs | OpenClaw-style `HOOK.md` + `handler.ts` layouts | Codex | +| MCP tools | Bundle MCP config merged into embedded Pi settings; supported stdio servers launched as subprocesses | All formats | +| Settings | Claude `settings.json` imported as embedded Pi defaults | Claude | ### Detected but not executed -These surfaces are detected, shown in bundle capabilities, and may appear in -diagnostics/info output, but OpenClaw does not run them yet: +These are recognized and shown in diagnostics, but OpenClaw does not run them: -- Claude `agents` -- Claude `hooks.json` automation -- Claude `lspServers` -- Claude `outputStyles` -- Cursor `.cursor/agents` -- Cursor `.cursor/hooks.json` -- Cursor `.cursor/rules` +- Claude `agents`, `hooks.json` automation, `lspServers`, `outputStyles` +- Cursor `.cursor/agents`, `.cursor/hooks.json`, `.cursor/rules` - Codex inline/app metadata beyond capability reporting -## Capability reporting +## Bundle formats -`openclaw plugins inspect ` shows bundle capabilities from the normalized -bundle record. + + + Markers: `.codex-plugin/plugin.json` -Supported capabilities are loaded quietly. Unsupported capabilities produce a -warning such as: + Optional content: `skills/`, `hooks/`, `.mcp.json`, `.app.json` -```text -bundle capability detected but not wired into OpenClaw yet: agents -``` + Codex bundles fit OpenClaw best when they use skill roots and OpenClaw-style + hook-pack directories (`HOOK.md` + `handler.ts`). -Current exceptions: + -- Claude `commands` is considered supported because it maps to skills -- Claude `settings` is considered supported because it maps to embedded Pi settings -- Cursor `commands` is considered supported because it maps to skills -- bundle MCP is considered supported because it maps into embedded Pi settings - and exposes supported stdio tools to embedded Pi -- Codex `hooks` is considered supported only for OpenClaw hook-pack layouts + + Two detection modes: -## Format differences + - **Manifest-based:** `.claude-plugin/plugin.json` + - **Manifestless:** default Claude layout (`skills/`, `commands/`, `agents/`, `hooks/`, `.mcp.json`, `settings.json`) -The formats are close, but not byte-for-byte identical. These are the practical -differences that matter in OpenClaw. + Claude-specific behavior: -### Codex + - `commands/` is treated as skill content + - `settings.json` is imported into embedded Pi settings (shell override keys are sanitized) + - `.mcp.json` exposes supported stdio tools to embedded Pi + - `hooks/hooks.json` is detected but not executed + - Custom component paths in the manifest are additive (they extend defaults, not replace them) -Typical markers: + -- `.codex-plugin/plugin.json` -- optional `skills/` -- optional `hooks/` -- optional `.mcp.json` -- optional `.app.json` + + Markers: `.cursor-plugin/plugin.json` -Codex bundles fit OpenClaw best when they use skill roots and OpenClaw-style -hook-pack directories. + Optional content: `skills/`, `.cursor/commands/`, `.cursor/agents/`, `.cursor/rules/`, `.cursor/hooks.json`, `.mcp.json` -### Claude + - `.cursor/commands/` is treated as skill content + - `.cursor/rules/`, `.cursor/agents/`, and `.cursor/hooks.json` are detect-only -OpenClaw supports both: + + -- manifest-based Claude bundles: `.claude-plugin/plugin.json` -- manifestless Claude bundles that use the default Claude layout +## Detection precedence -Default Claude layout markers OpenClaw recognizes: +OpenClaw checks for native plugin format first: -- `skills/` -- `commands/` -- `agents/` -- `hooks/hooks.json` -- `.mcp.json` -- `.lsp.json` -- `settings.json` +1. `openclaw.plugin.json` or valid `package.json` with `openclaw.extensions` — treated as **native plugin** +2. Bundle markers (`.codex-plugin/`, `.claude-plugin/`, or default Claude/Cursor layout) — treated as **bundle** -Claude-specific notes: +If a directory contains both, OpenClaw uses the native path. This prevents +dual-format packages from being partially installed as bundles. -- `commands/` is treated like skill content -- `settings.json` is imported into embedded Pi settings -- `.mcp.json` and manifest `mcpServers` can expose supported stdio tools to - embedded Pi -- `hooks/hooks.json` is detected, but not executed as Claude automation +## Security -### Cursor +Bundles have a narrower trust boundary than native plugins: -Typical markers: +- OpenClaw does **not** load arbitrary bundle runtime modules in-process +- Skills and hook-pack paths must stay inside the plugin root (boundary-checked) +- Settings files are read with the same boundary checks +- Supported stdio MCP servers may be launched as subprocesses -- `.cursor-plugin/plugin.json` -- optional `skills/` -- optional `.cursor/commands/` -- optional `.cursor/agents/` -- optional `.cursor/rules/` -- optional `.cursor/hooks.json` -- optional `.mcp.json` - -Cursor-specific notes: - -- `.cursor/commands/` is treated like skill content -- `.cursor/rules/`, `.cursor/agents/`, and `.cursor/hooks.json` are - detect-only today - -## Claude custom paths - -Claude bundle manifests can declare custom component paths. OpenClaw treats -those paths as **additive**, not replacing defaults. - -Currently recognized custom path keys: - -- `skills` -- `commands` -- `agents` -- `hooks` -- `mcpServers` -- `lspServers` -- `outputStyles` - -Examples: - -- default `commands/` plus manifest `commands: "extra-commands"` => - OpenClaw scans both -- default `skills/` plus manifest `skills: ["team-skills"]` => - OpenClaw scans both - -## Security model - -Bundle support is intentionally narrower than native plugin support. - -Current behavior: - -- bundle discovery reads files inside the plugin root with boundary checks -- skills and hook-pack paths must stay inside the plugin root -- bundle settings files are read with the same boundary checks -- supported stdio bundle MCP servers may be launched as subprocesses for - embedded Pi tool calls -- OpenClaw does not load arbitrary bundle runtime modules in-process - -This makes bundle support safer by default than native plugin modules, but you -should still treat third-party bundles as trusted content for the features they -do expose. - -## Install examples - -```bash -openclaw plugins install ./my-codex-bundle -openclaw plugins install ./my-claude-bundle -openclaw plugins install ./my-cursor-bundle -openclaw plugins install ./my-bundle.tgz -openclaw plugins marketplace list -openclaw plugins install @ -openclaw plugins inspect my-bundle -``` - -If the directory is a native OpenClaw plugin/package, the native install path -still wins. - -For Claude marketplace names, OpenClaw reads the local Claude known-marketplace -registry at `~/.claude/plugins/known_marketplaces.json`. Marketplace entries -can resolve to bundle-compatible directories/archives or to native plugin -sources; after resolution, the normal install rules still apply. +This makes bundles safer by default, but you should still treat third-party +bundles as trusted content for the features they do expose. ## Troubleshooting -### Bundle is detected but capabilities do not run + + + Run `openclaw plugins inspect `. If a capability is listed but marked as + not wired, that is a product limit — not a broken install. + -Check `openclaw plugins inspect `. + + Make sure the bundle is enabled and the markdown files are inside a detected + `commands/` or `skills/` root. + -If the capability is listed but OpenClaw says it is not wired yet, that is a -real product limit, not a broken install. + + Only embedded Pi settings from `settings.json` are supported. OpenClaw does + not treat bundle settings as raw config patches. + -### Claude command files do not appear + + `hooks/hooks.json` is detect-only. If you need runnable hooks, use the + OpenClaw hook-pack layout or ship a native plugin. + + -Make sure the bundle is enabled and the markdown files are inside a detected -`commands` root or `skills` root. +## Related -### Claude settings do not apply - -Current support is limited to embedded Pi settings from `settings.json`. -OpenClaw does not treat bundle settings as raw OpenClaw config patches. - -### Claude hooks do not execute - -`hooks/hooks.json` is only detected today. - -If you need runnable bundle hooks today, use the normal OpenClaw hook-pack -layout through a supported Codex hook root or ship a native OpenClaw plugin. +- [Install and Configure Plugins](/tools/plugin) +- [Building Plugins](/plugins/building-plugins) — create a native plugin +- [Plugin Manifest](/plugins/manifest) — native manifest schema diff --git a/docs/plugins/community.md b/docs/plugins/community.md index 0ff0ef7e69c..6aeda3aca44 100644 --- a/docs/plugins/community.md +++ b/docs/plugins/community.md @@ -45,17 +45,6 @@ openclaw plugins install - - Supports text, image, and file exchange with keyword-triggered conversations. - - - **npm:** `@icesword760/openclaw-wechat` - - **repo:** [github.com/icesword0760/openclaw-wechat](https://github.com/icesword0760/openclaw-wechat) - - ```bash - openclaw plugins install @icesword760/openclaw-wechat - ``` - - ## Submit your plugin diff --git a/docs/plugins/sdk-migration.md b/docs/plugins/sdk-migration.md index 53ac7d71750..52501f5b9c7 100644 --- a/docs/plugins/sdk-migration.md +++ b/docs/plugins/sdk-migration.md @@ -1,41 +1,52 @@ --- title: "Plugin SDK Migration" sidebarTitle: "SDK Migration" -summary: "Migrate from legacy compat surfaces to focused plugin-sdk subpaths and injected runtime helpers" +summary: "Migrate from the legacy backwards-compatibility layer to the modern plugin SDK" read_when: - You see the OPENCLAW_PLUGIN_SDK_COMPAT_DEPRECATED warning - You see the OPENCLAW_EXTENSION_API_DEPRECATED warning - - You are updating a plugin from the monolithic plugin-sdk import to scoped subpaths - - You are updating a plugin away from openclaw/extension-api + - You are updating a plugin to the modern plugin architecture - You maintain an external OpenClaw plugin --- # Plugin SDK Migration -OpenClaw is migrating from broad compatibility surfaces to narrower, documented -contracts: +OpenClaw has moved from a broad backwards-compatibility layer to a modern plugin +architecture with focused, documented imports. If your plugin was built before +the new architecture, this guide helps you migrate. -- `openclaw/plugin-sdk/compat` -> focused `openclaw/plugin-sdk/` imports -- `openclaw/extension-api` -> injected runtime helpers such as `api.runtime.agent.*` +## What is changing -This page explains what changed, why, and how to migrate. +The old plugin system provided two wide-open surfaces that let plugins import +anything they needed from a single entry point: - - The compat import still works at runtime. This is a deprecation warning, not - a breaking change yet. But new plugins **must not** use it, and existing - plugins should migrate before the next major release removes it. - +- **`openclaw/plugin-sdk/compat`** — a single import that re-exported dozens of + helpers. It was introduced to keep older hook-based plugins working while the + new plugin architecture was being built. +- **`openclaw/extension-api`** — a bridge that gave plugins direct access to + host-side helpers like the embedded agent runner. + +Both surfaces are now **deprecated**. They still work at runtime, but new +plugins must not use them, and existing plugins should migrate before the next +major release removes them. + + + The backwards-compatibility layer will be removed in a future major release. + Plugins that still import from these surfaces will break when that happens. + ## Why this changed -The old monolithic `openclaw/plugin-sdk/compat` re-exported everything from one -entry point. This caused slow startup (importing one helper loaded dozens of -unrelated modules), circular dependency risk, and an unclear API surface. +The old approach caused problems: -Focused subpaths fix all three: each subpath is a small, self-contained module -with a clear purpose. +- **Slow startup** — importing one helper loaded dozens of unrelated modules +- **Circular dependencies** — broad re-exports made it easy to create import cycles +- **Unclear API surface** — no way to tell which exports were stable vs internal -## Migration steps +The modern plugin SDK fixes this: each import path (`openclaw/plugin-sdk/\`) +is a small, self-contained module with a clear purpose and documented contract. + +## How to migrate @@ -43,91 +54,66 @@ with a clear purpose. ```bash grep -r "plugin-sdk/compat" my-plugin/ - grep -r "openclaw/extension-api" extensions/my-plugin/ + grep -r "openclaw/extension-api" my-plugin/ ``` - - Each export from compat maps to a specific subpath. Replace the import - source: + + Each export from the old surface maps to a specific modern import path: ```typescript - // Before (compat entry) + // Before (deprecated backwards-compatibility layer) import { createChannelReplyPipeline, createPluginRuntimeStore, resolveControlCommandGate, } from "openclaw/plugin-sdk/compat"; - // After (focused subpaths) + // After (modern focused imports) import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth"; ``` - If your plugin imports from `openclaw/extension-api`, you will now see: - - ```text - [OPENCLAW_EXTENSION_API_DEPRECATED] Warning: openclaw/extension-api is deprecated. - Migrate to api.runtime.agent.* or focused openclaw/plugin-sdk/ imports. - ``` - - That bridge also still works at runtime today. It exists to preserve older - plugins while they migrate to the injected plugin runtime. - - Move host-side helpers onto the injected plugin runtime instead of - importing them directly: + For host-side helpers, use the injected plugin runtime instead of importing + directly: ```typescript // Before (deprecated extension-api bridge) import { runEmbeddedPiAgent } from "openclaw/extension-api"; + const result = await runEmbeddedPiAgent({ sessionId, prompt }); - const result = await runEmbeddedPiAgent({ - sessionId, - sessionFile, - workspaceDir, - prompt, - timeoutMs, - }); - - // After (preferred injected runtime) - const result = await api.runtime.agent.runEmbeddedPiAgent({ - sessionId, - sessionFile, - workspaceDir, - prompt, - timeoutMs, - }); + // After (injected runtime) + const result = await api.runtime.agent.runEmbeddedPiAgent({ sessionId, prompt }); ``` - The same pattern applies to the other legacy `extension-api` helpers: + The same pattern applies to other legacy bridge helpers: - - `resolveAgentDir` -> `api.runtime.agent.resolveAgentDir` - - `resolveAgentWorkspaceDir` -> `api.runtime.agent.resolveAgentWorkspaceDir` - - `resolveAgentIdentity` -> `api.runtime.agent.resolveAgentIdentity` - - `resolveThinkingDefault` -> `api.runtime.agent.resolveThinkingDefault` - - `resolveAgentTimeoutMs` -> `api.runtime.agent.resolveAgentTimeoutMs` - - `ensureAgentWorkspace` -> `api.runtime.agent.ensureAgentWorkspace` - - session store helpers -> `api.runtime.agent.session.*` - - See the [subpath reference](#subpath-reference) below for the scoped import - mapping. + | Old import | Modern equivalent | + | --- | --- | + | `resolveAgentDir` | `api.runtime.agent.resolveAgentDir` | + | `resolveAgentWorkspaceDir` | `api.runtime.agent.resolveAgentWorkspaceDir` | + | `resolveAgentIdentity` | `api.runtime.agent.resolveAgentIdentity` | + | `resolveThinkingDefault` | `api.runtime.agent.resolveThinkingDefault` | + | `resolveAgentTimeoutMs` | `api.runtime.agent.resolveAgentTimeoutMs` | + | `ensureAgentWorkspace` | `api.runtime.agent.ensureAgentWorkspace` | + | session store helpers | `api.runtime.agent.session.*` | ```bash pnpm build - pnpm test -- extensions/my-plugin/ + pnpm test -- my-plugin/ ``` -## Subpath reference +## Import path reference - - | Subpath | Purpose | Key exports | + + | Import path | Purpose | Key exports | | --- | --- | --- | | `plugin-sdk/core` | Plugin entry definitions, base types | `defineChannelPluginEntry`, `definePluginEntry` | | `plugin-sdk/channel-setup` | Setup wizard adapters | `createOptionalChannelSetupSurface` | @@ -151,22 +137,22 @@ with a clear purpose. | `plugin-sdk/testing` | Test utilities | Test helpers and mocks | -Use the narrowest subpath that matches the job. If you cannot find an export, +Use the narrowest import that matches the job. If you cannot find an export, check the source at `src/plugin-sdk/` or ask in Discord. ## Removal timeline -| When | What happens | -| --- | --- | -| **Now** | Compat import and `openclaw/extension-api` emit runtime warnings | -| **Next major release** | These legacy bridges may be removed; plugins still using them will fail | +| When | What happens | +| ---------------------- | ----------------------------------------------------------------------- | +| **Now** | Deprecated surfaces emit runtime warnings | +| **Next major release** | Deprecated surfaces will be removed; plugins still using them will fail | All core plugins have already been migrated. External plugins should migrate before the next major release. -## Suppressing the warning temporarily +## Suppressing the warnings temporarily -Set this environment variable while you work on migrating: +Set these environment variables while you work on migrating: ```bash OPENCLAW_SUPPRESS_PLUGIN_SDK_COMPAT_WARNING=1 openclaw gateway run @@ -178,5 +164,5 @@ This is a temporary escape hatch, not a permanent solution. ## Related - [Building Plugins](/plugins/building-plugins) -- [Plugin Architecture](/plugins/architecture) +- [Plugin Internals](/plugins/architecture) - [Plugin Manifest](/plugins/manifest) From 740b345a2e347dd88d86b71d15e856bfa201726b Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 20 Mar 2026 11:33:51 -0700 Subject: [PATCH 23/24] docs: sort Tools nav group alphabetically --- docs/docs.json | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/docs.json b/docs/docs.json index 87ae0c5a120..be9fa476ea7 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1073,9 +1073,6 @@ { "group": "Tools", "pages": [ - "tools/exec", - "tools/exec-approvals", - "tools/elevated", "tools/apply-patch", { "group": "Browser", @@ -1086,24 +1083,27 @@ "tools/browser-wsl2-windows-remote-cdp-troubleshooting" ] }, + "tools/btw", + "tools/diffs", + "tools/elevated", + "tools/exec", + "tools/exec-approvals", + "tools/llm-task", + "tools/lobster", + "tools/loop-detection", + "tools/pdf", + "tools/reactions", + "tools/thinking", { "group": "Web and search", "pages": [ "tools/web", "tools/brave-search", "tools/firecrawl", - "tools/tavily", - "tools/perplexity-search" + "tools/perplexity-search", + "tools/tavily" ] - }, - "tools/pdf", - "tools/diffs", - "tools/llm-task", - "tools/lobster", - "tools/reactions", - "tools/thinking", - "tools/loop-detection", - "tools/btw" + } ] }, { From b26edfe1ff57a1d660917b7469c711b75d68948c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 20 Mar 2026 18:35:09 +0000 Subject: [PATCH 24/24] test: trim plugin-heavy unit test imports --- src/acp/persistent-bindings.test.ts | 171 ++++++++++++++++-- src/auto-reply/reply/route-reply.test.ts | 95 +++++++--- src/auto-reply/reply/telegram-context.test.ts | 11 +- .../plugins/message-capability-matrix.test.ts | 101 ++++++++++- ...time-errors-channels-status-output.test.ts | 12 +- src/commands/message.test.ts | 31 ++-- src/infra/exec-approval-forwarder.test.ts | 63 ++++++- src/infra/outbound/channel-adapters.test.ts | 43 ++++- .../outbound/message-action-params.test.ts | 85 ++++++--- src/infra/outbound/outbound-policy.test.ts | 35 +++- src/infra/outbound/outbound.test.ts | 5 +- src/plugins/commands.test.ts | 5 +- 12 files changed, 532 insertions(+), 125 deletions(-) diff --git a/src/acp/persistent-bindings.test.ts b/src/acp/persistent-bindings.test.ts index b9fc0c9e9b3..2be5eabe372 100644 --- a/src/acp/persistent-bindings.test.ts +++ b/src/acp/persistent-bindings.test.ts @@ -1,11 +1,11 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { discordPlugin } from "../../extensions/discord/src/channel.js"; -import { feishuPlugin } from "../../extensions/feishu/src/channel.js"; -import { telegramPlugin } from "../../extensions/telegram/src/channel.js"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { parseFeishuConversationId } from "../../extensions/feishu/src/conversation-id.js"; +import { parseTelegramTopicConversation } from "../../extensions/telegram/runtime-api.js"; import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; +import type { ChannelConfiguredBindingProvider, ChannelPlugin } from "../channels/plugins/types.js"; import type { OpenClawConfig } from "../config/config.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; -import { createTestRegistry } from "../test-utils/channel-plugins.js"; +import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; import * as persistentBindingsResolveModule from "./persistent-bindings.resolve.js"; import { buildConfiguredAcpSessionKey } from "./persistent-bindings.types.js"; const managerMocks = vi.hoisted(() => ({ @@ -39,6 +39,10 @@ type PersistentBindingsModule = Pick< "ensureConfiguredAcpBindingSession" | "resetAcpSessionInPlace" >; let persistentBindings: PersistentBindingsModule; +let lifecycleBindingsModule: Pick< + typeof import("./persistent-bindings.lifecycle.js"), + "ensureConfiguredAcpBindingSession" | "resetAcpSessionInPlace" +>; type ConfiguredBinding = NonNullable[number]; type BindingRecordInput = Parameters< @@ -58,6 +62,131 @@ const baseCfg = { const defaultDiscordConversationId = "1478836151241412759"; const defaultDiscordAccountId = "default"; +const discordBindings: ChannelConfiguredBindingProvider = { + compileConfiguredBinding: ({ conversationId }) => { + const normalized = conversationId.trim(); + return normalized ? { conversationId: normalized } : null; + }, + matchInboundConversation: ({ compiledBinding, conversationId, parentConversationId }) => { + if (compiledBinding.conversationId === conversationId) { + return { conversationId, matchPriority: 2 }; + } + if ( + parentConversationId && + parentConversationId !== conversationId && + compiledBinding.conversationId === parentConversationId + ) { + return { conversationId: parentConversationId, matchPriority: 1 }; + } + return null; + }, +}; + +const telegramBindings: ChannelConfiguredBindingProvider = { + compileConfiguredBinding: ({ conversationId }) => { + const parsed = parseTelegramTopicConversation({ conversationId }); + if (!parsed || !parsed.chatId.startsWith("-")) { + return null; + } + return { + conversationId: parsed.canonicalConversationId, + parentConversationId: parsed.chatId, + }; + }, + matchInboundConversation: ({ compiledBinding, conversationId, parentConversationId }) => { + const incoming = parseTelegramTopicConversation({ + conversationId, + parentConversationId, + }); + if (!incoming || !incoming.chatId.startsWith("-")) { + return null; + } + if (compiledBinding.conversationId !== incoming.canonicalConversationId) { + return null; + } + return { + conversationId: incoming.canonicalConversationId, + parentConversationId: incoming.chatId, + matchPriority: 2, + }; + }, +}; + +function isSupportedFeishuDirectConversationId(conversationId: string): boolean { + const trimmed = conversationId.trim(); + if (!trimmed || trimmed.includes(":")) { + return false; + } + if (trimmed.startsWith("oc_") || trimmed.startsWith("on_")) { + return false; + } + return true; +} + +const feishuBindings: ChannelConfiguredBindingProvider = { + compileConfiguredBinding: ({ conversationId }) => { + const parsed = parseFeishuConversationId({ conversationId }); + if ( + !parsed || + (parsed.scope !== "group_topic" && + parsed.scope !== "group_topic_sender" && + !isSupportedFeishuDirectConversationId(parsed.canonicalConversationId)) + ) { + return null; + } + return { + conversationId: parsed.canonicalConversationId, + parentConversationId: + parsed.scope === "group_topic" || parsed.scope === "group_topic_sender" + ? parsed.chatId + : undefined, + }; + }, + matchInboundConversation: ({ compiledBinding, conversationId, parentConversationId }) => { + const incoming = parseFeishuConversationId({ + conversationId, + parentConversationId, + }); + if ( + !incoming || + (incoming.scope !== "group_topic" && + incoming.scope !== "group_topic_sender" && + !isSupportedFeishuDirectConversationId(incoming.canonicalConversationId)) + ) { + return null; + } + const matchesCanonicalConversation = + compiledBinding.conversationId === incoming.canonicalConversationId; + const matchesParentTopicForSenderScopedConversation = + incoming.scope === "group_topic_sender" && + compiledBinding.parentConversationId === incoming.chatId && + compiledBinding.conversationId === `${incoming.chatId}:topic:${incoming.topicId}`; + if (!matchesCanonicalConversation && !matchesParentTopicForSenderScopedConversation) { + return null; + } + return { + conversationId: matchesParentTopicForSenderScopedConversation + ? compiledBinding.conversationId + : incoming.canonicalConversationId, + parentConversationId: + incoming.scope === "group_topic" || incoming.scope === "group_topic_sender" + ? incoming.chatId + : undefined, + matchPriority: matchesCanonicalConversation ? 2 : 1, + }; + }, +}; + +function createConfiguredBindingTestPlugin( + id: ChannelPlugin["id"], + bindings: ChannelConfiguredBindingProvider, +): Pick { + return { + ...createChannelTestPluginBase({ id }), + bindings, + }; +} + function createCfgWithBindings( bindings: ConfiguredBinding[], overrides?: Partial, @@ -185,20 +314,26 @@ beforeEach(() => { persistentBindingsResolveModule.resolveConfiguredAcpBindingRecord, resolveConfiguredAcpBindingSpecBySessionKey: persistentBindingsResolveModule.resolveConfiguredAcpBindingSpecBySessionKey, - ensureConfiguredAcpBindingSession: async (...args) => { - const lifecycleModule = await import("./persistent-bindings.lifecycle.js"); - return await lifecycleModule.ensureConfiguredAcpBindingSession(...args); - }, - resetAcpSessionInPlace: async (...args) => { - const lifecycleModule = await import("./persistent-bindings.lifecycle.js"); - return await lifecycleModule.resetAcpSessionInPlace(...args); - }, + ensureConfiguredAcpBindingSession: lifecycleBindingsModule.ensureConfiguredAcpBindingSession, + resetAcpSessionInPlace: lifecycleBindingsModule.resetAcpSessionInPlace, }; setActivePluginRegistry( createTestRegistry([ - { pluginId: "discord", plugin: discordPlugin, source: "test" }, - { pluginId: "telegram", plugin: telegramPlugin, source: "test" }, - { pluginId: "feishu", plugin: feishuPlugin, source: "test" }, + { + pluginId: "discord", + plugin: createConfiguredBindingTestPlugin("discord", discordBindings), + source: "test", + }, + { + pluginId: "telegram", + plugin: createConfiguredBindingTestPlugin("telegram", telegramBindings), + source: "test", + }, + { + pluginId: "feishu", + plugin: createConfiguredBindingTestPlugin("feishu", feishuBindings), + source: "test", + }, ]), ); managerMocks.resolveSession.mockReset(); @@ -211,6 +346,10 @@ beforeEach(() => { sessionMetaMocks.readAcpSessionEntry.mockReset().mockReturnValue(undefined); }); +beforeAll(async () => { + lifecycleBindingsModule = await import("./persistent-bindings.lifecycle.js"); +}); + describe("resolveConfiguredAcpBindingRecord", () => { it("resolves discord channel ACP binding from top-level typed bindings", () => { const cfg = createCfgWithBindings([ diff --git a/src/auto-reply/reply/route-reply.test.ts b/src/auto-reply/reply/route-reply.test.ts index 515d71726fb..c0eca8d6996 100644 --- a/src/auto-reply/reply/route-reply.test.ts +++ b/src/auto-reply/reply/route-reply.test.ts @@ -1,6 +1,4 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { mattermostPlugin } from "../../../extensions/mattermost/src/channel.js"; -import { slackPlugin } from "../../../extensions/slack/src/channel.js"; import { discordOutbound, imessageOutbound, @@ -9,7 +7,12 @@ import { telegramOutbound, whatsappOutbound, } from "../../../test/channel-outbounds.js"; -import type { ChannelOutboundAdapter, ChannelPlugin } from "../../channels/plugins/types.js"; +import type { + ChannelMessagingAdapter, + ChannelOutboundAdapter, + ChannelPlugin, + ChannelThreadingAdapter, +} from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { PluginRegistry } from "../../plugins/registry.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; @@ -28,13 +31,22 @@ const mocks = vi.hoisted(() => ({ sendMessageSlack: vi.fn(async () => ({ messageId: "m1", channelId: "c1" })), sendMessageTelegram: vi.fn(async () => ({ messageId: "m1", chatId: "c1" })), sendMessageWhatsApp: vi.fn(async () => ({ messageId: "m1", toJid: "jid" })), - sendMessageMattermost: vi.fn(async () => ({ messageId: "m1", channelId: "c1" })), + sendMessageMattermost: vi.fn(async (..._args: unknown[]) => ({ + messageId: "m1", + channelId: "c1", + })), deliverOutboundPayloads: vi.fn(), })); -vi.mock("../../../extensions/discord/src/send.js", () => ({ - sendMessageDiscord: mocks.sendMessageDiscord, -})); +vi.mock("../../../extensions/discord/src/send.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + sendMessageDiscord: mocks.sendMessageDiscord, + sendPollDiscord: mocks.sendMessageDiscord, + sendWebhookMessageDiscord: vi.fn(), + }; +}); vi.mock("../../../extensions/imessage/src/send.js", () => ({ sendMessageIMessage: mocks.sendMessageIMessage, })); @@ -44,21 +56,17 @@ vi.mock("../../../extensions/signal/src/send.js", () => ({ vi.mock("../../../extensions/slack/src/send.js", () => ({ sendMessageSlack: mocks.sendMessageSlack, })); -vi.mock("../../../extensions/telegram/src/send.js", () => ({ - sendMessageTelegram: mocks.sendMessageTelegram, -})); -vi.mock("../../../extensions/telegram/src/send.js", () => ({ - sendMessageTelegram: mocks.sendMessageTelegram, -})); +vi.mock("../../../extensions/telegram/src/send.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + sendMessageTelegram: mocks.sendMessageTelegram, + }; +}); vi.mock("../../../extensions/whatsapp/src/send.js", () => ({ sendMessageWhatsApp: mocks.sendMessageWhatsApp, sendPollWhatsApp: mocks.sendMessageWhatsApp, })); -vi.mock("../../../extensions/discord/src/send.js", () => ({ - sendMessageDiscord: mocks.sendMessageDiscord, - sendPollDiscord: mocks.sendMessageDiscord, - sendWebhookMessageDiscord: vi.fn(), -})); vi.mock("../../../extensions/mattermost/src/mattermost/send.js", () => ({ sendMessageMattermost: mocks.sendMessageMattermost, })); @@ -132,6 +140,47 @@ const createMSTeamsPlugin = (params: { outbound: ChannelOutboundAdapter }): Chan outbound: params.outbound, }); +const slackMessaging: ChannelMessagingAdapter = { + enableInteractiveReplies: ({ cfg }) => + (cfg.channels?.slack as { capabilities?: { interactiveReplies?: boolean } } | undefined) + ?.capabilities?.interactiveReplies === true, + hasStructuredReplyPayload: ({ payload }) => { + const blocks = (payload.channelData?.slack as { blocks?: unknown } | undefined)?.blocks; + if (typeof blocks === "string") { + return blocks.trim().length > 0; + } + return Array.isArray(blocks) && blocks.length > 0; + }, +}; + +const slackThreading: ChannelThreadingAdapter = { + resolveReplyTransport: ({ threadId, replyToId }) => ({ + replyToId: replyToId ?? (threadId != null && threadId !== "" ? String(threadId) : undefined), + threadId: null, + }), +}; + +const mattermostOutbound: ChannelOutboundAdapter = { + deliveryMode: "direct", + sendText: async ({ to, text, cfg, accountId, replyToId, threadId }) => { + const result = await mocks.sendMessageMattermost(to, text, { + cfg, + accountId: accountId ?? undefined, + replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined), + }); + return { channel: "mattermost", ...result }; + }, + sendMedia: async ({ to, text, cfg, accountId, replyToId, threadId, mediaUrl }) => { + const result = await mocks.sendMessageMattermost(to, text, { + cfg, + accountId: accountId ?? undefined, + replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined), + mediaUrl, + }); + return { channel: "mattermost", ...result }; + }, +}; + async function expectSlackNoSend( payload: Parameters[0]["payload"], overrides: Partial[0]> = {}, @@ -553,8 +602,8 @@ const defaultRegistry = createTestRegistry([ pluginId: "slack", plugin: { ...createOutboundTestPlugin({ id: "slack", outbound: slackOutbound, label: "Slack" }), - messaging: slackPlugin.messaging, - threading: slackPlugin.threading, + messaging: slackMessaging, + threading: slackThreading, }, source: "test", }, @@ -595,7 +644,11 @@ const defaultRegistry = createTestRegistry([ }, { pluginId: "mattermost", - plugin: mattermostPlugin, + plugin: createOutboundTestPlugin({ + id: "mattermost", + outbound: mattermostOutbound, + label: "Mattermost", + }), source: "test", }, ]); diff --git a/src/auto-reply/reply/telegram-context.test.ts b/src/auto-reply/reply/telegram-context.test.ts index b38397a1c01..7b58b780180 100644 --- a/src/auto-reply/reply/telegram-context.test.ts +++ b/src/auto-reply/reply/telegram-context.test.ts @@ -1,15 +1,6 @@ -import { beforeEach, describe, expect, it } from "vitest"; -import { telegramPlugin } from "../../../extensions/telegram/src/channel.js"; -import { setActivePluginRegistry } from "../../plugins/runtime.js"; -import { createTestRegistry } from "../../test-utils/channel-plugins.js"; +import { describe, expect, it } from "vitest"; import { resolveTelegramConversationId } from "./telegram-context.js"; -beforeEach(() => { - setActivePluginRegistry( - createTestRegistry([{ pluginId: "telegram", source: "test", plugin: telegramPlugin }]), - ); -}); - describe("resolveTelegramConversationId", () => { it("builds canonical topic ids from chat target and message thread id", () => { const conversationId = resolveTelegramConversationId({ diff --git a/src/channels/plugins/message-capability-matrix.test.ts b/src/channels/plugins/message-capability-matrix.test.ts index 459193d0792..153d9e7c424 100644 --- a/src/channels/plugins/message-capability-matrix.test.ts +++ b/src/channels/plugins/message-capability-matrix.test.ts @@ -29,13 +29,104 @@ vi.mock("../../../extensions/discord/src/runtime.js", () => ({ }), })); -const { slackPlugin } = await import("../../../extensions/slack/src/channel.js"); const { telegramPlugin } = await import("../../../extensions/telegram/src/channel.js"); const { discordPlugin } = await import("../../../extensions/discord/src/channel.js"); -const { mattermostPlugin } = await import("../../../extensions/mattermost/src/channel.js"); -const { feishuPlugin } = await import("../../../extensions/feishu/src/channel.js"); -const { msteamsPlugin } = await import("../../../extensions/msteams/src/channel.js"); -const { zaloPlugin } = await import("../../../extensions/zalo/src/channel.js"); + +// Keep this matrix focused on capability wiring. The extension packages already +// cover their own full channel/plugin boot paths, so local stubs are enough here. +const slackPlugin: Pick = { + actions: { + describeMessageTool: ({ cfg }) => { + const account = cfg.channels?.slack; + const enabled = + typeof account?.botToken === "string" && + account.botToken.trim() !== "" && + typeof account?.appToken === "string" && + account.appToken.trim() !== ""; + const capabilities = new Set(); + if (enabled) { + capabilities.add("blocks"); + } + if ( + account?.capabilities && + (account.capabilities as { interactiveReplies?: unknown }).interactiveReplies === true + ) { + capabilities.add("interactive"); + } + return { + actions: enabled ? ["send"] : [], + capabilities: Array.from(capabilities) as Array<"blocks" | "interactive">, + }; + }, + supportsAction: () => true, + }, +}; + +const mattermostPlugin: Pick = { + actions: { + describeMessageTool: ({ cfg }) => { + const account = cfg.channels?.mattermost; + const enabled = + account?.enabled !== false && + typeof account?.botToken === "string" && + account.botToken.trim() !== "" && + typeof account?.baseUrl === "string" && + account.baseUrl.trim() !== ""; + return { + actions: enabled ? ["send"] : [], + capabilities: enabled ? (["buttons"] as const) : [], + }; + }, + supportsAction: () => true, + }, +}; + +const feishuPlugin: Pick = { + actions: { + describeMessageTool: ({ cfg }) => { + const account = cfg.channels?.feishu; + const enabled = + account?.enabled !== false && + typeof account?.appId === "string" && + account.appId.trim() !== "" && + typeof account?.appSecret === "string" && + account.appSecret.trim() !== ""; + return { + actions: enabled ? ["send"] : [], + capabilities: enabled ? (["cards"] as const) : [], + }; + }, + supportsAction: () => true, + }, +}; + +const msteamsPlugin: Pick = { + actions: { + describeMessageTool: ({ cfg }) => { + const account = cfg.channels?.msteams; + const enabled = + account?.enabled !== false && + typeof account?.tenantId === "string" && + account.tenantId.trim() !== "" && + typeof account?.appId === "string" && + account.appId.trim() !== "" && + typeof account?.appPassword === "string" && + account.appPassword.trim() !== ""; + return { + actions: enabled ? ["poll"] : [], + capabilities: enabled ? (["cards"] as const) : [], + }; + }, + supportsAction: () => true, + }, +}; + +const zaloPlugin: Pick = { + actions: { + describeMessageTool: () => ({ actions: [], capabilities: [] }), + supportsAction: () => true, + }, +}; describe("channel action capability matrix", () => { afterEach(() => { diff --git a/src/commands/channels.surfaces-signal-runtime-errors-channels-status-output.test.ts b/src/commands/channels.surfaces-signal-runtime-errors-channels-status-output.test.ts index 83ef8718b0a..e2437c8b667 100644 --- a/src/commands/channels.surfaces-signal-runtime-errors-channels-status-output.test.ts +++ b/src/commands/channels.surfaces-signal-runtime-errors-channels-status-output.test.ts @@ -1,10 +1,18 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { signalPlugin } from "../../extensions/signal/src/channel.js"; +import { collectStatusIssuesFromLastError } from "../plugin-sdk/status-helpers.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; -import { createTestRegistry } from "../test-utils/channel-plugins.js"; +import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; import { createIMessageTestPlugin } from "../test-utils/imessage-test-plugin.js"; import { formatGatewayChannelsStatusLines } from "./channels/status.js"; +const signalPlugin = { + ...createChannelTestPluginBase({ id: "signal" }), + status: { + collectStatusIssues: (accounts: Parameters[1]) => + collectStatusIssuesFromLastError("signal", accounts), + }, +}; + describe("channels command", () => { beforeEach(() => { setActivePluginRegistry( diff --git a/src/commands/message.test.ts b/src/commands/message.test.ts index 29df194cf2d..daeb4e95893 100644 --- a/src/commands/message.test.ts +++ b/src/commands/message.test.ts @@ -5,6 +5,7 @@ import type { ChannelPlugin, } from "../channels/plugins/types.js"; import type { CliDeps } from "../cli/deps.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; import type { RuntimeEnv } from "../runtime.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; import { captureEnv } from "../test-utils/env.js"; @@ -69,21 +70,17 @@ vi.mock("../../extensions/whatsapp/runtime-api.js", () => ({ handleWhatsAppAction, })); +import { messageCommand } from "./message.js"; + let envSnapshot: ReturnType; +const EMPTY_TEST_REGISTRY = createTestRegistry([]); -const setRegistry = async (registry: ReturnType) => { - const { setActivePluginRegistry } = await import("../plugins/runtime.js"); - setActivePluginRegistry(registry); -}; - -beforeEach(async () => { - vi.resetModules(); +beforeEach(() => { envSnapshot = captureEnv(["TELEGRAM_BOT_TOKEN", "DISCORD_BOT_TOKEN"]); process.env.TELEGRAM_BOT_TOKEN = ""; process.env.DISCORD_BOT_TOKEN = ""; testConfig = {}; - ({ messageCommand } = await import("./message.js")); - await setRegistry(createTestRegistry([])); + setActivePluginRegistry(EMPTY_TEST_REGISTRY); callGatewayMock.mockClear(); webAuthExists.mockClear().mockResolvedValue(false); handleDiscordAction.mockClear(); @@ -197,8 +194,6 @@ const createTelegramPollPluginRegistration = () => ({ }), }); -let messageCommand: typeof import("./message.js").messageCommand; - function createTelegramSecretRawConfig() { return { channels: { @@ -247,7 +242,7 @@ async function runTelegramDirectOutboundSend(params: { messageId: "msg-2", chatId: "123456", })); - await setRegistry( + setActivePluginRegistry( createTestRegistry([ { pluginId: "telegram", @@ -288,7 +283,7 @@ describe("messageCommand", () => { rawConfig: rawConfig as unknown as Record, resolvedConfig: resolvedConfig as unknown as Record, }); - await setRegistry( + setActivePluginRegistry( createTestRegistry([ { ...createTelegramSendPluginRegistration(), @@ -379,7 +374,7 @@ describe("messageCommand", () => { it("defaults channel when only one configured", async () => { process.env.TELEGRAM_BOT_TOKEN = "token-abc"; - await setRegistry( + setActivePluginRegistry( createTestRegistry([ { ...createTelegramSendPluginRegistration(), @@ -401,7 +396,7 @@ describe("messageCommand", () => { it("requires channel when multiple configured", async () => { process.env.TELEGRAM_BOT_TOKEN = "token-abc"; process.env.DISCORD_BOT_TOKEN = "token-discord"; - await setRegistry( + setActivePluginRegistry( createTestRegistry([ { ...createTelegramSendPluginRegistration(), @@ -426,7 +421,7 @@ describe("messageCommand", () => { it("sends via gateway for WhatsApp", async () => { callGatewayMock.mockResolvedValueOnce({ messageId: "g1" }); - await setRegistry( + setActivePluginRegistry( createTestRegistry([ { pluginId: "whatsapp", @@ -456,7 +451,7 @@ describe("messageCommand", () => { }); it("routes discord polls through message action", async () => { - await setRegistry( + setActivePluginRegistry( createTestRegistry([ { ...createDiscordPollPluginRegistration(), @@ -485,7 +480,7 @@ describe("messageCommand", () => { }); it("routes telegram polls through message action", async () => { - await setRegistry( + setActivePluginRegistry( createTestRegistry([ { ...createTelegramPollPluginRegistration(), diff --git a/src/infra/exec-approval-forwarder.test.ts b/src/infra/exec-approval-forwarder.test.ts index 2dfc1c97dbd..4e1f0b003e2 100644 --- a/src/infra/exec-approval-forwarder.test.ts +++ b/src/infra/exec-approval-forwarder.test.ts @@ -1,9 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { discordPlugin } from "../../extensions/discord/src/channel.js"; -import { telegramPlugin } from "../../extensions/telegram/src/channel.js"; +import { isDiscordExecApprovalClientEnabled } from "../../extensions/discord/src/exec-approvals.js"; +import { buildTelegramExecApprovalButtons } from "../../extensions/telegram/src/approval-buttons.js"; +import { isTelegramExecApprovalClientEnabled } from "../../extensions/telegram/src/exec-approvals.js"; +import type { ChannelPlugin } from "../channels/plugins/types.js"; import type { OpenClawConfig } from "../config/config.js"; +import { buildExecApprovalPendingReplyPayload } from "../infra/exec-approval-reply.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; -import { createTestRegistry } from "../test-utils/channel-plugins.js"; +import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; import { createExecApprovalForwarder } from "./exec-approval-forwarder.js"; const baseRequest = { @@ -23,15 +26,65 @@ afterEach(() => { }); const emptyRegistry = createTestRegistry([]); +const telegramApprovalPlugin: Pick< + ChannelPlugin, + "id" | "meta" | "capabilities" | "config" | "execApprovals" +> = { + ...createChannelTestPluginBase({ id: "telegram" }), + execApprovals: { + shouldSuppressForwardingFallback: ({ cfg, target, request }) => { + if (target.channel !== "telegram" || request.request.turnSourceChannel !== "telegram") { + return false; + } + const accountId = target.accountId?.trim() || request.request.turnSourceAccountId?.trim(); + return isTelegramExecApprovalClientEnabled({ cfg, accountId }); + }, + buildPendingPayload: ({ request, nowMs }) => { + const payload = buildExecApprovalPendingReplyPayload({ + approvalId: request.id, + approvalSlug: request.id.slice(0, 8), + approvalCommandId: request.id, + command: request.request.command, + cwd: request.request.cwd ?? undefined, + host: request.request.host === "node" ? "node" : "gateway", + nodeId: request.request.nodeId ?? undefined, + expiresAtMs: request.expiresAtMs, + nowMs, + }); + const buttons = buildTelegramExecApprovalButtons(request.id); + if (!buttons) { + return payload; + } + return { + ...payload, + channelData: { + ...payload.channelData, + telegram: { buttons }, + }, + }; + }, + }, +}; +const discordApprovalPlugin: Pick< + ChannelPlugin, + "id" | "meta" | "capabilities" | "config" | "execApprovals" +> = { + ...createChannelTestPluginBase({ id: "discord" }), + execApprovals: { + shouldSuppressForwardingFallback: ({ cfg, target }) => + target.channel === "discord" && + isDiscordExecApprovalClientEnabled({ cfg, accountId: target.accountId }), + }, +}; const defaultRegistry = createTestRegistry([ { pluginId: "telegram", - plugin: telegramPlugin, + plugin: telegramApprovalPlugin, source: "test", }, { pluginId: "discord", - plugin: discordPlugin, + plugin: discordApprovalPlugin, source: "test", }, ]); diff --git a/src/infra/outbound/channel-adapters.test.ts b/src/infra/outbound/channel-adapters.test.ts index ca39b403226..7656c879b3b 100644 --- a/src/infra/outbound/channel-adapters.test.ts +++ b/src/infra/outbound/channel-adapters.test.ts @@ -1,15 +1,42 @@ -import { Separator, TextDisplay } from "@buape/carbon"; +import { Container, Separator, TextDisplay } from "@buape/carbon"; import { beforeEach, describe, expect, it } from "vitest"; -import { discordPlugin } from "../../../extensions/discord/src/channel.js"; -import { DiscordUiContainer } from "../../../extensions/discord/src/ui.js"; +import type { ChannelPlugin } from "../../channels/plugins/types.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; -import { createTestRegistry } from "../../test-utils/channel-plugins.js"; +import { + createChannelTestPluginBase, + createTestRegistry, +} from "../../test-utils/channel-plugins.js"; import { getChannelMessageAdapter } from "./channel-adapters.js"; +class TestDiscordUiContainer extends Container {} + +const discordCrossContextPlugin: Pick< + ChannelPlugin, + "id" | "meta" | "capabilities" | "config" | "messaging" +> = { + ...createChannelTestPluginBase({ id: "discord" }), + messaging: { + buildCrossContextComponents: ({ originLabel, message, cfg, accountId }) => { + const trimmed = message.trim(); + const components: Array = []; + if (trimmed) { + components.push(new TextDisplay(message)); + components.push(new Separator({ divider: true, spacing: "small" })); + } + components.push(new TextDisplay(`*From ${originLabel}*`)); + void cfg; + void accountId; + return [new TestDiscordUiContainer(components)]; + }, + }, +}; + describe("getChannelMessageAdapter", () => { beforeEach(() => { setActivePluginRegistry( - createTestRegistry([{ pluginId: "discord", plugin: discordPlugin, source: "test" }]), + createTestRegistry([ + { pluginId: "discord", plugin: discordCrossContextPlugin, source: "test" }, + ]), ); }); @@ -31,10 +58,10 @@ describe("getChannelMessageAdapter", () => { cfg: {} as never, accountId: "primary", }); - const container = components?.[0] as DiscordUiContainer | undefined; + const container = components?.[0] as TestDiscordUiContainer | undefined; expect(components).toHaveLength(1); - expect(container).toBeInstanceOf(DiscordUiContainer); + expect(container).toBeInstanceOf(TestDiscordUiContainer); expect(container?.components).toEqual([ expect.any(TextDisplay), expect.any(Separator), @@ -49,7 +76,7 @@ describe("getChannelMessageAdapter", () => { message: " ", cfg: {} as never, }); - const container = components?.[0] as DiscordUiContainer | undefined; + const container = components?.[0] as TestDiscordUiContainer | undefined; expect(components).toHaveLength(1); expect(container?.components).toEqual([expect.any(TextDisplay)]); diff --git a/src/infra/outbound/message-action-params.test.ts b/src/infra/outbound/message-action-params.test.ts index 3442711eab4..309a237af52 100644 --- a/src/infra/outbound/message-action-params.test.ts +++ b/src/infra/outbound/message-action-params.test.ts @@ -2,8 +2,8 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { slackPlugin } from "../../../extensions/slack/src/channel.js"; -import { telegramPlugin } from "../../../extensions/telegram/src/channel.js"; +import { parseSlackTarget } from "../../../extensions/slack/src/targets.js"; +import { parseTelegramTarget } from "../../../extensions/telegram/src/targets.js"; import type { ChannelThreadingToolContext } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import { @@ -27,28 +27,67 @@ function createToolContext( }; } +function resolveSlackAutoThreadId(params: { + to: string; + toolContext?: { + currentChannelId?: string; + currentThreadTs?: string; + replyToMode?: "off" | "first" | "all"; + hasRepliedRef?: { value: boolean }; + }; +}): string | undefined { + const context = params.toolContext; + if (!context?.currentThreadTs || !context.currentChannelId) { + return undefined; + } + if (context.replyToMode !== "all" && context.replyToMode !== "first") { + return undefined; + } + const parsedTarget = parseSlackTarget(params.to, { defaultKind: "channel" }); + if (!parsedTarget || parsedTarget.kind !== "channel") { + return undefined; + } + if (parsedTarget.id.toLowerCase() !== context.currentChannelId.toLowerCase()) { + return undefined; + } + if (context.replyToMode === "first" && context.hasRepliedRef?.value) { + return undefined; + } + return context.currentThreadTs; +} + +function resolveTelegramAutoThreadId(params: { + to: string; + toolContext?: { currentThreadTs?: string; currentChannelId?: string }; +}): string | undefined { + const context = params.toolContext; + if (!context?.currentThreadTs || !context.currentChannelId) { + return undefined; + } + const parsedTo = parseTelegramTarget(params.to); + const parsedChannel = parseTelegramTarget(context.currentChannelId); + if (parsedTo.chatId.toLowerCase() !== parsedChannel.chatId.toLowerCase()) { + return undefined; + } + return context.currentThreadTs; +} + describe("message action threading helpers", () => { it("resolves Slack auto-thread ids only for matching active channels", () => { expect( - slackPlugin?.threading?.resolveAutoThreadId?.({ - cfg, - accountId: undefined, + resolveSlackAutoThreadId({ to: "#c123", toolContext: createToolContext(), }), ).toBe("thread-1"); expect( - slackPlugin?.threading?.resolveAutoThreadId?.({ - cfg, - accountId: undefined, + resolveSlackAutoThreadId({ to: "channel:C999", toolContext: createToolContext(), }), ).toBeUndefined(); expect( - slackPlugin?.threading?.resolveAutoThreadId?.({ - cfg, - accountId: undefined, + resolveSlackAutoThreadId({ to: "user:U123", toolContext: createToolContext(), }), @@ -57,9 +96,7 @@ describe("message action threading helpers", () => { it("skips Slack auto-thread ids when reply mode or context blocks them", () => { expect( - slackPlugin?.threading?.resolveAutoThreadId?.({ - cfg, - accountId: undefined, + resolveSlackAutoThreadId({ to: "C123", toolContext: createToolContext({ replyToMode: "first", @@ -68,17 +105,13 @@ describe("message action threading helpers", () => { }), ).toBeUndefined(); expect( - slackPlugin?.threading?.resolveAutoThreadId?.({ - cfg, - accountId: undefined, + resolveSlackAutoThreadId({ to: "C123", toolContext: createToolContext({ replyToMode: "off" }), }), ).toBeUndefined(); expect( - slackPlugin?.threading?.resolveAutoThreadId?.({ - cfg, - accountId: undefined, + resolveSlackAutoThreadId({ to: "C123", toolContext: createToolContext({ currentThreadTs: undefined }), }), @@ -87,9 +120,7 @@ describe("message action threading helpers", () => { it("resolves Telegram auto-thread ids for matching chats across target formats", () => { expect( - telegramPlugin?.threading?.resolveAutoThreadId?.({ - cfg, - accountId: undefined, + resolveTelegramAutoThreadId({ to: "telegram:group:-100123:topic:77", toolContext: createToolContext({ currentChannelId: "tg:group:-100123", @@ -97,9 +128,7 @@ describe("message action threading helpers", () => { }), ).toBe("thread-1"); expect( - telegramPlugin?.threading?.resolveAutoThreadId?.({ - cfg, - accountId: undefined, + resolveTelegramAutoThreadId({ to: "-100999:77", toolContext: createToolContext({ currentChannelId: "-100123", @@ -107,9 +136,7 @@ describe("message action threading helpers", () => { }), ).toBeUndefined(); expect( - telegramPlugin?.threading?.resolveAutoThreadId?.({ - cfg, - accountId: undefined, + resolveTelegramAutoThreadId({ to: "-100123", toolContext: createToolContext({ currentChannelId: undefined }), }), diff --git a/src/infra/outbound/outbound-policy.test.ts b/src/infra/outbound/outbound-policy.test.ts index 43e71afb923..72abac24d58 100644 --- a/src/infra/outbound/outbound-policy.test.ts +++ b/src/infra/outbound/outbound-policy.test.ts @@ -1,8 +1,12 @@ +import { Container, Separator, TextDisplay } from "@buape/carbon"; import { beforeEach, describe, expect, it } from "vitest"; -import { discordPlugin } from "../../../extensions/discord/src/channel.js"; +import type { ChannelPlugin } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; -import { createTestRegistry } from "../../test-utils/channel-plugins.js"; +import { + createChannelTestPluginBase, + createTestRegistry, +} from "../../test-utils/channel-plugins.js"; import { applyCrossContextDecoration, buildCrossContextDecoration, @@ -10,6 +14,29 @@ import { shouldApplyCrossContextMarker, } from "./outbound-policy.js"; +class TestDiscordUiContainer extends Container {} + +const discordCrossContextPlugin: Pick< + ChannelPlugin, + "id" | "meta" | "capabilities" | "config" | "messaging" +> = { + ...createChannelTestPluginBase({ id: "discord" }), + messaging: { + buildCrossContextComponents: ({ originLabel, message, cfg, accountId }) => { + const trimmed = message.trim(); + const components: Array = []; + if (trimmed) { + components.push(new TextDisplay(message)); + components.push(new Separator({ divider: true, spacing: "small" })); + } + components.push(new TextDisplay(`*From ${originLabel}*`)); + void cfg; + void accountId; + return [new TestDiscordUiContainer(components)]; + }, + }, +}; + const slackConfig = { channels: { slack: { @@ -28,7 +55,9 @@ const discordConfig = { describe("outbound policy helpers", () => { beforeEach(() => { setActivePluginRegistry( - createTestRegistry([{ pluginId: "discord", plugin: discordPlugin, source: "test" }]), + createTestRegistry([ + { pluginId: "discord", plugin: discordCrossContextPlugin, source: "test" }, + ]), ); }); diff --git a/src/infra/outbound/outbound.test.ts b/src/infra/outbound/outbound.test.ts index f90fc7f221e..006a160e6ab 100644 --- a/src/infra/outbound/outbound.test.ts +++ b/src/infra/outbound/outbound.test.ts @@ -2,7 +2,6 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { discordPlugin } from "../../../extensions/discord/src/channel.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; import { setDefaultChannelPluginRegistryForTests } from "../../commands/channel-test-helpers.js"; import type { OpenClawConfig } from "../../config/config.js"; @@ -44,9 +43,7 @@ import { import { runResolveOutboundTargetCoreTests } from "./targets.shared-test.js"; beforeEach(() => { - setActivePluginRegistry( - createTestRegistry([{ pluginId: "discord", plugin: discordPlugin, source: "test" }]), - ); + setActivePluginRegistry(createTestRegistry([])); }); describe("delivery-queue", () => { diff --git a/src/plugins/commands.test.ts b/src/plugins/commands.test.ts index 9f10ae7fe81..51997a53fff 100644 --- a/src/plugins/commands.test.ts +++ b/src/plugins/commands.test.ts @@ -1,5 +1,4 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { discordPlugin } from "../../extensions/discord/src/channel.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; import { __testing, @@ -21,9 +20,7 @@ async function importCommandsModule(cacheBust: string): Promise } beforeEach(() => { - setActivePluginRegistry( - createTestRegistry([{ pluginId: "discord", source: "test", plugin: discordPlugin }]), - ); + setActivePluginRegistry(createTestRegistry([])); }); afterEach(() => {