首页
/ 用 claude-cookbooks 搭建 Slack × Claude Managed Agent 无状态 Webhook 桥:从心智模型到逐条排错

用 claude-cookbooks 搭建 Slack × Claude Managed Agent 无状态 Webhook 桥:从心智模型到逐条排错

2026-09-07 15:05:16作者:余洋婵Anita

本文以 managed_agents/slack 目录下的 skill.md 为骨架,配合该示例的真实源码展开。你将掌握一条完整的「Slack @mention → CMA 会话 → 会话空闲回调 → 回帖到原线程」链路,以及官方文档不会明说、却最容易烧掉调试时间的八个配置陷阱和一套静默失败排查方法。读完即可在自己的仓库里把 bun run dev 的桥接服务跑通,并具备生产化改造的判断力。


一、先建立心智模型:两个 Webhook、一条无状态桥

示例的整体拓扑在 README.md 中用一幅 ASCII 流程图描述得很清楚:

Slack @mention ──▶ /slack/events ──▶ sessions.create (+ metadata) ──▶ 200
                                              │
                            Claude runs to idle on Anthropic infra
                                              │
/cma-webhook ◀── session.status_idled ◀───────┘
      │
      └──▶ sessions.retrieve → read metadata → chat.postMessage

桥本身只是一个极小的 Bun HTTP 服务,路由集中在 src/main.ts

方法 路径 方向 触发条件
POST /slack/events Slack → 桥 有人 @mention 机器人(或对机器人发 DM)
POST /cma-webhook Anthropic → 桥 该工作区内的 CMA 会话进入空闲/终止
GET / 健康检查 返回 {"status":"ok"}

1.1 两个方向都只携带「信号 + ID」,从不携带输出

  • Slack → 桥:当用户 @mention 机器人时,Slack 投递的 event_callback 载荷里携带 channeltsthread_tsusertext 等字段。注意它不包含 Agent 的最终回答。
  • Anthropic → 桥:当 CMA 会话空闲(session.status_idled)或终止(session.status_terminated)时,Anthropic 投递的载荷只包含 CMA 会话 ID(顶层结构是 {type, id}),同样不含任何输出文本。

把两个方向拼起来看,桥收到的始终只是两枚「门铃」——一个告诉它「用户发起了请求」,另一个告诉它「会话结束了,去取结果吧」。

1.2 Webhook 是门铃,不是快递:Push 信号、Pull 数据

正因为 session.status_idled 的载荷被刻意做得很薄,收到空闲回调后桥必须主动拉取两样东西:

  1. sessions.retrieve(id)——取回会话元数据;
  2. sessions.events.list(id)——遍历会话事件流,捞出 Agent 的最终输出。

这个「Push 信号、Pull 数据」的完整实现在 src/cma-webhook.ts 中。拉取回答时,代码按页迭代事件,只收集类型为 agent.message 事件中 content 里的 text 块并拼接:

const parts: string[] = [];
for await (const e of anthropic.beta.sessions.events.list(claudeSessionId)) {
  if (e.type === "agent.message") {
    for (const block of e.content ?? []) {
      if (block.type === "text") parts.push(block.text);
    }
  }
}
const responseText = parts.join("").trim();
if (!responseText) return new Response(null, { status: 204 });

注意 for await 对分页对象的迭代会自动翻页,因此即使会话很长、事件被分成多页,这段代码也能完整收齐所有文本块,这是很容易被忽略的 SDK 行为。

1.3 metadata 就是全部路由状态——这是「无状态」的根基

空闲回调只给你一个会话 ID,那桥怎么知道该把回答发到哪个 Slack 频道、哪个线程?答案是把路由信息预先塞进会话的 metadata 字段

在发起会话的那一刻(src/agent.ts),桥把 Slack 侧的三个关键值随 sessions.create 一起提交:

const session = await anthropic.beta.sessions.create({
  agent: CLAUDE_AGENT_ID,
  environment_id: CLAUDE_ENVIRONMENT_ID,
  metadata: {
    slack_channel: m.channel,       // 回复目标频道
    slack_thread_ts: m.thread_ts,   // 回复目标线程(无线程则回消息自身 ts)
    slack_team: m.team,             // 团队标识,多团队扩展时使用
  },
});

等空闲回调带着会话 ID 回来后,src/cma-webhook.ts 读出这三个键并据此精确回帖:

