API
The HTTP API behind every Nessie client, and how to authenticate against it.
What the API is
Nessie has one HTTP API. The admin web app, the macOS and iOS clients and the remote executor daemon are all ordinary clients of it, and nothing they can do is reachable any other way. It is a JSON API over a Fastify server, it is not GraphQL, and it has no separate “public” subset: the surface your own scripts call is the surface the product calls.
There is no version segment in the path. Every route lives under /api, except the inbound MCP endpoint at POST /mcp and a handful of /.well-known/ discovery documents. Compatibility is managed by only ever adding fields, so a client that ignores what it does not recognise keeps working.
Base URL
Nessie is self-hosted, so the base URL is whatever origin your deployment serves the API on — the operator sets it as NESSIE_API_PUBLIC_URL, and the same value is what the API uses when it has to mint an absolute URL of its own. On the instance we run, that is https://api.nessie.works; a development instance started with pnpm dev answers on http://localhost:5454.
The admin web app is a different origin from the API, so browser clients are subject to a CORS allowlist (NESSIE_CORS_ORIGINS). Requests from a script or a server have no origin to check and are unaffected.
Authenticating
Every request outside a small set of deliberately public routes carries an access token in the standard header:
Authorization: Bearer <token>The token is a signed JWT minted by the API. It is short-lived — thirty minutes by default (NESSIE_AUTH_TOKEN_TTL) — and it is the only credential an ordinary caller ever presents. Nessie has no long-lived personal API keys. If you want a script to call the API, it signs in the same way a person’s browser does and renews its token as it goes.
Getting a token
POST /api/auth/session mints a session. It answers with the access token and the caller’s identity, and sets the renewal token as a separate HTTP-only cookie.
How you prove who you are depends on how the instance is deployed. An instance running in local mode accepts an email and password. On a hosted or selfHosted instance password sign-in is refused outright with 403 PASSWORD_AUTH_DISABLED, and the only way in is the configured identity provider — so the account can never grow a second, weaker identity path beside it.
Signing in through an identity provider
Single sign-on is an OAuth authorisation-code exchange with PKCE, in three steps. Nessie’s own identity provider is UnlikeOtherAI, which also owns the organisation and team structure an instance mirrors; the exchange below is the same shape for any configured provider.
GET /api/auth/providerslists what this instance accepts, each with aproviderId, a label and whether the sign-in page should redirect to it automatically.GET /api/auth/providers/:providerId/authorize, withcodeChallenge,redirectUriandstateas query parameters, answers with{ data: { authorizeUrl } }. Send the person there.- The provider returns them to your
redirectUriwith a code. Post that back asPOST /api/auth/sessionwith{ providerId, code, codeVerifier, redirectUri }— all four are required together, and an incomplete set is a400 EXTERNAL_AUTH_INCOMPLETE.
Renewing a session
The renewal token is never in a response body. It is set as an HTTP-only cookie named nessie_refresh, scoped to the /api/auth path, with a thirty-day lifetime by default (NESSIE_AUTH_REFRESH_TOKEN_TTL). Calling POST /api/auth/refresh with that cookie consumes it, issues a fresh access token and sets a new cookie in its place — the tokens rotate, so a stolen one is good only until the real client next refreshes.
A browser signed into several accounts keeps one such cookie per sign-in, named nessie_refresh_<slot>, and names the one a request is about with the x-nessie-account-slot header. A client that sends no such header is about nessie_refresh, which is all a command-line client needs.
A command-line client therefore needs a cookie jar. With curl that is --cookie-jar on the sign-in call and --cookie on the refresh, as in the worked example below.
What is checked on every request
Authentication is not only a signature check. Before a handler runs, the API verifies the token’s signature and expiry, confirms the session has not been revoked (both by the user-wide token version and by the individual session id), and loads the caller’s membership of the organisation the token names. A deactivated membership is a 403. When the organisation is backed by an external identity provider, the caller’s current standing is re-checked against that provider on the request rather than read from the token, so access removed upstream stops working immediately instead of at the next expiry.
Credentials that are not session tokens
Two other bearer credentials exist, and neither is a general-purpose key. A voice device credential is accepted only on the voice call routes; an agent access credential is accepted only on the MCP endpoint. Presenting either anywhere else is refused with a 403 naming the credential, not retried as a weaker session — see MCP and connected tools for how the agent credential is issued.
The response envelope
Every successful response is an object with a data key. There is no bare array and no top-level payload at the root, so a client never has to discriminate on shape:
{
"data": { "id": "3f1c…", "label": "engineering" }
}List endpoints add a meta object beside it. Nothing else is ever added at the top level.
Failures use one shape too, whatever produced them:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "A message needs content or at least one attachment",
"field": "body",
"details": { "formErrors": [], "fieldErrors": {} }
}
}code is the machine-readable part and is what you should branch on; message is for a person. field and details are present when the failure is specific enough to locate. An unhandled server error is always 500 INTERNAL_ERROR with a fixed message — the underlying error text is logged, never returned, because it can have picked up request data on its way out.
Error codes you will meet first
| Status | Code | Meaning |
|---|---|---|
| 400 | VALIDATION_ERROR | The body or query did not match the schema. |
| 401 | AUTH_REQUIRED | No bearer token, or the header was malformed. |
| 401 | TOKEN_EXPIRED | The access token is past its expiry. Refresh and retry. |
| 401 | TOKEN_REVOKED | The session was signed out, or every session was forced out. |
| 403 | ACCOUNT_DEACTIVATED | The membership of this organisation has been deactivated. |
| 404 | NOT_FOUND | No such route, or no such record visible to this caller. |
| 429 | RATE_LIMITED | A bucket is full. Read Retry-After. |
| 500 | INTERNAL_ERROR | An unhandled server error, deliberately opaque. |
Pagination
List endpoints page with an opaque keyset cursor, not an offset — rows are inserted while somebody reads, and with an offset the second page silently repeats a record the first page already showed.
Three query parameters are accepted: cursor, limit and direction (forward or backward). The default page is 25 and the ceiling is 100; a larger value is clamped rather than rejected. Endpoints that also sort or filter add sort, order and a free-text q.
The page’s meta tells you where you are:
{
"data": [ … ],
"meta": {
"hasMore": true,
"nextCursor": "2026-09-12T08:31:44.005Z|8b2f…",
"prevCursor": null,
"total": 134
}
}nextCursor and prevCursor are both real: Previous and Next are both controls a person expects. They are opaque — the server encodes its own sort key into them, and a client that parses or constructs one will break the moment a list changes how it orders. total is present on most lists and omitted where counting is not meaningful, such as ranked search results.
Rate limits
Limits are per fixed window and are enforced before the request body is even read. A rejected request is 429 with the code RATE_LIMITED and a Retry-After header in seconds.
Most buckets are keyed on the client IP; the credential-sensitive ones — sign-in, refresh, password re-proof, MCP credential writes — keep a per-account counter as well and reject when either trips, so one address spraying many accounts and one account attacked from many addresses both run into a wall. Sign-in defaults to 10 attempts per IP and 5 per email address per ten minutes; posting a message defaults to 60 per minute; writes under /api/agents to 60 per minute. Every threshold is configurable per deployment, so treat these as the shipped defaults rather than a contract. Health checks and the realtime streams are exempt.
Realtime
Polling a list is the wrong way to follow a conversation. Three streaming endpoints exist instead:
GET /api/events/stream— server-sent events for everything the signed-in person can see.GET /api/threads/:threadId/stream— server-sent events for one thread. It honoursLast-Event-ID, so a reconnect resumes rather than restarts.GET /api/activity— a WebSocket carrying the activity feed.
Browsers cannot set headers on a WebSocket handshake, so the WebSocket route also accepts the access token as a ?token= query parameter. That is the only place a token belongs in a URL; every other route takes the header.
The resource families
There are well over a thousand endpoints, which is far too many to list. They group into families, and once you know a family’s prefix the individual routes are predictable — a collection at the prefix, a record beneath it, and named actions as POST sub-paths.
| Family | Prefix | What it covers |
|---|---|---|
| Auth | /api/auth | Sign-in, renewal, sign-out, the current identity, a person’s own sessions. |
| Channels and threads | /api/channels, /api/threads | The conversation model: channels hold threads, threads hold messages, replies nest under a root. |
| Agents | /api/agents | Agent definitions, their channel bindings, conversations, to-dos and status. |
| Projects and boards | /api/projects, /api/tasks, /api/iterations | Work tracking: boards and columns under a project, tasks on them, iterations over those. |
| Knowledge | /api/knowledge-base | Spaces of versioned pages and files, with comments, backlinks and search. |
/api/mail, /api/mailbox-connections | Connected mailboxes, agent mailboxes, and drafts an agent wrote for a person to approve. | |
| Approvals | /api/approvals | The queue of actions an agent may not take unilaterally, and the one call that resolves them. |
| Audit | /api/audit-log | The organisation’s hash-chained audit trail, with a verification endpoint. Owners and admins. |
| Cost and usage | /api/ledger, /api/billing | Token and cost accounting, budgets and run timings; separately, credits and top-ups. Owners and admins. |
| Organisation and teams | /api/organizations, /api/teams, /api/users | Tenant, team and membership administration, including invitations. |
| Triggers | /api/triggers | Schedules and webhooks that start agent work, with their firing history. |
| Calls and voice | /api/calls, /api/voice | In-channel calling, and the voice assistant’s session, transcript and device tokens. |
| Executors | /api/executors, /api/execution-environment-templates | The machines agents work on, their environments, and the daemon pairing channel. |
| Runs | /api/runs | Live agent runs: cancel, continue, restart, bind to an executor. |
| Uploads | /api/uploads, /api/attachments | Upload a file first, then link the returned id to a message. |
| Search | /api/messages/search | Message search, organisation-wide or scoped to one channel. |
| Secrets and settings | /api/secrets, /api/settings/scoped | Write-only credential storage with explicit grants, and layered configuration values. |
| Connected tools | /api/mcp | MCP servers this instance calls out to. See MCP and connected tools. |
| Health | /api/health | Liveness and readiness, both public. /api/health/ready also checks the database. |
Rights are enforced per route, not per family. Broadly: the audit log, the cost ledger, policy rules and the inference control plane are surfaces for organisation owners and admins, while conversations, projects, knowledge and approvals are open to any member and narrowed by what that member can actually see.
Worked examples
Sign in and keep the renewal cookie
On a local-mode instance, with a password. The access token comes back in the body; the renewal token lands in the cookie jar.
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
}
}
}
}When the token expires, exchange the cookie for a new one:
curl -sS -X POST https://api.example.com/api/auth/refresh \
--cookie nessie.cookies --cookie-jar nessie.cookiesThe response is the same { token, me } shape, and the cookie jar now holds the rotated renewal token.
List the channels you can see
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
}
]
}Add ?teamId=… to narrow to one team, or ?includeArchived=true to include archived channels. The defaultThreadId is what you post to next.
Post a message
A message needs either some content or at least one uploaded attachment. Send an Idempotency-Key and a retried request resolves to the message the first attempt created rather than posting a second copy.
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."}'A new message is 201:
{
"data": {
"message": {
"id": "d70e…",
"threadId": "c41a…",
"role": "user",
"content": "Deploying the migration now.",
"createdAt": "2026-09-16T09:04:11.882Z"
},
"pendingAgentInvites": []
}
}A replay of the same idempotency key answers 200 with the original message and an empty pendingAgentInvites, because the first attempt already did the alerting and dispatch that the array describes.
Two refusals on this route are worth handling explicitly. Content past the chat length limit is 413 MESSAGE_TOO_LARGE, and the right response is to upload the text as a file instead. Content that structurally looks like a credential is 422 SECRET_INTERCEPTED — the server refuses before the message is stored, dispatched or logged, and the credential belongs in /api/secrets.