nessie

API

De HTTP-API achter elke Nessie-client, en hoe je je daarmee authenticeert.

Wat de API is

Nessie heeft één HTTP-API. De admin-webapp, de macOS- en iOS-clients en de externe executor-daemon zijn allemaal gewone clients daarvan, en niets wat zij kunnen doen is op een andere manier bereikbaar. Het is een JSON-API op een Fastify-server, geen GraphQL, en er bestaat geen aparte „publieke” subset: wat jouw eigen scripts aanroepen, is precies wat het product aanroept.

Er zit geen versiesegment in het pad. Elke route staat onder /api, behalve het inkomende MCP-endpoint POST /mcp en een handvol /.well-known/-discoverydocumenten. Compatibiliteit wordt beheerd door alleen velden toe te voegen, zodat een client die niet-herkende velden negeert gewoon blijft werken.

Basis-URL

Nessie wordt zelf gehost, dus de basis-URL is de oorsprong waarop jouw installatie de API serveert. De beheerder stelt die in als NESSIE_API_PUBLIC_URL, en dezelfde waarde gebruikt de API wanneer zij zelf een absolute URL moet samenstellen. Op de instantie die wij draaien is dat https://api.nessie.works; een ontwikkelinstantie die je met pnpm dev start, antwoordt op http://localhost:5454.

De admin-webapp draait op een andere oorsprong dan de API, dus browserclients vallen onder een CORS-toegangslijst (NESSIE_CORS_ORIGINS). Verzoeken van een script of een server hebben geen oorsprong om te controleren en worden er niet door geraakt.

Authenticeren

Elk verzoek buiten een kleine set bewust openbare routes draagt een toegangstoken in de standaardheader:

Authorization: Bearer <token>

Het token is een ondertekende JWT die de API uitgeeft. Het is kortlevend — standaard dertig minuten (NESSIE_AUTH_TOKEN_TTL) — en het is het enige credential dat een gewone aanroeper ooit aanbiedt. Nessie heeft geen langlevende persoonlijke API-sleutels. Als je wilt dat een script de API aanroept, logt het in zoals de browser van een persoon dat doet, en vernieuwt het zijn token onderweg.

Een token ophalen

POST /api/auth/session maakt een sessie aan. Het antwoord bevat het toegangstoken en de identiteit van de aanroeper, en zet het vernieuwingstoken in een afzonderlijke HTTP-only cookie.

Hoe je je identiteit bewijst, hangt af van hoe de instantie is uitgerold. Een instantie die in de local-modus draait, accepteert een e-mailadres en wachtwoord. Op een hosted- of selfHosted-instantie wordt inloggen met een wachtwoord zonder meer geweigerd met 403 PASSWORD_AUTH_DISABLED, en de enige toegang is de geconfigureerde identiteitsprovider. Zo kan het account nooit naast die provider een tweede, zwakkere identiteitsroute krijgen.

Inloggen via een identiteitsprovider

Single sign-on is een OAuth-uitwisseling van de autorisatiecode met PKCE, in drie stappen. De eigen identiteitsprovider van Nessie is UnlikeOtherAI, die ook de organisatie- en teamstructuur beheert die een instantie spiegelt; de uitwisseling hieronder heeft dezelfde vorm voor elke geconfigureerde provider.

  1. GET /api/auth/providers geeft een lijst van wat deze instantie accepteert, elk met een providerId, een label en de vraag of de inlogpagina automatisch naar die provider moet doorverwijzen.
  2. GET /api/auth/providers/:providerId/authorize, met codeChallenge, redirectUri en state als queryparameters, antwoordt met { data: { authorizeUrl } }. Stuur de persoon daarheen.
  3. De provider stuurt de persoon terug naar jouw redirectUri met een code. Stuur die terug als POST /api/auth/session met { providerId, code, codeVerifier, redirectUri } — alle vier zijn samen verplicht, en een onvolledige set levert een 400 EXTERNAL_AUTH_INCOMPLETE op.

Een sessie vernieuwen

Het vernieuwingstoken staat nooit in een responsebody. Het wordt ingesteld als HTTP-only cookie met de naam nessie_refresh, beperkt tot het pad /api/auth, met standaard een levensduur van dertig dagen (NESSIE_AUTH_REFRESH_TOKEN_TTL). Een POST /api/auth/refresh met die cookie verbruikt het token, geeft een nieuw toegangstoken uit en zet een nieuwe cookie in de plaats — de tokens roteren, dus een gestolen token is alleen geldig tot de echte client de volgende keer vernieuwt.

