Confer — API 规范
定义客户端 ↔ 服务器、服务器 ↔ A2A peer 的所有 API。
通用约定
- Base URL:
https://{instance}/api - 编码: JSON, UTF-8
- 时间格式: ISO 8601, UTC(
2024-11-15T14:30:00Z) - ID: ULID (
01HXKQ7Z2N3M4P5R6T7Y8Z9A0B) - 错误格式:
{
"error": {
"code": "invalid_request",
"message": "Human-readable message",
"details": { /* optional */ }
}
}
认证
- 用户客户端:
Authorization: Bearer <jwt_access_token> - Access token TTL: 15 分钟;refresh token TTL: 90 天
- 两种 token 靠
typclaim 区分(access/refresh),互不通用:Authorization头只收access,POST /auth/refresh只收refresh。两者此前只有exp不同, 于是 refresh token 在所有需要鉴权的接口上都是一张 90 天的通行证,access token 的 15 分钟形同虚设 - Refresh 每次轮换,并核对
sessions.refresh_token_hash;对不上视为重放,整个 session 作废。sessions.expires_at是会话的绝对上限,轮换不会顺延它 - Token 存放在客户端本地存储,不是 HTTP-only cookie(客户端是 Tauri 桌面应用, 同源 cookie 那套在这里没有对应物)
客户端 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 并在每个候选里带上本地 id(peer_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_json。Merge 语义:入站 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):
- 所有查询 scope 到
user.sub(跨用户隔离)。 - PUT 前校验 peer 是该用户联系人(
peer_contacts),非联系人返回403 not_a_contact。 - PUT 用 upsert:首次写入
version=1,再次写入version递增并刷新updated_at。facts 与 decisions 各自独立——写一个 section 不会清空另一个。 - GET facts/decisions 在 (project, peer) 尚无记忆时返回
200+ 空串 +version:0(不返回 404;「该 peer 暂无沉淀」是读语义下的正常态)。 project_id受^[a-zA-Z0-9._\-/]+$(1–255 字符)校验,非法返回400 invalid_project_id。GET peers在空项目下返回空数组;记忆通过 PUT facts/decisions 隐式建立 (project, peer) 关系(本期不做POST peers显式注册)。
知识库(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 复位为 processing、chunk_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 必须是 access、sid
必须指向一条仍然存在的 session、账号状态不能是 disabled。三条缺一不可——缺了
它们,被封禁的账号只要 token 没过期就能一直重连收消息,而封禁本身(删光 session)
在这条路径上什么都没撤销。封禁同时会关掉该用户已经打开的 socket:nginx 给
/ws 的 proxy_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/shared 的
permissionRequestEventSchema 单独拥有: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.0 的 AgentCard(字段取自 a2aproject/A2A 的 specification/a2a.proto @ v1.0.1,走 proto3 JSON 映射,故为 camelCase)。目的是让 A2A 生态发现本实例的 Agent —— 名字撞了但协议不通:对方的发现文档在 /.well-known/agent-card.json,本实例原本只有 /.well-known/agents.json。
几个刻意的取舍:
- 每个 Agent 一张 Card,
supportedInterfaces[].tenant= 用户名。spec 的 well-known 假设一个域名一个 Agent,而本实例是多租户;tenant正是 spec 为「单个 A2A 端点后面多个 Agent」定义的路由选择器。/.well-known/agent-card.json只在恰好一个公开 Agent 时作答(单人自托管场景),否则 404 并在错误信息里指向agents.json—— 随便挑一个账号称之为「本域名的 Agent」是错的。 streaming: false。确实有流式端点,但那是 Confer 自己的形状,不是 spec 的SendStreamingMessage。宣称一个标准客户端用不了的能力,比不宣称更糟。- 不声明
securitySchemes。spec 那套是 API key / HTTP auth / OAuth2 / OIDC / mTLS,本端点一个都不收——它要的是请求签名。随便挑一个填上,等于告诉客户端可以用一种必然被拒的方式认证。真实要求改用必需扩展(capabilities.extensions,uri为 RFC 9421 的地址,required: true)声明,这正是 spec 为此提供的机制。 -
Card 是发现文档,可见性与
/.well-known/agents.json完全一致:非公开或已停用的 Agent 一律 404,否则这条路由就成了枚举主人没打算公开的账号的办法。 - 只宣称一套绑定。Confer 自己的方言同在这个 URL 下,但不写进 Card:§5.1 要求一个 Agent 声明的每套绑定在功能上等价,而方言没有 task 生命周期。它经
/.well-known/agents.json发现,Card 里就不留一句兑现不了的话。
消息层(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.peer、permission.granted、thread.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/messages 携 thread_id 返回,gateway 按 thread_id 挂回本线程)。wait 上限 55s。
200 { status: "answered", message }— 收到回复200 { status: "pending" }— 超时仍无回复,可稍后再轮询
GET /api/v1/consult/:conversationId
返回该咨询线程的完整消息历史(最多 200 条)。
契约:入站 A2A 仅对
message.type==='question'触发本地 agent 自动回复;answer/notification只落库 + 广播,避免咨询回复触发无限对答。