nessie

API

L’API HTTP alla base di ogni client Nessie, e come autenticarsi.

Che cos’è l’API

Nessie ha una sola API HTTP. L’app web di amministrazione, i client per macOS e iOS e il daemon dell’executor remoto sono client normali di questa API, e tutto ciò che possono fare è raggiungibile solo così. È un’API JSON su un server Fastify, non è GraphQL e non ha un sottoinsieme separato «pubblico»: l’insieme di endpoint che usano i tuoi script è lo stesso che usa il prodotto.

Il percorso non contiene alcun segmento di versione. Tutte le rotte stanno sotto /api, tranne l’endpoint MCP in ingresso POST /mcp e alcuni documenti di scoperta sotto /.well-known/. La compatibilità è garantita aggiungendo solo campi, così un client che ignora ciò che non riconosce continua a funzionare.

URL di base

Nessie è self-hosted, quindi l’URL di base è l’origine su cui la tua installazione serve l’API: l’operatore la imposta come NESSIE_API_PUBLIC_URL, e lo stesso valore è quello che l’API usa quando deve generare un URL assoluto. Sull’istanza che gestiamo noi è https://api.nessie.works; un’istanza di sviluppo avviata con pnpm dev risponde su http://localhost:5454.

L’app web di amministrazione è un’origine diversa dall’API, quindi i client browser sono soggetti a una allowlist CORS (NESSIE_CORS_ORIGINS). Le richieste da uno script o da un server non hanno un’origine da controllare e non ne sono interessate.

Autenticazione

Ogni richiesta al di fuori di un piccolo insieme di rotte deliberatamente pubbliche porta un token di accesso nell’intestazione standard:

Authorization: Bearer <token>

Il token è un JWT firmato emesso dall’API. È di breve durata — trenta minuti per impostazione predefinita (NESSIE_AUTH_TOKEN_TTL) — ed è l’unica credenziale che un normale chiamante presenta mai. Nessie non ha chiavi API personali a lunga durata. Se vuoi che uno script chiami l’API, deve accedere come fa il browser di una persona e rinnovare il token man mano.

Ottenere un token

POST /api/auth/session crea una sessione. Risponde con il token di accesso e l’identità del chiamante, e imposta il token di rinnovo in un cookie separato, HTTP-only.

Il modo in cui dimostri chi sei dipende da come è installata l’istanza. Un’istanza in modalità local accetta email e password. Su un’istanza hosted o selfHosted l’accesso con password è rifiutato senza eccezioni, con 403 PASSWORD_AUTH_DISABLED, e l’unico accesso possibile è il provider di identità configurato: così l’account non può avere una seconda identità, più debole, accanto a quella.

Accesso tramite un provider di identità

Il single sign-on è uno scambio di codice di autorizzazione OAuth con PKCE, in tre passaggi. Il provider di identità di Nessie è UnlikeOtherAI, che possiede anche la struttura di organizzazione e team che un’istanza rispecchia; lo scambio qui sotto ha la stessa forma per qualsiasi provider configurato.

  1. GET /api/auth/providers elenca i provider accettati da questa istanza, ciascuno con un providerId, un’etichetta e l’indicazione se la pagina di accesso debba reindirizzarvi automaticamente.
  2. GET /api/auth/providers/:providerId/authorize, con codeChallenge, redirectUri e state come parametri della query, risponde con { data: { authorizeUrl } }. Invia lì la persona.
  3. Il provider riporta la persona al tuo redirectUri con un codice. Invialo come POST /api/auth/session con { providerId, code, codeVerifier, redirectUri }: tutti e quattro i campi sono obbligatori insieme, e un insieme incompleto restituisce 400 EXTERNAL_AUTH_INCOMPLETE.

Rinnovare una sessione

Il token di rinnovo non compare mai nel corpo di una risposta. È impostato come cookie HTTP-only chiamato nessie_refresh, limitato al percorso /api/auth, con una durata di trenta giorni per impostazione predefinita (NESSIE_AUTH_REFRESH_TOKEN_TTL). Chiamare POST /api/auth/refresh con quel cookie lo consuma, emette un nuovo token di accesso e imposta un nuovo cookie al suo posto: i token ruotano, quindi uno rubato vale solo fino al prossimo rinnovo del client reale.

Un browser connesso a più account conserva un cookie del genere per ogni accesso, chiamato nessie_refresh_<slot>, e indica quello a cui si riferisce una richiesta con l’intestazione x-nessie-account-slot. Un client che non invia quell’intestazione si riferisce a nessie_refresh, che è tutto ciò che serve a un client da riga di comando.

Un client da riga di comando ha quindi bisogno di un cookie jar. Con curl si usa --cookie-jar nella chiamata di accesso e --cookie nel rinnovo, come nell’esempio svolto più sotto.

Cosa viene verificato a ogni richiesta

