這篇 Channels SDK 教學要解決一個常見問題:你已經有會查資料、呼叫工具的 AI Agent,但團隊有人只用 Slack、社群在 Discord,企業客戶又要求 Microsoft Teams。若替三個平台各寫一隻 bot,很快就會出現三套 prompt、三份權限判斷,以及三種互不相通的審批狀態。
做法是保留同一個 Agent backend,讓 CopilotKit Channels SDK 把各平台事件正規化成共同的 Thread、訊息與 portable UI,再交給 Slack、Discord、Teams adapter 呈現。2026 年 8 月 9 日晚間快照,Hacker News 發表頁有 119 points、24 comments,channels-sdk GitHub repo有 799 stars、53 forks;這些數字只代表開發者注意力,不等於成熟度或採用率。
本文會從零建立一個可編譯的小專案,把同一個 Agent 同時接到三個 direct adapters,在單一 process 內保存 per-thread state,示範通過 audience/ACL 檢查後才開啟的 user transcript bridge,做出有審批者檢查的批准/拒絕按鈕,並接好 interrupt 後的 resume handler。你可以只啟用 Slack+Discord;Teams 章節則提供本機測試與正式環境邊界。跨重啟保存會在後文另外處理。
先記住這條公式:多通道 Agent = 共用核心(模型、工具、規則)+ 通道 adapter(事件、身分、原生 UI)。可共用的是 business logic,不是平台假設。
先說結論:共用 Agent 核心,不共用平台假設
Channels SDK 適合「已有 Agent,現在要把它帶進聊天平台」的團隊。你可以在同一個 createChannel() 共用 agent factory、tools、handlers、conversation state schema 與批准流程;Slack、Discord、Teams 的 token、事件入口、使用者 ID、UI capability 與發布審核仍各自處理。
- Prototype:先選 Slack 或 Discord,使用 direct adapter 與 MemoryStore 跑通。
- 跨平台:第二個 adapter 接入前,先把 identity、conversation key 與 domain state 分層。
- Production:換 durable StateStore,補齊去重、lock、queue、approval authorization 與各平台真實環境測試。
Channels SDK 教學開始前:先看版本,再寫程式
Channels SDK 是 Agent 與聊天平台之間的通道層。Agent 不必理解 Slack 的 thread_ts、Discord 的 interaction 或 Teams 的 activity;adapter 接收原生事件,轉成共同語意,再把回覆翻譯成 Block Kit、Discord Components V2 或 Adaptive Cards。這和AI Agent Harness的分工一致:模型負責判斷,系統負責 transport、狀態、權限與可觀測性。
截至 2026 年 8 月 9 日,官方 direct-adapter 文件列出 Slack、Teams、Discord、Telegram 與 WhatsApp;同一個 createChannel() 可以放多個 adapters。Managed Intelligence 目前列 Slack 與 Teams,Discord 則應走 direct adapter。Direct 代表平台 token、socket/webhook 與 transport reliability 在你的 process 裡,不代表完全不需要 CopilotRuntime/Intelligence connection。
版本漂移警告:官方 reference仍示範 @copilotkit/channels@0.6.1+@copilotkit/runtime@1.65.0;npm 當天 latest 已是 @copilotkit/channels@0.8.0+@copilotkit/runtime@1.66.4。本文鎖定後者,並在 Node.js 24.15、TypeScript 5.9 執行 tsc --noEmit 通過;這是型別驗證,不是三平台真實 token 的端到端驗收。不要把舊教學的 createBot、bot.start() 混進本文的 createChannel API。
架構先畫對:Agent 核心與平台 shell 分離

