nessie

API

L’API HTTP qui se trouve derrière chaque client Nessie, et comment vous authentifier auprès d’elle.

Ce qu’est l’API

Nessie expose une seule API HTTP. L’application web d’administration, les clients macOS et iOS et le démon d’exécuteur à distance sont tous des clients ordinaires de cette API, et rien de ce qu’ils peuvent faire n’est accessible autrement. C’est une API JSON reposant sur un serveur Fastify ; ce n’est pas du GraphQL, et elle n’a pas de sous-ensemble « public » distinct : la surface qu’appellent vos propres scripts est celle qu’appelle le produit.

Le chemin ne comporte aucun segment de version. Toutes les routes se trouvent sous /api, à l’exception du point de terminaison MCP entrant POST /mcp et de quelques documents de découverte /.well-known/. La compatibilité est assurée en n’ajoutant que des champs, si bien qu’un client qui ignore ceux qu’il ne reconnaît pas continue de fonctionner.

URL de base

Nessie est auto-hébergé, donc l’URL de base est l’origine sur laquelle votre déploiement sert l’API. L’opérateur la définit dans NESSIE_API_PUBLIC_URL, et c’est aussi la valeur que l’API utilise lorsqu’elle doit générer une URL absolue. Sur l’instance que nous exploitons, il s’agit de https://api.nessie.works ; une instance de développement lancée avec pnpm dev répond sur http://localhost:5454.

L’application web d’administration n’a pas la même origine que l’API : les clients de navigateur sont donc soumis à une liste d’autorisation CORS (NESSIE_CORS_ORIGINS). Les requêtes émises par un script ou un serveur n’ont pas d’origine à vérifier et ne sont pas concernées.

Authentification

Chaque requête, hors un petit nombre de routes volontairement publiques, transporte un jeton d’accès dans l’en-tête standard :

Authorization: Bearer <token>

Le jeton est un JWT signé, émis par l’API. Sa durée de vie est courte : trente minutes par défaut (NESSIE_AUTH_TOKEN_TTL). C’est le seul identifiant qu’un appelant ordinaire présente. Nessie ne propose aucune clé d’API personnelle de longue durée. Si vous voulez qu’un script appelle l’API, il se connecte comme le fait le navigateur d’une personne et renouvelle son jeton au fil de l’eau.

Obtenir un jeton

POST /api/auth/session crée une session. La réponse contient le jeton d’accès et l’identité de l’appelant, et le jeton de renouvellement est placé dans un cookie HttpOnly distinct.

La manière de prouver votre identité dépend du mode de déploiement de l’instance. Une instance fonctionnant en mode local accepte une adresse e-mail et un mot de passe. Sur une instance hosted ou selfHosted, la connexion par mot de passe est refusée d’emblée avec 403 PASSWORD_AUTH_DISABLED ; la seule voie d’entrée est le fournisseur d’identité configuré. Ainsi, le compte ne peut jamais se doter d’une seconde voie d’identification, plus faible, à côté de celle-ci.

Se connecter via un fournisseur d’identité

La connexion unique repose sur un échange de code d’autorisation OAuth avec PKCE, en trois étapes. Le fournisseur d’identité de Nessie est UnlikeOtherAI, qui gère aussi la structure des organisations et des équipes que reflète une instance ; l’échange ci-dessous a la même forme pour tout fournisseur configuré.

  1. GET /api/auth/providers liste les fournisseurs acceptés par cette instance, chacun avec un providerId, un libellé et l’indication de savoir si la page de connexion doit y rediriger automatiquement.
  2. GET /api/auth/providers/:providerId/authorize, avec codeChallenge, redirectUri et state comme paramètres de requête, répond par { data: { authorizeUrl } }. Envoyez la personne vers cette adresse.
  3. Le fournisseur renvoie la personne vers votre redirectUri avec un code. Renvoyez-le à POST /api/auth/session avec { providerId, code, codeVerifier, redirectUri } : les quatre éléments sont obligatoires ensemble, et un ensemble incomplet donne une erreur 400 EXTERNAL_AUTH_INCOMPLETE.