Een browser die op meerdere accounts is ingelogd, houdt per login zo’n cookie bij, genaamd nessie_refresh_<slot>, en geeft met de header x-nessie-account-slot aan op welke een verzoek betrekking heeft. Een client die zo’n header niet stuurt, bedoelt nessie_refresh, wat voor een commandoregelclient ruimschoots genoeg is.

Een commandoregelclient heeft dus een cookiejar nodig. Met curl is dat --cookie-jar bij de inlogaanroep en --cookie bij de vernieuwing, zoals in het uitgewerkte voorbeeld hieronder.

Wat bij elk verzoek wordt gecontroleerd

Authenticatie is niet alleen een handtekeningcontrole. Voordat een handler draait, controleert de API de handtekening en vervaldatum van het token, bevestigt zij dat de sessie niet is ingetrokken (zowel via de gebruikersbrede tokenversie als via het id van de afzonderlijke sessie), en laadt zij het lidmaatschap van de aanroeper van de organisatie die het token noemt. Een gedeactiveerd lidmaatschap levert een 403 op. Wanneer de organisatie wordt ondersteund door een externe identiteitsprovider, wordt de huidige status van de aanroeper bij die provider opnieuw gecontroleerd bij het verzoek, en niet uit het token gelezen. Toegang die bij de provider is ingetrokken, stopt daardoor meteen, in plaats van pas bij de volgende vervaldatum.

Credentials die geen sessietoken zijn

Er bestaan twee andere bearer-credentials, en geen van beide is een sleutel voor algemeen gebruik. Een credential van het spraakapparaat wordt alleen geaccepteerd op de routes voor spraakoproepen; een agenttoegangscredential alleen op het MCP-endpoint. Als je er een ergens anders aanbiedt, wordt dat geweigerd met een 403 die het credential noemt, en niet als zwakkere sessie opnieuw geprobeerd. Zie MCP en verbonden tools voor hoe het agentcredential wordt uitgegeven.

De responsestructuur

Elk geslaagd antwoord is een object met een data-sleutel. Er is geen kale array en geen payload op het hoogste niveau, zodat een client nooit op de vorm hoeft te onderscheiden:

{
  "data": { "id": "3f1c…", "label": "engineering" }
}

Lijstendpoints voegen een meta-object naast die data toe. Verder wordt nooit iets op het hoogste niveau toegevoegd.

Fouten gebruiken ook één vorm, ongeacht wat ze veroorzaakt:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "A message needs content or at least one attachment",
    "field": "body",
    "details": { "formErrors": [], "fieldErrors": {} }
  }
}

code is het machineleesbare deel en waarop je moet vertakken; message is bedoeld voor een mens. field en details zijn aanwezig wanneer de fout specifiek genoeg is om te lokaliseren. Een onverwachte serverfout is altijd 500 INTERNAL_ERROR met een vaste melding — de onderliggende foutmelding wordt gelogd, nooit teruggestuurd, omdat die onderweg verzoekgegevens kan hebben opgepikt.

Foutcodes die je als eerste tegenkomt

StatusCodeBetekenis
400VALIDATION_ERRORDe body of query voldeed niet aan het schema.
401AUTH_REQUIREDGeen bearer-token, of de header was ongeldig.
401TOKEN_EXPIREDHet toegangstoken is over de vervaldatum heen. Vernieuw het en probeer het opnieuw.
401TOKEN_REVOKEDDe sessie is uitgelogd, of alle sessies zijn geforceerd beëindigd.
403ACCOUNT_DEACTIVATEDHet lidmaatschap van deze organisatie is gedeactiveerd.
404NOT_FOUNDGeen zodanige route, of geen zodanig record zichtbaar voor deze aanroeper.
429RATE_LIMITEDEen bucket is vol. Lees Retry-After.
500INTERNAL_ERROREen onverwachte serverfout, bewust vaag gehouden.

Paginering

Lijstendpoints pagineren met een ondoorzichtige keyset-cursor, niet met een offset — er worden rijen ingevoegd terwijl iemand leest, en met een offset herhaalt de tweede pagina stilletjes een record dat de eerste pagina al liet zien.

