Caricamento in corso...
Caricamento in corso...
Last synced: Today, 22:00
Technical reference for the OpenClaw framework. Real-time synchronization with the official documentation engine.
Use this file to discover all available pages before exploring further.
This guide walks through building a channel plugin that connects OpenClaw to a messaging platform. By the end you will have a working channel with DM security, pairing, reply threading, and outbound messaging.
Channel plugins do not need their own send/edit/react tools. OpenClaw keeps one shared
messageCore owns the shared message tool, prompt wiring, the outer session-key shape, generic
:thread:If your channel supports typing indicators outside inbound replies, expose
heartbeat.sendTyping(...)heartbeat.clearTyping(...)If your channel adds message-tool params that carry media sources, expose those param names through
describeMessageTool(...).mediaSourceParams{ "set-profile": ["avatarUrl", "avatarPath"] }If your platform stores extra scope inside conversation ids, keep that parsing in the plugin with
messaging.resolveSessionConversation(...)rawIdbaseConversationIdparentConversationCandidatesparentConversationCandidatesUse
openclaw/plugin-sdk/channel-route{ channel, to, accountId, threadId }String(threadId)resolveChannelRouteTargetWithParser(...)Bundled plugins that need the same parsing before the channel registry boots can also expose a top-level
session-key-api.tsresolveSessionConversation(...)messaging.resolveParentConversationCandidates(...)resolveSessionConversation(...).parentConversationCandidatesresolveParentConversationCandidates(...)Most channel plugins do not need approval-specific code.
/approveapprovalCapabilityChannelPlugin.approvalsapprovalCapabilityplugin.authapprovalCapability.authorizeActorActionapprovalCapability.getActionAvailabilityStateapprovalCapability.getActionAvailabilityStateapprovalCapability.getExecInitiatingSurfaceStateenableddisabledcreateApproverRestrictedNativeApprovalCapability(...)outbound.shouldSuppressLocalPayloadPromptoutbound.beforeDeliverPayloadapprovalCapability.deliveryapprovalCapability.nativeRuntimecreateLazyChannelApprovalNativeRuntimeAdapter(...)approvalCapability.renderapprovalCapability.describeExecApprovalSetup{ channel, channelLabel, accountId }channels.<channel>.accounts.<id>.execApprovals.*createResolvedApproverActionAuthAdapteropenclaw/plugin-sdk/approval-runtime/approvecreateChannelExecApprovalProfilecreateChannelNativeOriginTargetResolvercreateChannelApproverDmTargetResolvercreateApproverRestrictedNativeApprovalCapabilityopenclaw/plugin-sdk/approval-runtimeapprovalCapability.nativeRuntimecreateChannelApprovalNativeRuntimeAdapter(...)createLazyChannelApprovalNativeRuntimeAdapter(...)nativeRuntimecreateChannelNativeOriginTargetResolver{ to, accountId, threadId }targetsMatchnormalizeTargetForMatchcreateChannelNativeOriginTargetResolvertargetsMatchnormalizeTargetavailabilitypresentationtransportinteractionsobserveopenclaw/plugin-sdk/channel-runtime-contextcreateChannelApprovalHandlercreateChannelNativeApprovalRuntimeaccountIdapprovalKindaccountIdapprovalKindcreateChannelNativeApprovalRuntimecreateApproverRestrictedNativeApprovalAdapterapprovalCapabilityFor hot channel entrypoints, prefer the narrower runtime subpaths when you only need one part of that family:
openclaw/plugin-sdk/approval-auth-runtimeopenclaw/plugin-sdk/approval-client-runtimeopenclaw/plugin-sdk/approval-delivery-runtimeopenclaw/plugin-sdk/approval-gateway-runtimeopenclaw/plugin-sdk/approval-handler-adapter-runtimeopenclaw/plugin-sdk/approval-handler-runtimeopenclaw/plugin-sdk/approval-native-runtimeopenclaw/plugin-sdk/approval-reply-runtimeopenclaw/plugin-sdk/channel-runtime-contextLikewise, prefer
openclaw/plugin-sdk/setup-runtimeopenclaw/plugin-sdk/setup-adapter-runtimeopenclaw/plugin-sdk/reply-runtimeopenclaw/plugin-sdk/reply-dispatch-runtimeopenclaw/plugin-sdk/reply-referenceopenclaw/plugin-sdk/reply-chunkingFor setup specifically:
openclaw/plugin-sdk/setup-runtimecreatePatchedAccountSetupAdaptercreateEnvPatchedAccountSetupAdaptercreateSetupInputPresenceValidatorpromptResolvedAllowFromsplitSetupEntriesopenclaw/plugin-sdk/setup-adapter-runtimecreateEnvPatchedAccountSetupAdapteropenclaw/plugin-sdk/channel-setupcreateOptionalChannelSetupSurfacecreateOptionalChannelSetupAdapterIf your channel supports env-driven setup or auth and generic startup/config flows should know those env names before runtime loads, declare them in the plugin manifest with
channelEnvVarsenvVarsIf your channel can appear in
statuschannels listchannels statusopenclaw.setupEntrypackage.jsonKeep the main channel entry import path narrow too. Discovery can evaluate the entry and the channel plugin module to register capabilities without activating the channel. Files such as
channel-plugin-api.tsregisterFull(...)createOptionalChannelSetupWizardDEFAULT_ACCOUNT_IDcreateTopLevelChannelDmPolicysetSetupChannelEnabledsplitSetupEntriesopenclaw/plugin-sdk/setupmoveSingleAccountChannelSectionToDefaultAccount(...)If your channel only wants to advertise "install this plugin first" in setup surfaces, prefer
createOptionalChannelSetupSurface(...)For other hot channel paths, prefer the narrow helpers over broader legacy surfaces:
openclaw/plugin-sdk/account-coreopenclaw/plugin-sdk/account-idopenclaw/plugin-sdk/account-resolutionopenclaw/plugin-sdk/account-helpersopenclaw/plugin-sdk/inbound-envelopeopenclaw/plugin-sdk/inbound-reply-dispatchopenclaw/plugin-sdk/messaging-targetsopenclaw/plugin-sdk/outbound-mediaopenclaw/plugin-sdk/outbound-runtimebuildThreadAwareOutboundSessionRoute(...)openclaw/plugin-sdk/channel-corereplyToIdthreadId:thread:openclaw/plugin-sdk/thread-bindings-runtimeopenclaw/plugin-sdk/agent-media-payloadopenclaw/plugin-sdk/telegram-command-configAuth-only channels can usually stop at the default path: core handles approvals and the plugin just exposes outbound/auth capabilities. Native approval channels such as Matrix, Slack, Telegram, and custom chat transports should use the shared native helpers instead of rolling their own approval lifecycle.
Keep inbound mention handling split in two layers:
Use
openclaw/plugin-sdk/channel-mention-gatingopenclaw/plugin-sdk/channel-inboundGood fit for plugin-local logic:
Good fit for the shared helper:
requireMentionPreferred flow:
resolveInboundMentionDecision({ facts, policy })decision.effectiveWasMentioneddecision.shouldBypassMentiondecision.shouldSkiptypescriptimport { implicitMentionKindWhen, matchesMentionWithExplicit, resolveInboundMentionDecision, } from "openclaw/plugin-sdk/channel-inbound"; const mentionMatch = matchesMentionWithExplicit(text, { mentionRegexes, mentionPatterns, }); const facts = { canDetectMention: true, wasMentioned: mentionMatch.matched, hasAnyMention: mentionMatch.hasExplicitMention, implicitMentionKinds: [ ...implicitMentionKindWhen("reply_to_bot", isReplyToBot), ...implicitMentionKindWhen("quoted_bot", isQuoteOfBot), ], }; const decision = resolveInboundMentionDecision({ facts, policy: { isGroup, requireMention, allowedImplicitMentionKinds: requireExplicitMention ? [] : ["reply_to_bot", "quoted_bot"], allowTextCommands, hasControlCommand, commandAuthorized, }, }); if (decision.shouldSkip) return;
api.runtime.channel.mentionsbuildMentionRegexesmatchesMentionPatternsmatchesMentionWithExplicitimplicitMentionKindWhenresolveInboundMentionDecisionIf you only need
implicitMentionKindWhenresolveInboundMentionDecisionopenclaw/plugin-sdk/channel-mention-gatingThe older
resolveMentionGating*openclaw/plugin-sdk/channel-inboundresolveInboundMentionDecision({ facts, policy })text<CodeGroup> ```json package.json theme={"theme":{"light":"min-light","dark":"min-dark"}} { "name": "@myorg/openclaw-acme-chat", "version": "1.0.0", "type": "module", "openclaw": { "extensions": ["./index.ts"], "setupEntry": "./setup-entry.ts", "channel": { "id": "acme-chat", "label": "Acme Chat", "blurb": "Connect OpenClaw to Acme Chat." } } } ``` ```json openclaw.plugin.json theme={"theme":{"light":"min-light","dark":"min-dark"}} { "id": "acme-chat", "kind": "channel", "channels": ["acme-chat"], "name": "Acme Chat", "description": "Acme Chat channel plugin", "configSchema": { "type": "object", "additionalProperties": false, "properties": {} }, "channelConfigs": { "acme-chat": { "schema": { "type": "object", "additionalProperties": false, "properties": { "token": { "type": "string" }, "allowFrom": { "type": "array", "items": { "type": "string" } } } }, "uiHints": { "token": { "label": "Bot token", "sensitive": true } } } } } ``` </CodeGroup> `configSchema` validates `plugins.entries.acme-chat.config`. Use it for plugin-owned settings that are not the channel account config. `channelConfigs` validates `channels.acme-chat` and is the cold-path source used by config schema, setup, and UI surfaces before the plugin runtime loads.
textCreate `src/channel.ts`: ```typescript src/channel.ts theme={"theme":{"light":"min-light","dark":"min-dark"}} import { createChatChannelPlugin, createChannelPluginBase, } from "openclaw/plugin-sdk/channel-core"; import type { OpenClawConfig } from "openclaw/plugin-sdk/channel-core"; import { acmeChatApi } from "./client.js"; // your platform API client type ResolvedAccount = { accountId: string | null; token: string; allowFrom: string[]; dmPolicy: string | undefined; }; function resolveAccount( cfg: OpenClawConfig, accountId?: string | null, ): ResolvedAccount { const section = (cfg.channels as Record<string, any>)?.["acme-chat"]; const token = section?.token; if (!token) throw new Error("acme-chat: token is required"); return { accountId: accountId ?? null, token, allowFrom: section?.allowFrom ?? [], dmPolicy: section?.dmSecurity, }; } export const acmeChatPlugin = createChatChannelPlugin<ResolvedAccount>({ base: createChannelPluginBase({ id: "acme-chat", setup: { resolveAccount, inspectAccount(cfg, accountId) { const section = (cfg.channels as Record<string, any>)?.["acme-chat"]; return { enabled: Boolean(section?.token), configured: Boolean(section?.token), tokenStatus: section?.token ? "available" : "missing", }; }, }, }), // DM security: who can message the bot security: { dm: { channelKey: "acme-chat", resolvePolicy: (account) => account.dmPolicy, resolveAllowFrom: (account) => account.allowFrom, defaultPolicy: "allowlist", }, }, // Pairing: approval flow for new DM contacts pairing: { text: { idLabel: "Acme Chat username", message: "Send this code to verify your identity:", notify: async ({ target, code }) => { await acmeChatApi.sendDm(target, `Pairing code: ${code}`); }, }, }, // Threading: how replies are delivered threading: { topLevelReplyToMode: "reply" }, // Outbound: send messages to the platform outbound: { attachedResults: { sendText: async (params) => { const result = await acmeChatApi.sendMessage( params.to, params.text, ); return { messageId: result.id }; }, }, base: { sendMedia: async (params) => { await acmeChatApi.sendFile(params.to, params.filePath); }, }, }, }); ``` For channels that accept both canonical top-level DM keys and legacy nested keys, use the helpers from `plugin-sdk/channel-config-helpers`: `resolveChannelDmAccess`, `resolveChannelDmPolicy`, `resolveChannelDmAllowFrom`, and `normalizeChannelDmPolicy` keep account-local values ahead of inherited root values. Pair the same resolver with doctor repair through `normalizeLegacyDmAliases` so runtime and migration read the same contract. <Accordion title="What createChatChannelPlugin does for you"> Instead of implementing low-level adapter interfaces manually, you pass declarative options and the builder composes them: | Option | What it wires | | -------------------------- | --------------------------------------------------------- | | `security.dm` | Scoped DM security resolver from config fields | | `pairing.text` | Text-based DM pairing flow with code exchange | | `threading` | Reply-to-mode resolver (fixed, account-scoped, or custom) | | `outbound.attachedResults` | Send functions that return result metadata (message IDs) | You can also pass raw adapter objects instead of the declarative options if you need full control. Raw outbound adapters may define a `chunker(text, limit, ctx)` function. The optional `ctx.formatting` carries delivery-time formatting decisions such as `maxLinesPerMessage`; apply it before sending so reply threading and chunk boundaries are resolved once by shared outbound delivery. Send contexts also include `replyToIdSource` (`implicit` or `explicit`) when a native reply target was resolved, so payload helpers can preserve explicit reply tags without consuming an implicit single-use reply slot. </Accordion>
text```typescript index.ts theme={"theme":{"light":"min-light","dark":"min-dark"}} import { defineChannelPluginEntry } from "openclaw/plugin-sdk/channel-core"; import { acmeChatPlugin } from "./src/channel.js"; export default defineChannelPluginEntry({ id: "acme-chat", name: "Acme Chat", description: "Acme Chat channel plugin", plugin: acmeChatPlugin, registerCliMetadata(api) { api.registerCli( ({ program }) => { program .command("acme-chat") .description("Acme Chat management"); }, { descriptors: [ { name: "acme-chat", description: "Acme Chat management", hasSubcommands: false, }, ], }, ); }, registerFull(api) { api.registerGatewayMethod(/* ... */); }, }); ``` Put channel-owned CLI descriptors in `registerCliMetadata(...)` so OpenClaw can show them in root help without activating the full channel runtime, while normal full loads still pick up the same descriptors for real command registration. Keep `registerFull(...)` for runtime-only work. If `registerFull(...)` registers gateway RPC methods, use a plugin-specific prefix. Core admin namespaces (`config.*`, `exec.approvals.*`, `wizard.*`, `update.*`) stay reserved and always resolve to `operator.admin`. `defineChannelPluginEntry` handles the registration-mode split automatically. See [Entry Points](/plugins/sdk-entrypoints#definechannelpluginentry) for all options.
text```typescript setup-entry.ts theme={"theme":{"light":"min-light","dark":"min-dark"}} import { defineSetupPluginEntry } from "openclaw/plugin-sdk/channel-core"; import { acmeChatPlugin } from "./src/channel.js"; export default defineSetupPluginEntry(acmeChatPlugin); ``` OpenClaw loads this instead of the full entry when the channel is disabled or unconfigured. It avoids pulling in heavy runtime code during setup flows. See [Setup and Config](/plugins/sdk-setup#setup-entry) for details. Bundled workspace channels that split setup-safe exports into sidecar modules can use `defineBundledChannelSetupEntry(...)` from `openclaw/plugin-sdk/channel-entry-contract` when they also need an explicit setup-time runtime setter.
text```typescript} registerFull(api) { api.registerHttpRoute({ path: "/acme-chat/webhook", auth: "plugin", // plugin-managed auth (verify signatures yourself) handler: async (req, res) => { const event = parseWebhookPayload(req); // Your inbound handler dispatches the message to OpenClaw. // The exact wiring depends on your platform SDK — // see a real example in the bundled Microsoft Teams or Google Chat plugin package. await handleAcmeChatInbound(api, event); res.statusCode = 200; res.end("ok"); return true; }, }); } ``` <Note> Inbound message handling is channel-specific. Each channel plugin owns its own inbound pipeline. Look at bundled channel plugins (for example the Microsoft Teams or Google Chat plugin package) for real patterns. </Note>
text```typescript src/channel.test.ts theme={"theme":{"light":"min-light","dark":"min-dark"}} import { describe, it, expect } from "vitest"; import { acmeChatPlugin } from "./channel.js"; describe("acme-chat plugin", () => { it("resolves account from config", () => { const cfg = { channels: { "acme-chat": { token: "test-token", allowFrom: ["user1"] }, }, } as any; const account = acmeChatPlugin.setup!.resolveAccount(cfg, undefined); expect(account.token).toBe("test-token"); }); it("inspects account without materializing secrets", () => { const cfg = { channels: { "acme-chat": { token: "test-token" } }, } as any; const result = acmeChatPlugin.setup!.inspectAccount!(cfg, undefined); expect(result.configured).toBe(true); expect(result.tokenStatus).toBe("available"); }); it("reports missing config", () => { const cfg = { channels: {} } as any; const result = acmeChatPlugin.setup!.inspectAccount!(cfg, undefined); expect(result.configured).toBe(false); }); }); ``` ```bash} pnpm test -- <bundled-plugin-root>/acme-chat/ ``` For shared test helpers, see [Testing](/plugins/sdk-testing).
text<bundled-plugin-root>/acme-chat/ ├── package.json # openclaw.channel metadata ├── openclaw.plugin.json # Manifest with config schema ├── index.ts # defineChannelPluginEntry ├── setup-entry.ts # defineSetupPluginEntry ├── api.ts # Public exports (optional) ├── runtime-api.ts # Internal runtime exports (optional) └── src/ ├── channel.ts # ChannelPlugin via createChatChannelPlugin ├── channel.test.ts # Tests ├── client.ts # Platform API client └── runtime.ts # Runtime store (if needed)
Fixed, account-scoped, or custom reply modes
describeMessageTool and action discovery
inferTargetChatType, looksLikeId, resolveTarget
TTS, STT, media, subagent via api.runtime
Shared inbound turn lifecycle: ingest, resolve, record, dispatch, finalize
© 2024 TaskFlow Mirror
Powered by TaskFlow Sync Engine