Renouveler une session

Le jeton de renouvellement n’apparaît jamais dans le corps d’une réponse. Il est placé dans un cookie HttpOnly nommé nessie_refresh, limité au chemin /api/auth, avec une durée de vie de trente jours par défaut (NESSIE_AUTH_REFRESH_TOKEN_TTL). Appeler POST /api/auth/refresh avec ce cookie le consomme, délivre un nouveau jeton d’accès et remplace le cookie par un nouveau : les jetons changent à chaque utilisation, de sorte qu’un jeton volé ne reste utilisable que jusqu’à la prochaine actualisation du client légitime.

Un navigateur connecté à plusieurs comptes conserve un cookie de ce type par connexion, nommé nessie_refresh_<slot>, et indique celui qui concerne une requête grâce à l’en-tête x-nessie-account-slot. Un client qui n’envoie pas cet en-tête vise nessie_refresh, ce qui suffit à un client en ligne de commande.

Un client en ligne de commande a donc besoin d’un cookie jar. Avec curl, il s’agit de --cookie-jar sur l’appel de connexion et de --cookie sur l’actualisation, comme dans l’exemple détaillé ci-dessous.

Ce qui est vérifié à chaque requête

L’authentification ne se résume pas à une vérification de signature. Avant l’exécution d’un gestionnaire, l’API vérifie la signature et l’expiration du jeton, confirme que la session n’a pas été révoquée (à la fois par la version de jeton propre à l’utilisateur et par l’identifiant de session individuel) et charge l’appartenance de l’appelant à l’organisation nommée dans le jeton. Une appartenance désactivée donne un 403. Lorsque l’organisation s’appuie sur un fournisseur d’identité externe, la situation actuelle de l’appelant est revérifiée auprès de ce fournisseur à chaque requête, au lieu d’être lue dans le jeton : un accès retiré en amont cesse donc immédiatement de fonctionner, et non à la prochaine expiration.

Identifiants qui ne sont pas des jetons de session

Il existe deux autres identifiants de type bearer, et aucun n’est une clé à usage général. Un identifiant d’appareil vocal n’est accepté que sur les routes d’appel vocal ; un identifiant d’accès d’agent n’est accepté que sur le point de terminaison MCP. Le présenter ailleurs est refusé avec un 403 qui désigne l’identifiant, et il n’est pas retenté comme une session plus faible : voir MCP et outils connectés pour savoir comment l’identifiant d’agent est délivré.

L’enveloppe de réponse

Chaque réponse réussie est un objet doté d’une clé data. Il n’existe ni tableau nu ni charge utile de premier niveau, si bien qu’un client n’a jamais à distinguer les formes :

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

Les points de terminaison de liste ajoutent un objet meta à côté. Rien d’autre n’est jamais ajouté au premier niveau.

Les erreurs suivent elles aussi une seule forme, quelle qu’en soit l’origine :

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

code est la partie lisible par une machine, et c’est sur elle qu’il faut s’appuyer ; message s’adresse à une personne. field et details sont présents lorsque l’erreur est assez précise pour être localisée. Une erreur serveur non gérée donne toujours 500 INTERNAL_ERROR avec un message fixe : le texte de l’erreur sous-jacente est journalisé, jamais renvoyé, car il peut avoir capté des données de requête en chemin.

Les codes d’erreur que vous rencontrerez en premier

StatutCodeSignification
400VALIDATION_ERRORLe corps ou la requête ne correspond pas au schéma.
401AUTH_REQUIREDAucun jeton bearer, ou en-tête mal formé.
401TOKEN_EXPIREDLe jeton d’accès a expiré. Actualisez-le puis réessayez.
401TOKEN_REVOKEDLa session a été déconnectée, ou toutes les sessions ont été fermées de force.
403ACCOUNT_DEACTIVATEDL’appartenance à cette organisation a été désactivée.
404NOT_FOUNDRoute inexistante, ou enregistrement invisible pour cet appelant.
429RATE_LIMITEDLe quota est épuisé. Lisez Retry-After.
500INTERNAL_ERRORUne erreur serveur non gérée, volontairement sans détail.