把 Agent 想成廚房,createChannel() 是出餐流程,三個 adapter 是不同外送平台,Thread 是桌號,StateStore 是訂單簿。餐點邏輯可以共用,但每個外送平台的地址、按鈕與回覆時限都不同。
- Agent backend:模型、tools、prompt、業務規則與「何時需要人工批准」。
- Channel core:
Thread、conversationKey、runAgent()、resume()、state 與 portable JSX。 - Platform adapter:接收原生事件、驗證憑證、ACK/defer、render UI、更新訊息與處理平台限制。
- 應用程式資料層:跨平台帳號綁定、真正的業務狀態、durable queue、去重與稽核。
這個切法也解釋了為何「同一個 backend」可行,但「一次開發、三處無差異」不可行。你的 domain action 可以只有一份,Slack app 審核、Discord privileged intents、Teams tenant policy 卻不能互相代替。
步驟 1:建立最小 Channels SDK 專案
準備 Node.js 22 以上、CopilotKit Intelligence project key、模型供應商 API key,以及至少一個測試平台的 app token。Direct listener 是長時間運行的 Node process;先用本機或 container,不要直接塞進會任意休眠的短生命週期函式。
mkdir channels-demo
cd channels-demo
npm init -y
npm pkg set type=module
npm install --save-exact \
@copilotkit/channels@0.8.0 \
@copilotkit/runtime@1.66.4 \
zod
npm install --save-dev tsx typescript @types/node
建立 tsconfig.json。副檔名一定要用 .tsx,因為後面會用 JSX 寫跨平台卡片。
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"jsx": "react-jsx",
"jsxImportSource": "@copilotkit/channels",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["node"]
},
"include": ["*.ts", "*.tsx"]
}
步驟 2:寫出 Channels SDK 教學核心程式
把下面存成 channel.tsx。它包含四件事:一個共用 Agent factory、三個 adapters、per-thread state 與預設關閉的 transcript bridge,以及可在 interrupt 後繼續的批准卡片。本文只註冊 onMessage,避免 DM 落入只有 mention handler 才處理的空隙。
import { createServer } from "node:http";
import {
Actions, Button, Header, Message, Section, createChannel,
} from "@copilotkit/channels";
import { discord } from "@copilotkit/channels/discord";
import { slack } from "@copilotkit/channels/slack";
import { teams } from "@copilotkit/channels/teams";
import {
BuiltInAgent, CopilotKitIntelligence, CopilotRuntime,
} from "@copilotkit/runtime/v2";
import { createCopilotNodeListener } from "@copilotkit/runtime/v2/node";
import { z } from "zod";
function required(name: string) {
const value = process.env[name];
if (!value) throw new Error(`Missing ${name}`);
return value;
}
function makeAgent(threadId: string) {
const agent = new BuiltInAgent({
model: required("MODEL"),
apiKey: required("MODEL_API_KEY"),
});
agent.threadId = threadId;
return agent;
}
const workflowState = z.object({
stage: z.enum(["idle", "waiting-approval", "approved", "rejected"]),
requestedBy: z.string().optional(),
allowedApproverId: z.string().optional(),
approvalId: z.string().optional(),
});
const approvalInterrupt = z.object({
summary: z.string().min(1),
allowedApproverId: z.string().min(1),
approvalId: z.string().min(1),
});
// 單一 process 的 prototype guard。Production 必須改成在 durable
// application store 原子消耗這個 ID,再 resume 工作流。
const claimedApprovals = new Set<string>();
const enablePrivateTranscriptBridge =
process.env.ENABLE_PRIVATE_TRANSCRIPT_BRIDGE === "true";
function ApprovalCard({
summary,
allowedApproverId,
approvalId,
}: {
summary: string;
allowedApproverId: string;
approvalId: string;
}) {
return (
<Message accent="#6C5CE7">
<Header>需要人工審批</Header>
<Section>{summary}</Section>
<Actions>
<Button
style="primary"
value={{ approved: true }}
onClick={async ({ thread, message, actor }) => {
if (actor.id !== allowedApproverId) {
await thread.post("你不是這次操作指定的審批者。");
return;
}
const claimKey = approvalId;
if (claimedApprovals.has(claimKey)) {
await thread.post("這次審批已經完成或正在處理。");
return;
}
claimedApprovals.add(claimKey);
const current = workflowState.safeParse(await thread.state());
if (
!current.success ||
current.data.stage !== "waiting-approval" ||
current.data.approvalId !== approvalId
) {
await thread.post("這次審批已經完成或失效。");
return;
}
await thread.resume({ approved: true });
await thread.setState({
stage: "approved",
requestedBy: current.data.requestedBy,
allowedApproverId: current.data.allowedApproverId,
approvalId: current.data.approvalId,
});
if (message.ref.id) {
try {
await thread.update(message.ref, "已批准,Agent 正在繼續。");
} catch {
// 更新舊卡片是 best-effort;resume 仍要執行。
}
}
}}
>
批准
</Button>
<Button
style="danger"
value={{ approved: false }}
onClick={async ({ thread, message, actor }) => {
if (actor.id !== allowedApproverId) {
await thread.post("你不是這次操作指定的審批者。");
return;
}
const claimKey = approvalId;
if (claimedApprovals.has(claimKey)) {
await thread.post("這次審批已經完成或正在處理。");
return;
}
claimedApprovals.add(claimKey);
const current = workflowState.safeParse(await thread.state());
if (
!current.success ||
current.data.stage !== "waiting-approval" ||
current.data.approvalId !== approvalId
) {
await thread.post("這次審批已經完成或失效。");
return;
}
await thread.resume({ approved: false });
await thread.setState({
stage: "rejected",
requestedBy: current.data.requestedBy,
allowedApproverId: current.data.allowedApproverId,
approvalId: current.data.approvalId,
});
if (message.ref.id) {
try {
await thread.update(message.ref, "已拒絕,操作已停止。");
} catch {
// 更新舊卡片是 best-effort;resume 仍要執行。
}
}
}}
>
拒絕
</Button>
</Actions>
</Message>
);
}
const channel = createChannel({
name: "alphalab-multi-channel",
identifyUser: "platform",
agent: makeAgent,
adapters: [
slack({
botToken: required("SLACK_BOT_TOKEN"),
appToken: required("SLACK_APP_TOKEN"),
}),
discord({
botToken: required("DISCORD_BOT_TOKEN"),
appId: required("DISCORD_APP_ID"),
guildId: process.env.DISCORD_GUILD_ID,
}),
teams({
// 本機 M365 Agents Playground 可省略 credentials。
clientId: process.env.TEAMS_CLIENT_ID,
clientSecret: process.env.TEAMS_CLIENT_SECRET,
tenantId: process.env.TEAMS_TENANT_ID,
port: 3978,
}),
],
store: {
state: workflowState,
transcripts: { retention: "30d", maxPerUser: 200 },
concurrency: "serial",
},
components: [ApprovalCard],
});
channel.onMessage(async ({ thread, message }) => {
if (message.operation.kind !== "created") return;
await thread.runAgent({
context: [
{ description: "Originating platform", value: thread.platform },
{ description: "Conversation key", value: thread.conversationKey },
],
...(enablePrivateTranscriptBridge
? { transcript: { limit: 20 } }
: {}),
});
});
channel.onInterrupt<z.infer<typeof approvalInterrupt>>(
"on_interrupt",
async ({ thread, payload, actor }) => {
const parsed = approvalInterrupt.safeParse(payload);
if (!parsed.success) {
await thread.post("審批資料格式錯誤,已停止這次流程。");
return;
}
await thread.setState({
stage: "waiting-approval",
requestedBy: actor.id,
allowedApproverId: parsed.data.allowedApproverId,
approvalId: parsed.data.approvalId,
});
await thread.post(
<ApprovalCard
summary={parsed.data.summary}
allowedApproverId={parsed.data.allowedApproverId}
approvalId={parsed.data.approvalId}
/>,
);
},
);
const intelligence = new CopilotKitIntelligence({
apiKey: required("INTELLIGENCE_API_KEY"),
});
const runtime = new CopilotRuntime({
agents: {},
intelligence,
channels: [channel],
});
const listener = createCopilotNodeListener({
runtime,
basePath: "/api/copilotkit",
});
const server = createServer(listener);
const stop = async () => {
await listener.channels.stop();
if (server.listening) server.close();
};
process.once("SIGINT", stop);
process.once("SIGTERM", stop);
server.listen(Number(process.env.PORT ?? 3000));
await listener.channels.ready({ timeoutMs: 30_000 });
console.log(listener.channels.status());
先畫清楚範例邊界:上面的 BuiltInAgent 可直接跑一般訊息,但沒有會發出 Channels legacy on_interrupt custom event 的工具;因此目前接好的是批准卡片與 resume 的接收端 handler。若要真的暫停 backend,請換成會發出該事件、並能消費 resume command 的 AG-UI-compatible backend。Adapters 不必改,只要把 makeAgent() 換成指向既有 endpoint 的 HttpAgent:
import { HttpAgent } from "@copilotkit/channels";
function makeAgent(threadId: string) {
const agent = new HttpAgent({ url: required("AGENT_URL") });
agent.threadId = threadId;
return agent;
}
先把下列內容存成 .env,並把檔案加入 .gitignore。未測的平台可暫時從 adapters 陣列移除;不要填假 token。ready() 會在所有 adapters 都失敗時 reject,但部分成功時可能 degraded start,所以還要檢查啟動 log,並逐平台做 smoke test。
INTELLIGENCE_API_KEY=<project-api-key>
MODEL=openai/gpt-4.1-mini
MODEL_API_KEY=sk-...
ENABLE_PRIVATE_TRANSCRIPT_BRIDGE=false
PORT=3000
SLACK_BOT_TOKEN=xoxb-...
SLACK_APP_TOKEN=xapp-...
DISCORD_BOT_TOKEN=...
DISCORD_APP_ID=...
DISCORD_GUILD_ID=...
TEAMS_CLIENT_ID=...
TEAMS_CLIENT_SECRET=...
TEAMS_TENANT_ID=...
MODEL=openai/gpt-4.1-mini 是目前 package 支援的 provider/model 格式範例;換 Anthropic 或 Google 時,provider prefix 與 MODEL_API_KEY 必須一起換。INTELLIGENCE_API_KEY 供 CopilotKit Channels runtime 使用;MODEL_API_KEY 才是 BuiltInAgent 呼叫模型的 provider key,兩者不能互相替代。若改用 HttpAgent,模型 key 通常改由遠端 backend 管理。
檢查型別後啟動:
npx tsc --noEmit
npx tsx --env-file=.env channel.tsx
步驟 3:接上 Slack 測試 workspace

