Confer — API 规范

定义客户端 ↔ 服务器、服务器 ↔ A2A peer 的所有 API。

通用约定

{
  "error": {
    "code": "invalid_request",
    "message": "Human-readable message",
    "details": { /* optional */ }
  }
}

认证

客户端 API(用户客户端使用)

认证

POST   /api/v1/auth/register
POST   /api/v1/auth/login
POST   /api/v1/auth/refresh
POST   /api/v1/auth/logout
POST   /api/v1/auth/oauth/{provider}    # OAuth callback

POST /api/v1/auth/login 请求:

{
  "username": "laowang",
  "password": "...",
  "device_id": "ios-abc123",
  "device_info": { "platform": "ios", "model": "iPhone 15", "os": "17.1" }
}

响应:

{
  "access_token": "eyJ...",
  "refresh_token": "...",
  "expires_in": 900,
  "user": { /* User object */ }
}

用户和 Agent 配置

GET    /api/v1/users/me
PATCH  /api/v1/users/me
GET    /api/v1/agents/me
PATCH  /api/v1/agents/me
PUT    /api/v1/agents/me/policies
GET    /api/v1/agents/me/llm-keys      # 每个 provider 是否已配置(只返回布尔,不返回密钥)
PUT    /api/v1/agents/me/llm-keys      # 加密存储 LLM API keys
DELETE /api/v1/agents/me/llm-keys/{provider}
GET    /api/v1/agents/me/llm-keys/{provider}/models   # 向厂商实时查询可用模型

provider 取值来自 @confer/shared 的 provider 目录(packages/shared/src/llm/catalog.ts), 外加工具服务 tavily。目录同时被 gateway、agent-runtime 和客户端读取——base URL、模型列表 路径、默认模型都只写在那一处,新增厂商只改目录。

/models 直接转发厂商自己的模型清单,永远不返回本地维护的名单:

{ "models": [{ "id": "gpt-4o" }] }
// 空列表必定带上原因,四种互不相同,各自对应不同的补救动作
{ "models": [], "error": "no_key" }        // 该 provider 还没配置密钥
{ "models": [], "error": "unauthorized" }  // 厂商拒绝了这个密钥(401/403)
{ "models": [], "error": "unreachable" }   // 连不上厂商,或它返回了其他错误
{ "models": [], "error": "unsupported" }   // 该厂商不提供模型清单接口

联系人 / Peer Agents

GET    /api/v1/contacts                     # 列出联系人。分页:?limit=&offset=
POST   /api/v1/contacts                     # 添加联系人
GET    /api/v1/contacts/{contact_id}        # 单个联系人详情(带 peer)
DELETE /api/v1/contacts/{contact_id}
PATCH  /api/v1/contacts/{contact_id}        # 局部修改 alias / tags / pinned / muted(未传字段不清空)

POST   /api/v1/contacts/lookup              # 按 DID / 域名 / username 查找

POST /api/v1/contacts/lookup 请求:

{
  "method": "domain",          // domain | did | username | qr_code | phone
  "value": "abc-industries.com"
}

GET /api/v1/contacts 返回 { contacts, total }limit 默认 50、上限 100,offset 默认 0;按 id(ULID)倒序,即最新在前——排序是唯一且确定的,offset 窗口才不会漏行或重行。total 是全量计数而非本页条数,客户端据此判断”已到底”。无法解析的 limit/offset 取默认值而非报错。

响应:返回找到的候选 Agent 列表。lookup 会把发现到的 peer 落库到 peer_agents 并在每个候选里带上本地 idpeer_id)——POST /api/v1/contacts 正是用这个 id 添加联系人。POST /contacts 幂等:重复添加同一 peer 返回已存在的联系人(200)而非报错。

添加联系人是接收方授予对方”可消费我的 Agent”的同意:被加为联系人的 peer 才能触发我的 Agent 回答(消耗我的 LLM 预算)。未连接 peer 发来的 A2A 消息会被挂起为待批连接请求,见 03-protocol.md 的「连接同意闸门」。

POST   /api/v1/contacts/{contact_id}/policies   # 设置 standing policies(整体替换,PUT 语义)

