qwen-code/docs/developers/channel-plugins.md
tanzhenxin 987eebd1c4 docs(channels): add plugin developer guide and rename mock to plugin-example
- Add comprehensive developer guide for building channel plugins
- Add user-facing docs for installing/configuring custom channel plugins
- Replace custom-channels.md with new plugins.md
- Rename @qwen-code/channel-mock to @qwen-code/channel-plugin-example
- Add messageId field to Envelope type for response correlation

This provides clear documentation for developers building custom channel
adapters and renames the mock package to better reflect its purpose as
a reference implementation example.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-27 03:19:34 +00:00

5.9 KiB

Channel Plugin Developer Guide

A channel plugin connects Qwen Code to a messaging platform. It's packaged as an extension and loaded at startup. For user-facing docs on installing and configuring plugins, see Plugins.

How It Fits Together

Your plugin sits in the Platform Adapter layer. You handle platform-specific concerns (connecting, receiving messages, sending responses). ChannelBase handles everything else (access control, session routing, prompt queuing, slash commands, crash recovery).

Your Plugin  →  builds Envelope  →  handleInbound()
ChannelBase  →  gates → commands → routing → AcpBridge.prompt()
ChannelBase  →  calls your sendMessage() with the agent's response

The Plugin Object

Your extension entry point exports a plugin conforming to ChannelPlugin:

import type { ChannelPlugin } from '@qwen-code/channel-base';
import { MyChannel } from './MyChannel.js';

export const plugin: ChannelPlugin = {
  channelType: 'my-platform', // Unique ID, used in settings.json "type" field
  displayName: 'My Platform', // Shown in CLI output
  requiredConfigFields: ['apiKey'], // Validated at startup (beyond standard ChannelConfig)
  createChannel: (name, config, bridge, options) =>
    new MyChannel(name, config, bridge, options),
};

The Channel Adapter

Extend ChannelBase and implement three methods:

import { ChannelBase } from '@qwen-code/channel-base';
import type { Envelope } from '@qwen-code/channel-base';

export class MyChannel extends ChannelBase {
  async connect(): Promise<void> {
    // Connect to your platform, register message handlers
    // When a message arrives:
    const envelope: Envelope = {
      channelName: this.name,
      senderId: '...', // Stable, unique platform user ID
      senderName: '...', // Display name
      chatId: '...', // Chat/conversation ID (distinct for DMs vs groups)
      text: '...', // Message text (strip @mentions)
      isGroup: false, // Accurate — used by GroupGate
      isMentioned: false, // Accurate — used by GroupGate
      isReplyToBot: false, // Accurate — used by GroupGate
    };
    this.handleInbound(envelope);
  }

  async sendMessage(chatId: string, text: string): Promise<void> {
    // Format markdown → platform format, chunk if needed, deliver
  }

  disconnect(): void {
    // Clean up connections
  }
}

The Envelope

The normalized message object you build from platform data. The boolean flags drive gate logic, so they must be accurate.

Field Type Required Notes
channelName string Yes Use this.name
senderId string Yes Must be stable across messages (used for session routing + access control)
senderName string Yes Display name
chatId string Yes Must distinguish DMs from groups
text string Yes Strip bot @mentions
threadId string No For sessionScope: "thread"
messageId string No Platform message ID — useful for response correlation
isGroup boolean Yes GroupGate relies on this
isMentioned boolean Yes GroupGate relies on this
isReplyToBot boolean Yes GroupGate relies on this
referencedText string No Quoted message — prepended as context
imageBase64 string No Base64-encoded image for multimodal models
imageMimeType string No e.g., image/jpeg

For files: download from your platform, save to a temp directory, include the file path in text.

Extension Manifest

Your qwen-extension.json declares the channel type. The key must match channelType in your plugin object:

{
  "name": "my-channel-extension",
  "version": "1.0.0",
  "channels": {
    "my-platform": {
      "entry": "dist/index.js",
      "displayName": "My Platform Channel"
    }
  }
}

Optional Extension Points

Custom slash commands — register in your constructor:

this.registerCommand('mycommand', async (envelope, args) => {
  await this.sendMessage(envelope.chatId, 'Response');
  return true; // handled, don't forward to agent
});

Working indicators — override handleInbound() to show platform-specific typing indicators:

override async handleInbound(envelope: Envelope): Promise<void> {
  await this.platformClient.sendTyping(envelope.chatId); // your platform API
  try { await super.handleInbound(envelope); }
  finally { await this.platformClient.stopTyping(envelope.chatId); }
}

Tool call hooks — override onToolCall() to display agent activity (e.g., "Running shell command...").

Media — download from your platform, set imageBase64/imageMimeType on the Envelope before calling handleInbound().

Reference Implementations

  • Plugin example (packages/channels/plugin-example/) — minimal WebSocket-based adapter, good starting point
  • Telegram (packages/channels/telegram/) — full-featured: images, files, formatting, typing indicators
  • DingTalk (packages/channels/dingtalk/) — stream-based with rich text handling