- 在 Slack 建立測試 app,最省事的起點是官方範例的 Slack app manifest。
- 開啟 Socket Mode 與 Interactivity;建立具有
connections:write的 app-level token,得到xapp-...。Interactivity 沒開時,批准按鈕不會送回 callback。 - Bot 至少需要
app_mentions:read、chat:write、im:history與公開頻道的channels:history;若要在私人頻道使用,再加groups:history。訂閱app_mention與message.im。SDK 會重建 thread history;缺 history scope 時,Agent 可能拿不到完整 prompt。 - 安裝或重新安裝到測試 workspace,讓新增 scope 生效,再複製最新
xoxb-...bot token。 - 啟動 runner,在 DM 傳訊息,或在 channel
@你的Bot。預設 app mention 會回 thread;一般 thread reply 是否繼續觸發取決於 routing 設定。
依官方 Slack adapter README,Socket Mode 透過 WebSocket 收事件,因此本機測試不需公開 provider webhook。若你改成 Slack HTTP mode,才要設定 signing secret、public endpoint 與 raw-body HMAC 驗證。本文只涵蓋單一 internal 測試 workspace,不包含 OAuth 多租戶安裝或 Slack Marketplace 發布;截至查核日,商業散布的非 Marketplace 新安裝呼叫 conversations.replies可能只有每分鐘 1 次、每次 15 筆,internal/Marketplace app 則保留 Tier 3,因此 production 必須另做 history fetch 與 rate-limit 容量規劃。
步驟 4:接上 Discord 測試 guild

