Linear × Claude Managed Agents:用无状态 Webhook 桥接实现「@提及即可召唤 Claude 回帖」的完整工程实践
导读
本指南围绕 claude-cookbooks 仓库中 managed_agents/linear 示例,深入讲解如何把 Claude Managed Agent(CMA)接入 Linear 议题系统:当用户在任何 Linear Issue 里 @mention 智能体时,系统会在 Anthropic 托管的基础设施上运行 Claude 会话,并把最终回答以评论形式回贴到对应议题。文章核心剖析这座「桥」的完整数据流——Linear AgentSessionEvent → CMA 会话(携带路由 metadata)→ session.status_idled Webhook → createAgentActivity 回贴——同时给出环境变量、签名密钥、事件去重、10 秒 ack 规则等全部陷阱与本地调试清单,让读者能够自己跑通并扩展这条链路。
为什么需要一座「桥」
核心矛盾:Linear 的 Agent Platform 与 Anthropic 的 CMA 并不共享线格式(wire format)与凭证体系。
- 入向:Linear 通过自己的 AgentSessionEvent Webhook 通知你「有人 @了智能体」,payload 携带
agentSession.id、organizationId与议题上下文; - 出向:Anthropic 通过
session.status_idledWebhook 通知你「CMA 会话已空闲」,payload 只携带 CMA 会话 ID; - 二者之间没有任何一方知道对方的"回调地址",也没有一方直接携带对方的输出。
因此必然需要一个翻译层:在入口把 "Linear @mention" 翻译成 sessions.create + user.message,在出口把 "CMA idle" 翻译成 Linear 评论,并同时持有两套密钥。这就是 src/ 中这座 stateless 桥的全部职责。
从仓库的目录结构与注释可以推断,这套实现刻意保持"薄":桥本身不保存任何业务数据,运行期会话路由完全依赖 CMA session 上的 metadata(linear_session_id、linear_org_id),这使得它天然适合部署成无状态函数或临时进程。
核心心智模型:Webhook 是门铃,不是快递
一个 Webhook 只是"某件事发生时调用我"
它是某个服务向你预先注册的 URL 发起的 HTTP POST,请求体是一个描述事件的小 JSON。注册一次 URL,之后每次事件触发都会收到回调:无轮询、无长连接。桥涉及两条 Webhook、一个中间层:
- Linear → 桥:
@mention时触发,payload 含agentSession.id、organizationId与议题上下文; - Anthropic → 桥:会话空闲时触发,payload 仅含 CMA session ID。
两者都不携带"回调 URL",也不携带 Agent 的最终输出,本质都是"带 ID 的信号"。
Push 信号,Pull 数据
Anthropic 的 session.status_idled payload 被刻意做得极薄(形如 {type, id})。正确的做法永远是随后调用 sessions.retrieve(id) 取 metadata、调用 sessions.events.list(id) 取输出——推信号、拉数据。这样重试成本极低,数据也永远不会过期。
metadata 就是全部路由状态
当桥创建 CMA 会话时写入 metadata: {linear_session_id, linear_org_id}(见 src/agent.ts);当 idle Webhook 只带 session ID 到达时,桥 sessions.retrieve 读回这两个键,就知道该回贴到哪个 Linear 议题,无需在桥内存储任何会话状态——这正是 stateless 的关键设计。
文件职责:读懂一座桥的解剖图
| 文件 | 职责 |
|---|---|
| setup/create-agent.ts | 一次性任务:agents.create + environments.create,打印 ID 供写入 .env.local |
| src/main.ts | Bun 服务器与路由注册,启动前做环境变量强校验 |
| src/oauth.ts | Linear OAuth(actor=app)+ 基于文件的 token 存储与自动刷新 |
| src/agent.ts | 收到 Linear 事件后:10 秒内 ack → sessions.create + user.message |
| src/cma-webhook.ts | 验签 → 事件去重 → retrieve 过滤 → 读回复 → createAgentActivity 回贴 |
| skill.md | 完整设置清单、全部已知坑位与调试对照表 |
| CLAUDE.md | 面向智能体的操作手册:先读 skill、再跑、按需扩展 |
项目依赖见 package.json:核心是 @anthropic-ai/sdk(≥ 0.95.1,提供 beta.sessions、beta.webhooks.unwrap 等 API)与 @linear/sdk(提供 LinearClient、LinearWebhookClient 等),脚本仅有三个:bun run dev(watch 模式)、bun run start、bun run setup。
服务器骨架:四个路由与启动即校验
src/main.ts 启动时先对四个必需环境变量做强校验,任一缺失即 FATAL 退出:
LINEAR_WEBHOOK_SIGNING_SECRET
ANTHROPIC_WEBHOOK_SIGNING_KEY
CLAUDE_AGENT_ID
CLAUDE_ENVIRONMENT_ID
随后注册四个路由:
GET /:健康检查,返回{status: "ok"};GET /oauth/authorize:跳转 Linear OAuth 授权页(安装 Agent 的入口);GET /oauth/callback:接收授权码、换 token、登记组织;POST /linear-webhook:交给LinearWebhookClient的 handler,订阅AgentSessionEvent;POST /cma-webhook:交给handleCmaWebhook处理 Anthropic 的会话状态事件。
其中 LinearWebhookClient(来自 @linear/sdk/webhooks)内置了对 Linear Webhook 请求体的验签与事件解析,事件回调里会收到完整的 AgentSessionEvent,随即调用 kickoffAgentSession(fire-and-forget 方式,异常仅打印日志,不阻塞 HTTP 响应)。
Linear 侧接入:OAuth、app user 与 10 秒 ack
actor=app 是承重墙参数
要创建"会出现在 @-picker 里"的 app user,必须在 OAuth 授权时带上 actor=app 与足够的 scope。见 src/oauth.ts:
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)回贴。handleOAuthCallback 用授权码换取 token 后,还会查询 organization { id name },以组织 ID 为主键把 token 存入本地文件 .linear-tokens.json,完成"安装到某个工作区"的登记。
刷新逻辑:提前 5 分钟续期
getAccessToken(orgId)(src/oauth.ts)在 token 距过期不足 5 分钟时自动用 refresh_token 换新并落盘,调用方无感知。需要注意 .linear-tokens.json 是本地文件方案,生产环境应替换为真正的密钥存储(见下文生产化)。
10 秒内先回一个 thought
Linear 要求在收到 AgentSessionEvent 的 10 秒内至少写入一条 agentActivity,否则会话被标记失败。因此 src/agent.ts 在创建 CMA 会话之前,先同步回贴:
await linear.createAgentActivity({
agentSessionId: agentSession.id,
content: { type: "thought", body: "Thinking..." },
});
这让 Linear 界面立即显示"Thinking…",给后续耗时较长的 CMA 启动留出余量。
入向翻译:创建 CMA 会话并绑定路由元数据
sessions.create + user.message
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,
},
});
await anthropic.beta.sessions.events.send(session.id, {
events: [{ type: "user.message", content: [{ type: "text", text: buildPrompt(event) }] }],
});
session 上挂的 metadata 是整个路由系统的唯一事实来源——这是"stateless"得以成立的前提。随后用 events.send 注入 user.message,Claude 即开始在 Anthropic 基础设施上运行。
prompt 的组装策略
buildPrompt(src/agent.ts)优先使用 Linear 事件自带的 promptContext(若存在),否则拼装议题(identifier + title + description)、历史评论、以及本次 @ 消息,最后兜底为 "Hello! How can I help?"。这一兜底逻辑说明:即便某次事件缺失上下文,Agent 也不会空转。
出向回贴:验签、去重、过滤、拉取、回复
cma-webhook.ts 是返回路径的全部实现,也是坑位最密集的地方。
1. 验签并解包
event = anthropic.beta.webhooks.unwrap(rawBody, {
headers: Object.fromEntries(req.headers),
});
unwrap 同时完成 HMAC 验签、时间戳校验与事件解析,读取环境变量 ANTHROPIC_WEBHOOK_SIGNING_KEY。验签失败返回 401。代码注释特别提醒:unwrap() 需要普通 header map,而非 fetch 的 Headers 对象。
2. 用 event.id 做幂等去重
Anthropic 对投递失败的请求会用相同的顶层 event.id 重试。代码用内存 Set<string> 去重:
if (seenEventIds.has(event.id)) return new Response(null, { status: 204 });
seenEventIds.add(event.id);
已处理或决定忽略的事件都应返回 2xx,否则会持续触发重试——约 20 次连续失败会自动禁用该 endpoint(详见 skill.md 的说明)。
3. 区分终止与空闲
session.status_terminated 走 postTerminationError,向 Linear 回贴一条 {type: "error", body: "Agent session terminated unexpectedly."};session.status_idled 才进入回复流程;其余事件一律 204 忽略。
4. retrieve-then-filter:工作区共享 Webhook 的必修课
Anthropic 的 Webhook 是工作区级(workspace-scoped)的:Console 里注册的 endpoint 会收到该工作区内所有会话的事件,包括别人的 Agent、脚本或团队成员触发的会话。因此必须检索会话并按 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 });
}
retrieve 抛 404/403 同样要捕获静默返回——同一工作区里由其他 API key 创建的会话,你的 key 本就不可读。这正是 skill.md 强调的 "retrieve-then-filter" 模式:先取数、再判断是不是自己的活。
5. 拉取回复并回贴
从事件历史中收集所有 agent.message 的文本块(对 sessions.events.list 返回的分页对象做 for await 迭代即自动翻页):
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);
}
}
}
reply is empty 的常见根因之一正是分页:Agent 产出大量事件时列表需要翻页,迭代分页对象即可解决。收集到文本后:
await linear.createAgentActivity({
agentSessionId: linearSessionId,
content: { type: "response", body: responseText },
});
回复以 response 类型活动回贴,用户即可在 Linear 议题评论中看到 Claude 的最终答案。整个过程对无回复的会话以 204 静默收尾。
一次性初始化:agent 与 environment
bun run setup 执行 setup/create-agent.ts,一次性创建运行环境与 Agent:
const env = await anthropic.beta.environments.create({
name: `linear-bridge-${Date.now()}`,
config: { type: "cloud", networking: { type: "unrestricted" } },
});
const agent = await anthropic.beta.agents.create({
name: "Linear Assistant",
model: "claude-opus-4-7",
system:
"You are a helpful assistant embedded in Linear. Keep replies concise and actionable — they are posted as comments. Do not invent issue IDs, users, or project names.",
tools: [{ type: "agent_toolset_20260401", default_config: { enabled: true } }],
});
值得注意的两点:environment 显式选择 type: "cloud"(即由 Anthropic 托管运行)与无限制网络;agent 的 system prompt 明确约束"回答会被贴成评论,保持简洁可执行,不得编造议题 ID/用户/项目名"——这是针对 Linear 场景的重要护栏。脚本最后打印两个 ID,复制进 .env.local 即可。
一键跑通:从安装到第一次 @ 提及
按 README.md 的 Quickstart 与 skill.md 的本地开发清单,最小落地路径为:
cd managed_agents/linear
bun install
claude # 让 Claude 依据 CLAUDE.md / skill.md 引导你完成后续配置
随后在 Claude Code 中直接说 "walk me through setting this up",它会按 skill 中"实际能跑通的顺序"驱动你完成:Linear OAuth 应用 → Anthropic Agent + Webhook → env vars → bun run dev。
如果不用 Claude 引导、完全手动,则依次执行:
ngrok http 3000(或cloudflared tunnel)得到公网 URL,后续所有地址都基于它;bun run setup,把CLAUDE_AGENT_ID/CLAUDE_ENVIRONMENT_ID复制进.env.local;- Linear OAuth 应用(
linear.app/<workspace>/settings/api→ 侧栏 Administration → API → OAuth Applications → Create new):Developer URL 填任意真实https://URL(仅展示用途,不影响功能);Callback 填<url>/oauth/callback;Webhook 填<url>/linear-webhook,事件选 Agent session events;复制 client ID/secret 与 webhook secret; - Anthropic Console → Webhooks:填
<url>/cma-webhook,事件仅订阅session.status_idled与session.status_terminated,复制whsec_...。务必与你的 API key 处于同一工作区; - 填好
.env.local,bun run dev; - 浏览器访问
<url>/oauth/authorize→ 同意授权 → 显示 "Agent installed."; - 在某个 Issue 中 @ 它。
.env.local 中所需的完整变量(结合 main.ts 与 oauth.ts 的读取点整理)包括:LINEAR_WEBHOOK_SIGNING_SECRET、ANTHROPIC_WEBHOOK_SIGNING_KEY、CLAUDE_AGENT_ID、CLAUDE_ENVIRONMENT_ID、LINEAR_CLIENT_ID、LINEAR_CLIENT_SECRET、BASE_URL(可选,默认 http://localhost:3000)、PORT(可选,默认 3000)。
坑位清单:把容易浪费调试时间的地方一次说清
skill.md 花了大量篇幅记录"文档里没写、却极耗调试时间"的坑位,逐条对照如下:
- Anthropic Webhook 是工作区级的:注册在 Console 的 endpoint 只收到同一工作区内会话的事件。若 API key 属于工作区 A、endpoint 注册在工作区 B,则会静默地一条都收不到。核对 API key 上的 Workspace 列与 Console Webhooks 页的工作区选择器一致。
- 工作区 Webhook 会收到该工作区每一个会话:共享工作区下,每个
session.status_idled都会打到你这里,handler 必须用retrieve+ metadata 过滤,无关事件回 204;retrieve的 404/403 也要捕获。 - 只订阅你需要的类型:宁可只订阅
session.status_idled、session.status_terminated,也不要选 "All events"。 - Linear OAuth 应用仅限工作区管理员:非管理员可开一个免费个人工作区做测试,避免把实验性 Agent 装进生产。
- 签名头名不一致:文档写
X-Webhook-Signature,实际线上走的是Webhook-Signature/Webhook-Id/Webhook-Timestamp(Standard Webhooks 规范)。用 SDK 的webhooks.unwrap()即可自动处理,只有手工验签时才需要在意。 - event.id 是幂等键:见上文去重逻辑。
- metadata 缺失即忽略:别人工作区共享 Webhook 打到你的 endpoint、或会话不可读时,一律 204,不产生任何副作用。
静默失败的调试对照表
skill.md 给出了三条高发故障的快速定位路径:
| 现象 | 排查方向 |
|---|---|
| Linear 里 "Thinking…" 从未出现 | Linear Webhook 没到你这里:检查 Linear app 的 webhook URL、ngrok 是否存活 |
| "Thinking…" 出现但没有回复 | curl localhost:4040/api/requests/http 看 ngrok 请求日志;若无 POST /cma-webhook → Anthropic 侧工作区不匹配或 endpoint 未保存;若 POST 到达但返回 401 → 签名密钥不匹配 |
| 回复内容为空 | sessions.events.list 可能分页,Agent 事件多时需迭代完整分页对象 |
扩展方向:从"只回帖"到"能干活"
基础桥跑通之后,CLAUDE.md 列出六个可选扩展(具体字段形状需查阅 Managed Agents API 参考):
- GitHub 仓库:通过
sessions.create上的resources: [{type: "github_repository", ...}]把仓库挂载进会话容器; - MCP 工具:例如 Linear 或 GitHub 的 MCP,让 Agent 不仅能回复、还能执行动作——在 agent 上配
mcp_servers+mcp_toolset,凭证经 vault 提供,会话上挂vault_ids; - Outcomes(结果评分):把
user.message换成user.define_outcome事件,走 rubric 评分的迭代循环; - Multiagent(多智能体):在 agent 上配置
multiagent: {type: "coordinator", agents: [...]},形成协调者 + 子智能体阵容; - Memory store:通过
resources: [{type: "memory_store", ...}]实现跨会话持久记忆; - 自定义工具:经
agent.custom_tool_use/user.custom_tool_result由宿主机侧执行。
需要改动的位置集中在 setup/create-agent.ts 与 src/agent.ts 两处。由于这套桥高度 stateless,扩展大多只涉及请求体字段的增改,不动链路骨架。
生产化:从本地玩具到可上线服务
skill.md 与 CLAUDE.md 共同给出了四点收敛方向,原则是"链路不变、替换薄弱组件":
- 用 Cloudflare Workers、Fly 等真实部署替换 ngrok,其余逻辑不变;
- 用 Redis 或数据库替换内存
seenEventIdsSet,实现多实例下的幂等去重; - 用真正的密钥存储替换
.linear-tokens.json文件; - 把 Anthropic endpoint 的事件订阅收窄到与你实际处理的事件一致(
session.status_idled、session.status_terminated)。
小结
Linear × Claude Managed Agents 桥的工程价值在于它示范了一种可复用的双向 Webhook 集成范式:用 session metadata 保存路由状态换取无状态实现,用 "push 信号、pull 数据" 换取可靠且廉价的回调处理,用 event.id 幂等换取安全的自动重试。这套「翻译层」模式同样适用于 Slack、GitHub Issues 或其他 IM/工单系统——仓库中 slack 目录即存在同构示例可供对照。若要深入理解每条 API 的精确字段,建议在实际开发时调用 Managed Agents 的完整 API 参考作为唯一事实来源,不要凭记忆猜测字段名。
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