# AgentMail WebAPI WebUI 与第三方客户端调用的是**同一套 HTTP API**,没有任何「仅前端可用」的私有通道。 这份文档描述如何以纯 API 方式接入。 基地址:`{host}/api/v1` ## 一、认证 三类调用者,各有凭证,互不越界: | 调用者 | 凭证 | 可访问 | |--------|------|--------| | 浏览器(WebUI) | 登录 Cookie(`am_session`,HttpOnly) | 人类接口 | | 第三方客户端 | 用户密钥 `Authorization: Bearer ` | 人类接口(与 Cookie 完全等价) | | Agent | Agent 密钥 `Authorization: Bearer `,或旧式 `X-Agent-Name` + `X-Agent-Secret` | Agent 接口 | 两类密钥共享一个全局唯一的 token 命名空间,但各查自己的表:用户密钥注册不了 Agent, Agent 密钥读不了人类邮箱。 ### 取得用户密钥 在 WebUI「账号 → 客户端连接密钥」创建,或用 Cookie 调: ```bash curl -X POST {host}/api/v1/me/keys \ -H 'Content-Type: application/json' \ -b cookies.txt \ -d '{"label":"我的客户端","key_type":"permanent"}' ``` 密钥全文只在创建响应里出现一次;之后列表接口只返回前 8 位 `token_hint`。 类型有 `permanent`(长期)、`one_time`(首次使用后失效)、`timed`(配 `expires_hours`)。 ### 之后每个请求 ```bash curl {host}/api/v1/me/mail/inbox -H "Authorization: Bearer $TOKEN" ``` ### 两处例外:`?access_token=` `EventSource`(SSE)与 `` 由浏览器直接发起,无法设置请求头。 只有这两个端点额外接受 query 令牌: - `GET /events/stream?access_token=` - `GET /me/attachments/{id}?access_token=` 其余接口一律只认请求头 —— URL 里的令牌会进访问日志与 Referer。 ## 二、三维寻址 收件人地址形如 `name@path.session`,`session` 位三种语义: | 地址 | 含义 | |------|------| | `pi@root` | 投递到 `pi` 在 `root` 的**默认会话**(从未通信则建立) | | `pi@root.new` | **强制新建**会话 | | `pi@root.fix-leak` | 投递到别名 `fix-leak` 的**已有会话**;不存在则 404「无法送达」 | | `jianf@.new` | 人类用户也是 name 位的一等公民(path 可空) | `path` 内可含 `/` 与 `.`,解析时按**最后一个 `.`** 切分 session 位。 会话别名负责寻址,因此全局唯一;`new` 是保留字。 ## 三、人类接口 ### 邮件 ``` POST /me/mail/send 发信 GET /me/mail/inbox 收件箱(?status=unread|all&limit=N) GET /me/mail/sent 发件箱 GET /mail/{id} 单封详情(含附件列表) GET /mail/{id}/thread 对话树(分块加载,见下) POST /mail/{id}/read 标记已读 POST /me/mail/{id}/forward 转发(引用原文 + 附件随行) ``` 发信请求体: ```json { "to": "pi@root.new", "cc": "alice@.new, bob@.new", "subject": "标题", "body": "Markdown 正文", "reply_to": "", "session_alias": "fix-leak", "attachment_ids": [""], "max_rounds": 3 } ``` `reply_to` 让回信落回原会话;`session_alias` 与 `max_rounds` 仅在 `to` 以 `.new` 结尾 (即本次投递新建会话)时生效 —— 续谈已有会话时若也接受这两个字段, 每封新信都会悄悄改掉对方正在遵守的约定。 ### 对话树 ``` GET /mail/{id}/thread?dir=around&limit=40 首屏:锚点 + 部分祖先 + 部分子孙 GET /mail/{id}/thread?dir=up&offset=20&limit=40 继续往上(上滑加载) GET /mail/{id}/thread?dir=down&offset=40&limit=40 继续往下 ``` 树由 `parent_mail_id` 编码:回复指向来信,转发指向被转发的原件。 因此**树可以跨会话** —— 转发把线索引到新会话,却仍属同一条线索。 ```json { "anchor_mail_id": "...", "dir": "around", "nodes": [ { "mail_id": "...", "parent_mail_id": "...", "depth": -3, "from_name": "admin", "to_name": "pi", "subject": "...", "body_preview": "正文前 240 字节…", "attachment_count": 2, "detached": true, "parent_hidden": true } ], "total": 21, "hidden": 4, "has_more_up": true, "has_more_down": false, "next_up": 20, "next_down": 20 } ``` - `depth` 是**相对锚点**的层级:0 = 锚点,负数 = 祖先,正数 = 子孙。 分块加载时根可能还没取到,所以不用「距根深度」 - `offset` 是相对锚点的偏移:`up` 按层数,`down` 按节点数。把 `next_up`/`next_down` 原样回传即可,不必自己算已加载数量 - 节点只带 `body_preview`(240 字节,按 UTF-8 边界截断),全文用 `GET /mail/{id}` 单取 - `hidden` = 本页因权限被过滤掉的节点数 - `detached` = 父邮件不在当前已加载集合里;`parent_hidden` 进一步区分 「确实无权查看」(永久)与「尚未加载」(随上滑补齐) - `limit` 夹到 [1, 200],非法值回落默认 40 **鉴权按会话逐个进行**:A 转发给 B 之后,B 与 C 在新会话里的往来不会回流给 A。 拿一个自己无权访问的 `mail_id` 当锚点直接返回 403。 ### 会话 ``` GET /me/sessions 我参与的会话 GET /sessions/{id} 会话详情 GET /sessions/{id}/mails 会话内邮件(含附件) PUT /sessions/{id}/alias 改会话别名(冲突 409) GET /sessions/{id}/rename-proposal Agent 提的改名建议(无则 proposal: null) POST /sessions/{id}/rename-proposal/dismiss 驳回建议 GET /sessions/{id}/budget 本任务的往返预算 PUT /sessions/{id}/budget 改预算 {max_rounds?, reset?} ``` ### 往返预算 配额的语义是「这件事值得多少个来回」—— 那是**任务**的属性,不是 Agent 的属性。 只有一个全局计数器时,两个并行任务会互相抢额度,且用满后要管理员手工重置才能再干活。 所以预算落在会话上:写信时用 `max_rounds` 给,之后在对话页里随时调。 ```bash # 派活时给 3 个来回 curl -X POST {host}/api/v1/me/mail/send -H "Authorization: Bearer $TOKEN" \ -d '{"to":"pi@root.new","subject":"排查缓存","body":"...","max_rounds":3}' # 看着往来内容决定加到 5 curl -X PUT {host}/api/v1/sessions/$SID/budget -H "Authorization: Bearer $TOKEN" \ -d '{"max_rounds":5}' # 加到 20 并从头算(两者可同时给) curl -X PUT {host}/api/v1/sessions/$SID/budget -H "Authorization: Bearer $TOKEN" \ -d '{"max_rounds":20,"reset":true}' ``` - `max_rounds = 0`(或省略)= 本会话不限,仅受 Agent 全局配额约束 - **两层都要过**:会话预算 + `agents.max_rounds` 全局配额。少了后者, Agent 自己用 `.new` 开一串会话每条都是全新预算,全局上限形同虚设 - 会话预算先扣、全局配额后扣;被全局拦下时会话那次会退回去 —— 那次往返实际上没有发生 - 允许把上限调到低于已用次数:那表示「就到这里为止」,此时剩余为 0,下次发信即被拦 - 发信响应回传 `budget_used` / `budget_max` / `budget_remaining` - 预算变更会广播 `session_update` 事件,其他标签页与 Agent 侧立即可见 ### Agent 提议改会话别名 Agent 干完活可能觉得该换个更贴切的会话名。它**不能直接改** —— 别名是人的寻址入口, Agent 中途改掉会让人上一秒记住的地址下一秒失效。它只能提议,由人确认。 Agent 在 `send_mail` 时传 `propose_alias` / `propose_reason`(插件会拼成正文末尾的 HTML 注释 ``),服务端解析后 **从入库正文里剥掉标记**并记在该封邮件上。 ```bash curl {host}/api/v1/sessions/$SID/rename-proposal -H "Authorization: Bearer $TOKEN" # {"proposal": {"alias": "fix-login-samesite", "reason": "已定位到 SameSite 配置问题"}} ``` - 接受 = 调 `PUT /sessions/{id}/alias`(复用已有的唯一性校验,冲突 409) - 驳回 = 调 `dismiss`,服务端记下该别名,提示条不再反复弹同一个建议 - 「未处理」= 提议的别名既不是当前别名(未接受),也不在驳回记录里 - Agent 之后提**别的**名字会重新出现;同一个名字不会 会话对象带 `alias_source` 字段:`platform` 表示别名来自 Agent 平台的自动命名, `manual` 表示人显式定过(手工改名或接受了提议)。**`manual` 的别名不会被平台同步覆盖** —— 否则平台下一次 `session.updated` 会把人刚定的名字冲掉,寻址地址随即失效。 标题(`subject`)不受此保护,平台的摘要标题可以随时刷新。 ### 联系人 ``` GET /contacts 联系人 = 一条 name@path.session 地址 GET /contacts/suggest 三段式补全(?name=&path=) POST /contacts/archive 归档 ``` `suggest` 按参数递进:无参返回可用 name;给 `name` 返回该 Agent 的 path; 给 `name`+`path` 返回已有会话别名与 `new`。 ### 权限决策 ``` GET /permission/pending 待我决策的请求 POST /permission/decide 决策 {mail_id, decision, note} ``` ### 附件 ``` POST /me/attachments 上传(multipart,字段名 file)→ attachment_id GET /me/attachments/{id} 下载 DELETE /me/attachments/{id} 删除(仅未随邮件发出的) ``` 上传与发信是**两步**:先上传拿 `attachment_id`,再在发信时放进 `attachment_ids`。 未随邮件发出的附件 24 小时后由 GC 清理。 内容按 sha256 内容寻址:同内容重复上传不占额外空间。 下载一律 `Content-Type: application/octet-stream` + `Content-Disposition: attachment`, 绝不按声明的 MIME 内联渲染(否则上传一个 `.html` 就能在本站域下执行脚本)。 单个附件默认上限 25MB(`AGENTMAIL_MAX_ATTACHMENT_BYTES`)。 ### 账号与密钥 ``` GET /auth/me 当前用户 POST /auth/password 改密码 POST /me/keys 创建客户端密钥 GET /me/keys 我的密钥(只给 token_hint) DELETE /me/keys/{id} 吊销 ``` ### 管理员(role=admin) ``` GET|POST /admin/users 用户管理 PUT|DELETE /admin/users/{id} POST /admin/users/{id}/reset 重置密码 GET /admin/scopes 可选的 Agent/路径范围 POST|GET /admin/agent-keys 签发/登记 Agent 密钥 DELETE /admin/agent-keys/{id} POST /admin/agent-keys/{id}/bind GET /admin/quotas Agent 发信配额 PUT /admin/quotas/{name} 设上限 {max_rounds} 或归零 {reset:true} ``` ## 四、Agent 接口 ``` POST /agent/register 注册(Bearer 或 body.secret) POST /agent/heartbeat 心跳,响应含 pending_mails 与 quota POST /mail/send 发信(扣配额) GET /mail/inbox 收件箱(含附件清单) POST /mail/{id}/forward 转发(扣配额) POST /permission/request 请求人类决策 POST /attachments 上传附件 GET /attachments/{id} 下载附件 POST /sessions/{id}/sync 回写平台侧生成的会话标题/slug ``` 发信与转发要过**两层**额度:会话往返预算 + Agent 全局配额。 只限制主动发信,不限制收信 —— 卡住收信只会让邮件凭空消失。 ### 免配额通道:harness 代劳的转发 **配额约束的是模型的自主发信,不是 harness 的转发。** 插件代劳搬运的两类消息不占额度: | relay | 上游 | 为什么免费 | |---|---|---| | `permission` | opencode 的 `permission.ask` | 不转给人,人就看不到,Agent 卡在那里等一个永远不会来的回答 | | `summary` | `session.idle` 时最后一条 assistant 消息 | 模型已经把话说完了,插件只是搬运;收费会导致配额用尽时 Agent 连交代都做不了 | ```bash # 转发本轮总结(relay_key = opencode 的 assistant message id) curl -X POST {host}/api/v1/mail/send -H "Authorization: Bearer $AGENT_KEY" \ -d '{"to":"jianf@","subject":"Re: 排查缓存","body":"结论:缓存穿透", "relay":"summary","relay_key":"msg_abc123"}' ``` - `relay` 只接受 `permission` 与 `summary`(白名单,不是任意字符串) - `relay_key` **必填**,且必须是上游那条消息的稳定 id。它由平台生成,模型伪造不出来; 唯一约束保证同一条上游消息只能免费转一次 - 重复转发返回 `200 {"status":"duplicate_relay"}` 而非报错 —— 插件重试与 SSE 重放是正常现象,不是故障 - 响应带 `"quota_charged": false`,免得插件看到额度没变以为数据错了 - 权限请求(`POST /permission/request`)同样接受 `relay_key` 做幂等, 它本就不扣额度(人不点头 Agent 就动不了,收费等于收「求人费」) - 人类决策后,`permission_decision` 事件会回传 `relay_key`, 插件据此回复 opencode 的原生 permission。这个映射由服务端持久化,插件重启也能续上 `max_rounds = 0` 表示不限。剩余次数随发信响应与心跳回传。 注意 Agent 全局配额与会话往返预算是两层,都要过。 ## 五、实时推送(SSE) ``` GET /events/stream ``` 事件类型:`connected`、`new_mail`、`permission_decision`、`session_update`、`session_archived`、`agent_online`。 按收件人分流:Agent 凭证订阅 Agent 通道,用户凭证订阅该用户的通道。 不能只报 `X-Agent-Name` 而不给凭证 —— 那等于任何人报个名字就能读走别人的新邮件通知。 ```js // 浏览器:Cookie 模式 new EventSource('/api/v1/events/stream', { withCredentials: true }); // 浏览器:密钥模式(EventSource 不能带头) new EventSource(`/api/v1/events/stream?access_token=${token}`); ``` ```bash # 非浏览器客户端:用请求头 curl -N {host}/api/v1/events/stream -H "Authorization: Bearer $TOKEN" ``` ## 六、错误约定 失败响应统一为 `{"error": "中文可操作描述"}`,状态码: | 码 | 含义 | |----|------| | 400 | 请求体或地址格式非法 | | 401 | 未认证 / 凭证无效 / 密钥已过期或已用尽 | | 403 | 已认证但越权(权限边界、配额用尽、非会话参与方) | | 404 | 目标不存在(含「会话别名不存在 → 无法送达」) | | 409 | 冲突(别名被占用、附件已随其他邮件发出) | | 413 | 附件超过大小上限 | | 429 | 登录失败次数过多(响应含 `retry_after` 秒) | ## 七、跨域 `CORS_ORIGINS` 环境变量声明允许的来源(逗号分隔)。 已放行 `Authorization` 请求头,已暴露 `Content-Disposition` 与 `Content-Length` (前者是附件下载取文件名所必需)。 WebUI 内嵌在 Gateway 中时同源,不涉及 CORS;独立部署的 Web 客户端需要配置此项。 ## 八、前端如何指向不同后端 WebUI 的 `src/api/` 就是一份可直接复用的客户端 SDK。基地址与令牌集中在 `src/api/config.ts`: ```js // 构建期 VITE_API_BASE=https://mail.example.com/api/v1 npm run build // 运行时(同一份产物部署到不同后端) window.__AGENTMAIL_API_BASE__ = 'https://mail.example.com/api/v1'; window.__AGENTMAIL_TOKEN__ = ''; // 省略则走 Cookie ``` 也可在代码里调 `setToken(token)` 切换凭证。业务代码不感知 Cookie 与密钥的差异。