- 按照官方 Discord adapter README,在 Discord Developer Portal 建立 Application 與 Bot,複製 bot token 與 Application ID。
- Bot 頁開啟 Message Content 與 Server Members 兩個 privileged intents;這是目前 adapter 會要求的 intents。
- OAuth2 URL Generator 勾
bot、applications.commands,至少授予 View Channels、Send Messages、Read Message History、Use Application Commands、Embed Links;若要測 thread,再加 Send Messages in Threads,然後邀請到測試 guild。 - 設定
DISCORD_GUILD_ID。開發期使用 guild-scoped commands 可立即更新;省略 guild ID 會走 global registration,傳播時間不要當成固定 SLA。 - 啟動同一個
channel.tsx,在 DM 或 guild mention 測試。Direct Discord 走 Gateway WebSocket,也不需要公開 provider webhook。
權限名稱與 bit flag 以Discord 官方權限表為準。Discord message event 沒有 HTTP ACK,但 slash command 與 component interaction 必須快速回覆或 defer。SDK 可把串流模擬成節流的 message edits;這不等於 Slack、Discord、Teams 會有一樣的 token 動畫或延遲。正式上線還要處理 sharding、rate-limit headers、app verification 與多 guild tenancy,不能只把測試 token 換成 production token。
步驟 5:用 Teams Playground 做本機驗證

