docs/developers/daemon/15-channel-adapters.md
packages/channels/ contains the IM channel adapters that turn a chat platform's incoming message into an agent prompt and send the agent response back to the chat platform. Four concrete channels ship today: DingTalk, WeChat (Weixin), Telegram, and Feishu. They share a base layer (packages/channels/base/) and an adapter-facing ChannelAgentBridge contract.
There are two current host modes:
qwen channel start [name] is the standalone ACP-backed channel service. It passes adapters an AcpBridge implementation of ChannelAgentBridge.qwen serve --channel <name> and qwen serve --channel all are experimental daemon-managed modes. qwen serve starts one out-of-process channel worker, the worker connects to the daemon through the SDK, and adapters receive a DaemonChannelBridge-backed ChannelAgentBridge facade.In daemon-managed mode, each channel maps inbound chat traffic to daemon sessions under a configurable SessionScope (user, thread, or single). The adapter delegates to DaemonChannelBridge, which delegates to the SDK's DaemonSessionClient (see 13-sdk-daemon-client.md). Channel workers remain primary-workspace only in Phase 2a, so every selected channel's cwd must resolve to the daemon primary workspace.
Webhook-triggered tasks are hosted by qwen serve and executed inside the daemon-managed channel worker. The HTTP route validates the source and forwards a ChannelWebhookTask to the worker over IPC. The worker calls ChannelBase.runWebhookTask(), so adapters do not implement webhook parsing.
Adapters still participate through proactive send support: supportsProactiveSend() tells the host whether a channel can send without an inbound message, supportsProactiveTarget() handles delivery limits for specific target shapes, and pushProactive() carries the outbound content.
(senderId, groupId?) into a daemon session via DaemonChannelSessionFactory.ChannelConfig.approvalMode.DaemonChannelBridge (shared base, packages/channels/base/src/DaemonChannelBridge.ts)class DaemonChannelBridge extends EventEmitter {
constructor(opts: {
cwd: string;
sessionFactory: DaemonChannelSessionFactory;
modelServiceId?: string;
sessionScope?: SessionScope;
});
newSession(cwd: string): Promise<string>;
loadSession(sessionId: string, cwd: string): Promise<string>;
prompt(sessionId: string, text: string, options?): Promise<string>;
cancelSession(sessionId: string): Promise<void>;
stop(): void;
}
Holds daemon session clients keyed by daemon sessionId; ChannelBase and SessionRouter decide which inbound chat target maps to that session. Each attached session has:
DaemonChannelSessionClient (shape of DaemonSessionClient minus channel-irrelevant methods).Events emitted: textChunk, toolCall, sessionUpdate, permissionRequest, permissionResolved, modelSwitched, modelSwitchFailed, sessionDied, promptComplete, and error. Channel adapters wire these into platform-native APIs.
ChannelBase (packages/channels/base/src/ChannelBase.ts)Abstract base every adapter extends:
abstract class ChannelBase {
abstract connect(): Promise<void>;
abstract sendMessage(chatId: string, text: string): Promise<void>;
abstract disconnect(): void;
handleInbound(envelope: Envelope): Promise<void>; // → SessionRouter.resolve + bridge.prompt
}
Handles common cross-cutting concerns: sender gating (allowlist / denylist), group gating, message block streaming (chunk size, throttling), inbound debounce.
| Adapter | File | Transport | Notes |
|---|---|---|---|
| DingTalk | packages/channels/dingtalk/src/DingtalkAdapter.ts | DingTalk Stream SDK WebSocket | Sends via sessionWebhook POST; media images downloaded via DT API, base64 in envelope. |
| WeChat (Weixin) | packages/channels/weixin/src/WeixinAdapter.ts | iLink Bot HTTP long-poll | Sends via proprietary sendText / sendImage API; typing indicators. |
| Telegram | packages/channels/telegram/src/TelegramAdapter.ts | Telegram Bot API long-poll (grammy) | Sends HTML chunks via sendMessage. |
| Feishu | packages/channels/feishu/src/FeishuAdapter.ts | Feishu/Lark Stream WebSocket (default) or HTTP webhook | Sends via Lark SDK as interactive cards; webhook mode requires encryptKey for HMAC signature verification. |
Each adapter implements:
{ senderId, groupId?, text, media?, raw }).ChannelBase).| Adapter | Transport | Identity | Permission UX | Auto-approve config |
|---|---|---|---|---|
| DingTalk | WebSocket stream | senderStaffId (+ optional conversationId for groups) | Inline buttons via DT markdown | ChannelConfig.approvalMode = 'auto' | 'prompt' |
| HTTP long-poll | senderWxid (+ optional groupWxid) | Text-only prompts with reply tokens | Same | |
| Telegram | Bot API long-poll | from.id (+ optional chat.id for groups) | Inline keyboard buttons | Same |
| Feishu | WebSocket stream / HTTP webhook | sender.open_id (+ optional chat_id for groups) | Interactive card buttons | Same |
Note: The "Permission UX" column describes each platform's native affordance, but none is wired up yet —
AcpBridge.requestPermissioncurrently auto-approves every request (packages/channels/base/src/AcpBridge.ts), andChannelConfig.approvalModeis declared but not yet read. Interactive approval is planned (Phase 5).
sequenceDiagram
autonumber
participant CH as Channel platform
participant AD as Channel adapter
participant CB as ChannelBase
participant BR as DaemonChannelBridge
participant SC as DaemonChannelSessionClient
participant D as Daemon
CH-->>AD: inbound message
AD->>AD: build Envelope { senderId, groupId?, text, media? }
AD->>CB: handleInbound(envelope)
CB->>CB: sender / group gating
CB->>CB: SessionRouter.resolve(...) → sessionId
CB->>BR: prompt(sessionId, promptText, attachments?)
BR->>SC: session.prompt({...})
SC->>D: POST /session/:id/prompt
sequenceDiagram
autonumber
participant D as Daemon
participant SC as DaemonChannelSessionClient
participant BR as DaemonChannelBridge
participant CB as ChannelBase
participant AD as Channel adapter
participant CH as Channel platform
D-->>SC: SSE: session_update (agent_message_chunk)
SC-->>BR: DaemonEvent
BR-->>CB: emit 'textChunk'
CB->>CB: assemble response / block streaming
CB->>AD: sendMessage(chatId, chunk or full response)
AD->>CH: sendText / sendMessage / sendChunk
sequenceDiagram
autonumber
participant D as Daemon
participant SC as DaemonChannelSessionClient
participant BR as DaemonChannelBridge
participant AD as Channel adapter
D-->>SC: SSE: permission_request
SC-->>BR: DaemonEvent
alt config.approvalMode == 'auto'
BR->>SC: session.respondToPermission({...})
else 'prompt'
BR-->>AD: emit 'permissionRequest' (renders chat-native UI)
AD->>BR: user picks option → respondToPermission
end
DaemonChannelBridge lives for the lifetime of the channel adapter; sessions inside it live according to the configured SessionScope.DaemonSessionClient.events() tracks lastSeenEventId so replay is correct.shutdown() closes every active session and the underlying transport (the channel's WebSocket / long-poll).timeout parameter.POST /workspace/channel/reload)The daemon reads channel settings from settings.json once, when the channel worker starts (packages/cli/src/commands/channel/daemon-worker.ts → loadSettings → loadChannelsConfig). To apply changes without a full daemon restart, the daemon exposes POST /workspace/channel/reload (strict mutation gate; SDK DaemonClient.reloadChannelWorker(); CLI qwen channel reload):
ChannelWorkerSupervisor.restart() (packages/cli/src/serve/channel-worker-supervisor.ts), which stops the current worker child and relaunches it. The relaunched worker re-reads settings.json, so channel tokens, proxy, and per-channel model all take effect.restart() also resets the crash-restart budget, so a worker parked in failed recovers on an explicit reload.GET /daemon/status reports failed.channel_reload capability and the route are advertised only when the daemon was started with --channel. Adding a brand-new channel name to a --channel <names> selection still requires a daemon restart; --channel all picks up newly-configured channels on reload.packages/channels/base/ — ChannelBase, DaemonChannelBridge, types.ts (ChannelConfig, Envelope, SessionScope, ChannelPlugin).packages/sdk-typescript/src/daemon/ — DaemonSessionClient and friends.@dingtalk/stream (DingTalk), proprietary iLink Bot HTTP (Weixin), grammy (Telegram).ChannelConfig (from packages/channels/base/src/types.ts):
| Knob | Effect |
|---|---|
sessionScope | 'user' (sender + chat), 'thread' (thread id or chat), or 'single' (one shared session per channel). |
approvalMode | 'auto' (auto-respond) / 'prompt' (render UI). |
allowlist?: string[] | Sender ids allowed; missing = open. |
denylist?: string[] | Sender ids denied. |
chunkSize, chunkIntervalMs | Outbound block streaming settings. |
daemon: { baseUrl, token?, clientId? } | Forwarded to DaemonChannelSessionFactory. |
Channel-specific keys layer on top (DingTalk: streamCredentials; WeChat: ilinkUrl, botId; Telegram: botToken; Feishu: clientId (appId), clientSecret (appSecret), verificationToken, encryptKey (webhook mode)).
@qwen-code/sdk. They go through ChannelBase → DaemonChannelBridge → DaemonChannelSessionClient (which the bridge constructs from the SDK). The indirection lets the bridge swap implementations, such as a test stub, without requiring channel changes.AcpBridge; interactive approval is planned.) No common "interactive permission widget" abstraction yet.permission_mediation policy still applies; auto-approve only means the channel responds without prompting the human. Do not combine auto with enforce-grade workflows.DaemonChannelBridge only handles chunking; pushing past WeChat's per-message size or Telegram's flood limit is on the adapter.packages/channels/base/src/DaemonChannelBridge.tspackages/channels/base/src/ChannelBase.tspackages/channels/base/src/types.tspackages/cli/src/serve/channel-worker-supervisor.ts (worker supervision + restart())packages/cli/src/serve/routes/workspace-channel-control.ts (POST /workspace/channel/reload)packages/channels/dingtalk/src/DingtalkAdapter.tspackages/channels/weixin/src/WeixinAdapter.tspackages/channels/telegram/src/TelegramAdapter.tspackages/channels/plugin-example/ (reference plugin scaffold)../channel-plugins.md.13-sdk-daemon-client.md.