Er worden drie queryparameters geaccepteerd: cursor, limit en direction (forward of backward). De standaardpagina bevat 25 items en het maximum is 100; een grotere waarde wordt afgekapt in plaats van geweigerd. Endpoints die ook sorteren of filteren, voegen sort, order en een vrije zoekterm q toe.

De meta van de pagina laat zien waar je staat:

{
  "data": [ … ],
  "meta": {
    "hasMore": true,
    "nextCursor": "2026-09-12T08:31:44.005Z|8b2f…",
    "prevCursor": null,
    "total": 134
  }
}

nextCursor en prevCursor zijn beide echt: Vorige en Volgende zijn bedieningselementen die een persoon verwacht. Ze zijn ondoorzichtig — de server codeert er zijn eigen sorteersleutel in, en een client die er een parseert of samenstelt, breekt zodra een lijst zijn volgorde wijzigt. total staat op de meeste lijsten en wordt weggelaten waar tellen niet zinvol is, zoals bij gerangschikte zoekresultaten.

Snelheidslimieten

Limieten gelden per vast tijdvenster en worden afgedwongen voordat de body van het verzoek zelfs is gelezen. Een afgewezen verzoek is 429 met de code RATE_LIMITED en een Retry-After-header in seconden.

De meeste buckets zijn gekoppeld aan het client-IP. De gevoelige — inloggen, vernieuwen, het wachtwoord opnieuw bewijzen en schrijfacties op MCP-credentials — houden daarnaast een teller per account bij, en wijzen af wanneer een van beide aanslaat. Zo lopen zowel één adres dat veel accounts probeert als één account dat vanaf veel adressen wordt aangevallen tegen een muur aan. Inloggen staat standaard op 10 pogingen per IP en 5 per e-mailadres per tien minuten; een bericht plaatsen op 60 per minuut; schrijfacties onder /api/agents op 60 per minuut. Elke drempel is per installatie instelbaar, dus beschouw deze waarden als de standaardwaarden die worden meegeleverd, niet als een contract. Health checks en de realtime-streams zijn uitgezonderd.

Realtime

Een lijst blijven pollen is de verkeerde manier om een gesprek te volgen. Er bestaan daarom drie streamingendpoints:

  • GET /api/events/stream — server-sent events voor alles wat de ingelogde persoon kan zien.
  • GET /api/threads/:threadId/stream — server-sent events voor één thread. Het honoreert Last-Event-ID, dus een herverbinding hervat de stream in plaats van opnieuw te beginnen.
  • GET /api/activity — een WebSocket met de activiteitenfeed.

Browsers kunnen geen headers instellen bij een WebSocket-handshake, dus de WebSocket-route accepteert het toegangstoken ook als queryparameter ?token=. Dat is de enige plek waar een token in een URL hoort; elke andere route neemt de header.

De resourcegroepen

Er zijn ruim duizend endpoints, veel te veel om op te sommen. Ze zijn in groepen ingedeeld, en zodra je het voorvoegsel van een groep kent, zijn de afzonderlijke routes voorspelbaar — een verzameling op het voorvoegsel, een record eronder, en benoemde acties als POST-subpaden.

