--- title: "Building Channel Plugins" sidebarTitle: "Channel Plugins" summary: "Step-by-step guide to building a messaging channel plugin for OpenClaw" read_when: - You are building a new messaging channel plugin - You want to connect OpenClaw to a messaging platform - You need to understand the ChannelPlugin adapter surface --- # 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. If you have not built any OpenClaw plugin before, read [Getting Started](/plugins/building-plugins) first for the basic package structure and manifest setup. ## 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 - **Outbound** — sending text, media, and polls to the platform - **Threading** — how replies are threaded Core owns the shared message tool, prompt wiring, session bookkeeping, and dispatch. ## Walkthrough Create the standard plugin files. The `channel` field in `package.json` is what makes this a channel plugin: ```json package.json { "name": "@myorg/openclaw-acme-chat", "version": "1.0.0", "type": "module", "openclaw": { "extensions": ["./index.ts"], "setupEntry": "./setup-entry.ts", "channel": { "id": "acme-chat", "label": "Acme Chat", "blurb": "Connect OpenClaw to Acme Chat." } } } ``` ```json openclaw.plugin.json { "id": "acme-chat", "kind": "channel", "channels": ["acme-chat"], "name": "Acme Chat", "description": "Acme Chat channel plugin", "configSchema": { "type": "object", "additionalProperties": false, "properties": { "acme-chat": { "type": "object", "properties": { "token": { "type": "string" }, "allowFrom": { "type": "array", "items": { "type": "string" } } } } } } } ``` The `ChannelPlugin` interface has many optional adapter surfaces. Start with the minimum — `id` and `setup` — and add adapters as you need them. Create `src/channel.ts`: ```typescript src/channel.ts import { createChatChannelPlugin, createChannelPluginBase, } from "openclaw/plugin-sdk/core"; import type { OpenClawConfig } from "openclaw/plugin-sdk/core"; import { acmeChatApi } from "./client.js"; // your platform API client type ResolvedAccount = { accountId: string | null; token: string; allowFrom: string[]; dmPolicy: string | undefined; }; 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 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); }, }, }, }); ``` Instead of implementing low-level adapter interfaces manually, you pass declarative options and the builder composes them: | Option | What it wires | | --- | --- | | `security.dm` | Scoped DM security resolver from config fields | | `pairing.text` | Text-based DM pairing flow with code exchange | | `threading` | Reply-to-mode resolver (fixed, account-scoped, or custom) | | `outbound.attachedResults` | Send functions that return result metadata (message IDs) | You can also pass raw adapter objects instead of the declarative options if you need full control. Create `index.ts`: ```typescript index.ts import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core"; import { acmeChatPlugin } from "./src/channel.js"; export default defineChannelPluginEntry({ id: "acme-chat", name: "Acme Chat", description: "Acme Chat channel plugin", plugin: acmeChatPlugin, registerFull(api) { api.registerCli( ({ program }) => { program .command("acme-chat") .description("Acme Chat management"); }, { commands: ["acme-chat"] }, ); }, }); ``` `defineChannelPluginEntry` handles the setup/full registration split automatically. See [Entry Points](/plugins/sdk-entrypoints#definechannelpluginentry) for all options. Create `setup-entry.ts` for lightweight loading during onboarding: ```typescript setup-entry.ts import { defineSetupPluginEntry } from "openclaw/plugin-sdk/core"; import { acmeChatPlugin } from "./src/channel.js"; export default defineSetupPluginEntry(acmeChatPlugin); ``` OpenClaw loads this instead of the full entry when the channel is disabled or unconfigured. It avoids pulling in heavy runtime code during setup flows. See [Setup and Config](/plugins/sdk-setup#setup-entry) for details. Your plugin needs to receive messages from the platform and forward them to OpenClaw. The typical pattern is a webhook that verifies the request and dispatches it through your channel's inbound handler: ```typescript registerFull(api) { api.registerHttpRoute({ path: "/acme-chat/webhook", auth: "plugin", // plugin-managed auth (verify signatures yourself) handler: async (req, res) => { const event = parseWebhookPayload(req); // Your inbound handler dispatches the message to OpenClaw. // The exact wiring depends on your platform SDK — // see a real example in extensions/msteams or extensions/googlechat. await handleAcmeChatInbound(api, event); res.statusCode = 200; res.end("ok"); return true; }, }); } ``` Inbound message handling is channel-specific. Each channel plugin owns its own inbound pipeline. Look at bundled channel plugins (e.g. `extensions/msteams`, `extensions/googlechat`) for real patterns. Write colocated tests in `src/channel.test.ts`: ```typescript src/channel.test.ts import { describe, it, expect } from "vitest"; import { acmeChatPlugin } from "./channel.js"; describe("acme-chat plugin", () => { it("resolves account from config", () => { const cfg = { channels: { "acme-chat": { token: "test-token", allowFrom: ["user1"] }, }, } as any; const account = acmeChatPlugin.setup!.resolveAccount(cfg, undefined); expect(account.token).toBe("test-token"); }); it("inspects account without materializing secrets", () => { const cfg = { channels: { "acme-chat": { token: "test-token" } }, } as any; const result = acmeChatPlugin.setup!.inspectAccount!(cfg, undefined); expect(result.configured).toBe(true); expect(result.tokenStatus).toBe("available"); }); it("reports missing config", () => { const cfg = { channels: {} } as any; const result = acmeChatPlugin.setup!.inspectAccount!(cfg, undefined); expect(result.configured).toBe(false); }); }); ``` ```bash pnpm test -- extensions/acme-chat/ ``` For shared test helpers, see [Testing](/plugins/sdk-testing). ## File structure ``` extensions/acme-chat/ ├── package.json # openclaw.channel metadata ├── openclaw.plugin.json # Manifest with config schema ├── index.ts # defineChannelPluginEntry ├── setup-entry.ts # defineSetupPluginEntry ├── api.ts # Public exports (optional) ├── runtime-api.ts # Internal runtime exports (optional) └── src/ ├── channel.ts # ChannelPlugin via createChatChannelPlugin ├── channel.test.ts # Tests ├── client.ts # Platform API client └── runtime.ts # Runtime store (if needed) ``` ## 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 - [Provider Plugins](/plugins/sdk-provider-plugins) — if your plugin also provides models - [SDK Overview](/plugins/sdk-overview) — full subpath import reference - [SDK Testing](/plugins/sdk-testing) — test utilities and contract tests - [Plugin Manifest](/plugins/manifest) — full manifest schema