const channel = session.metadata?.slack_channel;
const thread_ts = session.metadata?.slack_thread_ts;
if (!channel || !thread_ts) {
  return new Response(null, { status: 204 });
}

桥自身不落地任何会话状态,路由状态完全存在 CMA 会话的元数据里——这正是整个设计能保持无状态、可水平扩展的原因,也让桥的多个实例共享同一逻辑成为可能。

1.4 Slack 的 3 秒确认窗口决定了处理方式

Slack 对任何在 3 秒内没有收到 2xx 的事件都会重试。而 CMA 会话从创建到空闲往往耗时几十秒到数分钟,桥绝不能在回调里同步等待会话完成。因此正确的姿势是(src/slack-events.ts):

  1. 先做签名校验;
  2. event_id 去重;
  3. await 地触发 kickoffAgentSession()(fire-and-forget);
  4. 立刻返回 204 确认收到。
// Fire-and-forget so we ack Slack within its 3s window.
kickoffAgentSession({
  channel: ev.channel,
  thread_ts: ev.thread_ts ?? ev.ts,
  user: ev.user,
  text: stripMention(ev.text),
  team: payload.team_id,
}).catch((err) => console.error("[slack] kickoff error:", err));

return new Response(null, { status: 204 });

值得注意的细节:thread_ts: ev.thread_ts ?? ev.ts 巧妙地统一了「线程内回复」与「频道顶楼回复」两种情形——有线程则回线程,无线程则把消息自身的 ts 当作虚拟线程锚点,之后 chat.postMessagethread_ts 即可形成线程回复。此外 stripMention 会用正则 <@[A-Z0-9]+>@机器人 的提及部分从文本中剥离,只把干净的用户消息内容喂给 Agent。


二、本地联调完整清单:八步把链路跑通

skill.md 给出了一份「顺序本身就有讲究」的本地开发清单。下面按源码逐一展开,并说明每一步对应的仓库文件和被验证的行为。

第 0 步:拉起隧道 ngrok http 3000,记下公开 URL。后续所有 Slack / Anthropic 的 Request URL 都基于它,例如 <url>/slack/events<url>/cma-webhook

第 1 步:一次性创建 Agent 与 Environmentpackage.json 中,bun run setup 实际执行的是 setup/create-agent.ts。它会用 @anthropic-ai/sdk 分别创建:

  • 一个云端环境:environments.create,配置 { type: "cloud", networking: { type: "unrestricted" } }
  • 一个 Agent:agents.create,模型为 claude-opus-4-7,system 提示词要求「回答要简洁、口语化,因为是作为线程回复发布的;用纯文本或 Slack mrkdwn(如 *粗体*`代码`),避免 Markdown 标题」;
  • 工具集 agent_toolset_20260401 默认开启。

脚本最后会打印 CLAUDE_ENVIRONMENT_ID=CLAUDE_AGENT_ID=,把它们复制进 .env.local后续粘贴 Slack 密钥时不要覆盖这两个值。

为什么这两个 ID 如此关键?因为 src/main.ts 启动时会强制校验五个环境变量,缺失任何一个都会打印 FATAL: ... is requiredprocess.exit(1)

for (const v of [
  "SLACK_SIGNING_SECRET",
  "SLACK_BOT_TOKEN",
  "ANTHROPIC_WEBHOOK_SIGNING_KEY",
  "CLAUDE_AGENT_ID",
  "CLAUDE_ENVIRONMENT_ID",
]) { ... }

第 2~3 步:配 Slack 机器人 Slack App → OAuth & Permissions → Bot Token Scopes,至少添加 app_mentions:readchat:write(如果要支持 DM,还要加 im:history),然后 Install to Workspace,把得到的 xoxb-… 存为 SLACK_BOT_TOKEN。再到 Basic Information 复制 Signing Secret 存为 SLACK_SIGNING_SECRET

第 4 步:在 Anthropic Console 注册 webhook Manage → Webhooks 里注册 <url>/cma-webhook,订阅 session.status_idled + session.status_terminated,把 whsec_… 签名密钥存为 ANTHROPIC_WEBHOOK_SIGNING_KEY。关键前提:该端点所在工作区必须与你的 API Key 所属工作区一致(详见第三节第 5 条陷阱)。

