API
所有 Nessie 客户端背后的 HTTP API,以及如何对其进行身份验证。
API 是什么
Nessie 只有一套 HTTP API。管理后台网页应用、macOS 和 iOS 客户端以及远程执行器守护进程,都只是它的普通客户端,它们能做的任何事情也只能通过这套 API 完成。它是运行在 Fastify 服务器上的 JSON API,不是 GraphQL,也没有单独划出的“公开”子集:你自己的脚本调用的接口,就是产品本身调用的接口。
路径中没有版本段。所有路由都位于 /api 之下,入站 MCP 端点 POST /mcp 和少数 /.well-known/ 发现文档除外。兼容性通过只增加字段来保证,因此忽略自己无法识别的字段的客户端仍可正常工作。
基础 URL
Nessie 支持自行托管,因此基础 URL 就是你的部署提供 API 服务的源地址。运维人员将其设置为 NESSIE_API_PUBLIC_URL,API 需要生成自身的绝对 URL 时也使用同一个值。在我们运行的实例上,它是 https://api.nessie.works;用 pnpm dev 启动的开发实例则在 http://localhost:5454 上响应。
管理后台网页应用与 API 不同源,因此浏览器客户端受 CORS 白名单(NESSIE_CORS_ORIGINS)约束。来自脚本或服务器的请求没有需要检查的源,不受影响。
身份验证
除少数刻意公开的路由外,每个请求都要在标准请求头中携带访问令牌:
Authorization: Bearer <token>令牌是 API 签发的已签名 JWT。它的有效期很短,默认为三十分钟(NESSIE_AUTH_TOKEN_TTL),也是普通调用方唯一需要出示的凭证。Nessie 没有长期有效的个人 API 密钥。如果你希望脚本调用 API,它应采用与人使用浏览器时相同的方式登录,并在过程中持续续期令牌。
获取令牌
POST /api/auth/session 用于创建会话。它会返回访问令牌和调用方的身份信息,并将刷新令牌设置为一个单独的仅 HTTP Cookie。
你如何证明身份,取决于实例的部署方式。以 local 模式运行的实例接受邮箱和密码。在 hosted 或 selfHosted 实例上,密码登录会被直接拒绝,返回 403 PASSWORD_AUTH_DISABLED,唯一的登录方式是已配置的身份提供方。这样,账户就不会在它旁边另外形成第二条更弱的身份路径。
通过身份提供方登录
单点登录是采用 PKCE 的 OAuth 授权码交换,分为三步。Nessie 自身的身份提供方是 UnlikeOtherAI,它同时负责实例所映射的组织与团队结构。下面的交换流程对任何已配置的提供方都是同样的结构。
GET /api/auth/providers列出该实例接受的登录方式,每项都包含providerId、名称,以及登录页面是否应自动跳转到该提供方。GET /api/auth/providers/:providerId/authorize以codeChallenge、redirectUri和state作为查询参数,返回{ data: { authorizeUrl } }。将用户引导至该地址。- 提供方会携带一个授权码,将用户送回你的
redirectUri。随后以{ providerId, code, codeVerifier, redirectUri }作为请求体,将其 POST 到POST /api/auth/session。这四项必须同时提供,缺少任何一项都会返回400 EXTERNAL_AUTH_INCOMPLETE。
续期会话
刷新令牌永远不会出现在响应正文中。它以名为 nessie_refresh 的仅 HTTP Cookie 形式设置,作用范围限定在 /api/auth 路径下,默认有效期为三十天(NESSIE_AUTH_REFRESH_TOKEN_TTL)。携带该 Cookie 调用 POST /api/auth/refresh 会消耗它,签发新的访问令牌,并以新 Cookie 替换旧的一个。令牌会轮换,因此被盗的令牌只在真实客户端下一次刷新之前有效。
在浏览器中登录多个账户时,每次登录各保留一个此类 Cookie,名称为 nessie_refresh_<slot>,并通过 x-nessie-account-slot 请求头指明某个请求针对的是哪一个。未发送该请求头的客户端针对的是 nessie_refresh;命令行客户端只需要它就够了。
因此,命令行客户端需要一个 Cookie 存储。使用 curl 时,在登录调用上使用 --cookie-jar,在刷新调用上使用 --cookie,见下方的示例。
每个请求都会检查什么
身份验证不只是检查签名。在处理程序运行之前,API 会验证令牌的签名和有效期,确认会话未被吊销(既检查用户级的令牌版本,也检查单个会话 ID),并加载调用方在该令牌所指定组织中的成员身份。已停用的成员身份会返回 403。当组织由外部身份提供方支撑时,调用方的当前状态会在该请求中向该提供方重新核实,而不是从令牌中读取。因此,在上游被撤销的访问权限会立即失效,而不是等到下一次令牌过期。
并非会话令牌的凭证
还存在另外两种 Bearer 凭证,且两者都不是通用密钥。语音设备凭证仅在语音通话路由上被接受;智能体访问凭证仅在 MCP 端点上被接受。在其他任何位置出示这两种凭证,都会返回一个注明该凭证名称的 403,而不会被降级为较弱的会话重试。关于智能体凭证的签发方式,请参阅 MCP 与已连接工具。
响应封装格式
每个成功的响应都是一个带有 data 键的对象。根部不会有裸数组,也没有顶层的载荷,因此客户端无需根据结构进行判断:
{
"data": { "id": "3f1c…", "label": "engineering" }
}列表接口会在旁边添加一个 meta 对象。顶层绝不会添加其他内容。
无论出错原因是什么,失败响应也采用同一种结构:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "A message needs content or at least one attachment",
"field": "body",
"details": { "formErrors": [], "fieldErrors": {} }
}
}code 是机器可读的部分,客户端应据此进行分支判断;message 面向用户。当失败足够具体、可以定位时,会包含 field 和 details。未处理的服务器错误始终返回 500 INTERNAL_ERROR 及固定消息。底层错误文本只会被记录,绝不返回,因为它在传出途中可能已混入请求数据。
你最先会遇到的错误代码
| 状态 | 代码 | 含义 |
|---|---|---|
| 400 | VALIDATION_ERROR | 请求体或查询参数与架构不匹配。 |
| 401 | AUTH_REQUIRED | 缺少 Bearer 令牌,或请求头格式有误。 |
| 401 | TOKEN_EXPIRED | 访问令牌已过期。请刷新后重试。 |
| 401 | TOKEN_REVOKED | 会话已退出登录,或所有会话均已被强制下线。 |
| 403 | ACCOUNT_DEACTIVATED | 你在该组织中的成员身份已被停用。 |
| 404 | NOT_FOUND | 路由不存在,或调用方无法看到该记录。 |
| 429 | RATE_LIMITED | 限流计数桶已满。请读取 Retry-After。 |
| 500 | INTERNAL_ERROR | 未处理的服务器错误,内容刻意保持不透明。 |
分页
列表接口使用不透明的键集游标进行分页,而不是偏移量。数据在有人读取时仍会不断插入,使用偏移量时,第二页会悄无声息地重复第一页已展示过的记录。
接受三个查询参数:cursor、limit 和 direction(forward 或 backward)。默认每页 25 条,上限为 100 条;超过上限的值会被截断,而不是被拒绝。同时支持排序或筛选的接口还会添加 sort、order 和自由文本参数 q。
页面的 meta 会告诉你当前所处的位置:
{
"data": [ … ],
"meta": {
"hasMore": true,
"nextCursor": "2026-09-12T08:31:44.005Z|8b2f…",
"prevCursor": null,
"total": 134
}
}nextCursor 和 prevCursor 都是真实可用的:用户习惯于同时看到“上一页”和“下一页”两个控件。游标是不透明的——服务器会将自己的排序键编码在其中,因此,解析或自行构造游标的客户端,会在列表排序方式改变的那一刻出错。total 出现在大多数列表中;在计数没有意义的地方(例如排名搜索结果)则会省略。
速率限制
限制按固定时间窗口计算,并且在请求体被读取之前就已强制执行。被拒绝的请求返回 429,代码为 RATE_LIMITED,并附带以秒为单位的 Retry-After 请求头。
大多数限流桶以客户端 IP 为键;对凭证敏感的桶(登录、刷新、重新验证密码、MCP 凭证写入)还会额外维护一个按账户计数的计数器,并且两者任一触发即拒绝。因此,无论是一个地址尝试大量账户,还是一个账户被多个地址攻击,都会碰到限制。登录的默认值为:每个 IP 每十分钟 10 次尝试,每个邮箱地址每十分钟 5 次;发送消息的默认值为每分钟 60 次;/api/agents 下的写入操作为每分钟 60 次。每项阈值都可以按部署单独配置,因此请把这些数字视为出厂默认值,而非承诺。健康检查和实时流不受限制。
实时
轮询列表并不是跟进对话的正确方式。为此,系统提供了三个流式端点:
GET /api/events/stream—— 服务器发送事件,推送已登录用户可见的全部内容。GET /api/threads/:threadId/stream—— 针对单个线程的服务器发送事件。它遵循Last-Event-ID,因此重新连接时会从断点继续,而不是从头开始。GET /api/activity—— 承载活动动态的 WebSocket。
浏览器无法在 WebSocket 握手时设置请求头,因此 WebSocket 路由同时接受以 ?token= 查询参数传递的访问令牌。这是令牌唯一可以出现在 URL 中的地方;其他所有路由都通过请求头传递。
资源系列
共有一千多个端点,数量太多,无法一一列出。它们按系列分组。只要知道某个系列的前缀,各个路由就很容易预测:前缀处是集合,其下是单条记录,命名操作则是 POST 子路径。
| 系列 | 前缀 | 涵盖内容 |
|---|---|---|
| 身份验证 | /api/auth | 登录、续期、退出登录、当前身份,以及个人自己的会话。 |
| 频道与线程 | /api/channels, /api/threads | 对话模型:频道包含线程,线程包含消息,回复嵌套在根消息之下。 |
| 智能体 | /api/agents | 智能体定义、其频道绑定、对话、待办事项和状态。 |
| 项目与看板 | /api/projects, /api/tasks, /api/iterations | 工作跟踪:项目下的看板和列,列上的任务,以及对这些任务的迭代。 |
| 知识库 | /api/knowledge-base | 带版本的页面和文件空间,包括评论、反向链接和搜索。 |
| 邮件 | /api/mail, /api/mailbox-connections | 已连接的邮箱、智能体邮箱,以及智能体为供人审批而撰写的草稿。 |
| 审批 | /api/approvals | 智能体不得单方面执行的操作队列,以及处理这些操作的唯一调用。 |
| 审计 | /api/audit-log | 组织的哈希链审计日志,附带验证端点。所有者和管理员可用。 |
| 成本与用量 | /api/ledger, /api/billing | token 与成本核算、预算和运行耗时;另外还包括积分和充值。所有者和管理员可用。 |
| 组织与团队 | /api/organizations, /api/teams, /api/users | 租户、团队和成员管理,包括邀请。 |
| 触发器 | /api/triggers | 启动智能体工作的定时任务和 Webhook,以及它们的触发记录。 |
| 通话与语音 | /api/calls, /api/voice | 频道内通话,以及语音助手的会话、转录和设备令牌。 |
| 执行器 | /api/executors, /api/execution-environment-templates | 智能体工作所在的机器、它们的运行环境,以及守护进程的配对通道。 |
| 运行 | /api/runs | 正在运行的智能体任务:取消、继续、重启、绑定到执行器。 |
| 上传 | /api/uploads, /api/attachments | 先上传文件,再将返回的 ID 关联到消息。 |
| 搜索 | /api/messages/search | 消息搜索,可覆盖整个组织或限定在某一个频道内。 |
| 密钥与设置 | /api/secrets, /api/settings/scoped | 只写凭证存储(需明确授权),以及分层配置值。 |
| 已连接工具 | /api/mcp | 该实例对外调用的 MCP 服务器。参见 MCP 与已连接工具。 |
| 健康检查 | /api/health | 存活与就绪检查,均为公开接口。/api/health/ready 还会检查数据库。 |
权限按路由强制执行,而不是按系列。大体而言:审计日志、成本账本、策略规则和推理控制平面是面向组织所有者和管理员的界面;对话、项目、知识库和审批对任何成员开放,但会按该成员实际可见的范围收窄。
示例
登录并保留刷新 Cookie
在 local 模式的实例上,使用密码登录。访问令牌在响应正文中返回;刷新令牌则保存到 Cookie 存储中。
curl -sS https://api.example.com/api/auth/session \
--cookie-jar nessie.cookies \
-H 'Content-Type: application/json' \
-d '{"email":"ada@example.com","password":"…"}'{
"data": {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…",
"me": {
"user": {
"id": "1d4e…",
"email": "ada@example.com",
"displayName": "Ada Lovelace",
"roleIds": ["owner"],
"superAdmin": false
},
"session": {
"sessionId": "9c07…",
"issuedAt": "2026-09-16T09:00:00.000Z",
"expiresAt": "2026-09-16T09:30:00.000Z"
},
"context": {
"organizationId": "5a81…",
"projectId": "7b12…",
"teamId": "2e60…",
"channelId": null,
"bootstrapMode": false
},
"auth": {
"providerId": "local",
"providerType": "local-bootstrap",
"autoRedirectToSso": false
}
}
}
}令牌过期时,用 Cookie 换取新的令牌:
curl -sS -X POST https://api.example.com/api/auth/refresh \
--cookie nessie.cookies --cookie-jar nessie.cookies响应结构与 { token, me } 相同,Cookie 存储中此时保存的是已轮换的刷新令牌。
列出你可以看到的频道
curl -sS https://api.example.com/api/channels \
-H "Authorization: Bearer $TOKEN"{
"data": [
{
"id": "8b2f…",
"label": "engineering",
"type": "standard",
"visibility": "public",
"projectId": "…",
"projectName": "Platform",
"teamId": "…",
"defaultThreadId": "c41a…",
"unreadCount": 3,
"lastMessageAt": "2026-09-15T18:22:07.441Z",
"viewerCanManage": true
}
]
}添加 ?teamId=… 可限定为某一个团队,或添加 ?includeArchived=true 以包含已归档的频道。defaultThreadId 即为你下一条消息要发送到的线程。
发送消息
消息需要包含 content 或至少一个已上传的附件。发送 Idempotency-Key 后,重试的请求会返回首次尝试所创建的那条消息,而不会再发送一份副本。
curl -sS https://api.example.com/api/threads/c41a…/messages \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: 0f9c2d16-8f4a-4c1e-9a77-6b0d4f2e1a53' \
-d '{"content":"Deploying the migration now."}'新消息返回 201:
{
"data": {
"message": {
"id": "d70e…",
"threadId": "c41a…",
"role": "user",
"content": "Deploying the migration now.",
"createdAt": "2026-09-16T09:04:11.882Z"
},
"pendingAgentInvites": []
}
}使用相同幂等键的重放请求会返回 200,附带原始消息以及空的 pendingAgentInvites,因为首次尝试已经完成了该数组所描述的提醒和分发。
该路由上有两种拒绝情况需要单独处理。超出聊天长度上限的内容返回 413 MESSAGE_TOO_LARGE,正确的做法是改为将文本作为文件上传。在结构上看起来像凭证的内容返回 422 SECRET_INTERCEPTED——服务器会在消息被存储、分发或记录之前即予以拒绝,凭证应保存到 /api/secrets 中。