依官方 Teams adapter README,direct adapter 使用 Microsoft 365 Agents SDK,預設在 port 3978 接收 POST /api/messages。本機可以不填 Teams credentials,另開一個終端執行M365 Agents Playground npm 套件:
npx @microsoft/m365agentsplayground
Playground 會開啟本機介面,連到 127.0.0.1:3978/api/messages,適合先驗證文字、按鈕與 Adaptive Card。進真 tenant 時,才加入 clientId、clientSecret、可選的 tenantId,並依Microsoft app 註冊流程設定 public HTTPS https://你的網域/api/messages messaging endpoint;本機 tenant 測試要用 HTTPS tunnel,正式環境則部署公開 endpoint。接著還要建立 Teams app manifest、sideload、admin policy 與 production authentication。這些是另一個發布流程;截至本文查核日,CopilotKit 的產品頁把 managed Slack+Teams 標為 generally available。這個產品狀態不會替你完成 direct adapter 的 tenant app 發布、權限與管理員政策驗收。
Thread、User、State 怎麼保存?先分清三件事

1. Thread:一段原生 conversation
thread.conversationKey 是 SDK 提供的穩定 opaque key,供 state、transcript、action 與對話鎖定使用;agent factory 收到的 threadId 則由 adapter 決定。0.8.0 的 Slack、Teams 會建立不同的 per-turn agent thread ID,Discord 目前才沿用 conversation key,因此兩者都應視為 opaque,不能假設相等,也不要拆字串推測 workspace、guild 或 tenant。需要回覆原生位置時,交給 adapter 與 MessageRef。訊息更新屬 best effort,舊卡片失效不能讓真正的 Agent action 一起失敗。
2. User:平台 actor 不等於應用程式使用者
官方 identity 文件把這兩者明確分開:message.actor 是這次事件的 Slack/Discord/Teams 原生帳號;message.user 則是 identifyUser 映射後的 application user。範例的 identifyUser: "platform" 會保留 provider、tenant 與 actor 邊界,因此同一真人在 Slack 與 Discord 預設是兩個人。不要用顯示名稱或 email 自動合併;要跨平台延續個人 transcript,應讓使用者登入並明確綁定:
// 應用程式資料庫的示意,不是 SDK 內建 accounts 物件。
identifyUser: async ({ provider, tenant, actor }) => {
const account = await accounts.findByExternalIdentity({
provider,
tenantId: tenant.id,
externalUserId: actor.id,
});
return account ? { id: account.id, name: account.name } : null;
}
3. State:工作流進度,不是長期業務資料庫
thread.state()/setState() 以 conversation key 保存流程進度。setState() 是整個 value 替換,不是 partial merge,所以批准/拒絕 handler 先用 Zod 解析舊值,再把要保留的欄位寫回。transcripts 搭配 runAgent({ transcript: { limit: 20 } }) 會按已識別 application user 保存並注入先前對話;若使用者沒有成功映射,bridge 會跳過。範例預設設成 ENABLE_PRIVATE_TRANSCRIPT_BRIDGE=false,只有在你確認目前 surface 是相同受眾的私人測試後才開啟。
Transcript bridge 有 audience 邊界:0.8.0 會以 application user ID 取回先前紀錄,不會自動確認「先前 DM 與目前公開 channel 是否屬於同一批讀者」。一旦自訂 account linking,把同一真人跨平台映射成同一 user ID,過去內容就可能進入目前 prompt。共享頻道應保持關閉,改由應用程式 DB 依 tenant、conversation、資料分類與 ACL 明確取用。
範例還不是 restart-safe:官方 persistence 文件說明,沒有傳入 store.adapter 時,SDK 使用 process-local MemoryStore;state、transcript、callback snapshot 都會在重啟後消失。Production 必須提供完整 StateStore,涵蓋 kv、list、lock、dedup、queue,並用官方 conformance suite 驗證。本文查核的 0.8.0 public exports 與官方 persistence 文件未列出可直接替換的官方 Redis/Postgres production adapter,上線前應以當時版本再確認。
還要再分一層:Channels store.adapter 會持久化 SDK state、user transcript、actions 與 continuation,但 0.8.0 的Teams adapter-native conversation history仍是 process-local。換 durable StateStore 不會自動保存這份 native history;restart 後要用 Teams/Graph 可取得的紀錄重建,或把必要訊息另存應用程式資料層並做 ACL。
store: {
adapter: durableStore, // 你的 Redis/Postgres StateStore 實作
state: workflowState,
transcripts: { retention: "30d", maxPerUser: 200 },
concurrency: "serial",
dedupTtl: 5 * 60 * 1000,
actionRetentionMs: 7 * 24 * 60 * 60 * 1000,
}
真正跨 conversation 的 incident、訂單或 project 狀態,仍應放在應用程式 DB,以 domain ID 為 key。若你還在釐清「工作記憶、對話狀態與長期記憶」的差別,可搭配Agent Memory 實作教學閱讀。
按鈕、審批與 interrupt/resume:把它當狀態機
官方 interactive/approval 流程示範,portable JSX 的 Message、Header、Section、Actions、Button 會由 adapter 映射成原生元件。上面程式把 ApprovalCard 註冊到 components,收到 on_interrupt 時先 post 卡片並 return;下一次按鈕點擊才呼叫 thread.resume({ approved })。這比在第一次 delivery 裡一直阻塞等待,更適合 managed Slack/Teams,也更接近可持久化的工作流。
怎麼真的觸發 on_interrupt?
以 LangGraph backend 為例,在不可逆 action 前呼叫官方 interrupt()。經 CopilotKit 的 AG-UI/LangGraph 整合送出後,Channels handler 會收到 payload;只有 backend checkpoint 與原始 backend thread_id mapping 都存活時,thread.resume() 的值才會回到同一個節點。以下是 backend 節點核心,實際專案還要用 AG-UI endpoint 暴露 graph:
from langgraph.types import interrupt
def deploy_node(state):
decision = interrupt({
"summary": f"部署 {state['release']} 到 production",
# 必須是目前平台的原生 actor ID。
"allowedApproverId": state["allowed_approver_id"],
# 每次審批都要有全域唯一、不可重用的 opaque ID。
"approvalId": state["approval_id"],
})
if not decision.get("approved"):
return {"deploy_status": "rejected"}
# 真正的不可逆操作只放在批准分支之後。
return perform_deploy(state)
allowedApproverId 必須使用這次平台可比對的原生 actor ID;Slack user ID、Discord user ID、Teams account ID 不是同一個 namespace。approvalId 則是 backend 為每次審批產生的全域唯一 opaque ID。文章中的 Zod schema 會在 runtime 驗證 interrupt payload,避免把 TypeScript 泛型誤當資料驗證。
Direct adapter 也提供 awaitChoice(),做 prototype 很方便;但目前 waiter 是 process memory,不能承諾重啟後舊按鈕仍可接回原工作。要支援跨重啟 resume,至少需要:
- 具名 component 註冊在
components。 - 完整且可持久化的 Channels
StateStore。 - 按鈕 callback 呼叫
thread.resume(value)。 - Agent framework 確實能發出並消費對應的 interrupt;不同 backend 要做 provider smoke test。
- Backend 自己的 durable checkpointer,以及 approval/continuation 到原始 backend
thread_id的持久 mapping。Channels StateStore 不能取代 LangGraph checkpoint;尤其 0.8.0 direct Slack/Teams 會為 agent factory 建立不同的 per-turn ID,不能直接把它當 checkpoint ID。
此外,按鈕不是授權。範例的 claimedApprovals 只在單一 Node process 內同步 claim approvalId,能避免同一 process 的批准/拒絕競速,重啟後即失效。Production 的 approval record 應保存 opaque nonce、允許的 approver、payload digest、到期時間與狀態 pending → approved|rejected|expired;點擊時重新驗證 actor,並在 durable application store 原子消耗 nonce,讓跨 instance 重複點擊、錯的人點擊與逾時都有確定結果。這和Agent Gatekeeper 與人工核准的治理問題相通,但本文處理的是外部聊天 adapter。
最小驗收清單
- 一般訊息只觸發一次,edit/delete 不會重新跑 Agent。
- 同一 process 的下一輪能讀到 state;只有在相同受眾的私人測試開啟 bridge 時,才驗收 user transcript。
- Backend 發出一次
on_interrupt後,只出現一張批准卡。 - 格式錯誤的 payload 被 Zod 擋下;非指定 actor 點擊不會 resume。
- 同一 process 內,指定 actor 的第一個有效
approvalId只 resume 一次;同時點批准與拒絕不會產生第二個 domain action。 - 使用 MemoryStore 時,重啟後 state、transcript、prototype claim 與舊 callback 消失;這是預期結果,不是 durable 驗收通過。
Slack、Discord、Teams 能力比較:哪些不能硬共用?

