跳到主要內容

【2026 最新】Channels SDK 教學:同一個 AI Agent 接上 Slack、Discord 與 Teams

最後更新: ·
Channels SDK 教學首圖,同一個 AI Agent backend 串接 Slack Discord 與 Teams

這篇 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 的端到端驗收。不要把舊教學的 createBotbot.start() 混進本文的 createChannel API。

架構先畫對:Agent 核心與平台 shell 分離

Channels SDK 教學架構圖,同一個 AI Agent 經 Channel Core 接到 Slack Discord Teams adapter
Agent 核心只處理模型、工具與工作流;每個 adapter 自己處理事件、transport、identity、原生 UI 與 rate limit。

把 Agent 想成廚房,createChannel() 是出餐流程,三個 adapter 是不同外送平台,Thread 是桌號,StateStore 是訂單簿。餐點邏輯可以共用,但每個外送平台的地址、按鈕與回覆時限都不同。

  • Agent backend:模型、tools、prompt、業務規則與「何時需要人工批准」。
  • Channel core:ThreadconversationKeyrunAgent()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

Channels SDK 在 Slack 以 Block Kit 顯示工具呼叫與人工審批按鈕
Channels SDK 在 Slack 的官方示意:同一個 Agent 可串流內容、顯示工具狀態,再等待人工決定。圖片來源:CopilotKit channels-sdk 官方 repo。
  1. 在 Slack 建立測試 app,最省事的起點是官方範例的 Slack app manifest
  2. 開啟 Socket Mode 與 Interactivity;建立具有 connections:write 的 app-level token,得到 xapp-...。Interactivity 沒開時,批准按鈕不會送回 callback。
  3. Bot 至少需要 app_mentions:readchat:writeim:history 與公開頻道的 channels:history;若要在私人頻道使用,再加 groups:history。訂閱 app_mentionmessage.im。SDK 會重建 thread history;缺 history scope 時,Agent 可能拿不到完整 prompt。
  4. 安裝或重新安裝到測試 workspace,讓新增 scope 生效,再複製最新 xoxb-... bot token。
  5. 啟動 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

Channels SDK 在 Discord 以 Components V2 顯示圖表與 Agent 回覆
同一個 Agent 在 Discord 會改由 Components V2 呈現;圖表與進階元件仍應準備文字 fallback。圖片來源:CopilotKit channels-sdk 官方 repo。
  1. 按照官方 Discord adapter README,在 Discord Developer Portal 建立 Application 與 Bot,複製 bot token 與 Application ID。
  2. Bot 頁開啟 Message Content 與 Server Members 兩個 privileged intents;這是目前 adapter 會要求的 intents。
  3. OAuth2 URL Generator 勾 botapplications.commands,至少授予 View Channels、Send Messages、Read Message History、Use Application Commands、Embed Links;若要測 thread,再加 Send Messages in Threads,然後邀請到測試 guild。
  4. 設定 DISCORD_GUILD_ID。開發期使用 guild-scoped commands 可立即更新;省略 guild ID 會走 global registration,傳播時間不要當成固定 SLA。
  5. 啟動同一個 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 做本機驗證

Channels SDK 在 Microsoft Teams 以 Adaptive Cards 顯示檔案與工具狀態
Teams adapter 將 portable UI 映射成 Adaptive Cards;檔案在 personal chat 與 channel 走不同 API 與權限。圖片來源:CopilotKit channels-sdk 官方 repo。

官方 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 時,才加入 clientIdclientSecret、可選的 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 怎麼保存?先分清三件事

Channels SDK Thread User State 分層,區分 actor application user conversationKey 與 durable StateStore
平台帳號、對話地址與工作流進度是三種不同狀態;跨平台 continuity 必須先做明確身分綁定。

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,涵蓋 kvlistlockdedupqueue,並用官方 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 的 MessageHeaderSectionActionsButton 會由 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,至少需要:

  1. 具名 component 註冊在 components
  2. 完整且可持久化的 Channels StateStore
  3. 按鈕 callback 呼叫 thread.resume(value)
  4. Agent framework 確實能發出並消費對應的 interrupt;不同 backend 要做 provider smoke test。
  5. 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。

最小驗收清單

  1. 一般訊息只觸發一次,edit/delete 不會重新跑 Agent。
  2. 同一 process 的下一輪能讀到 state;只有在相同受眾的私人測試開啟 bridge 時,才驗收 user transcript。
  3. Backend 發出一次 on_interrupt 後,只出現一張批准卡。
  4. 格式錯誤的 payload 被 Zod 擋下;非指定 actor 點擊不會 resume。
  5. 同一 process 內,指定 actor 的第一個有效 approvalId 只 resume 一次;同時點批准與拒絕不會產生第二個 domain action。
  6. 使用 MemoryStore 時,重啟後 state、transcript、prototype claim 與舊 callback 消失;這是預期結果,不是 durable 驗收通過。

Slack、Discord、Teams 能力比較:哪些不能硬共用?

Slack Discord Teams direct adapter 比較,包含入口原生 UI 串流與本機測試方式
跨平台正確性依賴文字、按鈕與明確狀態;modal、ephemeral、reaction、chart 與 streaming 只做 capability-gated enhancement。
  • 事件入口: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.GroupFiles.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 個坑

  1. 照抄舊版 API:搜尋結果還可能出現 createBot;先鎖 package 版本,再看該版本 exports。
  2. 把 direct 當完全 standalone:provider traffic 在你這裡,但現行架構仍有 CopilotRuntime/Intelligence connection。
  3. 用顯示名稱合併使用者:同名、改名與跨 tenant 都會出錯;只能靠明確 account linking。
  4. 以為 setState() 會 merge:它會替換整個 value;漏帶欄位就會遺失。
  5. 把 MemoryStore 當持久化:本機看起來正常,重啟後 approval、state、transcript 全失效。
  6. 把 MessageRef 當永久地址:更新可能因權限、封存 thread 或平台狀態失敗;業務完成不可綁在 UI update 上。
  7. 用 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 件事

  1. Agent 核心可以共用,平台 transport、identity 與 UI 不能假裝相同。
  2. 先用精確版本與 lockfile;這個 SDK 在 2026 年 8 月仍快速變動。
  3. conversationKey、application user、workflow state 是三個不同維度。
  4. 審批要建成可持久化、可授權、可到期的狀態機,不是一顆漂亮按鈕。
  5. 先在一個測試 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 的人工智慧文章線上課程

ALPHALAB 社群

有問題?來 Telegram 聊

和 Terry、編輯、其他網友一起討論這篇文章。提問、分享觀點,回覆更即時。

加入 Telegram 討論

📩 訂閱 AlphaLab 電子報

每週一封,第一時間收到新文章與投資觀察。

我們不會 spam,隨時可退訂。