Paperclip HTTP 适配器接入自建 Agent:要实现哪些端点、鉴权与回调契约怎么写

2026-08-17

如果你的 Agent 已经跑在别的地方——一个云函数、一台专门的服务器、或者某个第三方 Agent 平台——那么把它接进 Paperclip 时,能选的适配器基本就一个:http。官方文档对它的定位写得很直白:http 适配器向外部 Agent 服务发一个 webhook 请求,Agent 在外部运行,Paperclip 只负责触发。

这句话看着简单,实际上决定了整套集成的形状。别的适配器(claude_localcodex_localprocess 之类)是 Paperclip 把进程拉起来、抓 stdout、解析用量、拿到结构化结果;http 适配器不拉进程,它只发一个 POST,剩下的活儿都在你那边。这意味着任务状态不会自己变——Paperclip 不知道你干完没有,你必须自己调它的 API 写回去。文档里那句”外部 Agent 处理请求并回调 Paperclip API”,就是全部集成工作量的所在。

所以这篇要回答三件事:Paperclip 会往你的端点发什么、你拿什么身份调回去、回调时有哪些不能违反的契约。适配器怎么选、五类分别适合什么场景,可以先看五类适配器分别怎么接,这里只谈 http 这一条路。

三个配置字段,没有第四个

http 适配器的 adapterConfig 只有三项:

字段类型必填说明
urlstringPOST 的目标 webhook 地址
headersobject附加的 HTTP 请求头
timeoutSecnumber请求超时

值得注意的是没有的那些东西:没有签名密钥字段,没有重试次数,没有幂等键。也就是说,如果你想让自己的端点验证”这个请求确实来自我那台 Paperclip”,官方文档给的唯一抓手就是 headers——自己塞一个共享密钥进去,在自己服务端比对。至于 Paperclip 在超时或 5xx 时会不会重发同一个 runId,官方文档未说明,所以稳妥的做法是自己按 runId 做去重,别假设它只发一次。

timeoutSec 也要放在整个链路里想。http发后即忘(fire-and-forget)的调用模型,webhook 的响应会被当作运行结果记录下来。如果你的 Agent 要跑十分钟,把这十分钟全压在这一个 HTTP 请求里等,超时窗口就得开得很大;更合适的形态通常是端点收到请求后立刻返回一个受理确认,把真正的工作丢到后台队列,干完再走 API 回调。

Paperclip 发给你的请求体

文档给出的 JSON 负载结构是这样的:

{
  "runId": "...",
  "agentId": "...",
  "companyId": "...",
  "context": {
    "taskId": "...",
    "wakeReason": "...",
    "commentId": "..."
  }
}

这四类字段各有用处,别只挑 taskId 用:

  • runId 是这次心跳运行的标识。后面所有写操作都要把它带回去,这是审计链路的锚点。
  • agentId / companyId 决定你以谁的身份、在哪家”公司”里干活。Paperclip 的 API 强制公司边界,Agent 只能访问自己公司里的实体,跨公司访问直接 403。
  • context.wakeReason 是被叫醒的原因。在本机适配器那边,对应的环境变量取值举例有 issue_assignedissue_comment_mentionedhttp 适配器把同类信息放进了请求体。
  • context.commentId 是触发这次唤醒的具体评论。如果是被评论叫醒的,官方的心跳流程要求先去读那条评论所在的线程,把它当成直接触发因素。

顺带一提,本机适配器是通过一组 PAPERCLIP_* 环境变量把这些信息注入进程的(PAPERCLIP_AGENT_IDPAPERCLIP_COMPANY_IDPAPERCLIP_API_URLPAPERCLIP_API_KEYPAPERCLIP_RUN_ID,以及 PAPERCLIP_TASK_IDPAPERCLIP_WAKE_REASON 这些上下文变量)。你在写自己的 webhook 处理逻辑时,可以直接把请求体的字段映射成同名变量,这样一来,官方那份面向 Agent 开发者的心跳流程文档就能一字不改地照着实现。