Pagination

Les points de terminaison de liste paginent à l’aide d’un curseur opaque de type keyset, et non d’un décalage (offset) : des lignes sont insérées pendant qu’on lit, et avec un décalage, la deuxième page répète sans prévenir un enregistrement déjà affiché sur la première.

Trois paramètres de requête sont acceptés : cursor, limit et direction (forward ou backward). La page par défaut compte 25 éléments et le plafond est de 100 ; une valeur plus grande est ramenée au plafond plutôt que rejetée. Les points de terminaison qui trient ou filtrent ajoutent aussi sort, order et un paramètre q de recherche libre.

Le meta de la page vous indique où vous en êtes :

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

nextCursor et prevCursor sont tous deux réels : Précédent et Suivant sont deux commandes qu’une personne attend. Ils sont opaques : le serveur y encode sa propre clé de tri, et un client qui analyse ou construit l’un d’eux cassera dès qu’une liste change son ordre. total figure sur la plupart des listes et est omis lorsque le comptage n’a pas de sens, comme pour les résultats de recherche classés.

Limites de débit

Les limites s’appliquent par fenêtre fixe et sont imposées avant même la lecture du corps de la requête. Une requête rejetée reçoit un 429 avec le code RATE_LIMITED et un en-tête Retry-After exprimé en secondes.

La plupart des compartiments sont indexés sur l’adresse IP du client ; ceux qui concernent des identifiants (connexion, actualisation, nouvelle saisie du mot de passe, écriture d’identifiants MCP) tiennent en plus un compteur par compte et rejettent dès que l’un ou l’autre est atteint : ainsi, une adresse qui multiplie les tentatives sur plusieurs comptes, et un compte attaqué depuis plusieurs adresses, se heurtent tous deux à une limite. Par défaut, la connexion autorise 10 tentatives par IP et 5 par adresse e-mail toutes les dix minutes ; l’envoi d’un message est limité à 60 par minute ; les écritures sous /api/agents à 60 par minute. Chaque seuil est configurable par déploiement : considérez donc ces valeurs comme les valeurs par défaut livrées, et non comme un engagement contractuel. Les contrôles de santé et les flux temps réel sont exemptés.

Temps réel

Interroger une liste en boucle n’est pas la bonne façon de suivre une conversation. Il existe à la place trois points de terminaison de flux :

  • GET /api/events/stream — événements envoyés par le serveur pour tout ce que la personne connectée peut voir.
  • GET /api/threads/:threadId/stream — événements envoyés par le serveur pour un seul fil. Il respecte Last-Event-ID : une reconnexion reprend donc là où elle s’était arrêtée, au lieu de recommencer.
  • GET /api/activity — une WebSocket qui transporte le flux d’activité.

Les navigateurs ne peuvent pas définir d’en-têtes lors de la négociation WebSocket : la route WebSocket accepte donc aussi le jeton d’accès comme paramètre de requête ?token=. C’est le seul endroit où un jeton peut figurer dans une URL ; toutes les autres routes acceptent l’en-tête.

Les familles de ressources

Il existe bien plus d’un millier de points de terminaison, beaucoup trop pour les lister. Ils se regroupent en familles ; une fois le préfixe d’une famille connu, les routes individuelles sont prévisibles : une collection au niveau du préfixe, un enregistrement en dessous, et des actions nommées sous forme de sous-chemins POST.

