Files
qwen-code/docs/developers/channel-plugins.md
曹潇缤andShaojin Wen ecc64a735a feat(qqbot): deliver proactive loop messages as active group messages (#15635)
* feat(qqbot): deliver proactive loop messages to the group target

* fix(qqbot): fail blocked proactive deliveries and post loop output once

- pushProactive now raises ChannelProactiveDeliveryError when
  sendMessageWithReplyContext reports a blocked outgoing route: the
  blocked send resolves instead of throwing, so a loop delivery that
  never reached the wire was being recorded as delivered. The block's
  disposition is kept so the scheduler can distinguish a permanent
  route failure from a retryable one; ACTIVE_MSG_DISABLED still throws
  unwrapped from the send path.
- QQ's streaming chunk sink is skipped for unattended turns (loop and
  webhook task, identified by the new protected isProactivePromptTurn
  predicate), so the full answer is posted once through pushProactive
  instead of being streamed to the group first and posted again. Base
  chunk bookkeeping, lifecycle events and attended streaming are
  unchanged.
- Tests: permanent and transient route blocks reject the loop and never
  send; streamed chunks of an unattended turn produce exactly one send
  carrying the full response.

* fix(qqbot): keep permanent delivery errors permanent and document proactive scope

- pushProactive rethrows QQ's permanent DeliveryError codes
  (RETRY_EXHAUSTED, ACTIVE_MSG_DISABLED, FALLBACK_FAILED) as
  ChannelProactiveDeliveryError('permanent', ...) so the daemon boundary
  classifies a group that cannot receive active messages as rejected
  instead of retryable. RATE_LIMITED stays transient.
- Drop the threaded-target guard copied verbatim from the base override;
  every entry point already gates on supportsProactiveTarget and QQ never
  produces a thread id, so the copy was unreachable drift.
- Name all four callers in the pushProactive doc comment (loop, webhook
  task, background/named-task response delivery, daemon deliverProactive)
  and state that background responses intentionally go out unanchored now.
- Document QQ in the proactive-delivery capability matrix and in the QQ
  channel guide: active messages, group quota, ACTIVE_MSG_DISABLED and
  the 5-failure auto-disable, no threaded targets, learned chat type
  required.

* feat(qqbot): retire loops for chats that can no longer receive proactive messages

A loop created before a group disabled active messages, or before the bot
was removed from the group, kept firing: each run rebuilt a session, ran
a full unattended agent turn, and could never deliver. Both chat-wide
state changes now retire the chat's loops.

- base: add an optional chat-scoped controller lookup (listForChat) and
  protected disableLoopJobsForChat(chatId), which disables every enabled
  loop in a chat regardless of the creator's exact target; the CLI
  controller implements the lookup over the loop store.
- qqbot: handleGroupDelRobot folds the disabled count into its existing
  cleanup log line; handleGroupMsgToggle disables the group's loops when
  active messages are turned off (both handlers become async and are
  dispatched with an explicit rejection log).
- qqbot: refuse proactive targets for a group whose active-message
  toggle is known to be off (supportsProactiveTarget), so loop creation
  is rejected up front and a firing refuses before burning an agent
  turn; direct messages never consult the group toggle.
- tests: creation refusal via the loop tool, run refusal with the prompt
  never called, removal and toggle-off teardown against the real
  controller, DM exemption, mid-flight toggle classification, and the
  base helper's contract.

* test(qqbot): keep the loop-store integration test honest

- Pin a rendered next-fire ISO timestamp instead of base's 'n/a'
  fallback: the copied controller now delegates nextFireTime the same way
  the production controller does, and the store clock is fixed so the
  value is deterministic.
- Redirect QWEN_HOME at the test's tmpdir before the channel constructor
  runs and assert the channels directory lands inside it, so the test no
  longer creates ~/.qwen/channels on the machine running it.
- Resolve the session through the locally constructed SessionRouter
  instead of reaching the protected ch.router, which was a TS2445 that
  no gate in the repo catches (test files are excluded from tsc).

* fix(qqbot): close the round-2 proactive delivery findings