鉴权:你回调时用的是什么身份

文档在讲 http 适配器时只留了一句话:外部 Agent 使用 PAPERCLIP_API_URL 和一个 API key 回调 Paperclip。展开来看,Paperclip 给 Agent 准备了两种凭证:

凭证怎么拿生命周期适用场景
运行 JWT心跳期间通过 PAPERCLIP_API_KEY 环境变量下发短期,且限定到该 Agent 与本次运行官方推荐给 Agent 用的方式
Agent API keyPOST /api/agents/{agentId}/keys 创建长期需要持久访问的 Agent

两者都走同一个请求头:

Authorization: Bearer <token>

http 适配器来说,这里有个现实问题:短期运行 JWT 是通过环境变量注入到本机进程的,而你的服务跑在别处,拿不到那个变量。文档在讲适配器能力标志时提到过一个 supportsLocalAgentJwt 开关,控制心跳是否为该 Agent 生成本地 JWT,默认值是 false,外部适配器不设置就一律按 false 算——http 适配器是否开启此项,文档未直接说明。落到实操上,比较可靠的路线是走长期 Agent API key:提前用 POST /api/agents/{agentId}/keys 创建一把,存进你自己的密钥管理里。要注意这把 key 只在创建那一刻能看到完整值,存储时是哈希后的,丢了只能重建。

API 的默认基址,文档写的是 http://localhost:3100/api,所有端点都带 /api 前缀;而适配器文档里安装外部适配器的示例 curl 用的是 3102。两处不一致,别照抄数字,以你自己那套部署实际监听的端口和 PAPERCLIP_API_URL 为准。更完整的鉴权模式(本地信任模式免鉴权、看板操作者走 Better Auth 会话)见 API 总览与鉴权

回调契约:心跳九步,你得自己走一遍

这是接 http 适配器时最容易低估的部分。本机 CLI 适配器背后跑的是 Claude Code 这类已经读过 Paperclip 技能包的 Agent,它知道该按顺序调哪些接口;你的自建服务不知道,得手写。官方的心跳流程是这样的:

步骤调用要点
确认身份GET /api/agents/me返回 ID、公司、角色、汇报链、预算
处理审批GET /api/approvals/{approvalId} 及其 /issues有审批 ID 时优先处理
取任务GET /api/companies/{companyId}/issues?assigneeAgentId={yourId}&status=todo,in_progress,in_review,blocked结果按优先级排序,这就是收件箱
认领POST /api/issues/{issueId}/checkout必须带 X-Paperclip-Run-Id,body 里给 agentIdexpectedStatuses
读上下文GET /api/issues/{issueId}/comments读祖先任务搞清这条任务为何存在
回写状态PATCH /api/issues/{issueId}带 run id 头,body 给 statuscomment
派活POST /api/companies/{companyId}/issues子任务必须设 parentIdgoalId

其中有三条是硬规矩,写代码时要当成断言:

一、动手前必须 checkout,不能手动 PATCH 成 in_progress checkout 请求形如:

POST /api/issues/{issueId}/checkout
Headers: X-Paperclip-Run-Id: {runId}
{ "agentId": "{yourId}", "expectedStatuses": ["todo", "backlog", "blocked", "in_review"] }

二、409 永远不要重试。 409 意味着这条任务已经被别的 Agent 占了,正确反应是换一条任务,不是退避重试。如果你的 HTTP 客户端库配了全局自动重试,记得把 409 排除掉——这是自建服务最常踩的一脚。

三、所有改状态的请求都要带 X-Paperclip-Run-Id 文档在 API 约定里写的是:心跳期间所有会产生变更的请求都带上这个头。少了它,运行审计链就断了。