L’autenticazione non è solo un controllo della firma. Prima che un handler venga eseguito, l’API verifica firma e scadenza del token, conferma che la sessione non sia stata revocata (sia tramite la versione del token a livello di utente sia tramite l’id della singola sessione) e carica l’appartenenza del chiamante all’organizzazione indicata dal token. Un’appartenenza disattivata dà un 403. Quando l’organizzazione è collegata a un provider di identità esterno, lo stato attuale del chiamante viene ricontrollato su quel provider a ogni richiesta invece di essere letto dal token, così un accesso revocato a monte smette di funzionare subito e non alla scadenza successiva.

Credenziali diverse dai token di sessione

Esistono altre due credenziali bearer, e nessuna è una chiave generica. Una credenziale di dispositivo vocale è accettata solo sulle rotte delle chiamate vocali; una credenziale di accesso dell’agente è accettata solo sull’endpoint MCP. Presentarle altrove viene rifiutato con un 403 che ne indica il nome, senza essere riprovata come sessione più debole — vedi MCP e strumenti collegati per come viene emessa la credenziale dell’agente.

La struttura della risposta

Ogni risposta riuscita è un oggetto con una chiave data. Non ci sono array semplici né payload di primo livello, così un client non deve mai controllare la forma:

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

Gli endpoint di elenco aggiungono accanto un oggetto meta. Al primo livello non viene aggiunto nient’altro.

Anche i fallimenti usano un’unica struttura, qualunque cosa li abbia prodotti:

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

code è la parte leggibile dalla macchina, ed è quella su cui conviene ramificare; message è per una persona. field e details sono presenti quando l’errore è abbastanza specifico da poter essere localizzato. Un errore del server non gestito è sempre 500 INTERNAL_ERROR con un messaggio fisso: il testo dell’errore originale viene registrato nel log, mai restituito, perché potrebbe aver raccolto dati della richiesta lungo il percorso.

Codici di errore che incontrerai per primi

StatoCodiceSignificato
400VALIDATION_ERRORIl corpo o la query non corrispondono allo schema.
401AUTH_REQUIREDNessun token bearer, oppure l’intestazione è malformata.
401TOKEN_EXPIREDIl token di accesso è scaduto. Rinnova e riprova.
401TOKEN_REVOKEDLa sessione è stata chiusa, oppure tutte le sessioni sono state terminate.
403ACCOUNT_DEACTIVATEDL’appartenenza a questa organizzazione è stata disattivata.
404NOT_FOUNDNessuna rotta con quel nome, oppure nessun record visibile a questo chiamante.
429RATE_LIMITEDUn bucket è pieno. Leggi Retry-After.
500INTERNAL_ERRORUn errore del server non gestito, volutamente opaco.

Paginazione

Gli endpoint di elenco paginano con un cursore keyset opaco, non con un offset: le righe vengono inserite mentre qualcuno legge, e con un offset la seconda pagina ripete in silenzio un record già mostrato nella prima.

Sono accettati tre parametri della query: cursor, limit e direction (forward o backward). La pagina predefinita contiene 25 elementi e il massimo è 100; un valore più alto viene riportato al massimo invece di essere rifiutato. Gli endpoint che ordinano o filtrano aggiungono sort, order e una ricerca testuale libera q.

I meta della pagina ti indicano dove sei:

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

nextCursor e prevCursor sono entrambi reali: Precedente e Successiva sono due controlli che una persona si aspetta. Sono opachi: il server codifica al loro interno la propria chiave di ordinamento, e un client che li analizza o li costruisce si romperà appena una lista cambia ordine. total è presente nella maggior parte delle liste e omesso dove contare non ha senso, per esempio nei risultati di ricerca ordinati per rilevanza.

Limiti di frequenza

I limiti sono per finestra fissa e vengono applicati prima ancora di leggere il corpo della richiesta. Una richiesta respinta riceve 429 con il codice RATE_LIMITED e un’intestazione Retry-After in secondi.

La maggior parte dei bucket è legata all’IP del client; quelli sensibili alle credenziali — accesso, rinnovo, riconferma della password, scritture di credenziali MCP — mantengono anche un contatore per account e scattano quando uno dei due viene superato, così sia un solo indirizzo che prova molti account sia un solo account attaccato da molti indirizzi incontrano un muro. L’accesso ha come predefinito 10 tentativi per IP e 5 per indirizzo email ogni dieci minuti; l’invio di un messaggio ha come predefinito 60 al minuto; le scritture sotto /api/agents 60 al minuto. Ogni soglia è configurabile per installazione, quindi considera questi valori come predefiniti e non come un contratto. I controlli di stato e i flussi in tempo reale sono esclusi.

Tempo reale

Interrogare una lista in continuo è il modo sbagliato per seguire una conversazione. Esistono invece tre endpoint di streaming:

  • GET /api/events/stream — eventi inviati dal server per tutto ciò che la persona connessa può vedere.
  • GET /api/threads/:threadId/stream — eventi inviati dal server per un solo thread. Rispetta Last-Event-ID, quindi una riconnessione riprende invece di ripartire da capo.
  • GET /api/activity — un WebSocket che trasporta il feed delle attività.

I browser non possono impostare intestazioni nell’handshake di un WebSocket, quindi la rotta WebSocket accetta anche il token di accesso come parametro della query ?token=. È l’unico punto in cui un token compare in un URL; tutte le altre rotte lo accettano nell’intestazione.