POST /contacts/{id}/policies 的 body 是 runtime 形 { default?: 'allow'|'ask_user'|'deny', rules?: [{ action, peer_did?, decision }] },整体写入 peer_contacts.policy_overrides_jsonMerge 语义:入站 A2A 决策时,该 per-contact 覆盖叠加在 agent 级 policy 之上——contact.default 在场则覆盖 agent 默认,contact.rules 前置于 agent rules 故先命中(per-contact 精确规则优先于 agent 通用规则);空覆盖 {} 为恒等,与无覆盖时的决策逐字节一致。

对话

GET    /api/v1/conversations                       # 列出我的对话(首页用)
POST   /api/v1/conversations                       # 创建新对话
GET    /api/v1/conversations/{id}
PATCH  /api/v1/conversations/{id}
DELETE /api/v1/conversations/{id}

GET    /api/v1/conversations/{id}/messages         # 分页:?before=&limit=
POST   /api/v1/conversations/{id}/messages         # 发消息
GET    /api/v1/conversations/{id}/messages/{msg_id}/stream    # SSE 流式接收 LLM 回复

POST   /api/v1/conversations/{id}/participants     # 加入 participant
DELETE /api/v1/conversations/{id}/participants/{p_id}

POST   /api/v1/conversations/{id}/read             # 标记已读

POST /api/v1/conversations/{id}/messages 请求:

{
  "content_type": "text",
  "content": "X100 寄存器 0x40 用什么功能码?",
  "in_reply_to": null,
  "via": "web"
}

响应:

{
  "id": "01HXKQ...",
  "delivery_status": "queued",
  "stream_url": "/api/v1/conversations/01HX.../messages/01HXK.../stream"
}

权限管理

GET    /api/v1/permissions/pending               # 待处理的 L2/L3 请求
POST   /api/v1/permissions/{id}/decide           # 批准/拒绝
GET    /api/v1/permissions/history               # 历史记录

POST /api/v1/permissions/{id}/decide 请求:

{
  "decision": "allow_always",       // allow_once | allow_always | deny | deny_always
  "scope": "peer_action"            // 限定范围
}

待批请求里 action='connect' 的是连接请求(陌生 peer 首次接触时由 A2A 入站生成)。批准(allow_*)会把该 peer 写入 peer_contacts,建立连接;拒绝则不建立。

action='ask' 的是已连接 peer 的待批提问——当主人的 Agent 策略对该提问判为 ask_user 时由 A2A 入站生成(见 03-protocol.md 的「Pending inbox(离线代答)」)。批准(allow_*)触发 Agent 代答这条挂起的提问;拒绝则不回答。

GET /pending 为每条请求附带 description(连接请求含发起方与首条留言;提问含发起方与问题正文)便于主人判断。

项目记忆(Claude Code 集成相关)

GET    /api/v1/projects/{project_id}/peers              # 该项目下有记忆的 peer(join 出 name/did)  ✅ 已实现
POST   /api/v1/projects/{project_id}/peers              # 显式注册 peer 到项目                       🔜 backlog
GET    /api/v1/projects/{project_id}/peers/{peer_id}/facts        # ✅ 已实现
PUT    /api/v1/projects/{project_id}/peers/{peer_id}/facts        # ✅ 已实现
GET    /api/v1/projects/{project_id}/peers/{peer_id}/decisions    # ✅ 已实现
PUT    /api/v1/projects/{project_id}/peers/{peer_id}/decisions    # ✅ 已实现

语义说明(v0.1):

知识库(RAG)

GET    /api/v1/knowledge-bases                                  # 列出我的知识库
POST   /api/v1/knowledge-bases                                  # 新建
PATCH  /api/v1/knowledge-bases/{kb_id}                          # 改名/描述,以及是否对外部 Agent 开放
DELETE /api/v1/knowledge-bases/{kb_id}                          # 连同其全部文档与向量一并删除

GET    /api/v1/knowledge-bases/{kb_id}/documents                # 分页:?limit=&offset=
POST   /api/v1/knowledge-bases/{kb_id}/documents                # multipart upload,字段名 file
DELETE /api/v1/knowledge-bases/{kb_id}/documents/{doc_id}
POST   /api/v1/knowledge-bases/{kb_id}/documents/{doc_id}/retry # 重新入库

POST /knowledge-bases 的 body 是 { name, description? }name 1–255 字符),返回 201 + { knowledge_base }

PATCH /knowledge-bases/{kb_id} 的 body 是 { name?, description?, shared_with_peers? },返回 { knowledge_base }shared_with_peers 只能在这里改,建库时不接受:知识库一律以「仅自己」诞生,对外开放是第二个有意为之的动作。