GroepVoorvoegselWat het omvat
Auth/api/authInloggen, vernieuwen, uitloggen, de huidige identiteit en de eigen sessies van een persoon.
Kanalen en threads/api/channels, /api/threadsHet gespreksmodel: kanalen bevatten threads, threads bevatten berichten, en antwoorden hangen onder een hoofdbericht.
Agents/api/agentsAgentdefinities, hun kanaalkoppelingen, gesprekken, to-do’s en status.
Projecten en borden/api/projects, /api/tasks, /api/iterationsWerktracking: borden en kolommen onder een project, taken daarop en iteraties daarover.
Kennis/api/knowledge-baseRuimtes met versiebeheerde pagina’s en bestanden, met opmerkingen, backlinks en zoeken.
Mail/api/mail, /api/mailbox-connectionsGekoppelde mailboxen, mailboxen van agents, en concepten die een agent voor een persoon heeft geschreven om goed te keuren.
Goedkeuringen/api/approvalsDe wachtrij met acties die een agent niet zelfstandig mag uitvoeren, en de ene aanroep die ze afhandelt.
Audit/api/audit-logHet hashketen-auditspoor van de organisatie, met een verificatie-endpoint. Eigenaren en beheerders.
Kosten en gebruik/api/ledger, /api/billingToken- en kostenadministratie, budgetten en uitvoeringstijden; daarnaast tegoed en opwaarderingen. Eigenaren en beheerders.
Organisatie en teams/api/organizations, /api/teams, /api/usersBeheer van tenant, teams en lidmaatschappen, inclusief uitnodigingen.
Triggers/api/triggersPlanningen en webhooks die agentwerk starten, met hun activeringsgeschiedenis.
Bellen en spraak/api/calls, /api/voiceBellen binnen een kanaal, en de sessie, het transcript en de apparaattokens van de spraakassistent.
Executors/api/executors, /api/execution-environment-templatesDe machines waarop agents werken, hun omgevingen en het koppelkanaal van de daemon.
Runs/api/runsLopende agent-runs: annuleren, voortzetten, opnieuw starten, koppelen aan een executor.
Uploads/api/uploads, /api/attachmentsUpload eerst een bestand en koppel daarna het teruggegeven id aan een bericht.
Zoeken/api/messages/searchBerichten zoeken, voor de hele organisatie of beperkt tot één kanaal.
Geheimen en instellingen/api/secrets, /api/settings/scopedAlleen-schrijven-opslag voor credentials met expliciete toekenningen, en gelaagde configuratiewaarden.
Verbonden tools/api/mcpMCP-servers waarmee deze instantie zelf verbinding maakt. Zie MCP en verbonden tools.
Gezondheid/api/healthLiveness en readiness, beide openbaar. /api/health/ready controleert ook de database.

Rechten worden per route afgedwongen, niet per groep. In grote lijnen zijn het auditlog, het kostenregister, de beleidsregels en de inferentiebesturing oppervlakken voor eigenaren en beheerders van de organisatie, terwijl gesprekken, projecten, kennis en goedkeuringen openstaan voor elk lid en worden beperkt tot wat dat lid werkelijk kan zien.

Uitgewerkte voorbeelden

Inloggen en de vernieuwingscookie bewaren

Op een instantie in de local-modus, met een wachtwoord. Het toegangstoken komt terug in de body; het vernieuwingstoken belandt in de cookiejar.

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
      }
    }
  }
}

Wanneer het token verloopt, wissel je de cookie in voor een nieuwe:

curl -sS -X POST https://api.example.com/api/auth/refresh \
  --cookie nessie.cookies --cookie-jar nessie.cookies

Het antwoord heeft dezelfde { token, me }-vorm, en de cookiejar bevat nu het geroteerde vernieuwingstoken.

De kanalen weergeven die je kunt zien

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
    }
  ]
}

Voeg ?teamId=… toe om te beperken tot één team, of ?includeArchived=true om gearchiveerde kanalen mee te nemen. De defaultThreadId is waar je het volgende bericht naartoe plaatst.

Een bericht plaatsen

Een bericht heeft ofwel wat content nodig, ofwel ten minste één geüploade bijlage. Stuur een Idempotency-Key mee, dan levert een opnieuw verzonden aanvraag het bericht op dat de eerste poging heeft aangemaakt, in plaats van een tweede exemplaar te plaatsen.

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."}'

Een nieuw bericht is 201:

{
  "data": {
    "message": {
      "id": "d70e…",
      "threadId": "c41a…",
      "role": "user",
      "content": "Deploying the migration now.",
      "createdAt": "2026-09-16T09:04:11.882Z"
    },
    "pendingAgentInvites": []
  }
}

Een herhaling met dezelfde idempotentiesleutel antwoordt met 200, het oorspronkelijke bericht en een lege pendingAgentInvites, omdat de eerste poging de meldingen en verzending al heeft gedaan die die array beschrijft.

Twee weigeringen op deze route verdienen aparte afhandeling. Inhoud die boven de chatlengtegrens uitkomt, is 413 MESSAGE_TOO_LARGE, en de juiste reactie is de tekst als bestand te uploaden. Inhoud die structureel op een credential lijkt, is 422 SECRET_INTERCEPTED — de server weigert het vóór het bericht wordt opgeslagen, verzonden of gelogd, en het credential hoort thuis in /api/secrets.