Building Channel Plugins
Building Channel Plugins
Section titled “Building Channel Plugins”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.
How channel plugins work
Section titled “How channel plugins work”Channel plugins do not need their own send/edit/react tools. OpenClaw keeps one
shared message tool in core. Your plugin owns:
- Config — account resolution and setup wizard
- Security — DM policy and allowlists
- Pairing — DM approval flow
- Session grammar — how provider-specific conversation ids map to base chats, thread ids, and parent fallbacks
- Outbound — sending text, media, and polls to the platform
- Threading — how replies are threaded
Core owns the shared message tool, prompt wiring, the outer session-key shape,
generic :thread: bookkeeping, and dispatch.
If your platform stores extra scope inside conversation ids, keep that parsing
in the plugin with messaging.resolveSessionConversation(...). That is the
canonical hook for mapping rawId to the base conversation id, optional thread
id, explicit baseConversationId, and any parentConversationCandidates.
When you return parentConversationCandidates, keep them ordered from the
narrowest parent to the broadest/base conversation.
Bundled plugins that need the same parsing before the channel registry boots
can also expose a top-level session-key-api.ts file with a matching
resolveSessionConversation(...) export. Core uses that bootstrap-safe surface
only when the runtime plugin registry is not available yet.
messaging.resolveParentConversationCandidates(...) remains available as a
legacy compatibility fallback when a plugin only needs parent fallbacks on top
of the generic/raw id. If both hooks exist, core uses
resolveSessionConversation(...).parentConversationCandidates first and only
falls back to resolveParentConversationCandidates(...) when the canonical hook
omits them.
Approvals and channel capabilities
Section titled “Approvals and channel capabilities”Most channel plugins do not need approval-specific code.
- Core owns same-chat
/approve, shared approval button payloads, and generic fallback delivery. - Prefer one
approvalCapabilityobject on the channel plugin when the channel needs approval-specific behavior. approvalCapability.authorizeActorActionandapprovalCapability.getActionAvailabilityStateare the canonical approval-auth seam.- Use
outbound.shouldSuppressLocalPayloadPromptoroutbound.beforeDeliverPayloadfor channel-specific payload lifecycle behavior such as hiding duplicate local approval prompts or sending typing indicators before delivery. - Use
approvalCapability.deliveryonly for native approval routing or fallback suppression. - Use
approvalCapability.renderonly when a channel truly needs custom approval payloads instead of the shared renderer. - If a channel can infer stable owner-like DM identities from existing config, use
createResolvedApproverActionAuthAdapterfromopenclaw/plugin-sdk/approval-runtimeto restrict same-chat/approvewithout adding approval-specific core logic. - If a channel needs native approval delivery, keep channel code focused on target normalization and transport hooks. Use
createChannelExecApprovalProfile,createChannelNativeOriginTargetResolver,createChannelApproverDmTargetResolver,createApproverRestrictedNativeApprovalCapability, andcreateChannelNativeApprovalRuntimefromopenclaw/plugin-sdk/approval-runtimeso core owns request filtering, routing, dedupe, expiry, and gateway subscription. - Native approval channels must route both
accountIdandapprovalKindthrough those helpers.accountIdkeeps multi-account approval policy scoped to the right bot account, andapprovalKindkeeps exec vs plugin approval behavior available to the channel without hardcoded branches in core. createApproverRestrictedNativeApprovalAdapterstill exists as a compatibility wrapper, but new code should prefer the capability builder and exposeapprovalCapabilityon the plugin.
Auth-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.
Walkthrough
Section titled “Walkthrough”Package and manifest
Create the standard plugin files. The
channelfield inpackage.jsonis what makes this a channel plugin:{"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."}}}{"id": "acme-chat","kind": "channel","channels": ["acme-chat"],"name": "Acme Chat","description": "Acme Chat channel plugin","configSchema": {"type": "object","additionalProperties": false,"properties": {"acme-chat": {"type": "object","properties": {"token": { "type": "string" },"allowFrom": {"type": "array","items": { "type": "string" }}}}}}}Build the channel plugin object
The
ChannelPlugininterface has many optional adapter surfaces. Start with the minimum —idandsetup— and add adapters as you need them.Create
src/channel.ts:import {createChatChannelPlugin,createChannelPluginBase,} from "openclaw/plugin-sdk/core";import type { OpenClawConfig } from "openclaw/plugin-sdk/core";import { acmeChatApi } from "./client.js"; // your platform API clienttype 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)?.[“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({ base: createChannelPluginBase({ id: “acme-chat”, setup: { resolveAccount, inspectAccount(cfg, accountId) { const section = (cfg.channels as Record
)?.[“acme-chat”]; return { enabled: Boolean(section?.token), configured: Boolean(section?.token), tokenStatus: section?.token ? “available” : “missing”, }; }, }, }),
// DM security: who can message the botsecurity: {dm: {channelKey: "acme-chat",resolvePolicy: (account) => account.dmPolicy,resolveAllowFrom: (account) => account.allowFrom,defaultPolicy: "allowlist",},},// Pairing: approval flow for new DM contactspairing: {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 deliveredthreading: { topLevelReplyToMode: "reply" },// Outbound: send messages to the platformoutbound: {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);},},},});```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.dmScoped DM security resolver from config fields pairing.textText-based DM pairing flow with code exchange threadingReply-to-mode resolver (fixed, account-scoped, or custom) outbound.attachedResultsSend functions that return result metadata (message IDs) You can also pass raw adapter objects instead of the declarative options if you need full control.
Wire the entry point
Create
index.ts:import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core";import { acmeChatPlugin } from "./src/channel.js";export default defineChannelPluginEntry({id: "acme-chat",name: "Acme Chat",description: "Acme Chat channel plugin",plugin: acmeChatPlugin,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. KeepregisterFull(...)for runtime-only work.defineChannelPluginEntryhandles the registration-mode split automatically. See Entry Points for all options.Add a setup entry
Create
setup-entry.tsfor lightweight loading during onboarding:import { defineSetupPluginEntry } from "openclaw/plugin-sdk/core";import { acmeChatPlugin } from "./src/channel.js";export default defineSetupPluginEntry(acmeChatPlugin);OpenClaw loads this instead of the full entry when the channel is disabled or unconfigured. It avoids pulling in heavy runtime code during setup flows. See Setup and Config for details.
Handle inbound messages
Your plugin needs to receive messages from the platform and forward them to OpenClaw. The typical pattern is a webhook that verifies the request and dispatches it through your channel’s inbound handler:
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;},});}Test
Write colocated tests in
src/channel.test.ts:```typescript src/channel.test.tsimport { 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);});});``````bashpnpm test --/acme-chat/ ```
For shared test helpers, see [Testing](/en/plugins/sdk-testing).
File structure
Section titled “File structure”<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)Advanced topics
Section titled “Advanced topics”Fixed, account-scoped, or custom reply modes
describeMessageTool and action discovery
inferTargetChatType, looksLikeId, resolveTarget
TTS, STT, media, subagent via api.runtime
Next steps
Section titled “Next steps”- Provider Plugins — if your plugin also provides models
- SDK Overview — full subpath import reference
- SDK Testing — test utilities and contract tests
- Plugin Manifest — full manifest schema