FamillePréfixeCe qu’elle couvre
Authentification/api/authConnexion, renouvellement, déconnexion, identité actuelle, sessions propres à une personne.
Canaux et fils/api/channels, /api/threadsLe modèle de conversation : les canaux contiennent des fils, les fils contiennent des messages, et les réponses s’imbriquent sous un message racine.
Agents/api/agentsDéfinitions des agents, leurs liaisons aux canaux, conversations, tâches à faire et statuts.
Projets et tableaux/api/projects, /api/tasks, /api/iterationsSuivi du travail : tableaux et colonnes sous un projet, tâches sur ces tableaux, itérations sur celles-ci.
Base de connaissances/api/knowledge-baseEspaces de pages et de fichiers versionnés, avec commentaires, liens entrants et recherche.
E-mail/api/mail, /api/mailbox-connectionsBoîtes mail connectées, boîtes mail des agents et brouillons rédigés par un agent pour validation par une personne.
Approbations/api/approvalsLa file des actions qu’un agent ne peut pas accomplir seul, et l’unique appel qui les tranche.
Audit/api/audit-logLa piste d’audit de l’organisation, chaînée par hachage, avec un point de terminaison de vérification. Propriétaires et administrateurs.
Coûts et usage/api/ledger, /api/billingComptabilité des tokens et des coûts, budgets et durées d’exécution ; à part, crédits et recharges. Propriétaires et administrateurs.
Organisation et équipes/api/organizations, /api/teams, /api/usersAdministration du locataire, des équipes et des appartenances, invitations comprises.
Déclencheurs/api/triggersPlanifications et webhooks qui lancent le travail des agents, avec leur historique de déclenchement.
Appels et voix/api/calls, /api/voiceAppels dans le canal, ainsi que la session, la transcription et les jetons d’appareil de l’assistant vocal.
Exécuteurs/api/executors, /api/execution-environment-templatesLes machines sur lesquelles travaillent les agents, leurs environnements et le canal d’appairage du démon.
Exécutions/api/runsExécutions d’agents en cours : annuler, poursuivre, redémarrer, lier à un exécuteur.
Téléversements/api/uploads, /api/attachmentsEnvoyez d’abord un fichier, puis reliez l’identifiant renvoyé à un message.
Recherche/api/messages/searchRecherche de messages, à l’échelle de l’organisation ou limitée à un canal.
Secrets et paramètres/api/secrets, /api/settings/scopedStockage en écriture seule des identifiants, avec autorisations explicites, et valeurs de configuration superposées.
Outils connectés/api/mcpServeurs MCP que cette instance appelle. Voir MCP et outils connectés.
Santé/api/healthVivacité et disponibilité, toutes deux publiques. /api/health/ready vérifie aussi la base de données.

Les droits sont appliqués route par route, et non famille par famille. De manière générale, le journal d’audit, le registre des coûts, les règles de politique et le plan de contrôle de l’inférence sont des surfaces réservées aux propriétaires et administrateurs de l’organisation, tandis que les conversations, projets, connaissances et approbations sont ouverts à tout membre, et restreints à ce que ce membre peut réellement voir.

Exemples détaillés

Se connecter et conserver le cookie de renouvellement

Sur une instance en mode local, avec un mot de passe. Le jeton d’accès revient dans le corps de la réponse ; le jeton de renouvellement est enregistré dans le 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
      }
    }
  }
}

Lorsque le jeton expire, échangez le cookie contre un nouveau :

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

La réponse a la même forme { token, me }, et le cookie jar contient désormais le jeton de renouvellement remplacé.

Lister les canaux visibles

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

Ajoutez ?teamId=… pour ne garder qu’une équipe, ou ?includeArchived=true pour inclure les canaux archivés. Le defaultThreadId est le fil où vous publiez par défaut.

Publier un message

Un message doit contenir soit du content, soit au moins une pièce jointe envoyée. Envoyez un Idempotency-Key : une requête renvoyée aboutit alors au message créé par la première tentative, au lieu de publier une deuxième copie.

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

Un nouveau message renvoie 201 :

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

Un rejeu avec la même clé d’idempotence répond 200 avec le message d’origine et un pendingAgentInvites vide, car la première tentative a déjà effectué les alertes et l’envoi que décrit ce tableau.

Deux refus de cette route méritent un traitement explicite. Un contenu qui dépasse la limite de longueur du chat renvoie 413 MESSAGE_TOO_LARGE ; la bonne réponse consiste alors à envoyer le texte sous forme de fichier. Un contenu qui ressemble structurellement à un identifiant renvoie 422 SECRET_INTERCEPTED : le serveur refuse avant que le message ne soit enregistré, transmis ou journalisé, et l’identifiant doit être placé dans /api/secrets.