basant307/AI_Governance_Project
045
1# Channel Plugin Developer Guide2 3A channel plugin connects Qwen Code to a messaging platform. It's packaged as an [extension](../users/extension/introduction) and loaded at startup. For user-facing docs on installing and configuring plugins, see [Plugins](../users/features/channels/plugins).4 5## How It Fits Together6 7Your 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).8 9```10Your Plugin → builds Envelope → handleInbound()11ChannelBase → gates → commands → routing → ChannelAgentBridge.prompt()12ChannelBase → calls your sendMessage() with the agent's response13```14 15`ChannelAgentBridge` is the adapter-facing bridge contract. The current standalone `qwen channel start` path provides an `AcpBridge`, but plugin code should type constructor parameters as `ChannelAgentBridge` so the same adapter can run behind other bridge implementations later.16 17Migration note for existing TypeScript plugins: if your adapter constructor or factory explicitly types `bridge` as `AcpBridge`, change that annotation to `ChannelAgentBridge` and keep using only the methods exposed by that contract. JavaScript plugins are unaffected at runtime, and standalone `qwen channel start` still passes the current `AcpBridge` implementation.18 19## Runtime Modes20 21The same plugin adapter can be hosted by either channel runtime:22 23- `qwen channel start [name]` is the standalone ACP-backed service. It still uses `AcpBridge` and remains the stable command for running channels outside a daemon.24- `qwen serve --channel <name>` and repeatable `--channel` flags start an experimental daemon-managed channel worker. `--channel all` starts all configured channels. The worker is owned by `qwen serve`, connects to that daemon through the SDK, and passes adapters a `ChannelAgentBridge` facade backed by `DaemonChannelBridge`.25 26Daemon-managed channels inherit the daemon's lifecycle and status reporting. They are intentionally out-of-process so adapter or platform SDK failures do not crash the daemon. The daemon is still bound to one workspace, so every selected channel config must use a `cwd` that resolves to the daemon workspace.27 28## The Plugin Object29 30Your extension entry point exports a `plugin` conforming to `ChannelPlugin`:31 32```typescript33import type { ChannelPlugin } from '@qwen-code/channel-base';34import { MyChannel } from './MyChannel.js';35 36export const plugin: ChannelPlugin = {37 channelType: 'my-platform', // Unique ID, used in settings.json "type" field38 displayName: 'My Platform', // Shown in CLI output39 requiredConfigFields: ['apiKey'], // Validated at startup (beyond standard ChannelConfig)40 createChannel: (name, config, bridge, options) =>41 new MyChannel(name, config, bridge, options),42};43```44 45## The Channel Adapter46 47Extend `ChannelBase` and implement three methods:48 49```typescript50import { ChannelBase } from '@qwen-code/channel-base';51import type {52 ChannelBaseOptions,53 ChannelAgentBridge,54 ChannelConfig,55 Envelope,56} from '@qwen-code/channel-base';57 58export class MyChannel extends ChannelBase {59 constructor(60 name: string,61 config: ChannelConfig,62 bridge: ChannelAgentBridge,63 options?: ChannelBaseOptions,64 ) {65 super(name, config, bridge, options);66 }67 68 async connect(): Promise<void> {69 // Connect to your platform, register message handlers70 // When a message arrives:71 const envelope: Envelope = {72 channelName: this.name,73 senderId: '...', // Stable, unique platform user ID74 senderName: '...', // Display name75 chatId: '...', // Chat/conversation ID (distinct for DMs vs groups)76 text: '...', // Message text (strip @mentions)77 isGroup: false, // Accurate — used by GroupGate78 isMentioned: false, // Accurate — used by GroupGate79 isReplyToBot: false, // Accurate — used by GroupGate80 };81 this.handleInbound(envelope);82 }83 84 async sendMessage(chatId: string, text: string): Promise<void> {85 // Format markdown → platform format, chunk if needed, deliver86 }87 88 disconnect(): void {89 // Clean up connections90 }91}92```93 94Most adapters should pass `options` through unchanged. If an adapter creates its own `SessionRouter` and passes that router to `super()`, set `registerBridgeEvents: true` in `ChannelBaseOptions` so `ChannelBase` still receives `toolCall` and `sessionDied` events directly. Leave it unset for routers supplied by the channel gateway.95 96If your adapter exposes shell-command behavior, check that `bridge.shellCommand` exists before enabling it. Daemon-managed workers omit that optional method unless the daemon advertises the `session_shell_command` capability.97 98## The Envelope99 100The normalized message object you build from platform data. The boolean flags drive gate logic, so they must be accurate.101 102| Field | Type | Required | Notes |103| ---------------- | ------------ | -------- | -------------------------------------------------------------------------- |104| `channelName` | string | Yes | Use `this.name` |105| `senderId` | string | Yes | Must be stable across messages (used for session routing + access control) |106| `senderName` | string | Yes | Display name |107| `chatId` | string | Yes | Must distinguish DMs from groups |108| `text` | string | Yes | Strip bot @mentions |109| `threadId` | string | No | For `sessionScope: "thread"` |110| `messageId` | string | No | Platform message ID — useful for response correlation |111| `isGroup` | boolean | Yes | GroupGate relies on this |112| `isMentioned` | boolean | Yes | GroupGate relies on this |113| `isReplyToBot` | boolean | Yes | GroupGate relies on this |114| `referencedText` | string | No | Quoted message — prepended as context |115| `imageBase64` | string | No | Base64-encoded image (legacy — prefer `attachments`) |116| `imageMimeType` | string | No | e.g., `image/jpeg` (legacy — prefer `attachments`) |117| `attachments` | Attachment[] | No | Structured media attachments (see below) |118 119### Attachments120 121Use the `attachments` array for images, files, audio, and video. `handleInbound()` resolves them automatically: images with base64 `data` are sent to the model as vision input, files with a `filePath` get their path appended to the prompt so the agent can read them.122 123```typescript124interface Attachment {125 type: 'image' | 'file' | 'audio' | 'video';126 data?: string; // base64-encoded data (images, small files)127 filePath?: string; // absolute path to local file (large files saved to disk)128 mimeType: string; // e.g. 'application/pdf', 'image/jpeg'129 fileName?: string; // original file name from the platform130}131```132 133Example — handling a file upload in your adapter:134 135```typescript136import { writeFileSync, mkdirSync, existsSync } from 'node:fs';137import { join } from 'node:path';138import { tmpdir } from 'node:os';139 140const buf = await downloadFromPlatform(fileId);141const dir = join(tmpdir(), 'channel-files');142if (!existsSync(dir)) mkdirSync(dir, { recursive: true });143const filePath = join(dir, fileName);144writeFileSync(filePath, buf);145 146envelope.attachments = [147 {148 type: 'file',149 filePath,150 mimeType: 'application/pdf',151 fileName,152 },153];154```155 156The legacy `imageBase64`/`imageMimeType` fields still work for backwards compatibility but `attachments` is preferred for new code.157 158## Extension Manifest159 160Your `qwen-extension.json` declares the channel type. The key must match `channelType` in your plugin object:161 162```json163{164 "name": "my-channel-extension",165 "version": "1.0.0",166 "channels": {167 "my-platform": {168 "entry": "dist/index.js",169 "displayName": "My Platform Channel"170 }171 }172}173```174 175## Optional Extension Points176 177**Custom slash commands** — register in your constructor:178 179```typescript180this.registerCommand('mycommand', async (envelope, args) => {181 await this.sendMessage(envelope.chatId, 'Response');182 return true; // handled, don't forward to agent183});184```185 186**Working indicators** — override `onPromptStart()` and `onPromptEnd()` to show platform-specific typing indicators. These hooks fire only when a prompt actually begins processing — not for buffered messages (collect mode) or gated/blocked messages:187 188```typescript189protected override onPromptStart(chatId: string, sessionId: string, messageId?: string): void {190 this.platformClient.sendTyping(chatId); // your platform API191}192 193protected override onPromptEnd(chatId: string, sessionId: string, messageId?: string): void {194 this.platformClient.stopTyping(chatId);195}196```197 198**Tool call hooks** — override `onToolCall()` to display agent activity (e.g., "Running shell command...").199 200**Streaming hooks** — override `onResponseChunk(chatId, chunk, sessionId)` for per-chunk progressive display (e.g., editing a message in-place). Override `onResponseComplete(chatId, fullText, sessionId)` to customize final delivery.201 202**Block streaming** — set `blockStreaming: "on"` in the channel config. The base class automatically splits responses into multiple messages at paragraph boundaries. No plugin code needed — it works alongside `onResponseChunk`.203 204**Media** — populate `envelope.attachments` with images/files. See [Attachments](#attachments) above.205 206## Reference Implementations207 208- **Plugin example** (`packages/channels/plugin-example/`) — minimal WebSocket-based adapter, good starting point209- **Telegram** (`packages/channels/telegram/`) — full-featured: images, files, formatting, typing indicators210- **DingTalk** (`packages/channels/dingtalk/`) — stream-based with rich text handling211 