第 5 步:先启动服务,再保存 Slack 的 Request URL bun run dev。服务必须先于第 6 步启动,因为保存 Event Subscriptions 的 Request URL 会立刻触发一次 url_verification POST——如果隧道后面没人监听,Slack 会报 "Your URL didn't respond" 且 URL 无法保存。源码里对这次验证请求做了专门处理(src/slack-events.ts):当载荷 type === "url_verification" 时,原样返回 challenge 字段作为响应体。

第 6 步:Slack 事件订阅 Slack App → Event Subscriptions → 开关 On → Request URL 填 <url>/slack/events → 等待显示 Verified ✓ → 在 Subscribe to bot events 中添加 app_mentionSave Changes(如有提示则重新安装 App)。

第 7 步:真实触发 在 Slack 频道里 /invite @你的机器人,然后发 @你的机器人 你好。如果没有 not_in_channel 报错、也没有任何回复,就进入下一节的排查流程。

环境变量与依赖的完整清单可对照 package.json:运行脚本为 bun run --watch src/main.ts(dev)与 bun run src/main.ts(start),依赖为 @anthropic-ai/sdk@^0.95.1(桥接功能要求 ≥ 0.95.1)与 @slack/web-api@^7.14.0


三、八个最容易烧时间的配置陷阱

陷阱 1:用 Slack 开发者沙箱,别直接装进公司工作区

Slack Developer Program 的 Sandboxes(位于 Developer Program → Sandboxes,不是 Agent 快速入门页)可以给你一个免费的 Enterprise Grid 测试组织:拥有管理员权限、自带假用户和假频道,完全不会把半成品机器人装进生产环境。流程是:加入 Slack Developer Program → 邮箱激活并接受服务条款 → 从项目面板 Provision Sandbox(可选空壳或预装假用户/频道)→ 按邮件邀请完成设置(你会成为 Primary Org Owner,命名组织并在其中创建至少一个工作区)→ 在该沙箱工作区中创建 App 正常开发。

陷阱 2:OAuth 作用域 ≠ 事件订阅,漏一步就是「零投递且无任何报错」

OAuth & Permissions → Bot Token Scopes 里加了 app_mentions:read,只代表机器人有权限看到提及,并不代表 Slack 会投递这些提及。必须再去 Event Subscriptions 页把开关打开、填好 Request URL,并在 "Subscribe to bot events" 下添加 app_mention。这是两个相互独立的页面,只做第一个的话,结果就是没有任何事件到达,且全程没有任何报错提示。

陷阱 3:xoxb-xapp- 是两套 token,抓错一个就 invalid_auth

Token 前缀 所在页面 用途
Bot User OAuth Token xoxb- OAuth & Permissions(安装后出现) chat.postMessage——桥要的是这个
App-Level Token xapp- Basic Information → App-Level Tokens 仅用于 Socket Mode 的 WebSocket 连接

xapp-chat.postMessage 会以 invalid_auth 失败。另外 xoxb- token 只有在添加了至少一个 bot scope 并点击过 Install/Reinstall to Workspace 之后才会存在,配完发现没有 token 就检查这两步。

陷阱 4:保存 Request URL 之前,桥必须先跑起来

参见第二节第 5 步:保存 Event Subscriptions URL 会立即触发 url_verification,隧道背后没人应答就保存失败。顺序铁律是:先 bun run dev,再粘贴 URL。

陷阱 5:Anthropic webhook 是「工作区级」的

你在 Console 里注册的端点只会收到同工作区内会话的事件。如果 ANTHROPIC_API_KEY 属于工作区 A,却在工作区 B 注册了端点,结果是静默零投递。配置时务必把 Webhooks 页的工作区选择器,与 API Key 的 Workspace 列对齐。

陷阱 6:工作区级 webhook 会为工作区里的每一个会话触发

如果 Anthropic 工作区被其他 Agent、脚本或同事共享,那么工作区里每次 session.status_idled 都会打到你的端点。因此处理器必须过滤,且过滤顺序有讲究:

  • 先 retrieve 再过滤,永远放在第一步sessions.retrieve(id) 后检查是否存在你的 slack_channel metadata 键,没有就直接 204 退出。必须在调用 events.list() 或做任何其他工作之前完成过滤,否则无关会话会在处理器更深处触发 404。
  • sessions.retrieve 捕获 404/403:同一工作区里由其他 API Key 创建的会话,你的 Key 读不到,访问会直接抛错,需要 try/catch 兜底。
  • 生产环境使用专用工作区:否则每个无关会话都要消耗一次 retrieve() 调用然后被丢弃;只含本 Agent 会话的工作区可以从根上避免这笔浪费。