- Resolve the target chat type through chatTypeFor() instead of
  chatTypeMap, and refuse an explicit group target whose type is unknown,
  so config-declared groups and never-learned groups are gated on the
  same type resolveRoute routes on instead of burning an agent turn that
  cannot deliver.
- Route background/named-task responses through pushProactive
  unconditionally: a toggle-off group must not silently receive them
  through the anchored passive reply path.
- Extract one PERMANENT_DELIVERY_CODES/isPermanentDeliveryError predicate
  and use it at the proactive seam and the five existing classification
  sites, preserving each site's behaviour.
- Fix the two comments that named a scheduler disposition check that does
  not exist (the daemon classifyChannelDeliveryError is the only reader)
  and word the blocked-route error as 'outgoing route unresolved
  (<disposition>)'.
- Include the group openid in group-event failure logs and pin the
  websocket dispatch rejection guard with a corrupt-store test.
- Stop retiring a group's loops when its active-message toggle turns
  off: the toggle is reversible, the pre-flight gate already prevents the
  turn burn, and the scheduler's 5-failure auto-disable retires the job.
  Retirement stays on bot removal, where the gate cannot see the change.
- Pin the permanent/transient arms with FAILED-fallback and RATE_LIMITED
  tests.

* test(cli): pin the chat-scoped loop lookup

listForChat decides which loops chat-wide teardown retires, and the QQ
suites substitute hand-rolled controllers whose filter mirrors it. Test
the real controller over a real ChannelLoopStore seeded with two chats
and two channel names, so dropping the filter (or only its channelName
clause) reds instead of silently retiring unrelated users' loops.

* docs(qqbot): record the proactive delivery contract

- Add the design record in English and Chinese covering the capability
  flip and the four base call sites it activates, the single-delivery
  rule for unattended turns, permanent/transient classification, chat
  retirement on bot removal, and the group active-message toggle
  contract.
- Complete the opt-in adapter list (DingTalk Workspace, Email with its
  proactiveRecipients qualifier) and state QQ's target restrictions and
  the unanchored background-response behaviour.
- Document isProactivePromptTurn in the base hook table and the
  adapter-author proactive delivery section.

---------

Co-authored-by: Shaojin Wen <[email protected]>
2026-11-05 18:28:31 +00:00

14 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 → ChannelAgentBridge.prompt()
ChannelBase  →  calls your sendMessage() with the agent's response

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.

Migration 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.

Runtime Modes

The same plugin adapter can be hosted by either channel runtime:

  • 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.
  • qwen serve --channel <name> and repeatable --channel flags start experimental daemon-managed channel workers. Named channels are grouped by owning workspace, with one worker per owning runtime. --channel all intentionally starts only the primary workspace's configured channels. Workers are owned by qwen serve, connect to that daemon through the SDK, and pass adapters a ChannelAgentBridge facade backed by DaemonChannelBridge.

Daemon-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. Every named channel must resolve to exactly one registered, trusted workspace; its worker receives that runtime's canonical cwd and environment overlay. A user/system channel with no cwd is ambiguous when several workspaces are registered, while a channel in a workspace-local settings file belongs to that workspace by default. --channel all remains primary-only and cannot be combined with named selections.

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 {
  ChannelBaseOptions,
  ChannelAgentBridge,
  ChannelConfig,
  Envelope,
  SessionTarget,
} from '@qwen-code/channel-base';

export class MyChannel extends ChannelBase {
  constructor(
    name: string,
    config: ChannelConfig,
    bridge: ChannelAgentBridge,
    options?: ChannelBaseOptions,
  ) {
    super(name, config, bridge, options);
  }

  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
  }
}

Most 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.

If your adapter exposes shell-command or BTW side-question behavior, check that the corresponding bridge.shellCommand / bridge.btw method exists before enabling it. Daemon-managed workers omit those optional methods unless the daemon advertises the matching session_shell_command / session_btw capability. These channel-adapter methods cover the existing JSON side-question API. Direct daemon clients that use SSE must separately check session_btw_stream before calling streamSessionBtw; the daemon advertises it only when every registered runtime ACP session bridge implements generateSessionBtwStream. The existing bridge.btw adapter contract does not imply streaming support.

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
chatName string No Group/conversation name when supplied by the platform
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 (legacy — prefer attachments)
imageMimeType string No e.g., image/jpeg (legacy — prefer attachments)
attachments Attachment[] No Structured media attachments (see below)

