Files
openclaw/docs/plugins/sdk-channel-plugins.md
Vincent Koc 1f93a99f47 docs(plugins): overhaul SDK pages with Mintlify components and IA restructure
- Rewrite building-plugins.md as focused quick-start with CardGroup routing
- Rewrite sdk-channel-plugins.md with Steps, CodeGroup, Accordion walkthrough
- Move SDK Migration under Building Plugins nav, rename to "Migrate to SDK"
- Fix code examples and use valid Lucide icons for Mintlify Cards
2026-03-22 11:51:09 -07:00

12 KiB

title, sidebarTitle, summary, read_when
title sidebarTitle summary read_when
Building Channel Plugins Channel Plugins Step-by-step guide to building a messaging channel plugin for OpenClaw
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:
<CodeGroup>
```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" }
          }
        }
      }
    }
  }
}
```
</CodeGroup>
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<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);
      },
    },
  },
});
```

<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.
</Accordion>
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;
    },
  });
}
```

<Note>
  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.
</Note>
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