Le famiglie di risorse

Gli endpoint sono ben più di mille, troppi per elencarli. Sono raggruppati in famiglie, e una volta noto il prefisso di una famiglia le singole rotte sono prevedibili: una collezione sul prefisso, un record sotto di essa e le azioni con nome come sotto-percorsi POST.

FamigliaPrefissoCosa copre
Autenticazione/api/authAccesso, rinnovo, disconnessione, identità attuale e le sessioni della persona.
Canali e thread/api/channels, /api/threadsIl modello di conversazione: i canali contengono thread, i thread contengono messaggi, le risposte si annidano sotto la radice.
Agenti/api/agentsDefinizioni degli agenti, le loro associazioni ai canali, le conversazioni, le cose da fare e lo stato.
Progetti e bacheche/api/projects, /api/tasks, /api/iterationsGestione del lavoro: bacheche e colonne sotto un progetto, attività su di esse, iterazioni su queste.
Knowledge base/api/knowledge-baseSpazi di pagine e file con versioni, con commenti, backlink e ricerca.
Posta/api/mail, /api/mailbox-connectionsCaselle collegate, caselle degli agenti e bozze che un agente ha scritto per farle approvare a una persona.
Approvazioni/api/approvalsLa coda delle azioni che un agente non può compiere da solo, e la sola chiamata che le risolve.
Audit/api/audit-logIl registro di audit dell’organizzazione, a catena di hash, con un endpoint di verifica. Per proprietari e admin.
Costi e utilizzo/api/ledger, /api/billingContabilità di token e costi, budget e tempi di esecuzione; a parte, crediti e ricariche. Per proprietari e admin.
Organizzazione e team/api/organizations, /api/teams, /api/usersAmministrazione di tenant, team e appartenenze, inclusi gli inviti.
Trigger/api/triggersPianificazioni e webhook che avviano il lavoro degli agenti, con la cronologia delle attivazioni.
Chiamate e voce/api/calls, /api/voiceChiamate nei canali, la sessione dell’assistente vocale, la sua trascrizione e i token dei dispositivi.
Executor/api/executors, /api/execution-environment-templatesLe macchine su cui lavorano gli agenti, i loro ambienti e il canale di associazione del daemon.
Esecuzioni/api/runsEsecuzioni di agenti in corso: annullare, continuare, riavviare, collegare a un executor.
Caricamenti/api/uploads, /api/attachmentsCarica prima un file, poi collega l’id restituito a un messaggio.
Ricerca/api/messages/searchRicerca nei messaggi, per tutta l’organizzazione o limitata a un canale.
Segreti e impostazioni/api/secrets, /api/settings/scopedArchiviazione di credenziali solo in scrittura, con autorizzazioni esplicite, e valori di configurazione a più livelli.
Strumenti collegati/api/mcpServer MCP a cui questa istanza si collega. Vedi MCP e strumenti collegati.
Stato del sistema/api/healthVerifica di attività (liveness) e di prontezza (readiness), entrambe pubbliche. /api/health/ready controlla anche il database.

I diritti sono applicati per rotta, non per famiglia. In generale, il registro di audit, il registro dei costi, le regole di policy e il piano di controllo dell’inferenza sono superfici per i proprietari e gli admin dell’organizzazione, mentre conversazioni, progetti, knowledge e approvazioni sono aperti a ogni membro e ristretti a ciò che quel membro può effettivamente vedere.

Esempi svolti

Accedi e conserva il cookie di rinnovo

Su un’istanza in modalità local, con password. Il token di accesso torna nel corpo della risposta; il token di rinnovo finisce nel 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
      }
    }
  }
}

Quando il token scade, scambia il cookie per uno nuovo:

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

La risposta ha la stessa struttura { token, me } di prima, e il cookie jar contiene ora il token di rinnovo ruotato.

Elenca i canali che puoi vedere

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

Aggiungi ?teamId=… per limitarti a un solo team, oppure ?includeArchived=true per includere i canali archiviati. Il defaultThreadId è dove scrivi il messaggio successivo.

Pubblica un messaggio

Un messaggio deve contenere del content o almeno un allegato caricato. Invia un Idempotency-Key: un tentativo ripetuto risolve nel messaggio creato dal primo tentativo, invece di pubblicarne una seconda copia.

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 nuovo messaggio risponde con 201:

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

Un replay con la stessa chiave di idempotenza risponde con 200, con il messaggio originale e un array pendingAgentInvites vuoto, perché il primo tentativo ha già fatto gli avvisi e l’invio che quell’array descrive.

Su questa rotta ci sono due rifiuti che vale la pena gestire esplicitamente. Un contenuto oltre il limite di lunghezza della chat restituisce 413 MESSAGE_TOO_LARGE, e la risposta giusta è caricare il testo come file. Un contenuto che ha la struttura di una credenziale restituisce 422 SECRET_INTERCEPTED: il server rifiuta prima che il messaggio venga memorizzato, inviato o registrato, e la credenziale va in /api/secrets.