shared_with_peers 决定的是入站 A2A 提问能不能搜到这个库,默认 false。owner 自己在网页里对话时不受它影响,永远搜得到全部。这个边界必须落在检索的作用域上、而不是提示词里:对端的问题和 owner 的指令一样只是模型眼里的文本,「让 Agent 自己判断该不该说」根本不构成边界。同理,入站 A2A 提问不召回任何长期记忆——长期记忆是从 owner 自己的对话里蒸馏出来的,没有任何一条被标记为可以离开本实例。

GET /knowledge-bases 返回 { knowledge_bases }不分页:一个用户的知识库是手工建的,数量有界。

GET /{kb_id}/documents 返回 { documents, total }limit 默认 50、上限 100,offset 默认 0;按 id(ULID)倒序,即最新在前——排序唯一且确定,offset 窗口才不会漏行或重行。total 是全量计数而非本页条数。无法解析的 limit/offset 取默认值而非报错。这是本节唯一会无界增长的列表,因为知识库正是上传目标。

上传走 multipart/form-data,文件字段名固定为 file,单个文件上限 10 MB(超出返回 400 bad_request)。Content-Type 优先取表单里带的,缺失时按扩展名推断。响应 201 + { document },此时 status 已是 processing切分、向量化、写入 Qdrant 是响应之后异步跑的,上传接口不等它完成。客户端据此轮询文档列表直到 status 变化。

status 取值:

含义
processing 已入库、正在切分/向量化。上传与 retry 后的初始态
ready 可被检索。chunk_count 为该文档的分片数
failed 入库失败(解析、embedding key 缺失或向量库写入失败)

POST /{doc_id}/retry 从对象存储取回原文件重新入库,先清掉该文档已有的向量再重跑,因此不会产生重复分片。原文件已不在(storage_key 为空)或文档仍在 processing 时返回 400。响应 { document }status 复位为 processingchunk_count 归零。

删除知识库会级联删除其全部文档行与 Qdrant 中的向量;删除单个文档同时清理向量与对象存储中的原文件。向量/对象存储的清理失败不会阻断数据库删除——留下孤儿对象好过留下指向已删数据的行。

所有端点都 scope 到 user.sub:访问他人的 kb 或文档返回 404(而非 403,不泄露其存在性)。

反向代理需放行 10 MB 请求体。infra/nginx.conf/api/ 上设了 client_max_body_size 10m;用 nginx 默认的 1 MB 时,1–10 MB 的文件根本到不了 gateway,浏览器拿到的是 nginx 自己的 413 页面。

文件附件

POST   /api/v1/attachments                       # multipart upload
GET    /api/v1/attachments/{id}                  # 下载(302 redirect 到签名 URL)
DELETE /api/v1/attachments/{id}

WebSocket

端点

WSS  /ws?token=<access_token>&device_id=<device_id>

握手的鉴权与 REST 完全一致,不是”验个签就放行”:typ 必须是 accesssid 必须指向一条仍然存在的 session、账号状态不能是 disabled。三条缺一不可——缺了 它们,被封禁的账号只要 token 没过期就能一直重连收消息,而封禁本身(删光 session) 在这条路径上什么都没撤销。封禁同时会关掉该用户已经打开的 socket:nginx 给 /wsproxy_read_timeout 是一天,只拦下一次握手拦不住已连上的那条。

消息格式

所有 WS 消息都是 JSON,含 type 字段:

{ "type": "message.new", "data": { /* ... */ } }

客户端 → 服务器

ping                          // 心跳
subscribe.conversation        // 订阅某个对话(服务端校验参与者身份)
unsubscribe.conversation
typing.start                  // 仅对已订阅的会话生效
typing.stop
read.ack                      // 已读确认

typing.* 的广播以该 socket 的订阅集为准。订阅有闸门而打字事件没有的时候,只要 知道一个会话 id 就能往任意会话里注入”某某正在输入”,还带着自己的用户名。

服务器 → 客户端

pong
message.new                   // 新消息
message.updated
message.deleted
typing.update                 // 谁在打字
presence.update               // 联系人上下线
permission.request            // 需要用户决定的权限请求
agent.status                  // 我的 Agent 在做什么("正在咨询 ABC Agent...")
conversation.updated

message.new 示例:

