Linear × Claude Managed Agents Webhook 桥接实战:从本地调试到生产部署的完整避坑指南
导读
在 Linear issue 中 @mention 一个 Claude Managed Agent,再由其把最终回复以评论形式贴回该 issue——这个看似顺滑的闭环,真正落地时却遍布"坑":actor=app 缺一不可的 OAuth 授权、Anthropic webhook 的工作区隔离、Linear 10 秒首次回执规则、无状态路由所需的 metadata 设计。本文以 claude-cookbooks 仓库中 managed_agents/linear/skill.md 为骨架,逐层讲解这套"Linear × CMA 双 webhook 桥接"的心智模型、全部已知陷阱与调试清单,并结合 managed_agents/linear 目录下真实源码印证每个结论。读完后你将能够独立复现该桥接、定位各种"静默失败",并为生产环境做出正确的架构取舍。
该 skill 文档本身是专供 Claude Agent 读取的"配置向导",仓库刻意把它放在 Agent 启动后即可读到的地方:claude 后询问"walk me through setting this up",Agent 就会依序驱动整套配置。本文对该文档的每个要点做了逐条源码级展开。
一、心智模型:先把"桥"想清楚,再动手写代码
skill.md 开篇就强调:文档之外最消耗调试时间的,往往是理解上的偏差,而不是代码本身。理解这套系统只需要四个心理模型。
1.1 Webhook 本质是"门铃",不是"快递"
Webhook 只是某个服务向你预先注册的 URL 发送的一次 HTTP POST,body 是一小段描述事件的 JSON。你只需注册一次 URL,之后每当事件发生,服务就来敲一次门——没有轮询、没有长连接。这一点决定了整个桥接的设计基调:回调里只做"轻量接单",绝不阻塞做重活。
1.2 桥接(bridge)在今天是必需的
Linear 的 Agent Platform 与 Claude Managed Agents(CMA)不共享线格式(wire format),也不共享凭证。必须有一个中间层完成两件事:
- 入口方向:把 "Linear @mention" 翻译成 "CMA
user.message"; - 出口方向:把 "CMA idle" 翻译成 "Linear comment";
同时持有并维护两套密钥。这正是 src/main.ts 这个 Bun 服务存在的全部理由。README 里的一张 ASCII 图把这个闭环画得很清楚:
Linear @mention ──▶ /linear-webhook ──▶ sessions.create (+ metadata) ──▶ 200
│
Claude runs to idle on Anthropic infra
│
/cma-webhook ◀── session.status_idled ◀──────────┘
│
└──▶ sessions.retrieve → read metadata → createAgentActivity
1.3 两个 webhook、一座桥:入站只带上下文,出站只带 ID
桥接两侧事件负载的信息量是严重不对称的,这常常是新手误判的根源:
| 方向 | 触发条件 | payload 携带内容 |
|---|---|---|
| Linear → bridge | issue 中出现 @mention | agentSession.id、organizationId 及 issue 上下文 |
| Anthropic → bridge | CMA session 进入 idle | 仅有 CMA session ID({type, id}) |
两边都不携带"回调 URL",也不携带 agent 的输出。 它们都只是带 ID 的"信号"而已。真正的数据,需要你主动去取。
1.4 出站侧策略:Push the signal, pull the data
正因为 Anthropic 的 session.status_idled payload 刻意做得很薄({type, id}),收到信号后你必须主动拉取:
sessions.retrieve(id)—— 拿 session 元数据;sessions.events.list(id)—— 拿 agent 的实际输出。
这一"信号推送、数据拉取"模式的好处是:重试成本极低(payload 很小),且数据永远不会因推送延迟而陈旧。在 src/cma-webhook.ts 中可以看到实现:用 for await 遍历事件列表,逐条收集 agent.message 中的 text block,拼接成最终回复正文。
1.5 metadata 就是全部路由状态:无状态的秘密
这是整个桥接"无状态"设计的支点,skill.md 称之为 "the entire routing state":
- 写入侧:当桥接创建 CMA session 时,把路由信息写进 metadata。见 src/agent.ts:
const session = await anthropic.beta.sessions.create({
agent: CLAUDE_AGENT_ID,
environment_id: CLAUDE_ENVIRONMENT_ID,
metadata: {
linear_session_id: agentSession.id, // 回帖目标
linear_org_id: organizationId, // 决定用哪把 Linear OAuth token
},
});
- 读取侧:稍后 idle webhook 只送来一个 session ID,桥接 retrieve 该 session、读回这两个 key,就知道该回复到哪个 issue、用哪个 org 的 token——桥接自身不落任何路由状态。
对照源码可以看到,这套"写入 metadata → retrieve 读回"的约定在多处复用:正常回复路径(cma-webhook.ts)和异常终止路径
postTerminationError(同文件 L80-L96)都用它定位回帖目标。org 维度还直接影响凭证选择——每个组织各自持有一份 OAuth token(见 src/oauth.ts 的tokensMap,key 是org.id)。
二、Gotchas 全清单:文档没写、但必然咬你一口的地方
skill.md 用大量篇幅记录了六类"不亲自踩一遍就想不到"的坑,逐一展开如下。
2.1 Anthropic webhook 是工作区(workspace)作用域的
你在 Anthropic Console 注册的 endpoint 只接收同一工作区内 session 的事件。最典型的静默失败是:
- 你的
ANTHROPIC_API_KEY属于工作区 A; - 但你在 Console 的 Webhooks 页面里把 endpoint 注册到了工作区 B;
结果是 零投递,而且完全不报错(silently)。对策:核对 API key 上的 Workspace 列,确保它与 Console Webhooks 页面的工作区选择器一致。
2.2 工作区 webhook 会收到工作区内每一个 session 的事件
注意:不是只有你自己的。如果 Anthropic 工作区与其他 agent、脚本或同事共享,那么工作区内每一次 session.status_idled 都会打到你的 endpoint。因此 handler 必须过滤,过滤顺序可以概括为"三步法":
sessions.retrieve(id)取出 session;- 检查是否存在你的
metadatakey(linear_session_id/linear_org_id); - 不是你的就返回 204(静默吞掉)。
看 src/cma-webhook.ts 的实现:
// 工作区 webhook 会触发工作区内 EVERY session —— 必须按 metadata 过滤
let session;
try {
session = await anthropic.beta.sessions.retrieve(claudeSessionId);
} catch {
return new Response(null, { status: 204 });
}
const linearSessionId = session.metadata?.linear_session_id;
const linearOrgId = session.metadata?.linear_org_id;
if (!linearSessionId || !linearOrgId) {
return new Response(null, { status: 204 });
}
关键细节:sessions.retrieve 抛出的 404/403 也要一并捕获——同一工作区内、由其他 API key 创建的 session,你的 key 根本读不到,此时同样返回 204 而不是报错刷屏。这解释了为什么 retrieve 的异常被 try/catch 整体吞掉并转为 204。
由以上两条推论出的实践准则:
- 只订阅你真正需要的事件类型(
session.status_idled、session.status_terminated),而不是 "All events"。代码里也正是这样分流的:session.status_terminated走postTerminationError,session.status_idled走正常回帖,其余一律 204(cma-webhook.ts)。 - 生产环境建议使用专用 Anthropic 工作区,从根上消除无关 session 的噪音。
2.3 Linear OAuth 应用只有工作区管理员能创建
OAuth 应用入口在 linear.app/<your-workspace>/settings/api(侧边栏路径为 Administration → API,再找 OAuth Applications 区)。如果你不是公司工作区的管理员,skill.md 的建议很务实:临时建一个免费的个人工作区用于测试——两分钟搞定,且不会把实验性 agent 装进生产环境。
创建应用时要注意:Developer URL 字段必填但纯属装饰(只是显示在授权同意页上的链接),填任何合法的 https:// URL 即可。
2.4 actor=app 是"承重墙"级别的 OAuth 参数
scope=app:assignable,app:mentionable + actor=app 组合,才会在 Linear 工作区内创建一个应用用户(app user),这个用户会出现在 @-picker 里,可供 issue 中 @mention。这一点在源码中得到完整印证——src/oauth.ts 的 authorize 跳转参数:
const params = new URLSearchParams({
client_id: LINEAR_CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: "code",
scope: "read,write,app:assignable,app:mentionable",
actor: "app",
});
return Response.redirect(`https://linear.app/oauth/authorize?${params}`);
个人 LINEAR_API_KEY 做不到这件事——桥接必须以应用身份(app)回帖,即通过 OAuth token 而不是个人 key。这也是整个桥接引入 OAuth 流程而不是简单用 API key 的根本原因。
2.5 Linear 的 10 秒回执规则
桥接收到 AgentSessionEvent 后的 10 秒内必须先发出一条 agentActivity(哪怕只是 {type: "thought"}),否则 Linear 会把该 session 标记为失败。这个动作要赶在创建 CMA session 之前完成。看 src/agent.ts 的实现顺序——先回执、后开跑:
// Linear 要求 10 秒内出现首个 activity。
await linear.createAgentActivity({
agentSessionId: agentSession.id,
content: { type: "thought", body: "Thinking..." },
});
kickoffAgentSession 的完整流程也因此是"fire-and-forget"式的:回执 → sessions.create(带 metadata)→ sessions.events.send 发送 user.message prompt → 直接返回。回复路径完全交给后续的 idle webhook(src/agent.ts)。注意 src/main.ts 中该回调是异步触发、异常只记日志不阻塞响应。
2.6 event.id 就是幂等键
Anthropic 重试失败投递时,顶层 event.id 保持不变,因此要基于它去重。处理完或决定忽略后一律返回 2xx——任何非 2xx 都会触发重试,而大约 连续 20 次失败会自动禁用你的 endpoint。
src/cma-webhook.ts 用内存 Set 演示了最小实现,并注明了生产替换方案:
// 重试去重(同一 event.id 跨重试复用)。生产环境换成 Redis/DB。
const seenEventIds = new Set<string>();
// ...
if (seenEventIds.has(event.id)) return new Response(null, { status: 204 });
seenEventIds.add(event.id);
2.7 签名头命名:文档与线上不一致
文档写的是 X-Webhook-Signature,但线上实际使用的是 Webhook-Signature / Webhook-Id / Webhook-Timestamp(即 Standard Webhooks 规范)。SDK 的 webhooks.unwrap() 会自动处理这套命名,所以只有当你手工验签时才需要在意。
src/cma-webhook.ts 展示了正确调用姿势,并暗含一个易错点——unwrap() 需要普通 header 映射对象,而不是 fetch 的 Headers 对象:
let event: Anthropic.Beta.BetaWebhookEvent;
try {
event = anthropic.beta.webhooks.unwrap(rawBody, {
headers: Object.fromEntries(req.headers), // 先转成 plain object
});
} catch (err) {
console.warn("[cma-webhook] signature verification failed");
return new Response("bad signature", { status: 401 });
}
验签失败的响应是 401,这与 local dev checklist 中"POST 到达但返回 401 → 签名密钥不匹配"的排障结论一一对应。同理,Linear 侧在 src/main.ts 使用 LinearWebhookClient(signingSecret).createHandler() 完成验签与事件分发。
三、本地开发核对清单(七步走)
skill.md 给出了一份严格排序的启动清单。这里结合源码把每步的要义、产出与环境变量落点讲透。先决条件:从仓库根目录进入 managed_agents/linear 并安装依赖(bun install)。
-
暴露公网 URL:
ngrok http 3000(或cloudflared tunnel),记下公网 URL<url>。后续一切配置都以它为基准,因为两个平台都需要回调你的本地服务。 -
创建 Claude Agent 与环境:
bun run setup。该脚本(setup/create-agent.ts)做两件一次性的事:environments.create({ name, config: { type: "cloud", networking: { type: "unrestricted" } } });agents.create({ name, model: "claude-opus-4-7", system, tools });
然后把打印出的
CLAUDE_AGENT_ID/CLAUDE_ENVIRONMENT_ID抄进.env.local。注意模型名与 agent toolset 均以仓库当前脚本为准。 -
创建 Linear OAuth 应用:入口 Administration → API → OAuth Applications → Create new。三个关键字段:
- Developer URL:任意合法
https://URL(纯装饰); - Redirect/Callback:
<url>/oauth/callback; - Webhook:
<url>/linear-webhook,事件订阅选 Agent session events;
创建后复制 client ID/secret 与 webhook secret(
LINEAR_WEBHOOK_SIGNING_SECRET)。 - Developer URL:任意合法
-
配置 Anthropic Console Webhook:URL 填
<url>/cma-webhook,事件只勾选session.status_idled+session.status_terminated,复制whsec_...(对应ANTHROPIC_WEBHOOK_SIGNING_KEY)。⚠️ 必须与你的 API key 同属一个工作区(对应 Gotcha 2.1)。 -
填环境变量、启动服务:把上述值补进
.env.local后执行bun run dev(等价bun run --watch src/main.ts,见 package.json)。全部必需变量由 src/main.ts 在启动时强制校验,缺失即 FATAL 退出:
LINEAR_WEBHOOK_SIGNING_SECRET
ANTHROPIC_WEBHOOK_SIGNING_KEY
CLAUDE_AGENT_ID
CLAUDE_ENVIRONMENT_ID
(可选 PORT 默认 3000、BASE_URL 默认 http://localhost:<PORT>。)
-
安装授权:访问
<url>/oauth/authorize→ 在 Linear 同意页批准 → 看到 "Agent installed."(源码中该页面还会显示 org 名称与 ID,见 src/oauth.ts)。 -
验收:在任意 issue 中 @mention 该应用用户。预期链路为"Thinking…"出现 → agent 在 Anthropic 侧跑到 idle → 回复以评论形式贴回 issue。
至此,Routes 全景可对照 src/main.ts 一览:/oauth/authorize、/oauth/callback、/linear-webhook(交给 LinearWebhookClient handler)、/cma-webhook,其余 404。
四、静默失败排查表:问题 → 检查点
skill.md 的调试节把三类最常见的"没反应"收敛成三条判断路径,全部可落到具体检查点:
| 现象 | 排查动作 |
|---|---|
| "Thinking…" 从不出现 | Linear webhook 没到达你这里。检查 Linear 应用里的 webhook URL 是否正确、ngrok 是否存活。 |
| "Thinking…" 出现但无回复 | 查 curl localhost:4040/api/requests/http(ngrok 的请求日志)。若无 POST 到 /cma-webhook:Anthropic 侧工作区不匹配(见 Gotcha 2.1),或 endpoint 未保存成功;若 POST 已到达但返回 401:签名密钥不匹配(见 Gotcha 2.7)。 |
| 回复为空 | sessions.events.list 可能分页;agent 事件较多时需迭代遍历。这正是源码 cma-webhook.ts 用 for await 逐页收集、并对空文本直接 204 的原因。 |
结合 2.2 与 2.5 两条 Gotcha,还可以再补两类隐蔽场景:回复被"非本桥 session 过滤"吞掉(检查是否真的写入了 metadata、retrieve 是否 403),以及"Thinking… 出现但 Linear 已标记失败"(检查 10 秒回执是否被 sessions.create 拖慢——务必先回执后建会话)。
五、生产化改造:架构不变的四处替换
skill.md 明确指出:从 ngrok 到生产,架构本身一行不改,只需四处基础件替换。这也是无状态设计带来的红利:
- 隧道 → 真部署:替换为 Cloudflare Workers、Fly 等任一托管平台,其余逻辑不变。
- 内存
seenEventIdsSet → Redis / 数据库:多实例部署时幂等去重必须共享(见 cma-webhook.ts 注释中的明确指引)。 .linear-tokens.json文件 → 真实密钥存储:当前实现(src/oauth.ts)把各 org 的 access/refresh token 落盘为 JSON;生产环境应替换为密钥管理服务。- 收窄 Anthropic endpoint 事件订阅:严格限定为你实际处理的
session.status_idled、session.status_terminated,减少无关流量与安全面。
此外,token 的刷新逻辑已经在源码中就位(src/oauth.ts):距过期不足 5 分钟即自动用 refresh_token 换新并落盘,生产化时只需替换存储层。
六、延伸方向:在同一桥架上扩展能力
skill.md 的配套 CLAUDE.md 记录了在基础桥接打通后可供用户挑选的扩展点,它们大多只需改动 setup/create-agent.ts 与 src/agent.ts 中的对象形状,桥接骨架不变:
- 挂载 GitHub 仓库:
sessions.create的resources: [{type: "github_repository", ...}]; - MCP 工具:给 agent 配
mcp_servers+mcp_toolset,让 agent 能"动手"而不只回话,凭证经 vault(vault_ids)注入 session; - Outcomes 迭代:用
user.define_outcome事件替代user.message,实现基于评分标准的迭代闭环; - 多 Agent:在 agent 上配
multiagent: {type: "coordinator", agents: [...]}; - 记忆库:
resources: [{type: "memory_store", ...}]实现跨 session 持久化; - 自定义工具:通过
agent.custom_tool_use/user.custom_tool_result在宿主侧执行。
需要精确字段形状时,官方建议以 /claude-api skill 提供的 Managed Agents API 全量参考文档为准,不要凭记忆猜字段名。
结语:整座桥的"不可见合同"
把 skill.md 与源码对照读完后会发现,这套桥接的成功完全建立在一张没有写在任何 API 文档里的"合同"上:Linear 侧按 org 管 token、10 秒内先回执;Anthropic 侧只订阅两个事件、按 event.id 去重、以 metadata 认领 session、验签失败回 401、无关事件回 204。任何一方破坏这张合同,都会表现为"什么都正常但就是没结果"。复现这套桥接时,建议把 skill.md 的核对清单当作配置顺序的唯一权威,而把 src/agent.ts、src/cma-webhook.ts、src/oauth.ts、src/main.ts 当作每一处约定的可执行证明。
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