mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-24 00:11:31 +00:00
- 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
371 lines
12 KiB
Markdown
371 lines
12 KiB
Markdown
---
|
|
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.
|
|
|
|
<Info>
|
|
If you have not built any OpenClaw plugin before, read
|
|
[Getting Started](/plugins/building-plugins) first for the basic package
|
|
structure and manifest setup.
|
|
</Info>
|
|
|
|
## 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
|
|
|
|
<Steps>
|
|
<Step title="Package and manifest">
|
|
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>
|
|
|
|
</Step>
|
|
|
|
<Step title="Build the channel plugin object">
|
|
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>
|
|
|
|
</Step>
|
|
|
|
<Step title="Wire the entry point">
|
|
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.
|
|
|
|
</Step>
|
|
|
|
<Step title="Add a setup entry">
|
|
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.
|
|
|
|
</Step>
|
|
|
|
<Step title="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:
|
|
|
|
```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>
|
|
|
|
</Step>
|
|
|
|
<Step title="Test">
|
|
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).
|
|
|
|
</Step>
|
|
</Steps>
|
|
|
|
## 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
|
|
|
|
<CardGroup cols={2}>
|
|
<Card title="Threading options" icon="git-branch" href="/plugins/sdk-entrypoints#registration-mode">
|
|
Fixed, account-scoped, or custom reply modes
|
|
</Card>
|
|
<Card title="Message tool integration" icon="puzzle" href="/plugins/architecture#channel-plugins-and-the-shared-message-tool">
|
|
describeMessageTool and action discovery
|
|
</Card>
|
|
<Card title="Target resolution" icon="crosshair" href="/plugins/architecture#channel-target-resolution">
|
|
inferTargetChatType, looksLikeId, resolveTarget
|
|
</Card>
|
|
<Card title="Runtime helpers" icon="settings" href="/plugins/sdk-runtime">
|
|
TTS, STT, media, subagent via api.runtime
|
|
</Card>
|
|
</CardGroup>
|
|
|
|
## 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
|