{
  "type": "message.new",
  "data": {
    "id": "01HXKQ...",
    "conversation_id": "01HX...",
    "sender_type": "peer_agent",
    "sender_id": "01HY...",
    "sender_did": "did:web:acme.com:agents:support",
    "content_type": "text",
    "content": "用 0x03 Read Holding Registers...",
    "citations": [
      {
        "source": "X100 通信手册 v3.2",
        "page": 87,
        "url": "https://acme.com/manuals/x100-v3.2.pdf#page=87",
        "trust_level": "authoritative"
      }
    ],
    "language": "zh",
    "created_at": "2024-11-15T14:30:00Z"
  }
}

permission.request 示例:

{
  "type": "permission.request",
  "data": {
    "id": "01HXP...",
    "level": "L2",
    "action": "share_files",
    "scope": {
      "peer": "did:web:acme.com:agents:support",
      "paths": ["src/modbus/"],
      "exclude": [".env", "secrets/"]
    },
    "peer_name": "ABC Agent",
    "peer_did": "did:web:acme.com:agents:support",
    "requested_at": "2024-11-15T14:30:00Z"
  }
}

载荷里没有 description,这是有意的。 服务端不知道读者用什么语言,所以只发结构化事实 (action + peer 身份 + 已存储的 scope),供审批阅读的句子由客户端按 i18n 拼装 (packages/client/src/lib/permission-text.ts)。这条契约由 @confer/sharedpermissionRequestEventSchema 单独拥有:gateway 出站前用它 parse,客户端入站后用它 parse。

GET /api/v1/permissions/pending 的每一行是同一个形状(额外带一个 decision 字段), 由同一个构造器生成,所以轮询到的行和 socket 推来的行逐字节一致。

SSE(LLM streaming)

GET  /api/v1/conversations/{id}/messages/{msg_id}/stream
Accept: text/event-stream

事件类型:

event: token
data: {"text":"用 "}

event: token
data: {"text":"0x03 "}

event: tool_call
data: {"tool":"agent_network.ask_peer","args":{...}}

event: tool_result
data: {"result":"..."}

event: citation
data: {"source":"X100 通信手册 v3.2","page":87}

event: done
data: {"finish_reason":"stop","tokens_used":523}

A2A API(对外,供其他 Confer 实例调用)

详见 docs/03-protocol.md。这里只列 endpoint。

两套绑定并存于同一前缀,共用同一套闸门(a2a/inbound.ts),只是线格式不同。

A2A 标准 HTTP+JSON 绑定(spec §11.3 的路径原样照抄,Agent Card 宣称的就是这一套):

POST   /a2a/v1/message:send              # SendMessage → Task
GET    /a2a/v1/tasks/{id}                # GetTask
GET    /a2a/v1/tasks                     # ListTasks(游标分页)
POST   /a2a/v1/tasks/{id}:cancel         # CancelTask → TaskNotCancelable
POST   /a2a/v1/message:stream            # 未实现 → UnsupportedOperation
POST   /a2a/v1/tasks/{id}:subscribe      # 未实现 → UnsupportedOperation
GET    /a2a/v1/extendedAgentCard         # 未实现 → UnsupportedOperation
*      /a2a/v1/tasks/{id}/pushNotificationConfigs…  # → PushNotificationNotSupported

Confer 自己的方言(实例之间用,经 /.well-known/agents.json 发现):

POST   /a2a/v1/messages                  # 接收外部 Agent 消息
GET    /a2a/v1/stream/{message_id}       # 流式拉回答(SSE)
GET    /a2a/v1/agent-facts/{agent_did}   # 公开 AgentFacts

所有 A2A 端点都要 HTTP Message Signature 验证。

.well-known endpoints

GET    /.well-known/did.json                # 主 DID document
GET    /.well-known/agents.json             # 本实例所有公开 Agent 列表
GET    /.well-known/agent-card.json         # A2A 标准 Agent Card(仅当实例只有一个公开 Agent)
GET    /.well-known/openid-configuration    # 未来:OIDC 兼容(v2)

A2A 标准 Agent Card(互操作发现层)

GET    /agents/{username}/agent-card.json   # 该 Agent 的 A2A 标准 Card
GET    /.well-known/agent-card.json         # 同上,仅当本实例只有一个公开 Agent

按 Linux Foundation Agent2Agent v1.0AgentCard(字段取自 a2aproject/A2Aspecification/a2a.proto @ v1.0.1,走 proto3 JSON 映射,故为 camelCase)。目的是让 A2A 生态发现本实例的 Agent —— 名字撞了但协议不通:对方的发现文档在 /.well-known/agent-card.json,本实例原本只有 /.well-known/agents.json