- 事件入口:Slack 預設 Socket Mode;Discord 用 Gateway;Teams direct 用
/api/messages。ACK/defer 時限與重試行為不同。 - 原生 UI:Slack 是 Block Kit、Discord 是 Components V2、Teams 是 Adaptive Cards。同一 portable node 可能映射、降級,或在不支援的平台被省略。
- Modal:direct Slack 支援較完整;Channels Discord 0.8.0 的 adapter modal 邊界較窄;Teams 平台本身有 dialogs,但 Channels portable API 沒有等價保證。不要把 modal 當核心審批路徑。
- Streaming:Slack 依 surface 使用原生或更新訊息;Discord 以節流 edit 模擬;Teams adapter 以
updateActivity模擬。完成 action 不能依賴某次 UI update 必定成功。 - Ephemeral:平台支援不等於目前 adapter capability 一致。先用公開訊息內的最小卡片,敏感資料不要因 fallback 被送到錯誤位置。
- 檔案:Teams personal chat 可走 file-consent API;channel 路徑要另配 Graph credentials、
ChannelMessage.Read.Group、Files.Read.All與 admin consent。Channels 0.8.0 尚未接 group-chat file path,不能把三種 conversation 混成同一能力。
Teams 檔案的 personal/channel 邊界可交叉核對 Microsoft bot files 文件與 0.8.0 的channel Graph 實作。實作時也要先查 adapter capabilities,並把 capability method 的 { ok: false } 當正常分支。想建立更完整的工程護欄,可延伸從零打造 AI Agent Harness中的工具權限、測試與可觀測性設計。
最常踩的 7 個坑
- 照抄舊版 API:搜尋結果還可能出現
createBot;先鎖 package 版本,再看該版本 exports。 - 把 direct 當完全 standalone:provider traffic 在你這裡,但現行架構仍有 CopilotRuntime/Intelligence connection。
- 用顯示名稱合併使用者:同名、改名與跨 tenant 都會出錯;只能靠明確 account linking。
- 以為
setState()會 merge:它會替換整個 value;漏帶欄位就會遺失。 - 把 MemoryStore 當持久化:本機看起來正常,重啟後 approval、state、transcript 全失效。
- 把 MessageRef 當永久地址:更新可能因權限、封存 thread 或平台狀態失敗;業務完成不可綁在 UI update 上。
- 用 reaction 當批准:reaction 是 provider-dependent 訊號,不是可稽核的一次性授權。
FAQ:Channels SDK 新手最常問的問題
Channels SDK 可以一次接 Slack、Discord、Teams 嗎?
可以。Direct createChannel() 可放多個 adapters,共用 agent factory、handlers 與最小 UI 語意;但三平台的 credentials、事件、權限、部署與 capability 仍分開。
Discord 是 managed provider 嗎?
截至 2026 年 8 月 9 日不是本文採用的路徑。官方文件列 Discord direct adapter;managed provider 目前列 Slack 與 Teams。
一定要重寫現有 Agent 嗎?
不一定。CopilotKit built-in agent 可直接使用;已有 AG-UI-compatible backend 時可用 HttpAgent 指向 endpoint。其他 backend 是否需要 adapter,要看它是否符合現行 agent contract。
對話 state 會自動寫進資料庫嗎?
不會。未提供 store.adapter 時是 process-local MemoryStore;正式環境要自行提供完整 durable StateStore。
同一個人在 Slack 與 Discord 會自動共用記憶嗎?
不會。identifyUser: "platform" 刻意保留平台與 tenant 邊界;要共用 transcript,先做使用者明確登入與帳號綁定。
Approval 按鈕重啟後還能用嗎?
只有把具名 component、durable StateStore、callback snapshot 與 resume() 接完整,並用真實 backend 驗證後才能承諾。單純 awaitChoice() 的 process-local waiter 不行。
Teams 本機測試一定要 Azure credentials 嗎?
不一定。Direct adapter 可配 M365 Agents Playground 做匿名本機測試;進真 tenant 才需要正式 app identity、manifest、policy 與 auth。
三平台 UI 可以完全一樣嗎?
不能保證。文字、摘要、兩顆按鈕是穩健 baseline;modal、chart、ephemeral、reaction 與 streaming 必須按 capability 漸進增強。
新手先帶走這 5 件事
- Agent 核心可以共用,平台 transport、identity 與 UI 不能假裝相同。
- 先用精確版本與 lockfile;這個 SDK 在 2026 年 8 月仍快速變動。
conversationKey、application user、workflow state 是三個不同維度。- 審批要建成可持久化、可授權、可到期的狀態機,不是一顆漂亮按鈕。
- 先在一個測試 workspace/guild 跑通 DM、channel、thread、重複點擊與 restart,再加第二平台。
接著閱讀
左右滑動查看更多推薦
結論:先共用大腦,再尊重每個平台
Channels SDK 真正省下的,不是三份平台設定,而是把 Agent 的模型、tools、工作流與最小互動語意集中成一份。Slack、Discord、Teams adapter 則各自承擔事件與 UI 翻譯。這個邊界畫對後,你才能在新增平台時保留同一個 Agent,而不是複製出第四套難以維護的 bot。
實作順序也很簡單:先選 Slack 或 Discord 跑通文字回覆,再加入 per-thread state,接著做 registered approval component,最後才換 durable StateStore、帳號綁定與第二平台。想持續補齊 Agent 工程能力,可前往 AlphaLab 的人工智慧文章與線上課程。






