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.

  1. GET /api/auth/providers lists what this instance accepts, each with a providerId, a label and whether the sign-in page should redirect to it automatically.
  2. GET /api/auth/providers/:providerId/authorize, with codeChallenge, redirectUri and state as query parameters, answers with { data: { authorizeUrl } }. Send the person there.
  3. The provider returns them to your redirectUri with a code. Post that back as POST /api/auth/session with { providerId, code, codeVerifier, redirectUri } — all four are required together, and an incomplete set is a 400 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

StatusCodeMeaning
400VALIDATION_ERRORThe body or query did not match the schema.
401AUTH_REQUIREDNo bearer token, or the header was malformed.
401TOKEN_EXPIREDThe access token is past its expiry. Refresh and retry.
401TOKEN_REVOKEDThe session was signed out, or every session was forced out.
403ACCOUNT_DEACTIVATEDThe membership of this organisation has been deactivated.
404NOT_FOUNDNo such route, or no such record visible to this caller.
429RATE_LIMITEDA bucket is full. Read Retry-After.
500INTERNAL_ERRORAn 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 honours Last-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.

FamilyPrefixWhat it covers
Auth/api/authSign-in, renewal, sign-out, the current identity, a person’s own sessions.
Channels and threads/api/channels, /api/threadsThe conversation model: channels hold threads, threads hold messages, replies nest under a root.
Agents/api/agentsAgent definitions, their channel bindings, conversations, to-dos and status.
Projects and boards/api/projects, /api/tasks, /api/iterationsWork tracking: boards and columns under a project, tasks on them, iterations over those.
Knowledge/api/knowledge-baseSpaces of versioned pages and files, with comments, backlinks and search.
Mail/api/mail, /api/mailbox-connectionsConnected mailboxes, agent mailboxes, and drafts an agent wrote for a person to approve.
Approvals/api/approvalsThe queue of actions an agent may not take unilaterally, and the one call that resolves them.
Audit/api/audit-logThe organisation’s hash-chained audit trail, with a verification endpoint. Owners and admins.
Cost and usage/api/ledger, /api/billingToken and cost accounting, budgets and run timings; separately, credits and top-ups. Owners and admins.
Organisation and teams/api/organizations, /api/teams, /api/usersTenant, team and membership administration, including invitations.
Triggers/api/triggersSchedules and webhooks that start agent work, with their firing history.
Calls and voice/api/calls, /api/voiceIn-channel calling, and the voice assistant’s session, transcript and device tokens.
Executors/api/executors, /api/execution-environment-templatesThe machines agents work on, their environments, and the daemon pairing channel.
Runs/api/runsLive agent runs: cancel, continue, restart, bind to an executor.
Uploads/api/uploads, /api/attachmentsUpload a file first, then link the returned id to a message.
Search/api/messages/searchMessage search, organisation-wide or scoped to one channel.
Secrets and settings/api/secrets, /api/settings/scopedWrite-only credential storage with explicit grants, and layered configuration values.
Connected tools/api/mcpMCP servers this instance calls out to. See MCP and connected tools.
Health/api/healthLiveness 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.cookies

The 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.