这段「先 retrieve、缺 metadata 即 204、捕获读取异常」的代码在 src/cma-webhook.ts

let session;
try {
  session = await anthropic.beta.sessions.retrieve(claudeSessionId);
} catch {
  return new Response(null, { status: 204 });
}

const channel = session.metadata?.slack_channel;
const thread_ts = session.metadata?.slack_thread_ts;
if (!channel || !thread_ts) {
  return new Response(null, { status: 204 });
}

陷阱 7:unwrap() 要的是普通 header 映射,不是 fetch 的 Headers 对象

client.beta.webhooks.unwrap(body, { headers })(SDK ≥ 0.95.1)期望 headersRecord<string, string> 这样的普通对象。直接传入 fetch 的 Headers 实例会失败,正确写法是 Object.fromEntries(req.headers)。桥里的用法见 src/cma-webhook.ts

陷阱 8:event.id 就是你的幂等键

  • Anthropic 侧:投递失败重试时,复用的是同一个顶层 event.id
  • Slack 侧:重试时复用的是载荷内的同一个 event_id(并带 X-Slack-Retry-Num 头)。

两套重试要用各自的事件 ID 分别去重。处理完或决定忽略后必须返回 2xx——任何其他状态都会触发重试;而且 Anthropic 约连续 20 次投递失败会自动禁用你的端点。桥中用两个内存 Set 实现双端去重:


四、把「门铃」做实:签名校验与握手

4.1 Slack 侧:HMAC-SHA256 + 时间戳容差

src/slack-events.ts 实现了 Slack 官方的请求签名方案,签名字符串构造为:

v0 = hex(HMAC-SHA256(signing_secret, "v0:{timestamp}:{body}"))

校验包含三道关卡:缺少 x-slack-request-timestamp / x-slack-signature 头直接拒绝;时间戳偏离当前时间超过 5 分钟(TOLERANCE_SEC = 5 * 60)拒绝,用于防御重放攻击;最后用 timingSafeEqual 做常数时间比较,避免时序侧信道。校验失败返回 401 bad signature

这也解释了为何 slack-events.ts 必须先 await req.text() 拿到原始 body 再校验:HMAC 是基于原始字节计算的,任何先 JSON 解析再序列化的操作都会破坏签名一致性。

4.2 Anthropic 侧:beta.webhooks.unwrap

src/cma-webhook.ts 交给 SDK 的 anthropic.beta.webhooks.unwrap(rawBody, { headers }) 完成验签与解析(校验 HMAC + 时间戳,读取 ANTHROPIC_WEBHOOK_SIGNING_KEY 环境变量),失败则 401。验签通过后,对事件类型做白名单判断——只处理 session.status_idledsession.status_terminated 两类,其他一律 204 忽略。

4.3 url_verification 握手与边界情况

当 payload 类型不是 event_callback(例如 url_verification)时统一直接应答;对 event_callback,只有当 ev.type === "app_mention",或是无 subtype 的 DM 消息(ev.type === "message" && ev.channel_type === "im"),且 ev.bot_id 不存在、ev.text 非空时,才真正触发 kickoff;编辑消息、机器人自己的回声等都应在入口被过滤(src/slack-events.ts)。

4.4 幂等键与「失败即重试」的语义

两个去重 Set 一旦命中就返回 204,代表「已处理过或决定忽略」;204 本身也是 Slack/Anthropic 认可的成功应答,不会触发重试。当前实现是进程内存态,重启即清空——这正是生产化时需要替换为 Redis/DB 的部分(见第六节)。


五、静默失败怎么查:按症状定位的三步排查表

最折磨人的往往是「什么报错都没有,就是不工作」。skill.md 给出了一张按症状分诊的表,结合源码可进一步拆解:

症状 A:桥的日志里什么都没有

说明 Slack 根本没到达你。先去查 ngrok 的请求日志:curl localhost:4040/api/requests/http。如果里面没有任何 /slack/events POST,则要么 Event Subscriptions 没保存成功,要么 App 开着 Socket Mode(事件走了 WebSocket 而非 HTTP,导致隧道侧完全无流量)。

症状 B:日志出现 [agent] kickoff 但没有回复