Attachments

Use 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.

interface Attachment {
  type: 'image' | 'file' | 'audio' | 'video';
  data?: string; // base64-encoded data (images, small files)
  filePath?: string; // absolute path to local file (large files saved to disk)
  mimeType: string; // e.g. 'application/pdf', 'image/jpeg'
  fileName?: string; // original file name from the platform
}

Example — handling a file upload in your adapter:

import { writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';

const buf = await downloadFromPlatform(fileId);
const dir = join(tmpdir(), 'channel-files');
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
const filePath = join(dir, fileName);
writeFileSync(filePath, buf);

envelope.attachments = [
  {
    type: 'file',
    filePath,
    mimeType: 'application/pdf',
    fileName,
  },
];

The legacy imageBase64/imageMimeType fields still work for backwards compatibility but attachments is preferred for new code.

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 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:

protected override onPromptStart(chatId: string, sessionId: string, messageId?: string): void {
  this.platformClient.sendTyping(chatId); // your platform API
}

protected override onPromptEnd(chatId: string, sessionId: string, messageId?: string): void {
  this.platformClient.stopTyping(chatId);
}

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

Streaming hooks — override onResponseChunk(chatId, chunk, sessionId, segment) for per-chunk progressive display (e.g., editing a message in-place). Override onResponseComplete(chatId, fullText, sessionId, segment) to customize final delivery. In daemon-managed named-task mode, segment.sourceLabel is immutable delivery metadata for that segment. Render it once on each independently visible message or card, including a separately visible final response, but do not add it to raw buffers or model text. Clear adapter-owned segment state from onOutputSegmentEnd().

Named-task attribution — sendThreadMessage(chatId, threadId, text, sourceLabel) receives the same optional plain-text label for one-shot and proactive delivery boundaries. The default implementation handles plain messages. Adapters that override delivery, split messages, emit cards, or provide fallback sends must repeat the label at every independently visible boundary, escape only the label for the target markup dialect, and include its rendered size in platform limits. Run no-reply checks, media-marker projection, audit hashing, transcript persistence, and retry-body capture against the raw response before presentation; if delivery is persisted for restart-safe retry, persist the captured label separately.

Interactive ChannelUserInputRequestContext also carries sourceLabel. Cards, terminal replacements, and plain fallbacks must retain it without weakening the existing request, session, run, owner, and target checks.

Proactive delivery — override supportsProactiveSend() to return true when the adapter can send without an active inbound request. ChannelBase uses this capability for persistent channel loops, webhook tasks, background-agent results, and daemon delivery. The default target policy rejects threaded targets; override the protected target checks only for target shapes your platform can deliver safely:

override supportsProactiveSend(): boolean {
  return true;
}

protected override supportsProactiveTarget(target: SessionTarget): boolean {
  return target.threadId === undefined;
}

protected override async pushProactive(
  target: SessionTarget,
  text: string,
  sourceLabel?: string,
): Promise<void> {
  await this.sendThreadMessage(
    target.chatId,
    target.threadId,
    text,
    sourceLabel,
  );
}

Use supportsProactiveDeliveryTarget() when generic daemon delivery accepts a different target shape, and supportsProactiveWebhookTarget() when webhook delivery differs from loops and background results. Keep unsupported targets rejected rather than falling back to another conversation.

An adapter that streams chunks from onResponseChunk must also consult isProactivePromptTurn(sessionId): it is true for loop and webhook turns, whose result ChannelBase delivers once, in full, through pushProactive and for which it never calls onResponseComplete. Posting progressive output for those turns would post the same text twice, so a chunk-flushing sink must suppress them.

Media — populate envelope.attachments with images/files. See Attachments above.

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