用 claude-cookbooks 搭建 Slack × Claude Managed Agent 无状态 Webhook 桥:从心智模型到逐条排错
本文以
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载荷里携带channel、ts、thread_ts、user、text等字段。注意它不包含 Agent 的最终回答。 - Anthropic → 桥:当 CMA 会话空闲(
session.status_idled)或终止(session.status_terminated)时,Anthropic 投递的载荷只包含 CMA 会话 ID(顶层结构是{type, id}),同样不含任何输出文本。
把两个方向拼起来看,桥收到的始终只是两枚「门铃」——一个告诉它「用户发起了请求」,另一个告诉它「会话结束了,去取结果吧」。
1.2 Webhook 是门铃,不是快递:Push 信号、Pull 数据
正因为 session.status_idled 的载荷被刻意做得很薄,收到空闲回调后桥必须主动拉取两样东西:
sessions.retrieve(id)——取回会话元数据;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):
- 先做签名校验;
- 按
event_id去重; - 不
await地触发kickoffAgentSession()(fire-and-forget); - 立刻返回 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.postMessage 加 thread_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 与 Environment
在 package.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 required 并 process.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:read、chat: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_mention → Save 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_channelmetadata 键,没有就直接 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)期望 headers 是 Record<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 实现双端去重:
- src/slack-events.ts 对 Slack:
if (seenEventIds.has(payload.event_id)) return 204; - src/cma-webhook.ts 对 Anthropic:
if (seenEventIds.has(event.id)) return 204;
四、把「门铃」做实:签名校验与握手
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_idled 与 session.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-webhookPOST → Anthropic 工作区不匹配(见陷阱 5),或端点未保存成功; - 有 POST 但返回 401 →
ANTHROPIC_WEBHOOK_SIGNING_KEY与 Console 里配置的密钥不一致; - 返回 204 但 Slack 没收到帖子 → 排查
SLACK_BOT_TOKEN(看看是不是误拿了xapp-前缀的 token),或缺少chat:writescope。
症状 C:报 400 Invalid agent ID
.env.local 里还留着 agent_... 占位符,没有替换成 bun run setup 打印的真实 ID。重新粘贴 CLAUDE_AGENT_ID。
症状 D:chat.postMessage 报 not_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.ts 与 src/agent.ts 的针对性改动实现:
- GitHub 仓库挂载:在
sessions.create的resources中加入{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 开始。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00