Kickoff 已发生说明 Slack 侧全链路正常,问题出在「Anthropic → 桥」这一半:

  • ngrok 日志里没有 /cma-webhook POST → Anthropic 工作区不匹配(见陷阱 5),或端点未保存成功;
  • 有 POST 但返回 401ANTHROPIC_WEBHOOK_SIGNING_KEY 与 Console 里配置的密钥不一致;
  • 返回 204 但 Slack 没收到帖子 → 排查 SLACK_BOT_TOKEN(看看是不是误拿了 xapp- 前缀的 token),或缺少 chat:write scope。

症状 C:报 400 Invalid agent ID

.env.local 里还留着 agent_... 占位符,没有替换成 bun run setup 打印的真实 ID。重新粘贴 CLAUDE_AGENT_ID

症状 D:chat.postMessagenot_in_channel

机器人还没被邀请进目标频道。先 /invite @你的机器人 再测试。对应地,桥在处理 session.status_terminated 时也会往原线程发一条 :warning: Agent session terminated unexpectedly. 的告警(src/cma-webhook.ts),如果这条也没发出去,同样先怀疑邀请与 token 问题。


六、生产化路径与能力扩展

skill.md 在生产笔记中给出的四条改造建议,与源码的耦合点如下:

6.1 部署形态

把 ngrok 换成真实部署(云主机、容器等),桥的业务逻辑一行都不用改——因为所有依赖外部可达性的只有「能被 Slack/Anthropic 回调」这一件事,服务本身无状态。项目内 managed_agents/hosting/ 提供了容器化部署的完整参考(Dockerfile、docker-compose、Kubernetes 清单)。

6.2 幂等存储

把两个内存 seenEventIds Set 换成 Redis 或数据库,以支持多实例部署。注意两个 Set 的语义略有不同:Slack 侧去重的是 payload.event_id,Anthropic 侧去重的是顶层 event.id,落库时键要区分开。

6.3 多工作区 Slack App

分布式(多团队)Slack App 不能再用静态 SLACK_BOT_TOKEN,需要换成以 metadata.slack_team 为键的按团队 token 存储,并引入 Slack OAuth 安装流程。这正是 kickoff 时把 team 一起写入 metadata 的原因(src/agent.ts)。

6.4 线程续聊的会话复用

当前实现里每次 @mention 都创建一个全新 CMA 会话,意味着跨轮次没有记忆。若要在同一线程里形成连续对话,需要自行缓存 thread_ts → session_id 的映射,后续轮次改用 sessions.events.send 追加消息,而不是 sessions.create。这也是 CLAUDE.md 中「threaded conversations」扩展点对应的底层操作。

6.5 从「只会回话」到「能干活」的扩展菜单

CLAUDE.md 列出了一系列在桥跑通后可选的进阶能力,均通过对 setup/create-agent.tssrc/agent.ts 的针对性改动实现:

  • GitHub 仓库挂载:在 sessions.createresources 中加入 {type: "github_repository", ...}
  • MCP 工具:为 Agent 配置 mcp_servers + mcp_toolset(如 Slack、GitHub MCP),凭据走 vault(vault_ids 挂在 session 上),让 Agent 能执行动作而不只是回复;
  • Outcomes 评分循环:用 user.define_outcome 事件替代普通 user.message,驱动 rubric 评分的迭代;
  • 多 Agent 协同:在 Agent 上启用 multiagent: {type: "coordinator", agents: [...]}
  • 跨会话记忆:通过 resources 挂载 memory_store
  • 自定义工具:用 agent.custom_tool_use / user.custom_tool_result 实现宿主机侧执行。

结语

这条 Slack × CMA 桥看起来只有两个路由、三个核心函数,真正难的不是代码,而是理解它的底层约定:两个 Webhook 都只是携带 ID 的门铃,路由状态全部藏在会话 metadata 里,Slack 的 3 秒确认窗口逼着你 fire-and-forget,工作区级投递逼着你 retrieve-then-filter。把 skill.md 里这组心智模型和八个陷阱装进脑子,再配合本文对应的源码位置逐行核对,你就能在本地十分钟内跑通,也能在生产化时做出「幂等存储换 Redis、单 token 换按团队 token 存储」这类正确决策。想系统化地把这套链路跑起来时,可从 managed_agents/slack 目录的 Quickstart 开始。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
898
5.82 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
921
1.84 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.8 K
1.02 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
531
596
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.02 K
519
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.36 K
1.46 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
548
391