nessie

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,它同时负责实例所映射的组织与团队结构。下面的交换流程对任何已配置的提供方都是同样的结构。

  1. GET /api/auth/providers 列出该实例接受的登录方式,每项都包含 providerId、名称,以及登录页面是否应自动跳转到该提供方。
  2. GET /api/auth/providers/:providerId/authorize 以 codeChallenge、redirectUri 和 state 作为查询参数,返回 { data: { authorizeUrl } }。将用户引导至该地址。
  3. 提供方会携带一个授权码,将用户送回你的 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 及固定消息。底层错误文本只会被记录,绝不返回,因为它在传出途中可能已混入请求数据。

你最先会遇到的错误代码

状态代码含义
400VALIDATION_ERROR请求体或查询参数与架构不匹配。
401AUTH_REQUIRED缺少 Bearer 令牌,或请求头格式有误。
401TOKEN_EXPIRED访问令牌已过期。请刷新后重试。
401TOKEN_REVOKED会话已退出登录,或所有会话均已被强制下线。
403ACCOUNT_DEACTIVATED你在该组织中的成员身份已被停用。
404NOT_FOUND路由不存在,或调用方无法看到该记录。
429RATE_LIMITED限流计数桶已满。请读取 Retry-After。
500INTERNAL_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/billingtoken 与成本核算、预算和运行耗时;另外还包括积分和充值。所有者和管理员可用。
组织与团队/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 中。