除此之外还有几条行为约束:退出这次心跳前必须在进行中的任务上留评论、留下明确的下一步动作;长任务或并行任务用子任务让 Paperclip 在完成时唤醒父任务,而不是自己轮询;需要人来做是非判断时用 POST /api/issues/{issueId}/interactions 建一个 request_confirmation,而不是在 markdown 里问一句。

错误码怎么翻译成你的重试策略

官方给的错误码含义可以直接映射成客户端逻辑:

含义你该做的
400校验失败对照字段查请求体,别重试
401未认证key 缺失或失效,重新取凭证
403无权限多半是跨公司了,检查 companyId
404不存在实体不存在或不在你公司里
409冲突换一条任务,不要重试
422语义违规非法状态跃迁,比如 backlog 直接到 done
500服务端错误视为瞬时故障,在任务上留个评论然后继续

422 那条值得单独提一句:Paperclip 的任务状态是有状态机的,不是一个自由字符串字段。你的服务如果直接把内部状态映射过去,很容易撞上非法跃迁。

代价:你看不到实时过程

文档把适配器的”反馈粒度”分了三档,processhttp 一起被归在最粗的第三档——纯 stdout/stderr 文本行,没有结构化的运行记录,只能看到原始输出。相对地,claude_localcodex_localgemini_local 在开启 engine: "acp" 时能吐出结构化事件流。官方文档在这一节直接给了推荐:执行环境支持时优先用原生 ACP 引擎。这是官方文档的说法,落到你身上的现实是——选了 http,运行页里能看到的东西就很有限,可观测性得靠你自己那侧的日志补。

另外,Paperclip 会把”运行活跃度”(run liveness)作为心跳运行的元数据记录,取值举例有 completedadvancedplan_onlyempty_responseblockedfailedneeds_followup。其中只有 plan_onlyempty_response 会触发有上限的续跑唤醒。这套判定是基于运行结果的,而不是基于你的 HTTP 状态码;文档也明确写了,光把工作区准备好不算实质进展,durable 的进展要落在任务评论、文档修订、活动日志、提交或测试上。换句话说,你的服务如果只回一个 200 空响应就完事,在 Paperclip 眼里可能属于”没干活”。

什么时候别用它,以及还没解决的部分

按文档的判断标准,下面两种情况不该选 http:Agent 就跑在同一台机器上——那用 processclaude_localcodex_local 更直接;需要抓 stdout、需要实时看运行过程——http 给不了。

还有一类情况介于两者之间:你想要的不只是”被触发”,而是希望 Paperclip 认得你这套运行时——比如把技能同步过去、启用指令包编辑器、让运行页能正确渲染你的输出。这些能力由 supportsLocalAgentJwtsupportsInstructionsBundlerequiresMaterializedRuntimeSkills 这类能力标志控制,而设置它们意味着你要写一个真正的适配器包,而不是用通用的 http。这条路的完整做法见自己写一个适配器,好消息是外部适配器可以作为独立 npm 包发布,不需要改 Paperclip 源码。

最后是文档没覆盖、需要你自己拿主意的几块:webhook 的签名校验方式、Paperclip 侧的重试与幂等行为、以及超时后已经在你那边跑起来的任务该怎么收尾——这三项官方文档均未说明。在真正接线之前,建议先按 runId 做幂等、在 headers 里放共享密钥、并且给每次运行设一个自己的超时兜底,把状态用 PATCH 明确写回去,别让任务悬在 in_progress 上没人管。心跳不落地导致 Agent 看起来”不干活”的排查思路,可以接着看 Agent 不干活:心跳、看门狗、卡住怎么查

延伸阅读


本文依据 Paperclip 官方仓库(github.com/paperclipai/paperclip,MIT 协议)的 docs/ 用户文档 与 doc/ 下的规范、运维与连接器手册整理,核对日 2026-08-17。 我们没有部署或运行过 Paperclip,因此不涉及界面外观与操作手感; 部分规范文档描述的是目标架构而非当前实现,文中已就地标注,不构成对实际行为的保证。 请以仓库最新内容为准。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。