几个刻意的取舍:

消息层(Task 语义)

POST /a2a/v1/message:send 收 spec 的 SendMessageRequest,返回 Task一个 task 就是一次入站提问:它的 id 是那条消息的 id,contextId 是归档它的会话,状态由后续发生的事推出来——不另立一张 tasks 表去影子同一个事实。

Confer 的异步 + 同意闸门模型正好落在 spec 的状态机上:

情况 状态
Agent 正在作答 TASK_STATE_WORKING
答完 TASK_STATE_COMPLETED
这一轮跑不起来(没配模型 / provider 报错) TASK_STATE_FAILED
ask_user 策略挂起,等主人批 TASK_STATE_AUTH_REQUIRED(中断态,非终态)
主人拒了 TASK_STATE_REJECTED

两处没有 task 可返回,因为压根没建行:陌生 peer(挂起为待批连接请求)和策略直接拒绝。两者都回 403 PERMISSION_DENIED,用 ErrorInfo.metadata.confer_status 区分——凭空造一个下一次调用就 404 的 task id 更糟。

其余按 spec 逐条对齐的行为:错误体是 google.rpc.Status 形状并必带 ErrorInfo.reason(多个 A2A 错误共用同一个 HTTP 状态码,reason 是唯一能分辨的字段);未声明必需扩展的客户端按 §3.3.4 回 ExtensionSupportRequiredError,而不是一个不解释任何事的 401;historyLength=0整个字段省略而不是空数组;nextPageToken 恒在,没有下一页时为空串。

两处刻意的偏离,都写在代码注释里:阻塞式 message:send 的等待有上限(55s,之后返回仍为 WORKING 的 task 让客户端轮询)——spec §3.2.2 没给超时出口,而一次 LLM 调用没有上界;messageId 幂等(§3.3.1 的 MAY)没做,因为租户安全的唯一键需要 owner 作用域,而首条消息的线格式里拿不到。

Webhooks(可选,v1.5+)

让外部系统订阅事件:

POST   /api/v1/webhooks
GET    /api/v1/webhooks
DELETE /api/v1/webhooks/{id}

支持的事件:message.new.peerpermission.grantedthread.archived

限流策略

路由 限制
/api/v1/auth/login 10/分钟 per IP
/api/v1/auth/register 3/小时 per IP
/api/v1/conversations/*/messages POST 60/分钟 per user
/a2a/v1/* 100/分钟 per peer-domain(白名单更高)
WSS 单用户最多 10 个并发连接

限流响应:

HTTP/1.1 429 Too Many Requests
Retry-After: 30
Content-Type: application/json

{ "error": { "code": "rate_limited", "message": "Too many requests" } }

咨询 API(用户发起的 A2A 出站)

让用户(或代表用户的 MCP server)主动向已是联系人的 peer agent 发问并取回异步回复。签名与投递全在 gateway 内完成,私钥不出 gateway。

与”会话 API”的区别:/api/v1/conversations + /api/v1/stream 是与自己的本地 LLM 助手对话;/api/v1/consult 才是经 A2A 发给别人的 agent

POST /api/v1/consult/:peerId

发起或续聊一个 type='consult' 会话(每个 peer 复用同一会话),签名并投递 message.type='question'

// 请求体(consultRequestSchema)
{ "question": "如何轮换密钥?", "code_context": "...可选代码...", "language": "zh" }
响应 含义
201 { conversation_id, message_id, status: "sent" } 已签名投递
502 { ..., status: "failed", error } 投递失败(peer 离线 / 无 endpoint / 验签问题)
403 not_a_contact peer 不是当前用户的联系人

GET /api/v1/consult/:conversationId/reply?after=:messageId&wait=:seconds

长轮询等待 peer 的异步回复(peer 经入站 /a2a/v1/messagesthread_id 返回,gateway 按 thread_id 挂回本线程)。wait 上限 55s。

GET /api/v1/consult/:conversationId

返回该咨询线程的完整消息历史(最多 200 条)。

契约:入站 A2A 仅对 message.type==='question' 触发本地 agent 自动回复;answer/notification 只落库 + 广播,避免咨询回复触发无限对答。

← 返回 Confer A2A · DID:web · RFC 9421 · NANDA