ai-engine · Intégration

Contrat plateforme v1 — la passerelle du moteur

Une plateforme — le simulateur de apps/web aujourd'hui, la vraie plateforme demain — ne parle au moteur que par ce contrat. Elle y passe tout : l'organisation, ses documents, la personne et son profil, ses documents personnels, la conversation. Le moteur tient compte de chacun de ces paramètres, et n'en garde que ce qu'il doit : l'empreinte de la clé, le cadre et la politique de l'organisation, les documents indexés.

navigateur ──► plateforme ──(clé du tenant, X-AI-Engine-User)──► passerelle :8090 ──► Restate :8080 (privé)
                                                                  │                      └─► agent :9080
                                                                  └──► StarRocks (registre, documents personnels)

Le chemin d'un appel du contrat, et lui seul. La topologie d'un poste — ce qui écoute où, et sur quelle interface — est dans DEMARRAGE.md ; celle d'un cluster, dans DEPLOIEMENT-K8S.md. Trois vues d'une même pile, et c'est DEMARRAGE.md qui fait foi sur les ports et les interfaces.

API.md et FRONTEND.md vont par paire. Celui-ci dit ce qu'est chaque route : en-têtes, corps, statuts. L'autre dit quoi construire, dans quel ordre, et qui détient quoi — le partage des rôles, le provisionnement, les réglages à exposer, ce qu'il faut afficher d'un tour. Le premier est un dictionnaire, le second un chemin.

  • Servie par pnpm api (services/agent/src/gateway/), sur API_PORT (8090).
  • La seule porte vers l'extérieur. L'ingress Restate ne vérifie rien : il reste privé, comme StarRocks. La console /admin est un outil de l'opérateur du moteur, pas une plateforme : elle garde ses appels directs.
  • De serveur à serveur. Le navigateur ne voit jamais une clé.
  • 54 des 79 routes s'essaient depuis Postman. postman/AI Engine — Passerelle v1 les couvre, en sept dossiers ; le dossier « Amorçage » passe d'un moteur vierge à un tenant utilisable en cinq requêtes. N'y sont pas encore les 25 routes arrivées avec le contrat plateforme v1 et la 1.1 : les 5 de connecteurs, les 3 de consommation, les 3 du contrat d'accès, les 2 de fiche et POST /v1/generate. Le dossier « Amorçage » porte en revanche les deux retraits, et une collection qui les jouerait en entier repart donc sans laisser de tenant derrière elle. Ces nombres ne sont plus écrits à la main : pnpm contract:openapi --check les confronte à la table des routes, et pnpm typecheck refuse de passer s'ils divergent. Mode d'emploi : ../postman/README.md, et ESSAIS.md pour le premier parcours.
  • Brancher un produit dessus — ce que ce document ne dit pas : FRONTEND.md. Trois de ses avertissements sont tombés depuis : le moteur tient l'index de ses conversations (GET /v1/conversations), X-AI-Engine-User se vérifie quand l'app le demande (person_identity: signed), et la progression d'un tour se pousse (GET …/events, en text/event-stream) autant qu'elle se sonde. Ce qui reste vrai : les jetons ne streament pas, la réponse arrive sur le POST.
  • Savoir que le contrat a changé. Chaque réponse porte X-AI-Engine-Contract, et GET /v1/health rend contract_version. Le majeur est le préfixe d'URL (1 tant que les routes s'appellent /v1/…) ; le mineur monte à chaque changement, même additif. Ce qui a changé, et lequel de ces changements casse un appelant, est dans CHANGELOG-CONTRAT.md.

1. Authentification

NiveauEn-têtesRoutes
installationAuthorization: Bearer <API_ADMIN_KEY>créer une app et (ré)émettre sa clé, créer un tenant et (ré)émettre la sienne
appAuthorization: Bearer app_<APP_ID>.<secret>créer et lister SES tenants, lire sa configuration et ses périmètres
tenantAuthorization: Bearer <TENANT_ID>.<secret>organisation et ses périmètres (lecture), documents et pages déclarées de l'entreprise
personnela clé du tenant, et X-AI-Engine-User: <identifiant>documents personnels, mémoire (lecture, effacement), effacement complet, conversation, pièces jointes
  • La clé désigne le tenant. Aucune route ne le prend ailleurs — ni dans l'URL, ni dans le corps.
  • La clé désigne l'app, pour la même raison. Un tenant créé sous une clé d'app naît dans CETTE app ; app_id dans le corps est refusé en 422.
  • Le préfixe app_ lève l'ambiguïté. <ID>.<secret> et app_<ID>.<secret> ont la même forme : sans lui, une clé d'app et une clé de tenant seraient indiscernables. Un identifiant d'app commençant par app_ est refusé à la création, pour que l'ambiguïté ne puisse pas naître par l'autre bout. Une clé d'app présentée sur une route de tenant reçoit 401, et réciproquement.
  • La clé en clair n'est rendue qu'une fois, à son émission. Le moteur n'en garde que l'empreinte (sha256, tables tenants et apps de la base de contrôle), relue par sa clé primaire à chaque requête et comparée en temps constant : une clé renouvelée cesse de valoir à la requête suivante. Elle n'entre jamais dans le journal Restate.
  • X-AI-Engine-User est l'identifiant que la plateforme donne à la personne : opaque, stable, 256 caractères au plus. Le moteur le croit parce que la clé prouve que c'est la plateforme qui le dit. Il n'en stocke que l'empreinte (u- + sha256).
  • API_ADMIN_KEY vide : les routes d'administration répondent 403. Les routes d'un tenant restent servies.

Une conversation appartient à la première personne qui y parle : une autre reçoit 403, sur les messages, la progression, l'effacement et les pièces jointes.

2. Statuts

Ce qui réussit

StatutQuand
200la réponse est là. Aussi sur une écriture qui ne change rien — PATCH d'une page déjà dans cet état, PUT d'un document personnel inchangé ou écarté
201une ressource est née et son identifiant est rendu : une app, un tenant, une pièce jointe, un document personnel indexé
202c'est accepté, pas fait. Le travail part en tâche de fond : dépôt et retrait d'un document, déclaration, activation et retrait d'une page. Ce qui en advient se relit par GET /v1/documents ou GET /v1/documents/sources
304sur If-None-Match, pour les trois routes d'illustrations. Les octets ne changent jamais sous un identifiant — c'est leur empreinte

Ce qui échoue

Toujours { "error": { "code": "…", "message": "…" } }. Le message dit quoi corriger.

StatutcodeQuand
401unauthorizedclé absente, mal formée, inconnue ou renouvelée
402payment_requiredla personne n'est pas inscrite, ou le contrat de l'organisation est suspendu ou échu. message reprend votre motif
403forbiddenroutes d'administration coupées ; conversation d'une autre personne
404not_foundroute, tenant, document, page déclarée ou pièce jointe inconnus
413payload_too_largecorps au-delà de la limite de la route
415unsupported_media_typeformat refusé, reconnu par les octets, jamais par l'extension seule
422invalid_requestvaleur invalide ; politique plus lâche que l'installation ; person hors contrat ; page déclarée locale ou privée
429rate_limitedtrop d'appels, trop vite. Toujours accompagné de Retry-After, en secondes
502engine_errorle moteur a échoué pendant le traitement
503unavailableRestate, StarRocks ou la mémoire des utilisateurs injoignable ; tenant non provisionné ; effacement qui n'a pas abouti, à rejouer

402 et 403 ne disent pas la même chose. 403 dit « cette clé n'a pas le droit » ; 402 dit « le droit existe, il n'est pas payé ». Un 402 ne se corrige pas en changeant de clé, mais en déclarant la personne ou en rétablissant le contrat de l'organisation.

Il n'y a jamais de 405. Une méthode qui n'est pas servie sur un chemin connu rend 404, et le message nomme les méthodes servies : « méthode PATCH non servie sur /v1/tenant — GET, PUT ». Un client qui distingue « chemin inconnu » de « méthode inconnue » doit donc lire le message, pas le statut.

2 ter. Le débit est plafonné, et deux des trois plafonds sont approximatifs

Un tour coûte de l'argent réel — environ 0,05 $US. Rien n'empêchait une boucle de les enchaîner : la protection était entièrement à votre charge.

Ce qui est bornéPortéeRéglage de l'installationCe qu'un tenant peut faire
Tours par heurel'organisationRATE_TURNS_PER_HOURpolicy.turns_per_hour, plus bas seulement
Tours par heureune personneRATE_TURNS_PER_HOUR_PERSON—
POST /v1/generate par heurele payeur (app ou tenant)RATE_GENERATE_PER_HOUR—
Dépense d'un jour UTCl'organisationRATE_DAILY_COST_USDpolicy.daily_cost_usd, plus bas seulement

Zéro vaut « aucun plafond », et c'est le défaut des quatre. Une installation qui ne règle rien se comporte exactement comme avant. Un tenant ne peut que durcir — même règle que policy.max_action, et le refus nomme le plafond de l'installation.

⚠️ Les plafonds de TOURS sont approximatifs, et il faut le savoir pour ne pas s'y fier. Ils tiennent dans la mémoire du processus qui sert la requête : à deux répliques de la passerelle, le débit réel double. C'est un garde-fou contre une boucle, pas un quota contractuel.

Le plafond de COÛT, lui, est exact : il se lit dans le registre des coûts, la même source que GET /v1/usage et que votre facture. Il tolère au plus une minute de retard (RATE_COST_CACHE_SECONDS), et ce que vous pouvez dépenser pendant cette minute est borné par le plafond de tours.

  • Le refus arrive avant l'ingress, donc avant le moindre appel de modèle : un tour refusé ne coûte rien, et n'entre pas au registre.
  • Retry-After est en secondes, toujours présent sur un 429, et jamais 0. Sur un plafond de coût, il vise la fin du jour UTC — c'est la frontière que le compteur observe.
  • Si le registre des coûts n'est pas lisible, le tour passe et l'incident est journalisé : un plafond qu'on ne sait pas lire ne doit pas se comporter comme un plafond atteint.

2 bis. Les listes se paginent

Sept routes rendent une liste, et elles rendaient tout. Une organisation avec cinq mille documents recevait cinq mille lignes, et rien ne permettait de les parcourir.

RouteCe qui est paginé
GET /v1/appsapps
GET /v1/tenantstenants
GET /v1/documentsdocuments — pas jobs : les dépôts en cours sont bornés par construction, et suivre un dépôt ne doit pas obliger à le chercher page après page
GET /v1/documents/sourcessources — pas orphans : les pages indexées que plus aucune déclaration ne porte sont un signal, et en tronquer la moitié le masquerait
GET /v1/me/documentsdocuments
GET /v1/conversationsconversations
GET /v1/usage/runsruns
ParamètreValeur
limit1 à 500, 100 par défaut. Hors bornes : 422 qui nomme le paramètre
cursorle next_cursor de la page précédente, opaque : à rendre tel quel, jamais à décoder

next_cursor n'est présent que s'il reste quelque chose. Son absence est la fin de la liste — il n'y a pas de page vide à demander pour s'en assurer.

⚠️ Le défaut tronque là où rien ne tronquait. Un appelant écrit avant cette version et qui lisait documents en entier n'en reçoit plus que cent. Les clés de réponse n'ont pas changé, aucune n'a disparu — c'est leur longueur qui a une borne. Passer limit=500 et suivre next_cursor restitue la liste complète.

Le curseur est opaque, et ce n'est pas une coquetterie. Deux mécanismes coexistent : les relevés qui lisent une table paginent en SQL, les listes que le moteur tient déjà en entier se tranchent après lecture. Les deux rendent le même jeton, et lequel sert où doit pouvoir changer sans casser personne.

3. Routes

Santé et contrat — sans clé

RouteCorpsRéponse
GET /v1/health—{ status, contract: "v1", contract_version, restate }
GET /v1/openapi.json—un document OpenAPI 3.1
  • GET /v1/health — restate: true est le seul champ qui prouve que le moteur s'est enregistré : la route répond 200 même quand Restate est mort.
  • GET /v1/openapi.json — le contrat, dérivé de la table des routes, donc toujours celui de la passerelle qui le sert. Sans clé : un intégrateur doit pouvoir lire la surface avant d'en avoir une. Il porte, en plus de ce qu'OpenAPI standardise, quatre extensions que le client engendré lit : x-ai-engine-access (le niveau de clé, tel que le moteur le nomme), x-ai-engine-list (la route pagine), x-ai-engine-binary (elle rend des octets, et le type de média dit lesquels) et x-ai-engine-template (le gabarit exact du moteur, quand il diffère du chemin du document — voir ci-dessous). La copie versionnée est docs/openapi.json ; pnpm contract:openapi --check, dans pnpm typecheck, interdit qu'elles diffèrent.

⚠️ Deux chemins du document ne portent pas le nom de segment que le moteur journalise, et c'est une contrainte d'OpenAPI, pas un choix : la spécification interdit deux chemins de même hiérarchie dont les segments s'appellent autrement — ils sont identiques. Or PUT /v1/me/documents/{name} attend un nom de fichier et DELETE /v1/me/documents/{id} un identifiant rendu par la liste ; de même pour les pièces jointes. Le document en retient un nom par hiérarchie, chaque opération gardant sa propre description de segment, et x-ai-engine-template porte le gabarit du moteur. Rien ne change pour un appelant — c'est la substitution qui compte, pas le nom du trou.

Administration — clé de l'installation

RouteCorpsRéponse
GET /v1/apps—{ apps: [{ …app, is_default }] }
POST /v1/apps{ id, mission, baseline_label, baseline_description, conduct?, answer_form?, person_identity?, label?, policy? }201 { app, api_key, integration, provisioned, provision_error?, catalogue_skills, catalogue_source?, catalogue_error? }
POST /v1/apps/{id}/api-key—{ app, api_key }
POST /v1/tenants{ id, label?, profile?, facts?, policy? }201 { tenant, api_key, provisioned, provision_error? }
POST /v1/tenants/{id}/api-key—{ tenant, api_key, replaced }
POST /v1/tenants/{id}/provision—{ tenant, provisioned, created, extended, rebuilt, unchanged } — aussi sous clé d'app, pour l'un des siens
DELETE /v1/tenants/{id}—202 { tenant, database, dropped, persons_removed, settings_removed, restate_state_cleared, kept } — aussi sous clé d'app, pour l'un des siens
DELETE /v1/apps/{id}—202 { app, database, dropped, settings_removed, kept }
  • GET /v1/apps liste les apps de l'installation, celle d'APP_ID en tête (is_default). C'est le pendant de GET /v1/tenants pour une clé d'app, et ce qui permet à une plateforme de NOMMER l'app d'un tenant et de proposer les autres : sans elle, une app n'existe pour elle que si un de ses tenants y est déjà branché.

  • POST /v1/apps valide l'identifiant (mêmes règles qu'un tenant, et jamais préfixé app_), écrit la ligne au registre et provisionne sa base (<SR_DATABASE>_app_<slug>).

    Son catalogue est HÉRITÉ du socle de l'installation quand elle en déclare un, par copie — vecteurs compris, donc zéro embedding : créer une app ne coûte jamais un embedding par surprise, et elle n'arrive plus muette pour autant. La réponse porte catalogue_skills (le nombre hérité) et catalogue_source (l'app d'où il vient). Si l'installation n'en déclare aucun, catalogue_skills vaut 0 et le bloc integration le dit en toutes lettres : un catalogue vide fait répondre « aucune compétence de diagnostic » à toute question, et déposer un document au socle n'y change rien — c'est le catalogue qui décide de ce que l'assistant sait traiter. Il s'amorce alors par PUT /v1/app/skills/{id} (§ Catalogue et capitalisation), par pnpm app:import --file=…, ou depuis la console. mission est ce que fait l'agent, au singulier — elle entre dans ses prompts à la place de « support informatique » ; baseline_* est le socle, toujours actif, sur la description duquel la porte d'entrée et l'analyse d'ingestion jugent. policy.max_action ne peut que DURCIR ce que l'installation permet.

  • POST /v1/apps/{id}/api-key (ré)émet la clé d'une app, et adopte une app déclarée dans .env.

  • POST /v1/tenants valide l'identifiant (lettres, chiffres, _, -, 48 caractères au plus, sans :), écrit la ligne avec l'empreinte de la clé, fait relire le registre à l'agent, puis provisionne la base (<SR_DATABASE>_<slug>) par le handler de /admin. Un provisionnement en échec rend provisioned: false : le tenant existe, l'écran Tenants de /admin le montre « base à créer ». Servie aussi sous clé d'app : le tenant naît alors dans cette app.

  • POST /v1/tenants/{id}/provision reprend un provisionnement en échec, et c'est le geste qui manquait : un tenant né provisioned: false rendait chaque tour en erreur, et la plateforme n'avait qu'un courriel à écrire à l'exploitant. Rejouable par construction — il n'existe aucune colonne provisioned : l'état est dérivé des tables présentes, et le provisionnement crée ce qui manque sans toucher à ce qui est là. Sur un tenant déjà en service, elle rend created: 0. Elle ne reconstruit pas une table dont la forme a dérivé (on y perdrait son contenu) : ce cas rend 422 en nommant la table, et demande un geste d'exploitant.

    Trois choses à savoir avant d'y revenir trop vite. Un tenant ne naît pas provisioned: false en général : c'est l'état d'un ÉCHEC, et le chemin ordinaire rend true. Le moteur réessaie désormais tout seul une panne passagère de la base — trois tentatives, et seule une divergence de schéma est définitive. Et du côté de l'exploitant, deux gestes existaient déjà : l'écran Tenants de la console porte un bouton « Provisionner », et pnpm db:bootstrap rejoue tous les tenants du registre.

  • POST /v1/tenants/{id}/api-key (ré)émet la clé. C'est aussi le geste d'adoption d'un tenant déclaré dans .env — POC_TENANT et son corpus compris : sa ligne naît avec sa clé, sans cadre ni politique, et .env garde la main sur sa base.

Retirer un client, retirer un produit

On savait créer un client par le contrat, pas le retirer : une plateforme qui a une obligation d'effacement ne pouvait l'honorer qu'en demandant un geste manuel à l'exploitant du moteur — c'est-à-dire en ne l'honorant pas.

DELETE /v1/tenants/{id} emporte, dans cet ordre :

  1. l'état Restate de ses objets — il ne vit pas en base, et aucun DROP DATABASE ne l'emporterait : l'état d'une conversation porte ce qui s'y est dit et les extraits retenus verbatim. Conversations, personnes, file de dépôts, synchronisations, sujets refusés ;
  2. sa base : documents, passages, historique des tours, index des conversations, documents personnels, périmètres, connecteurs ;
  3. ses octets : pièces jointes, documents déposés, illustrations ;
  4. ce que la base de contrôle garde de lui : personnes déclarées, réglages ;
  5. sa ligne de registre, en dernier — tant qu'elle est là, le tenant se relit, donc l'appel se rejoue et reprend par ce qui reste. La retirer d'abord rendrait invisible ce qui n'aurait pas été effacé.

Ce qui survit, et c'est par construction — la réponse le nomme dans kept :

Ce qui restePourquoi
usage_eventsle registre des coûts vit dans la base de contrôle précisément pour que ce qu'un client a consommé survive à son départ. Une facture ne se réécrit pas. Il ne porte que des montants, des empreintes u-… et des comptes — ni message, ni titre, ni nom
config_auditretirer un client ne doit pas effacer la trace de ce qui a été fait pour lui, y compris la ligne de ce retrait
  • Sous clé d'app, pour l'un des siens seulement. Le tenant d'une autre app rend 404, jamais 403 : le contrat ne dit pas à une app qu'un identifiant existe ailleurs.
  • Un tenant déclaré dans .env rend 422 : le retirer du contrat le laisserait renaître au démarrage suivant, sans sa base. Retirer d'abord sa ligne de configuration.
  • Si la base ne se dépose pas : 503, et la ligne de registre reste — donc l'appel se rejoue. Mieux qu'un tenant sans base, qui répondrait 503 à chaque tour.
  • 202 et non 200 : Restate applique le vidage d'état de façon asynchrone. restate_state_cleared compte les objets traités ; restate_state_failed, présent seulement s'il y a eu des échecs, les nomme.

DELETE /v1/apps/{id} (clé d'installation) : 422 tant qu'elle sert une organisation, et le refus les nomme. Une app retirée sous ses clients leur enlèverait d'un coup leur catalogue de thèmes, leur socle et tout ce qu'ils ont capitalisé — et leurs tours répondraient « aucune documentation » sans que rien ne dise pourquoi. L'ordre est donc : DELETE /v1/tenants/{id} pour chacun, puis l'app.

App — clé de l'app

RouteCorpsRéponse
GET /v1/app—{ app, tenants }
PUT /v1/app{ label?, mission?, baseline_label?, baseline_description?, conduct?, answer_form?, person_identity?, policy? }{ app }
GET /v1/app/scopes—{ app, baseline, scopes, max_scopes }
POST /v1/app/scopes{ label, description, enabled?, answers? }201 { scope }
PATCH /v1/app/scopes/{id}{ label?, description?, enabled?, answers? }{ scope }
DELETE /v1/app/scopes/{id}—{ scope_id, deleted, tenants_cleared }
GET /v1/app/documents—{ documents, total, max_mb }
PUT /v1/app/documents/{name}octets bruts (≤ UPLOAD_MAX_MB, 25 Mo)201 indexé, 200 inchangé ou écarté : { document }
DELETE /v1/app/documents/{name}—{ name, deleted }
GET /v1/app/documents/sources—{ sources, total }
POST /v1/app/documents/sources{ url, label?, note? }201 { source }
PATCH /v1/app/documents/sources/{id}{ enabled }{ source }
DELETE /v1/app/documents/sources/{id}—{ id, deleted }
GET /v1/tenants—{ app, tenants }
POST /v1/tenants{ id, label?, profile?, facts?, policy? }201 { tenant, api_key, … } — le tenant naît dans l'app de la clé

app vaut { id, label, origin, mission, baseline: { label, description }, conduct, answer_form, person_identity, policy, pack? }. GET /v1/app/scopes rend le socle de l'app et les périmètres qu'elle ouvre : c'est ce dont chacun de ses tenants hérite, et qu'il ne peut que durcir — le fermer, ou resserrer sa réponse de general à documentation.

PUT /v1/app remplace par clé présente : une clé absente n'est pas touchée. Une policy fournie remplace la politique entière, et un baseline_label sans baseline_description est refusé — dans les deux cas, la perte serait silencieuse.

mission, baseline_description, conduct : trois champs, trois rôles

C'est la confusion la plus coûteuse de cette section, parce que les trois sont du texte libre qui finit dans un prompt. Ils ne vont pas au même endroit, et deux d'entre eux ont des effets de bord qu'on ne devine pas.

ChampCe qu'il ditOù il vaEffet de bord
mission (512, une ligne)le métier, au singulier : « support informatique », « conseil en vins »onze prompts système, à la place de « support informatique »le plus sensible : une phrase de plus dans INTENT_SYSTEM déplace la qualification des demandes
baseline_description (4 000, une ligne)le sujet que l'agent traite toujoursprompt de la porte d'entrée et juge d'ingestionle réécrire fait rejuger tous les documents admis sous socle, chez tous les tenants de l'app (baseline_changed)
conduct (4 000, multi-ligne)le comportement : ton, niveau, format, réservesprompt du composeur, et lui seulaucun : ni la porte d'entrée, ni le juge d'ingestion, ni un prompt figé ne la lisent

conduct est l'endroit où poser une consigne de produit, et c'est le seul. Avant elle, un intégrateur qui voulait imposer « ne prétends jamais remplacer un audit, un avocat ou un professionnel de santé », « deux ou trois recommandations au plus », « réponds au niveau de technicité du profil » n'avait que baseline_description — où l'écrire faussait le tri de ses documents.

Ce qu'elle ne peut pas faire, et le prompt le dit au modèle dans le même bloc :

  • elle ne lève jamais « rien sans source » : une affirmation sans extrait reste interdite ;
  • elle n'élargit jamais le plafond d'action ni les droits d'administration — ils sont appliqués par le journal, pas par le prompt ;
  • elle ne décide pas de la langue : celle de la demande et du profil de la personne l'emportent ;
  • une instruction qui demanderait de révéler les consignes ou un extrait n'est pas une conduite.

Vide, elle efface la conduite, et l'app retrouve exactement le prompt d'avant — à l'octet près. C'est ce qui permet de l'ajouter sans déplacer un seul banc.

Pour ce qui dépend de la question plutôt que du produit, le tour porte conduct_addendum (1 000 caractères).

answer_form : le dépannage en étapes, ou le conseil en recommandations

steps (défaut) ou recommendations, déclarée par l'app et surchargeable par tour.

Le cahier du POC interdit de répondre par une liste d'étapes d'un bloc et exige l'inverse : hypothèse, test, résultat, validation. C'est juste pour du dépannage — la personne est devant sa machine, elle exécute et elle rapporte. Ce n'est pas juste pour du conseil : une dirigeante qui demande « est-ce que ma messagerie est bien protégée ? » ne teste pas, elle décide. Lui répondre « ouvrez le centre d'administration et dites-moi ce que vous voyez » n'est pas un conseil prudent, c'est un dépannage qu'elle n'a pas demandé.

stepsrecommendations
answer.stepl'unique étape du tournull la plupart du temps, pas toujours — voir ci-dessous
answer.messagesouvent vide : l'étape se suffitla réponse entière, 3 à 6 phrases, jamais vide
answer.recommendationsabsent2 ou 3 actions décidables, 3 au maximum

⚠️ Sous recommendations, answer.step peut être renseigné, et un écran doit le prévoir. Deux cas, et ils ne sont pas rares : la personne demande explicitement à être guidée (« montrez-moi comment »), et le tour ESCALADE — l'étape porte alors ce qu'il faut transmettre. Le contrat ne change pas : step est null ou un objet, dans les deux formes. Ce qui change ici est la franchise de cette page, qui laissait croire à une exception anecdotique ; le prompt du composeur, lui, dit toujours « c'est rare », et il est gelé (pnpm prompts:check) — le corriger demanderait de regeler les quatorze prompts et de remesurer un banc, ce qui n'est pas le prix d'un adverbe.

Deux apps distinctes n'auraient pas réglé la question : la même organisation a les deux besoins, souvent dans la même conversation, et le routage entre les deux serait revenu à l'appelant — qui ne sait pas, avant la réponse, si la demande était un dépannage. D'où le réglage par tour.

Ce que la forme ne change pas : « rien sans source » reste entier — une recommandation qui ne cite aucune preuve réelle est écartée par le serveur, elle n'atteint pas la personne. Le plafond d'action, l'admission des documents et la langue ne dépendent pas de la forme.

Sous steps, le prompt envoyé est identique à l'octet près à celui d'avant l'arrivée de ce réglage : c'est ce qui garantit qu'aucune app de dépannage ne voit sa réponse bouger.

Ce que ce contrat n'expose PAS : le modèle et le fournisseur

Aucun champ de PUT /v1/app ne nomme un modèle, un fournisseur, une clé ou un point d'entrée LLM. C'est délibéré, et ce n'est pas une lacune à combler.

La clé du fournisseur est celle du moteur, jamais apportée par un client :

  • elle n'est lue que sur env.* (packages/ai/src/providers.ts), l'instantané de .env figé au démarrage ;
  • SECRET_KEY écarte tout *_API_KEY de la table settings, à la lecture comme à l'écriture (packages/core/src/config.ts) : une clé LLM ne peut pas descendre en base, même par une écriture hors console ;
  • la liste blanche des identifiants qu'un tenant peut apporter (packages/core/src/tenant.ts) ne contient que des connecteurs documentaires — Google Drive, SharePoint, corpus local. Aucune clé LLM.

Conséquence pratique pour un intégrateur : vous ne choisissez pas le modèle qui vous sert, et vous ne pouvez pas nous faire appeler un fournisseur en votre nom. Le paramétrage LLM se règle par app, à chaud, depuis le backoffice opérateur — de ce côté-ci de la frontière (docs/BACKOFFICE.md, « Basculer de fournisseur pendant une panne »). Ce qui est facturé à l'appel est donc toujours imputable à l'exploitant du moteur, et la bascule d'un fournisseur en panne ne demande aucune action de votre part.

Réglages — clé de l'app, ou du tenant

RouteCorpsRéponse
PUT /v1/app/settings/{key}{ value }{ setting: { key, scope, scope_id, value, updated_at } }
DELETE /v1/app/settings/{key}—{ key, scope, scope_id, deleted }
PUT /v1/tenant/settings/{key}{ value }{ setting }
DELETE /v1/tenant/settings/{key}—{ key, scope, scope_id, deleted }

Une paire de routes pour toute clé, et c'est délibéré. Ce qui est réglable, dans quelle portée, et avec quelle valeur est une donnée du moteur, pas une liste de routes : une clé ajoutée au schéma devient réglable sans qu'une route s'ajoute ici. WEB_ALLOWLIST, WEB_SEARCH_ENABLED, CAPITALIZE_ENABLED, MCP_ENABLED, DOCUMENT_ADMISSION, SCOPE_CANDIDATES, USER_MEMORY, les planchers de routage et les bornes d'illustration passent tous par là.

La cascade est tenant → app → installation → .env → défaut : le plus précis l'emporte, et l'effet est au tour suivant, sans redémarrage. La portée vient de la ROUTE et la cible de la CLÉ — scope, scope_id, key et remove dans le corps sont refusés en 422, comme app, tenant et actor partout ailleurs.

Cinq refus, et le message dit lequel :

Le refusCe qu'il protège
la clé reste dans .envun secret, une adresse de service, ce qui sert à joindre la base
elle ne se change pas à chaudelle périmerait des données déjà écrites (EMBEDDING_*, ILLUSTRATIONS_ENABLED)
cette portée n'a pas d'effetLLM_* et WEB_ALLOWLIST se règlent par app, jamais par tenant
la valeur ne passe pas son schémaune faute de frappe ne s'installe pas en base
la clé appartient à l'exploitantle modèle et le fournisseur, le plafond de max_output_tokens, les plafonds de débit et de dépense, le plafond d'action, la déclaration des apps et des organisations, l'ancre de l'identité signée

Le dernier refus porte la raison déjà publiée pour chacun : « vous ne choisissez pas le modèle qui vous sert » (§ App), « le niveau est déclaré par la plateforme, le plafond est décidé par l'exploitant ». Un retrait refuse les mêmes clés : retirer la ligne de l'exploitant serait un moyen détourné de la lui reprendre.

Il n'y a pas de route de lecture, et c'est un choix. La réponse d'une écriture rend la ligne écrite et sa date — de quoi vérifier qu'elle a pris. Lire tout l'écran dirait aussi le plancher que l'exploitant pose pour l'installation, qui n'appartient pas à l'app.

Catalogue et capitalisation — clé de l'app, et l'écart du tenant

RouteCorpsRéponse
PUT /v1/app/skills/{id}la forme d'un thème (voir ci-dessous)201 créé, 200 mis à jour : { skill, created }
PUT /v1/app/skills/{id}/web{ web_research?, web_queries?, notes? }{ skill_id, web_research, web_queries, overlay }
PUT /v1/app/procedures/{id}{ enabled }{ procedure_id, theme, title, enabled }
PUT /v1/app/illustrations/{id}{ enabled }{ image_id, subject, kind, enabled }
PUT /v1/tenant/skills/{id}/overlay{ enabled?, risk_level?, escalation_only?, escalation_reason?, notes? }{ skill_id, catalogue, effective, overlay }

PUT /v1/app/skills/{id} est ce qui rend une app neuve utile. Sans catalogue, tout tour répond « aucune compétence de diagnostic », et déposer un document au socle n'y change rien : c'est le catalogue qui décide de ce que l'assistant sait traiter. Le corps est la forme d'un thème — name, category, description, risk_level, intents, symptoms, knowledge_queries, resolution_flow, success_condition, et les champs facultatifs (discriminant, required_context, optional_context, escalation_conditions, general_debug, tools, enabled, version). C'est le même contrat qu'un paquet livré, validé par le même schéma, les mêmes neuf invariants et le même lint de neutralité : un refus nomme le champ. Un theme.* ou un conseil.* se créent librement ; les quatre identifiants de routage se créent aussi — c'est ainsi qu'une app neuve les installe — mais il n'en existe pas de cinquième.

L'identifiant vient du chemin. Un id différent dans le corps est refusé : renommer un thème orphelinerait les documents et les procédures qui le référencent. Un embedding est payé seulement si le texte indexé a bougé.

Un tenant ne peut que durcir. PUT /v1/tenant/skills/{id}/overlay ferme un thème, relève son risque, impose l'escalade. Abaisser le risque d'un thème HIGH, ou lever une escalade que le catalogue impose, rend 422 — et ce n'est pas une politesse de validation : le garde effectif est recalculé à chaque lecture du catalogue, donc une ligne écrite à la main dans la base ne désarmerait rien non plus. La réponse rend les trois états : catalogue, overlay (la ligne, ou null), et effective — ce que l'agent applique. Un écart qui n'écarte plus rien supprime la ligne.

Ce qui reste hors du contrat, et les raisons sont écrites : le corps d'une fiche capitalisée et la promotion d'un brouillon — ils décideraient de ce que l'assistant dit, pas seulement d'où il cherche ; et tout écart valant pour l'installation entière, qui porte sur les clients des autres.

Socle documentaire de l'app — clé de l'app

Les documents que le produit déclare pour toutes les organisations qu'il sert.

Le cas est celui d'un intégrateur qui s'appuie sur une dizaine de documents officiels — France Num, ANSSI, CNIL — et qui ne décrivent l'environnement de personne. Jusqu'ici une source déclarée appartenait à une organisation : il fallait les redéclarer chez chacun de vos clients, donc les faire relire, redécouper et réembarquer autant de fois que vous avez d'organisations. Déclarés une fois ici, ils sont lus par tous vos tenants sans qu'une ligne soit copiée ni un vecteur recalculé.

Où le socle se place dans une réponse. Sous la documentation de l'organisation, au-dessus des références web. Un extrait de la documentation du client passe toujours avant : c'est lui qui sait quel outil l'organisation a déployé. Mais le socle est de la documentation — une étape peut s'y appuyer et se dire documentée, ce qu'une référence web ne peut pas.

Un tenant ne l'écrit pas. Ces sept routes exigent la clé d'app ; une clé d'organisation reçoit 401. C'est la même asymétrie que les périmètres (GET /v1/app/scopes) : le niveau au-dessus ouvre, celui du dessous hérite.

Déposer un document

PUT /v1/app/documents/{name} indexe pendant la requête — c'est la différence avec PUT /v1/documents/{name}, qui rend 202 et un job_id. Un socle est une liste courte, déposée une fois : la réponse porte donc l'issue réelle, pas un numéro de travail à suivre.

{
  "document": {
    "name": "hygiene-des-mots-de-passe.md",
    "title": "Hygiène des mots de passe",
    "status": "indexed",
    "chunks": 4,
    "theme": "securite.sensibilisation",
    "reason": "Le document traite l'hygiène des mots de passe : socle."
  }
}
statusSensStatut HTTP
indexedindexé, chunks passages, theme renseigné — y compris quand aucun thème du catalogue ne le couvrait : le dépôt crée alors un thème générique (voir ci-dessous)201
unchangedmêmes octets, même version d'index : rien n'a été réécrit ni repayé200
ignoredécarté par l'analyse de périmètre de l'app : reason porte le motif du juge200

Un document écarté rend 200 et non 4xx : la requête a abouti, c'est le verdict qui refuse, et le corps le dit. Les formats sont ceux du dépôt d'organisation ; un contenu chiffré, illisible ou sans lecteur rend 415 ou 422 avec la phrase qui dit quoi faire.

⚠️ Aucun document du socle ne reste sans thème — et ce n'était pas vrai avant le 02/10/2026. Le juge d'indexation ne juge que contre le catalogue existant : sur un catalogue vide, il ne pouvait rien retenir, et le document était indexé avec theme: null. Un passage retenu, une réponse rendue, et rien pour dire de quel thème il relevait. Le dépôt joue maintenant la couverture du socle — le même mécanisme que la synchronisation d'une organisation (THEME_AUTO_COVER) : le thème du catalogue qui couvre le sujet, ou un thème générique créé pour lui. Il tourne dans la requête, et coûte un appel de modèle par document non couvert. Une couverture impossible — aucune clé de modèle — ne fait pas échouer le dépôt : le document reste indexé, theme vaut null, et reason dit pourquoi.

Déclarer une page

POST /v1/app/documents/sources déclare et lit dans le même geste, une page et une seule. C'est l'autre différence avec le niveau organisation : là, un passage de la source url repasse derrière chaque geste, découvre les pages d'une documentation par son sommaire, et élague ce que la liste ne porte plus. Le socle n'a pas ce passage.

Trois conséquences, et il vaut mieux les connaître :

  • la réponse porte l'issue de la lecture dans detail, tout de suite ;
  • une lecture en échec ne défait pas la déclaration : la page reste dans la liste avec son motif, et une seconde requête la rejoue quand le site est revenu ;
  • désactiver ou retirer une page en retire les passages immédiatement, faute de passage suivant qui les élaguerait. Réactiver relit la page.
statusSens
indexedla dernière lecture l'a indexée ; detail dit ses passages
ignoredécartée par l'analyse de périmètre de l'app : detail porte le motif
failedla lecture a échoué : detail dit pourquoi (HTTP 404, page vide, redirection refusée)
pendingpas encore lue
disableddésactivée : ses passages ont quitté l'index

Mêmes règles d'adresse que POST /v1/documents/sources : http ou https, sans identifiants, ni localhost, ni adresse privée ou de lien local (422), et une page publique ne peut pas rediriger vers une telle adresse.

La liste blanche du web

Une app peut borner les domaines dont une référence web entre en preuve, pour toutes les organisations qu'elle sert : le réglage WEB_ALLOWLIST (portée app), hôtes nus séparés par des virgules, correspondance sur le suffixe — service-public.fr admet entreprendre.service-public.fr. Vide : aucun filtre, le comportement d'avant.

Elle borne ce qui entre en preuve, jamais ce qui est classé comme documentation d'éditeur : un domaine admis qui n'est pas celui d'un éditeur n'est pas présenté comme tel. Quand aucune référence ne passe, la synthèse de recherche part avec elles — elle condense les pages qu'on vient d'écarter — et le compte des écartées est rendu dans la trace du tour, à côté du nombre de références.

Ce réglage s'écrit par le contrat : PUT /v1/app/settings/WEB_ALLOWLIST, avec la clé de l'app (§ Réglages). Il s'écrit aussi depuis la console de l'exploitant, et les deux chemins posent la même ligne.

Connecteurs — clé du tenant

RouteCorpsRéponse
GET /v1/connectors—{ connectors } — une ligne par source, branchée ou non
PUT /v1/connectors/{source}{ mode, target }{ connector }
POST /v1/connectors/sync{ source, force?, credential? }202 { job }
GET /v1/connectors/sync/{id}—{ job }
PUT /v1/connectors/sync/{id}/credential{ access_token, expires_in? }{ job }

Le cahier demandait d'importer les connaissances depuis Google Drive / Workspace et Microsoft SharePoint (§1, §6). Le code existait, complet ; il n'était enregistré dans aucune route, donc injoignable par le contrat — et le §31, « 20 à 30 procédures tenant réellement importées », en dépendait.

Une ligne de connectors : { source, label, configured, mode, accepts_credential, simulated, reason, target, documents, last_sync, running }. reason dit pourquoi une source n'est pas branchée — c'est ce qu'il faut afficher plutôt qu'un simple « non configuré ».

Deux modes, et la différence porte sur qui détient le secret :

modeQui détient le jeton
envl'exploitant du moteur, dans son .env. La plateforme ne fournit rien
delegatedla plateforme. Elle déclare une fois une cible non secrète (target, par ex. un identifiant de dossier), puis confie un jeton court à chaque passage. Le moteur ne garde aucun identifiant durable

Un secret dans target est refusé (422) : la cible n'est pas l'endroit d'un jeton.

POST /v1/connectors/sync n'accepte pas source: "all", délibérément : cinq passages d'un seul appel mélangeraient sources déléguées et sources .env dans un même travail, et aucune ligne ne pourrait porter cinq statuts. La plateforme boucle sur les sources.

Un passage délégué dont le jeton expire avant la fin se réarme par PUT /v1/connectors/sync/{id}/credential — c'est ce qui permet une synchronisation plus longue que la durée de vie d'un jeton.

Le chemin littéral l'emporte sur le gabarit. /v1/connectors/sync est le lancement d'un passage, pas la source nommée « sync » : l'aiguillage retient toujours le chemin qui a le moins de paramètres. Même règle que /v1/documents/sources.

Qui est servi — le contrat, et les personnes déclarées

RouteCorpsRéponse
PUT /v1/tenants/{id}/contract (clé d'installation){ status?, persons?, expires_at?, reason? }{ tenant }
PUT /v1/person (clé du tenant + personne){ subscription?, plan?, expires_at?, reason? }{ person }
PUT /v1/me/profile (personne)le cadre de person{ profile }

X-AI-Engine-User n'était vérifié que dans sa forme. Changer la valeur de l'en-tête suffisait à lire la mémoire et les documents personnels de quelqu'un d'autre — au sein du même tenant, donc entre collègues. Le moteur n'a pas d'annuaire et n'en aura pas, mais il peut exiger que la plateforme ait déclaré la personne : un identifiant inventé ne désigne alors personne, et le tour est refusé avant d'avoir rien lu.

Ce n'est pas de l'authentification — c'est la plateforme qui authentifie. C'est une liste de qui est servi, et elle suffit à fermer la porte à un identifiant forgé.

contractEffet
statusactive, suspended ou expired. Autre chose qu'active : 402 tenant_contract sur chaque route de personne
personsopen (défaut) : toute personne présentée est servie. enrolled : seules les personnes déclarées par PUT /v1/person le sont, les autres reçoivent 402 person_subscription
expires_at2026-12-31 ou un instant ISO, lu et rendu en UTC. Passé, le contrat est échu quel que soit status
reasonce que le refus dira, repris mot pour mot

open par défaut, et c'est un choix, pas un oubli. Un défaut enrolled aurait rendu 402 sur chaque tour de chaque installation existante au premier déploiement. Le défaut préserve le comportement d'avant ; enrolled est le réglage à prendre dès que vos comptes sont stables, et c'est le seul qui ferme la porte à un identifiant forgé.

Ce qu'un 402 ne ferme JAMAIS, et le principe est plus large que « les routes qui effacent » : un impayé ne doit pas empêcher quelqu'un de récupérer ses données ou de les faire supprimer. Le 402 protège le service rendu, pas les droits d'une personne sur ce qui la concerne.

ExemptéPourquoi
PUT /v1/personc'est la route qui déclare la personne : la fermer ferait de l'inscription un verrou dont la clé est à l'intérieur
GET et DELETE /v1/me/memoryeffacer, et vérifier que l'effacement a abouti (residue_pending) — le fermer rendrait l'effacement invérifiable
GET /v1/me/documentsla liste : la procédure d'effacement ci-dessous en dépend explicitement. Sans elle, la boucle ne se parcourt pas et le droit devient théorique
DELETE /v1/me/documents/{id}, DELETE /v1/conversations/{cid}les deux autres pas de la même procédure
DELETE /v1/mel'effacement complet : l'exemption la plus évidente de la liste, et la seule qui rende les autres inutiles

Ce que le 402 ferme donc réellement : le tour, sa progression, ses pièces jointes, le dépôt d'un document personnel (PUT /v1/me/documents/{name} — gabarit distinct de la liste), l'écriture d'un profil. Tout ce qui consomme.

Le refus arrive avant l'ingress, donc avant le moindre appel de modèle : « un tour refusé ne coûte rien » ne tient que là. Et la personne n'est relue en base que sous enrolled — sous open, la garde ne coûte aucun aller-verso de plus.

PUT /v1/me/profile range le cadre de la personne, et il ne sert qu'à défaut : person joint à un message l'emporte en bloc. Un frontal qui joint person à chaque message se comporte exactement comme si cette route n'existait pas. Les quatre champs d'abonnement y sont refusés en 422 — ils disent ce qui est dû, et c'est la plateforme qui le déclare, pas la personne.

person_identity : comment le moteur sait qui parle

Réglage d'app : header (défaut) ou signed.

header — le moteur croit X-AI-Engine-User parce que la clé du tenant prouve que c'est la plateforme qui le dit. C'est le mode historique, et sa limite doit être nommée : la clé prouve que la plateforme est elle-même, pas que la personne est celle qu'elle annonce. Un bogue de votre côté — un identifiant qui vient d'un paramètre d'URL, d'un cookie non signé — devient un accès à la mémoire et aux documents personnels d'un collègue.

signed — la personne vient du sub d'un JWT que vous signez, porté par X-AI-Engine-User-Token et vérifié contre votre JWKS (PLATFORM_JWKS_URL, PLATFORM_JWT_ISSUER, PLATFORM_JWT_AUDIENCE). Deux contrôles de plus :

RefusPourquoi
jeton absent (401)l'app exige l'identité signée
jeton émis pour une autre organisation (401)le jeton porte le tenant et la personne, et les deux sont vérifiés : sans cela un jeton valide de A, présenté avec la clé de B, servirait chez B. La réponse ne nomme pas l'organisation du jeton
X-AI-Engine-User qui contredit le sub (422)sous signed, l'en-tête n'est plus une source. Une contradiction est un bogue de la plateforme, et le taire laisserait croire que l'en-tête a été pris en compte. Envoyez la même valeur, ou rien

C'est un réglage par app : un produit passe au signé quand sa plateforme est prête, sans attendre les autres. Le défaut reste header parce que basculer une installation qui n'a émis aucun jeton la couperait net.

En développement, pnpm dev:token --sub=alice --tenant=ACME émet un jeton signé par une clé du poste, avec les mêmes claims qu'un vrai.

Ce que la table des personnes ne contient pas : ni nom, ni e-mail, ni téléphone. L'empreinte u-… de l'identifiant, ce qui est dû, et le cadre rangé. Elle dit « cette empreinte est servie », jamais « voici qui c'est ».

Consommation — clé du tenant, et clé d'app

RouteCorpsRéponse
GET /v1/usage—{ from, to, totals, group_by?, groups? }
GET /v1/usage/runs—{ runs, next_cursor? }
GET /v1/app/usage (clé d'app)—{ app, from, to, totals, group_by?, groups? }

Le cahier demandait d'afficher par requête les sources, les jetons d'entrée, les jetons de sortie et la durée (§26). C'était livré — dans la console de l'opérateur du moteur. Une plateforme, elle, n'avait qu'un chemin : lire la clé trace d'un tour, celle que ce document lui dit de ne pas relayer. Ces trois routes lèvent la contradiction.

Paramètres de GET /v1/usage et /v1/app/usage

ParamètreValeur
from, to2026-09-01 ou un instant ISO complet, lus en UTC. À défaut, les 30 derniers jours. 400 jours au plus
group_byactivity, day, model, provider, source, user_key — et tenant_id sous clé d'app seulement. Absent : une seule ligne, le total
activitypour ne relever qu'une activité : conversation, generation, ingestion, personal_documents…

totals vaut { calls, input_tokens, output_tokens, turns, conversations, cost_usd, priced, unpriced }. cost_usd porte sa devise dans son nom : un montant sans devise se lit dans celle du lecteur, et l'écart entre un dollar américain et un canadien est de l'ordre de trente pour cent. unpriced compte les appels dont le tarif manque au barème — un total qui grimpe sans que cost_usd bouge s'explique par là.

Sous clé de tenant, group_by=tenant_id est refusé : ce serait la liste des voisins. Sous clé d'app, il est la répartition de sa propre facture entre ses clients.

GET /v1/usage/runs — une ligne par TOUR, du plus récent au plus ancien. C'est le §26 rendu par le contrat : un total ne dit pas quel échange a coûté cher.

ParamètreValeur
limit1 à 500, 100 par défaut
cursorle next_cursor de la page précédente, opaque : à rendre tel quel
conversation_idpour ne relever qu'une conversation. L'identifiant nu suffit — celui que GET /v1/conversations rend et que vous posez dans l'URL d'un tour ; la forme interne <TENANT_ID>:<cid> est acceptée aussi

Chaque ligne : { run_id, created_at, conversation_id, pipeline, status, input_tokens, output_tokens, duration_ms, documents_retrieved }. documents_retrieved est le « Sources » du cahier. Ni question, ni réponse, ni trace : un relevé de consommation n'est pas un historique, et c'est ce qui permet de le servir sous la clé du tenant.

503 si la table manque — usage_events naît au pnpm db:bootstrap, assistant_runs au provisionnement du tenant. Une installation qui ne les a pas encore n'est pas en panne : elle n'a rien à relever, et le dire évite de chercher une panne là où il manque un provisionnement.

Génération contrainte — clé d'app ou de tenant

RouteCorpsRéponse
POST /v1/generate{ name, schema, system?, input, max_output_tokens?, effort? }{ object, usage }

Pour tout ce qui n'est pas une conversation : une veille qui tourne la nuit, un plan d'action rédigé dans un écran de back-office, l'analyse d'un document au dépôt. Aucun de ces appels n'a de personne qui attend, d'étape à proposer ni de conversation à poursuivre — et aucun ne pouvait passer par ce contrat, parce qu'aucune route ne rendait un objet décrit par l'appelant.

ChampRègle
namerequis, 64 au plus, [A-Za-z0-9_.:-]. Il nomme l'appel dans la trace et au registre des coûts : c'est par lui qu'on retrouve ce qu'une fonction a dépensé
schemaJSON Schema, borné — voir ci-dessous. 32 Kio au plus
system8 000 au plus. La conduite de l'app y est préfixée : ce que le produit impose à son agent vaut pour tout ce que son moteur écrit
inputune chaîne, ou une liste de { role, content } (user, assistant, system). 100 000 caractères en tout
max_output_tokens16 à GENERATE_MAX_OUTPUT_TOKENS (4 000 par défaut). Le plancher est celui du fournisseur, qui refuse l'appel en dessous — cette page annonçait « 1 à 4 000 » jusqu'au 02/10/2026, et le moteur rendait un 502 opaque. Le plafond est celui de l'exploitant : on peut demander moins, jamais plus
effortlow, medium ou high. À défaut, celui de la tâche. Un effort au-dessus de low exige au moins 4 000 jetons de sortie : les jetons de raisonnement sont décomptés de max_output_tokens, donc un plafond serré tronque la réponse avant le JSON et le fournisseur ne rend aucun objet. Refusé en 422 avant l'appel, donc gratuitement

⚠️ La configuration passe devant votre effort. L'ordre de résolution est : la préférence d'un thème, puis LLM_GENERATE_EFFORT de l'app, puis le vôtre, puis le défaut de la tâche. Un effort posé par l'exploitant écrase donc le vôtre — en silence jusqu'au 02/10/2026. La réponse porte maintenant effort et effort_origin (skill, task ou default) quand l'effort appliqué n'est pas le vôtre, et rien quand il l'est : de quoi cesser de chercher dans son propre code pourquoi une sortie est plus courte que prévu.

usage rend { input_tokens, output_tokens, duration_ms } — ce que le §26 du cahier demandait par requête. Le coût, lui, se lit par GET /v1/usage : l'appel y entre sous l'activité generation, avec name en référence. Aucune route de ce contrat ne rend un prix à l'appel, et c'est délibéré — un seul endroit fait foi sur ce qui est dépensé.

Le schéma est borné, et le refus nomme le nœud

Un schéma est du code : il décide de ce que le modèle doit produire, donc de ce qu'on paie. Six règles, toutes vérifiées avant le moindre appel, et chaque refus donne le chemin du nœud fautif (phases[].subActions) parce qu'un « schéma invalide » sur deux cents lignes ne se cherche pas :

RèglePourquoi
racine { "type": "object" }une sortie est un objet nommé
"additionalProperties": false sur chaque objetsans lui, l'appelant croit tenir un contrat, il tient une suggestion
required nommant toutes les propriétésles fournisseurs à sortie stricte l'exigent, et le découvrir en 502 coûte une journée. Un champ facultatif s'écrit avec un type qui admet null ("type": ["string","null"]), pas en l'omettant de required — c'est la convention que le moteur s'impose déjà à lui-même
maxItems et items sur chaque tableauun tableau sans plafond est une réponse dont on ne peut pas prévoir le coût
ni $ref, $defs, oneOf, anyOf, allOf, not, ifla sortie doit être décidable sans résoudre de référence ni départager plusieurs formes ; un schéma récursif fait tourner un validateur en rond
profondeur ≤ 7, ≤ 200 propriétésau-delà, ce n'est plus un contrat de sortie, c'est un modèle de données. Un niveau de tableau consomme un cran : racine → implementation → steps[] → actions[] → title compte 7, et passe. C'est notre règle, pas celle d'un fournisseur ; la garde qui tient la dépense est la taille (32 Kio)

Le moteur n'impose aucun vocabulaire : aucun nom de champ, aucune forme. La garde ne dit qu'une chose — ce schéma-ci est finançable.

Ce que cette route n'est pas : un tunnel. Elle ne choisit pas son modèle — c'est LLM_GENERATE_* de l'app, réglé par l'exploitant du moteur, comme les dix-sept autres tâches. Un appelant ne nous fait pas appeler un fournisseur en son nom (voir plus haut).

Qui paie. Sous clé d'app, le coût est celui du produit — sa veille éditoriale n'appartient à aucun de ses clients. Sous clé de tenant, il est celui du client. X-AI-Engine-User est facultatif : un appel de back-office n'a pas de personne.

Organisation — clé du tenant

RouteCorpsRéponse
GET /v1/tenant—{ tenant }
PUT /v1/tenant{ label?, profile?, facts?, policy? }{ tenant } — contract ne s'y écrit pas : voir plus bas
GET /v1/tenant/facts—{ facts, limits }
PUT /v1/tenant/facts{ facts }{ facts }

tenant vaut :

{
  "id": "DEMO_ACME",
  "label": "Démo ACME",
  "origin": "registry",
  "profile": { "industry": "Industrie", "support_hours": "8 h – 18 h" },
  "facts_count": 12,
  "contract": { "status": "active", "persons": "open", "expires_at": "", "reason": "" },
  "policy": { "max_action": "observe", "local_admin": false },
  "installation": { "max_action": "configure", "local_admin": null },
  "effective": { "max_action": "observe", "local_admin": false }
}

Chaque clé présente dans le corps remplace la précédente : un champ absent de profile est retiré.

ChampEffet dans le moteur
label (120)Affichage seulement : jamais dans un prompt.
profile.industry (120), profile.note (600)Bloc « Organisation » du composeur : un cadre, jamais une source.
profile.support_hours (120), profile.escalation_contact (200)Ne servent qu'à une passe de main déjà décidée — escalade imposée, diagnostic qui la prévoit, extrait qui l'exige. N'en déclenchent jamais une.
profile.language (40)Langue de repli, après celle de la personne.
policy.max_actionobserve, configure ou intrusive. Entre dans le minimum catalogue ∧ installation ∧ tenant. Une valeur au-dessus de installation.max_action est refusée (422), et une ligne écrite à la main ne lève rien : le minimum est réappliqué à chaque tour.
policy.local_adminNe vaut que false : le tenant retire des droits, il n'en accorde jamais (true → 422).

facts_count est un compte, pas la fiche : celle-ci pèse jusqu'à 20 000 caractères et se lit par GET /v1/tenant/facts. Un écran de réglages qui n'affiche que « fiche renseignée » n'a rien à télécharger.

La fiche de l'organisation — le cadre et les faits ne sont pas la même chose

C'est la distinction la plus importante de cette section, et s'en tenir à l'une des deux donne un assistant qui répond juste mais génériquement.

profile — le cadrefacts — les faits
Ce que c'estcomment s'adresser à la personne, vers qui passer la maince que l'organisation tourne : messagerie, sauvegarde, applications métier, effectif
Dans le promptbloc « Organisation », annoncé comme un cadre, pas une sourcefamille de preuves [O…], annoncée comme faisant autorité sur son environnement
Se cite ?non, jamaisoui — le modèle la nomme dans used_document_ids, et elle ressort dans answer.sources avec source: "organisation"
Fonde une étape ?nonnon non plus — voir ci-dessous
Taille5 champs, ~1 080 caractères60 faits, 20 000 caractères

Un fait ne rend jamais une étape « documentée ». Une step de provenance documentation continue d'exiger l'extrait d'un vrai document : la fiche dit dans quel environnement on se trouve, la documentation dit quoi y faire. C'est ce qui permet d'ajouter la fiche sans toucher à la règle « rien sans source ».

Le moteur n'impose aucun vocabulaire. Ni liste de champs, ni valeurs admises : une plateforme qui fait remplir un questionnaire de vingt-et-un champs en pousse vingt-et-un, une autre en pousse trois. Le label est ce que le modèle lit ; la key est un identifiant de machine, stable, sur lequel une correction se rattachera.

{
  "facts": [
    { "key": "messagerie", "label": "Fournisseur de messagerie", "value": "Microsoft 365" },
    { "key": "mfa", "label": "Double authentification", "value": "Seulement sur certains comptes",
      "restricted": true },
    { "key": "apps", "label": "Applications métier", "value": "Sage\nSalesforce" }
  ]
}
ChampRègle
keyrequise. Minuscules, chiffres, _, -, . et :, 64 caractères au plus. Une clé en double est refusée (422), pas écrasée : deux valeurs pour la même clé ne se départagent pas.
labelrequis, 120 caractères au plus. C'est lui qui entre dans le prompt — email_provider n'apprend rien à un modèle, « Fournisseur de messagerie » si.
value600 caractères au plus, retours à la ligne conservés (une liste reste une liste). Vide : le fait est écarté sans erreur — un questionnaire à trous se pousse entier.
restrictedtrue : le fait ne part qu'à une personne dont le tour déclare facts_clearance: "full". Absent vaut « visible par tout le monde ».
en tout60 faits, et 20 000 caractères de libellés et de valeurs. Au-delà, 422 : ce n'est plus une fiche, c'est un corpus, et il s'ingère par PUT /v1/documents.

La fiche entière remplace la précédente, comme profile : un fait absent est un fait retiré. PUT /v1/tenant/facts avec { "facts": [] } efface la fiche.

GET /v1/tenant/facts rend aussi limits — { facts, key, label, value, total } : les bornes qu'un formulaire doit connaître pour ne pas faire découvrir la limite au 422.

Périmètres — clé du tenant

RouteCorpsRéponse
GET /v1/tenant/scopes—{ baseline, scopes, max_scopes, admission, provision_required }
POST /v1/tenant/scopes{ label, description, enabled?, answers? }201 { scope }
PATCH /v1/tenant/scopes/{id}{ label?, description?, enabled?, answers? }{ scope }
DELETE /v1/tenant/scopes/{id}—{ scope_id, deleted, reverted }
POST /v1/tenant/scope-candidates/{id}/decision{ action, label?, description?, answers? }{ candidate_id, action, opened?, uploads_resubmitted, next_pass, scopes }

Ce dont l'assistant du tenant accepte de parler. baseline est le socle — support informatique et sécurité —, toujours ouvert, hérité par tout tenant ; scopes, les périmètres ouverts au-dessus de lui :

{
  "baseline": { "scope_id": "socle", "label": "Support informatique et sécurité", "description": "…", "documents": 42 },
  "scopes": [
    {
      "scope_id": "notes-de-frais",
      "label": "Notes de frais",
      "description": "…",
      "enabled": true,
      "answers": "documentation",
      "documents": 1,
      "updated_at": "2026-09-11T16:02:10.000Z"
    }
  ],
  "admission": true,
  "provision_required": false
}
ChampSens
answersce que le périmètre répond quand aucun document ne répond : documentation le dit et s'arrête ; general répond en connaissance générale, annoncée comme telle
enabledfalse : fermé sans être oublié ; ses documents sont rejugés au passage suivant de leur source
documentsdocuments admis sous ce périmètre par l'analyse d'ingestion
admissionl'analyse de périmètre du moteur (DOCUMENT_ADMISSION) : éteinte, tout document est admis sans être jugé

Les périmètres s'écrivent — six routes, et deux niveaux

Cette section disait le contraire jusqu'au 30/09/2026 : « le contrat ne modifie pas les périmètres », l'écriture étant réservée à la console /admin. C'était faux — les six routes existent — et c'est l'écart le plus utile qu'un lecteur attentif nous ait signalé. Une application tierce ouvre et ferme les périmètres de ses clients elle-même, sans passer par nous.

Deux niveaux, et ils ne font pas la même chose :

NiveauCléCe qu'il écrit
Appclé d'appun périmètre hérité par tous ses tenants : la veille éditoriale du produit, faite une fois
Organisationclé du tenantun périmètre propre à ce client, ou le durcissement d'un hérité
ChampRègle à l'écriture
labelrequis à la création, 80 caractères au plus. C'est lui qui produit scope_id (Notes de frais → notes-de-frais)
descriptionrequise à la création, 600 caractères au plus. C'est elle que l'analyse d'ingestion lit pour juger un document
enableddéfaut true. false ferme sans oublier — les documents sont rejugés au passage suivant de leur source
answersdocumentation (défaut) ou general
scope_idrefusé dans le corps (422), à la création comme à la modification : il naît du libellé, et les documents déjà admis le portent

Cinq règles qu'il vaut mieux lire avant de câbler un formulaire :

  • PATCH fusionne, il ne remplace pas. {"enabled": false} seul suffit : le libellé et la description sont relus avant l'écriture. C'est l'inverse de PUT /v1/tenant, et c'est voulu — fermer un périmètre est le geste le plus fréquent, et il ne doit pas obliger à réécrire son texte.
  • Renommer ne change pas l'identifiant. « Cuisine » devenue « Cuisine et recettes » reste cuisine : les documents déjà admis le portent, et les désadmettre pour un renommage serait absurde.
  • Fermer et supprimer ne sont pas le même geste. PATCH {"enabled": false} se propage à tous les tenants et conserve le réglage de chacun ; DELETE libère un emplacement et efface les lignes que les tenants avaient écrites pour durcir ce périmètre — tenants_cleared les compte. Sans cette purge, un hérité durci par un client survivrait à sa suppression comme un périmètre lui appartenant, resté ouvert. Fermer est presque toujours le bon geste.
  • DELETE /v1/tenant/scopes/{id} sur un hérité annule le durcissement, il ne retire pas le périmètre de l'app : la réponse le dit (reverted: true). Sur un hérité que ce tenant n'a jamais touché : 422, pas 404 — le périmètre existe bien pour lui, il ne lui appartient simplement pas.
  • Les hérités comptent dans max_scopes. Un tenant peut être bloqué sans avoir rien ouvert lui-même, d'où max_scopes rendu avec la liste : un bouton se coupe avant le 422.

Ce qu'un périmètre ne fait toujours pas, et aucune de ces six routes ne le change : il n'ouvre ni outil, ni ticket, ni dépannage générique, ni recherche web ; un tenant ne peut que durcir son plafond d'action (policy) ; et un incident de sécurité passe avant lui.

Documents de l'entreprise — clé du tenant

Réserver un document à un groupe

Le moteur connaissait deux portées : toute l'organisation (/v1/documents) et une personne (/v1/me/documents). Il en connaît une troisième — un document réservé à un groupe — et il ne sait pas ce qu'un groupe signifie. C'est voulu : il compare des étiquettes opaques, il n'a ni annuaire, ni hiérarchie, ni héritage.

OùQuoi
PUT /v1/documents/{name}en-tête X-AI-Engine-Document-Audience : une étiquette (lettres, chiffres, _, -, ., :, 64 au plus). Vide ou absent : toute l'organisation
Le corps d'un tourperson.groups : jusqu'à 100 étiquettes, de la même forme que l'audience d'un document (lettres, chiffres, _, -, ., :, 64 au plus). Absent : la personne ne lit que les documents sans audience
Le corps d'un tourperson.sees_all_groups : true ouvre les documents de tous les groupes de l'organisation — le rôle d'un administrateur d'espace. Jamais les documents personnels des autres
GET /v1/documentsaudience sur chaque ligne — ce que vous avez déclaré, relisible
  • Le filtre est un prédicat de base, pas un tri après coup. Un document réservé n'entre pas dans les résultats d'une personne qui n'en est pas — il n'est pas retiré après avoir été trouvé. C'est la différence entre une recherche qui rend toujours ses meilleurs résultats visibles et une recherche qui en rend de moins en moins à mesure qu'une organisation réserve des documents.
  • ⚠️ En-tête ABSENT et en-tête VIDE ne sont pas la même chose. Absent dit « ne change rien » : un redépôt garde l'audience déjà déclarée. Vide dit « ouvre à toute l'organisation ». Sans cette distinction, un redépôt distrait élargirait en silence un document réservé. (En curl, un en-tête vide s'écrit -H "X-AI-Engine-Document-Audience;" — avec un point-virgule : -H "…: " supprime l'en-tête au lieu de l'envoyer vide.)
  • Changer l'audience d'un document ne coûte rien, même sur des octets identiques : les passages sont réétiquetés sans être réanalysés ni réembarqués. Un redépôt qui ne change que l'audience rend unchanged: true et prend effet tout de suite.
  • groups est refusé par PUT /v1/me/profile (422) : une appartenance ouvre des documents réservés, et personne ne se la déclare à soi-même. Elle vient de vous, avec le message, et de nulle part ailleurs : PUT /v1/person écrit l'inscription — abonnement, offre, échéance, motif — et ignore tout le reste. Cette page a dit le contraire jusqu'au 02/10/2026.
  • Les documents déjà indexés n'ont pas d'audience, donc restent lisibles par toute l'organisation : aucune migration, aucun document qui disparaît au déploiement.
  • Ce que le groupe ne fait pas : il n'entre dans aucun prompt, ne devient jamais un fait sur la personne, et ne se retrouve ni dans sa mémoire ni dans une procédure capitalisée. C'est une clé de lecture, pas une information sur quelqu'un.
RouteCorpsRéponse
GET /v1/documents—{ provision_required, documents, jobs }
PUT /v1/documents/{name}octets bruts (≤ UPLOAD_MAX_MB, 25 Mo)202 { document: { name, size_bytes, replaced, unchanged }, job_id }
DELETE /v1/documents/{name}—202 { job_id }
GET /v1/documents/jobs/{job_id}—{ job_id, kind, phase, items_total, items_done, items_failed, themes_created, error, created_at, finished_at, items }

L'index partagé du tenant, par le flux de dépôt de /admin : analyse de périmètre, indexation, thème (un thème du catalogue, sinon un thème générique créé). L'indexation est asynchrone ; l'état se relit de deux façons, et elles ne disent pas la même chose :

  • GET /v1/documents dit l'état de la documentation — un status par document. C'est ce qu'un écran affiche.
  • GET /v1/documents/jobs/{job_id} dit l'état d'un geste — le dépôt dont un 202 a rendu l'identifiant, avec une ligne par document (items[] : name, step, chunks, theme, theme_name, error, updated_at). C'est ce qu'une boucle d'attente interroge. La liste ne relit que les derniers dépôts : un job_id pouvait en sortir avant d'avoir été lu une seule fois. phase vaut queued, ingesting, covering, ready ou failed — et finished_at est renseigné dès qu'elle ne bougera plus ; un identifiant inconnu rend 404, un identifiant malformé 422.
documents[].statusSens
indexingun dépôt en cours le traite
indexedindexé, theme et theme_name renseignés
ignoredécarté par l'analyse de périmètre : ignored_reason porte le motif du juge
failederror dit pourquoi
storeddéposé, jamais indexé

Redéposer un même nom remplace la version précédente.

Sources déclarées — clé du tenant

RouteCorpsRéponse
GET /v1/documents/sources—{ sources }
POST /v1/documents/sources{ url, label? }202 { source }
PATCH /v1/documents/sources/{id}{ enabled }202 { source } ; 200 si rien ne change
DELETE /v1/documents/sources/{id}—202 { id }

Une page de documentation que le moteur lit lui-même : une page, ou toute une documentation quand le site publie son sommaire (sitemap.xml, llms.txt, index Orama, à défaut les liens de la page), sous le chemin déclaré. Aucun lien n'est suivi de proche en proche. L'index garde des passages et l'adresse : l'assistant cite la page vivante, jamais une copie.

  • Déclarer n'indexe pas pendant la requête. Chaque geste — déclarer, désactiver, retirer — lance en tâche de fond le passage de la source url du tenant : il relit la liste, lit chaque page, la juge, l'indexe, et retire de l'index ce que la liste ne porte plus. Les gestes qui arrivent pendant un passage sont regroupés en un passage de plus : une passerelle n'en mène jamais deux à la fois pour un même tenant.
  • Chaque page est jugée avant d'être indexée, par la même analyse de périmètre que les documents déposés : une page qui n'entre dans aucun périmètre ouvert (GET /v1/tenant/scopes) n'est pas indexée, et ressort ignored avec le motif du juge.
  • Une plateforme ne déclare que des pages publiques : http ou https, sans identifiants dans l'adresse, ni localhost, ni adresse IP privée ou de lien local, ni nom en .local ou .internal (422). Le connecteur refuse aussi qu'une page publique le redirige vers une telle adresse. L'intranet d'une installation se déclare depuis la console de l'opérateur.
  • id : l'empreinte de l'adresse normalisée (url:…, sans fragment ni barre finale). Redéclarer une adresse la réactive et remplace son libellé.

source :

{
  "id": "url:586863d210c4ed1c488d3d7c74ac809ce14efb93",
  "url": "https://support.microsoft.com/fr-fr/teams",
  "label": "Aide Microsoft Teams",
  "enabled": true,
  "added_at": "2026-09-16T08:29:27.000Z",
  "status": "indexed",
  "detail": "indexée · 1 passage"
}
statusSens
indexingun passage lancé par la passerelle est en cours
indexedle dernier passage l'a indexée ; pour une documentation, au moins une de ses pages
ignoredécartée par l'analyse de périmètre : detail porte le motif du juge
failedla lecture a échoué : detail dit pourquoi (HTTP 404, page vide, redirection refusée)
pendingpas encore lue, ou lue par un passage qui n'a pas écrit son issue
disableddésactivée : plus lue, ses pages quittent l'index au passage suivant

detail s'écrit pour une personne et se montre tel quel — « indexée · 12 passages », « ignorée : … », « indexée · 40 pages (sommaire via sitemap) : 38 indexées, 2 ignorées — /docs/tarifs : … » ; status se lit sans l'interpréter.

Documents personnels — clé du tenant + personne

RouteCorpsRéponse
GET /v1/me/documents—{ documents, max_mb }
PUT /v1/me/documents/{name}octets bruts (≤ max_mb : USER_DOCUMENT_MAX_MB, 1 Mo par défaut) : PDF, Word, TXT, CSV, JSON, Markdown201 indexé, 200 inchangé ou écarté : { outcome, document, theme_how, theme_created? }
DELETE /v1/me/documents/{id}—{ deleted, themes_removed }

Indexés pendant la requête, hors de l'index partagé, pour cette personne seule :

  1. Admission — le même juge que les documents de l'entreprise, contre les périmètres ouverts du tenant. Un document écarté n'est pas indexé ; il est listé avec admission.reason (outcome: "ignored").
  2. Thème — dans cet ordre : un thème du catalogue s'il en couvre un (sans ceux que le tenant a désactivés) ; sinon un thème personnel de la personne s'il est proche ; sinon un thème personnel généré — une famille, jamais un produit, neutre, jamais le doublon d'un thème du catalogue ni d'un autre thème personnel.
  3. Les octets ne sont pas gardés : la plateforme a l'original.

document :

{
  "document_id": "udoc-…",
  "name": "reglages-cao.md",
  "status": "indexed",
  "chunks": 1,
  "theme": "perso.cao-modelisation-3d-mises-en-plan",
  "theme_name": "CAO, modélisation 3D et mises en plan",
  "theme_origin": "personnel",
  "admission": { "admitted": true, "scope_id": "socle", "reason": "…" }
}

Un thème personnel est privé. Il ne route que pour sa propriétaire, n'est jamais écrit au catalogue (ni YAML, ni ligne skills), n'est jamais capitalisé, n'ouvre aucun périmètre, et disparaît avec son dernier document. /admin n'en montre qu'un compte.

Dans un tour, pour sa seule propriétaire, un document indexé est cherché :

  • avant les itérations de recherche, pour que la vérification de couverture le lise dès la première ; hors escalade ;
  • en hybride, sur plusieurs formulations — la requête du tour, son but, le message : la similarité cosinus et le score lexical (BM25) de chaque passage, le meilleur des deux retenu, au même plancher qu'un extrait de l'entreprise ; 4 passages au plus, lus jusqu'à 4 000 caractères. Sans passage au plancher, la trace nomme le plus proche ;
  • là où le tour décide du périmètre : une demande que la porte d'entrée refuse est traitée si un document personnel admis sous un périmètre actif y répond, et un périmètre ouvert réservé à la documentation répond depuis ce document plutôt que « aucune documentation ».

Un document personnel se cite, mais ne rend jamais une étape « documentée » par l'organisation, et ce qu'il ferait faire — commande ou geste — passe sous le plafond du tenant, même quand une procédure interne est citée à côté.

Mémoire de la personne — clé du tenant + personne

RouteCorpsRéponse
GET /v1/me/memory—{ enabled, facts, profile, paused_until?, erased_at?, residue_pending }
DELETE /v1/me/memory—{ enabled, erasure_id, erased: { messages, facts, scenes, profile }, paused_until, residue_pending }

Ce que le moteur retient de la personne d'une conversation à l'autre (TencentDB Agent Memory, USER_MEMORY=tdam) : les faits qu'elle a dits d'elle-même, extraits après chaque tour, et le profil que la mémoire en dérive.

{
  "enabled": true,
  "facts": [
    {
      "id": "m_1789502040916_19a0090a",
      "type": "persona",
      "content": "La personne utilise FortiClient comme VPN.",
      "created_at": "2026-09-15T19:54:00.993Z",
      "updated_at": "2026-09-15T19:54:00.993Z"
    }
  ],
  "profile": "…"
}
  • La mémoire évite de reposer une question ; elle n'évite pas la recherche documentaire. Un fait rappelé pré-remplit une clé qu'un thème déclare — le client VPN, l'imprimante —, que l'assistant fait confirmer d'un mot. Il ne se cite jamais comme source, ne choisit pas le thème, et la documentation est cherchée comme sans lui.
  • Rappelée au premier tour d'une conversation, sur son message ; puis une fois par thème nouveau qui déclare une clé encore inconnue, sur ce que ce thème a besoin de savoir. Jamais plus d'un rappel par tour.
  • Retenu : ce que la personne dit de son environnement de travail — poste, logiciels, incident résolu, préférence. Jamais ce que l'assistant a répondu, ni un secret énoncé, masqué avant l'envoi. Le prompt d'extraction écarte la demande du moment : « je veux installer le VPN » n'est pas un fait à retenir, « j'utilise FortiClient » en est un. Un fait apparaît quelques secondes à une minute après le tour.
  • type : persona (environnement), episodic (un épisode, un incident résolu), instruction (une préférence). enabled: false : mémoire éteinte sur ce moteur.
  • residue_pending est ABSENT des deux routes quand USER_MEMORY n'est pas tdam. La réponse se réduit alors à { "enabled": false, "facts": [], "profile": "" } pour GET, et à { "enabled": false } pour DELETE : ni residue_pending, ni paused_until, ni erasure_id, ni erased. Un client qui lit residue_pending sans vérifier enabled lit undefined, ce qui n'est pas false — et une purge de maintenance ne s'attend pas sur un moteur qui n'a pas de mémoire. Sous tdam, en revanche, il vaut toujours un booléen, y compris pour une personne jamais effacée.
  • profile est un texte français à afficher tel quel, dans le même sous-ensemble Markdown que les réponses : paragraphes, puces - , titres de section en **gras** (« Informations de base », « Préférences durables »…). Le moteur retire ce que la mémoire range dans le même fichier sans que ce soit un profil — son index interne des scènes, ses libellés de gabarit, ses emoji — et répare les accents que son modèle d'extraction abîme parfois. "" tant qu'aucun profil n'est construit. Le texte des facts a les mêmes accents réparés.
  • Ouvrir une autre conversation n'efface rien : la mémoire a le temps d'extraire ce qui vient d'être dit. DELETE /v1/conversations/{cid} efface ce que la conversation a enregistré, pas les faits déjà extraits : ils appartiennent à la personne, et DELETE /v1/me/memory les efface.

Effacer la mémoire

DELETE /v1/me/memory efface ce que la mémoire retient de la personne, sur toutes ses conversations :

NiveauCe qui est effacéCompté dans erased
L0les messages enregistrés, de toutes ses conversationsmessages
L1les faits extraitsfacts
L2les scènes que la mémoire en dérivescenes
L3le profil que la mémoire en dériveprofile : 1 s'il y en avait un
{
  "enabled": true,
  "erasure_id": "0b6f3f7e-…",
  "erased": { "messages": 14, "facts": 3, "scenes": 1, "profile": 1 },
  "paused_until": "2026-09-16T14:05:00.000Z",
  "residue_pending": true
}
  • Une pause, puis un second passage. Une extraction déjà lancée peut écrire un fait après l'effacement. La mémoire reste donc suspendue — ni rappel, ni enregistrement — jusqu'à paused_until (MEMORY_ERASE_RECHECK_SECONDS, 300 s par défaut) ; un second passage efface ce qui est arrivé entre-temps, puis la mémoire reprend, vide. Pendant la pause, GET /v1/me/memory rend paused_until et des listes vides ; ensuite, erased_at. Un nouvel appel pendant la pause recommence l'effacement, et la pause avec lui. Mémoire injoignable au second passage : la pause est prolongée, et le passage retenté.

  • Les copies. L'historique des tours du moteur ne garde rien de ce que la mémoire savait : ni faits, ni valeurs qu'elle a pré-remplies. Une conversation existante que la personne reprend perd ce que la mémoire y avait rappelé, et les clés qu'elle y avait pré-remplies ; le tour le note dans sa trace.

  • Les copies internes de la mémoire. Son volume garde de ce qu'elle a effacé des copies qu'aucune de ses routes n'atteint, sous l'empreinte de la personne :

    • les copies JSONL en ajout seul : conversations/<date>.jsonl (les messages, en installation standalone) et records/<date>.jsonl (les faits) ;
    • ses journaux de génération — celui du profil en porte le texte — et son journal d'audit, qui nomme chaque scène d'un titre tiré des faits (« Usage-de-FortiClient-sur-Windows11.md ») ;
    • le dossier du profil, et les pages de sa base que SQLite n'a pas réécrites.

    Elles partent au prochain passage de maintenance du moteur (pnpm memory:purge-residues, DEMARRAGE.md §6), qui les retire et vérifie que l'empreinte ne reste nulle part dans le volume. residue_pending le dit : true du premier passage de l'effacement jusqu'à ce passage de maintenance, false ensuite, et pour une personne jamais effacée. Une personne qui se ressert de la mémoire avant ce passage garde ses copies — elles ne se distinguent plus de sa mémoire —, et residue_pending reste true jusqu'à un passage où sa mémoire est vide, après un nouvel effacement.

  • Ce que la route n'atteint pas : le journal Restate des tours déjà joués, gardé un jour — ce qu'un tour a rappelé y passe.

  • Erreurs. Mémoire injoignable : 503, rien n'est annoncé effacé, et l'appel se rejoue — il recommence par ce qui reste. USER_MEMORY=off : { enabled: false }.

  • L'audit du tenant en garde une ligne par passage (person_memory) : l'identifiant de l'effacement et les comptes, rien de la personne, pas même son empreinte. Chaque passage de maintenance y ajoute une ligne : le nombre de personnes purgées et les comptes de copies retirées.

Effacer une personne entièrement — DELETE /v1/me

RouteCorpsRéponse
DELETE /v1/me—202 { erasure_id, conversations, documents, themes_removed, profile_cleared, memory }

Un seul appel. Il fallait auparavant les enchaîner soi-même, dans le bon ordre, en connaissant chaque identifiant — dont la liste des conversations, que rien n'énumérait. Un droit qu'on n'exerce qu'en devinant des identifiants n'est pas un droit.

Ce que le moteur efface, et dans cet ordre, parce qu'il compte :

  1. la mémoire — sa pause est ce qui empêche un tour encore en vol de réenregistrer pendant qu'on vide le reste ;
  2. les conversations, chacune par son propre effacement : le L0 de la mémoire, les tours, les refus de périmètre, les pièces jointes, l'état, puis sa ligne d'index ;
  3. les documents personnels et les thèmes personnels qu'ils portaient ;
  4. le cadre rangé de la personne (PUT /v1/me/profile).

Ce que l'appel ne touche pas, et c'est voulu : l'abonnement déclaré par PUT /v1/person. Le cadre est ce que la personne a dit d'elle, l'abonnement est ce que la plateforme a déclaré d'elle — retirer la ligne entière révoquerait un abonnement que personne n'a résilié, et sous persons: "enrolled" un effacement de données fermerait le service. Pour retirer une personne du service, PUT /v1/person avec subscription: "expired".

  • 202, et non 200. Ce qui est fait à la réponse l'est pour de bon : la mémoire est vidée et suspendue, les documents sont partis, le cadre est vide. Restent en cours l'effacement de chaque conversation et le second passage sur la mémoire, à la fin de la pause. conversations compte celles qui y sont entrées.
  • Rappeler la route est sans danger, et c'est la reprise prévue : elle recommence par ce qui reste. Une conversation qui a résisté garde sa ligne d'index — donc elle reste énumérable, donc reprenable. GET /v1/conversations dit ce qu'il en reste.
  • Vérifier que c'est fini : GET /v1/me/memory rend residue_pending: false une fois le passage de maintenance passé, et GET /v1/conversations ne rend plus rien pour cette personne.
  • Les conversations ouvertes avant la 1.1 n'ont pas de ligne d'index : elles s'effacent une à une par DELETE /v1/conversations/{cid}, comme avant.
  • Ce qui survit, par construction : le registre des coûts (usage_events), qui ne porte que des montants, des empreintes et des comptes — une facture ne se réécrit pas —, et le journal d'audit, qui garde la trace de l'effacement lui-même (objet person, l'identifiant du geste et des comptes). La trace qu'un droit a été exercé ne s'efface pas avec ce qu'il a fait disparaître.
  • USER_MEMORY autre que tdam : memory vaut { enabled: false }, et tout le reste est effacé quand même — les documents et les conversations ne dépendent pas de la mémoire.
  • Mémoire injoignable : 503, et RIEN n'est effacé. La mémoire passe en premier, et son échec arrête le geste avant qu'il ait touché à quoi que ce soit — même règle que DELETE /v1/me/memory, et pour la même raison : rien ne doit être annoncé à moitié effacé. L'appel se rejoue, et il reprend par ce qui reste.

Index des conversations — clé du tenant

RouteCorpsRéponse
GET /v1/conversations—{ conversations, next_cursor? }

Ce qui manquait, et qui ne se voyait pas. Le moteur accepte n'importe quel cid et y retrouve son état en silence : un produit qui ne tenait pas sa propre liste avait donc un chat qui marchait et aucun historique, sans une seule erreur pour le lui dire — et il ne pouvait pas effacer ce qu'il n'énumérait pas.

ParamètreValeur
limit1 à 500, 100 par défaut
cursorle next_cursor de la page précédente, opaque : à rendre tel quel

Chaque ligne : { id, user_key, app, created_at, last_turn_at, turns }, de la plus récemment servie à la plus ancienne.

  • X-AI-Engine-User est un FILTRE ici, pas une identité. Avec l'en-tête, les conversations de cette personne ; sans lui, toutes celles du tenant. La clé du tenant autorise déjà tout ce qui est à lui, et c'est la plateforme qui la détient — une route person lui aurait interdit de voir sa propre installation. Elle n'est donc pas soumise au 402, ni au jeton signé de person_identity.
  • id est l'identifiant nu, celui que vous avez posé dans l'URL du tour. GET /v1/usage/runs?conversation_id=<id> rend les tours correspondants et ce qu'ils ont coûté.
  • user_key est l'empreinte u-… de la personne, vide pour une conversation ouverte sans personne. Jamais l'identifiant en clair : le moteur ne le garde pas.
  • created_at est le PREMIER TOUR, pas l'ouverture : une clé de conversation sans tour n'existe pas pour le moteur. turns compte les tours écrits — un tour rejoué après une panne de base ne compte pas deux fois.
  • Aucun contenu n'en sort : ni question, ni réponse, ni titre. Des dates, un compte, une empreinte. Ce qui a été dit est dans l'historique des tours, que l'effacement emporte.
  • L'index commence à la 1.1 : les conversations tenues avant n'y sont pas. Elles fonctionnent, se poursuivent et s'effacent comme avant — elles ne se listent pas.
  • Un effacement retire la ligne, celui d'une conversation comme celui d'une personne. 503 si la table manque : le tenant attend un provisionnement.

Conversation — clé du tenant + personne

RouteCorpsRéponse
POST /v1/conversations/{cid}/messages{ message, attachments?, person?, facts?, facts_clearance?, mode? }AskResult : { answer, trace, expertise_hint? }
GET /v1/conversations/{cid}/progress—{ live: true, turn, events } pendant un tour, { live: false } sinon
GET /v1/conversations/{cid}/events—text/event-stream : progress à chaque changement d'étape, end quand plus rien ne tourne
DELETE /v1/conversations/{cid}—{ ok: true }
PUT /v1/conversations/{cid}/attachments/{name}octets bruts (≤ ATTACHMENT_MAX_MB)201 { attachment: { id, name, mimeType, size, href } }
GET /v1/conversations/{cid}/attachments/{id}—les octets, Content-Type déduit au dépôt, nosniff
  • cid : l'identifiant de conversation que la plateforme choisit — lettres, chiffres, ., _, -, 128 caractères au plus. La clé Restate est <TENANT_ID>:<cid>.

  • message : 20 000 caractères au plus. Un message vide est accepté s'il porte une pièce jointe.

  • attachments : ce que PUT …/attachments a rendu, au plus ATTACHMENT_MAX_PER_TURN. href n'est pas une adresse du moteur. C'est un chemin de l'application web — /api/attachments/<cid>/<id> (packages/core/src/attachments.ts) —, donc l'adresse sous laquelle la plateforme sert la pièce en relayant le GET du contrat. Le moteur ne l'appellera jamais : il ne s'en sert que pour composer le lien ou l'image de la réponse. À réémettre tel quel dans attachments au tour suivant : le schéma n'accepte qu'un chemin relatif commençant par /, et une adresse absolue rend 422.

  • facts : la fiche de l'organisation, jointe au message. Elle l'emporte EN BLOC sur celle du registre pour ce tour-là — jamais fait par fait, exactement comme person, et pour la même raison : le contrat écarte déjà les valeurs vides, donc « fait absent » et « fait effacé » seraient indiscernables, et une fusion par clé ferait remonter une messagerie que vous auriez précisément cessé d'envoyer.

    "facts": [] et facts absent ne sont pas le même geste : le premier dit « cette personne ne voit aucun fait », le second dit « prends le registre ». Une plateforme qui joint facts à chaque message se comporte exactement comme si les routes de fiche n'existaient pas — et c'est ce qui lui permet de filtrer selon le rôle du compte avant d'envoyer, sans que le moteur ait à connaître ses rôles. Il n'a pas d'annuaire.

    Un tour n'écrit jamais la fiche rangée. PUT /v1/tenant/facts et PUT /v1/tenant sont les deux seules écritures.

  • answer_form : steps ou recommendations, pour ce tour. À défaut, celle de l'app. Une autre valeur rend 422.

  • conduct_addendum (1 000) : un complément de conduite pour ce tour — « la personne a ouvert l'écran Facturation ». Il s'ajoute à la conduct de l'app, sous les mêmes règles de préséance. Une consigne durable se déclare une fois, par PUT /v1/app : la répéter à chaque message la fait payer à chaque message.

  • facts_clearance : shared (défaut) ou full. Sous shared, les faits restricted ne partent pas dans le prompt. C'est une affirmation de la plateforme : elle seule connaît les rôles de ses comptes. shared par défaut est le défaut sûr — une plateforme qui ne dit rien n'expose pas ses faits réservés. Une autre valeur rend 422.

  • mode : skill_first (défaut) ou naive_rag — le témoin du comparatif §30, sans profil, sans pièce jointe et sans fiche.

  • La réponse porte la trace du tour ; trace.person dit ce que le profil a amorcé.

  • Le corps ne rend que ce que AskResult déclare : answer, trace, et expertise_hint quand il y en a un. Il portait quatre clés de plus — state, illustration_request, refresh_request, scope_refusal —, dont state, qui contenait le verbatim de tous les extraits retenus et pesait à lui seul l'essentiel de la réponse. La passerelle projette désormais (turnView, gateway/routes.ts), et c'est un changement qui casse un appelant qui les lisait : ⚠️ 1.1 du changelog. L'état de la conversation n'est pas perdu pour autant — il vit sous la clé Restate <TENANT_ID>:<cid>, et c'est lui que le tour suivant relit.

  • trace, en revanche, reste : c'est la seule source des jetons et de la durée d'un tour, et elle ne se relaie pas à un navigateur (FRONTEND.md §7.2).

  • expertise_hint, rare et facultatif : le niveau déclaré sur un périmètre ne ressemble pas à ce que la conversation montre. { scope_id, scope_label, declared, suggested, reason }. Il naît à partir de trois tours sur le même périmètre, d'observations que la porte d'entrée produit déjà — un produit ou une version nommés précisément, une étape restée bloquée, une question sans réponse —, et il ne part qu'une fois par périmètre et par conversation.

    Il est à côté de answer, jamais dedans, et c'est délibéré : ce n'est pas une phrase que le chat prononce. « Vous semblez moins à l'aise que vous ne le déclarez » au milieu d'une procédure serait un jugement.

    Le moteur ne modifie jamais person.expertise : il n'a pas de table de personnes, et il relit le profil à chaque message. La plateforme range le signal dans le compte, l'y notifie, et la personne décide — ou l'ignore. Ignorer un signal est une réponse valable, et le tour suivant répond exactement comme avant.

  • answer.recommendations, sous la forme recommendations seulement : { title, description, sources }, trois au plus. sources ne porte que des document_id, tous présents dans answer.sources : l'écran y retrouve le titre et la provenance sans qu'on les recopie. Une recommandation qui ne citait aucune preuve réelle n'arrive jamais — le serveur l'écarte, et le journal le dit.

  • answer.fact_proposals, quand la personne a affirmé explicitement qu'un fait déclaré a changé : { key, label, current_value, proposed_value, reason }, trois au plus. Le moteur n'écrit rien — c'est une proposition, et l'écriture reste PUT /v1/tenant/facts, après validation par quelqu'un. Sans ce signal, la fiche se périme en silence et le tour suivant conseille sur un environnement qui n'existe plus.

    label et current_value viennent du serveur, pas du modèle : c'est la fiche qui fait foi sur son état actuel, et un « avant » rédigé par un modèle serait une affirmation de plus à vérifier. Une proposition ne porte jamais sur un fait que la personne n'a pas le droit de lire, et une proposition qui ne change rien est écartée.

  • answer.message peut être vide. Au-dessus d'une étape, il ne dit que ce que answer.step ne porte pas (réponse à une question posée, cause ou précaution que donne la documentation, documentation muette, page de l'éditeur nommée) : vide, on n'affiche que l'étape. Sans étape, c'est la réponse entière.

  • Le texte est en Markdown léger, dans message comme dans step.instruction :

    • paragraphes séparés par une ligne vide ;
    • puces - ;
    • **gras** pour un libellé d'interface, `code` pour ce qui se tape ou se copie ;
    • parfois [texte](url).

    Jamais de titre, de tableau ni de HTML. step.options est du texte brut : un clic le renvoie tel quel comme message. Les adresses des sources ne sont pas recopiées dans le texte : elles sont dans answer.sources[].url, sans paramètres utm_*.

  • DELETE /v1/conversations/{cid} est un effacement explicite et immédiat, dans cet ordre : ce que la mémoire a enregistré de la conversation, l'historique de ses tours, ses refus de périmètre, ses pièces jointes, sa ligne d'index, puis son état. La ligne d'index part après les tours, et pas avant : une conversation dont l'historique n'a pas pu être effacé doit rester énumérable, sinon l'échec deviendrait invisible. Une étape qui n'aboutit pas rend 503 et laisse la conversation entière : on peut la reprendre, ou rejouer l'effacement. Pour ouvrir une nouvelle conversation, il suffit d'un autre cid : rien n'est à effacer.

GET …/events — la progression, poussée

Le même contenu que …/progress, en text/event-stream, pour ne plus le sonder trente fois par tour. La route reste facultative : …/progress ne bouge pas, et un appelant qui sonde continue de marcher exactement comme avant.

C'EST LA PROGRESSION QUI STREAME, PAS LA RÉPONSE. Ce n'est pas un premier pas vers le jeton à jeton, et ce n'en sera pas un : la sortie du composeur est un objet structuré et validé — étape, options, sources, recommandations —, l'exact contraire d'un flux, parce qu'elle n'existe pas avant d'être complète et vérifiée. La réponse arrive donc toujours sur le POST, entière ou pas du tout. Ce qui change ici est seulement la façon d'apprendre où en est le tour pendant les trente à cent secondes qu'il dure.

Trois événements, et rien d'autre :

event:data:Quand
progress{ turn, stage, label, detail? }à chaque changement d'étape
end{ live: false }plus aucun tour ne tourne — le flux se ferme ensuite
error{ error: { code, message } }une panne pendant le flux — le flux se ferme ensuite

Le flux ouvre par retry: 2000 et un commentaire, puis pousse un : survie toutes les 15 s de silence. end porte la même forme que …/progress au repos : le même code relit les deux routes.

  • stage et label sont ceux de …/progress, aux mêmes libellés, déjà rédigés pour être affichés tels quels. turn est le numéro du tour dans la conversation — il change quand un tour succède à un autre sur la même conversation.
  • Une étape qui vit moins de 500 ms peut ne jamais être vue : le moteur relit la progression à ce rythme, celui que le client faisait lui-même. Le flux ne montre pas l'histoire du tour, il montre où il en est.
  • Le flux se ferme après DEUX relectures muettes — donc environ une seconde après la fin du tour, et environ une seconde s'il n'y a rien à observer. Un EventSource se reconnectera : c'est au consommateur de fermer quand la réponse du POST est arrivée. Sur réception d'un event: error, il faut appeler close() — sinon la reconnexion automatique bouclera sur la même panne.
  • Une erreur qui survient PENDANT le flux ne peut plus être un statut : les en-têtes sont partis en 200. Elle devient un event: error portant le même code que le corps d'erreur du contrat, puis le flux se ferme. Trois codes seulement peuvent y arriver : unavailable (le moteur ne répond plus à trois relectures de suite), forbidden (une autre personne a commencé un tour sur cette conversation entre-temps), engine_error. Tout le reste est refusé avant le flux, avec un vrai statut : 401 sans clé, 422 sans X-AI-Engine-User ou sur un cid mal formé, 402 sous persons: "enrolled", 403 sur la conversation d'une autre personne, 503 si le moteur est injoignable.
  • X-Accel-Buffering: no est sur la réponse, et il n'est pas décoratif : sans lui, nginx tamponne le corps entier et le flux n'arrive qu'à la fin — c'est-à-dire jamais pendant qu'il sert à quelque chose. Un proxy que vous ajouteriez devant doit faire de même, et ne pas compresser (Cache-Control: no-transform).
  • Aucune durée à lever de votre côté. Le moteur ne borne pas la durée d'une réponse ; la seule limite est celle de l'ingress (340 s), et le commentaire de survie la tient en échec.
  • À plusieurs répliques de l'agent, le flux peut rester muet. La progression vit en mémoire d'un processus (services/agent/src/progress.ts), et la relecture peut atteindre un processus qui ne déroule pas ce tour-là : le flux s'ouvre, n'annonce rien, et se ferme au bout d'une seconde. Le tour aboutit quand même sur son POST — c'est l'affichage qui manque, pas la réponse. Aucun contournement côté appelant : sonder …/progress a exactement la même limite.

Illustrations — clé du tenant

Coupées par défaut (ILLUSTRATIONS_ENABLED). Allumées, l'ingestion décrit les images des documents et l'étape qui reprend un passage illustré peut montrer son image.

RouteCorpsRéponse
GET /v1/illustrations/tenant/{id}—les octets de l'image, ETag, 304 sur If-None-Match
GET /v1/illustrations/shared/{id}—idem, pour une image de la bibliothèque partagée
GET /v1/conversations/{cid}/illustration-jobs/{id} (+ personne)—{ status: "pending" | "done" | "failed" | "unknown", illustration? }
GET /v1/admin/illustrations/shared/{id} (clé d'administration)—idem, sans tenant et sans le garde enabled — voir plus bas
  • answer.step.illustrations : deux au plus, sur une étape documentation (ou general_knowledge pour une image de la bibliothèque). Chaque entrée porte id (empreinte sha256 des octets), store, src, alt, kind, width, height, et son origine, qui dit sous quelle légende la montrer :

    • { origin: "document", attribution: { document_id, title, url? } } — une image de la documentation du tenant ;
    • { origin: "web", attribution: { url, domain, title? } } — la capture d'une page éditeur, à attribuer et lier ;
    • { origin: "generated", attribution: { notice } } — un schéma généré, indicatif.

    Une image web ou generated vient de la bibliothèque partagée : retrouvée pour le besoin de l'étape, ou gardée par une procédure partagée que la réponse cite.

    answer.illustrations et answer.illustration_job portent les mêmes objets, aux mêmes champs, quand le tour rend une réponse SANS étape — une réponse de conseil est un message et des sources, elle n'a pas d'étape et n'en aura jamais. Le besoin d'illustration naissait sur une étape, ce qui était une hypothèse d'informatique : une panne se répare en étapes, un conseil se rend d'un bloc. Les deux emplacements sont exclusifs — le serveur ne pose jamais les deux — et un client qui lit les deux affiche au bon endroit sans rien dupliquer. Un tour sans étape qui n'a rien rendu (manque documentaire, escalade, étape retirée) n'en porte aucune : il n'y a pas de réponse à montrer.

    src est l'adresse sous laquelle la plateforme sert l'image en relayant le GET : un chemin relatif (/api/illustrations/tenant/<id>, /api/illustrations/shared/<id>). L'étape se suit sans l'image : elle en est l'appui, jamais le remplacement.

  • answer.step.illustration_job : { id, subject, status: "pending" } quand l'étape attend une illustration cherchée hors du tour (ILLUSTRATION_SEARCH_ENABLED). Sonder …/illustration-jobs/{id} toutes les 2,5 s, deux minutes au plus : done porte l'illustration, failed ou l'échéance laissent l'étape sans image. Le travail est partagé entre tenants — même sujet, même travail — mais ne se lit que sur une conversation de la personne.

  • Bibliothèque partagée : 404 aussi quand le tenant l'a refusée (TENANT_<SLUG>_SHARED_ILLUSTRATIONS=false) ou que l'image a été retirée (pnpm illustrations disable <id>, ou le bouton de /admin/procedures).

  • Où l'agent cherche une image est propre à l'APP. APP_<SLUG>_ILLUSTRATION_WEB_HOSTS l'emporte sur ILLUSTRATION_WEB_HOSTS, comme la mission et le socle, et pour la même raison : deux apps ne cherchent pas leurs images aux mêmes endroits. La valeur spéciale none rend une liste vide — l'app n'a aucun hôte éditeur, aucune recherche de capture n'est payée, et le besoin va droit à la génération d'un schéma. C'est la voie d'une app dont les sujets sont des objets et non des écrans : un schéma au trait ne reprend les octets de personne et ne pose aucune question de droit, là où garder une capture d'éditeur demande une license_note. La règle « un écran ne s'invente pas » (ILLUSTRATION_GENERATE_SCREENS=false) n'a pas eu à être tordue : ces sujets-là sont du materiel, et la génération y était déjà autorisée. Coût mesuré le 19/09 : 3,34 c$US l'image (gpt-image-1-mini, 1024×1024, 30 s), plus son contrôle de vision — borné par ILLUSTRATION_DAILY_GENERATIONS (20) et ILLUSTRATION_MAX_PER_CONVERSATION (3). Vide ne suffit pas à dire « aucun » : partout une variable vide vaut « absente », et retomberait sur les 13 hôtes du lint.

  • GET /v1/admin/illustrations/shared/{id} — access: admin, donc Authorization: Bearer <API_ADMIN_KEY> et aucun tenant. Mêmes octets, mêmes en-têtes, même 404 unique, même record.displayable : une image dont la vision a relevé un secret y reste invisible. Deux gardes tombent, délibérément — le tenant, parce que la console est globale et qu'un opérateur n'en choisit pas ; et row.enabled, parce qu'un écran qui sert à retirer une image doit la montrer. C'est la route que la console relaie (/api/admin/illustrations/{id}) ; le chat, lui, garde la route du tenant.

  • Un seul 404 « illustration introuvable » : identifiant mal formé, image inconnue ou non montrable (décor, secret relevé, retrait), type non affiché, illustrations coupées pour ce tenant, clé d'un autre tenant. La passerelle et l'agent doivent avoir la même valeur de ILLUSTRATIONS_ENABLED : sinon l'image est en 404, et l'interface la masque.

  • En-têtes : Content-Type lu dans les octets (PNG, JPEG, WebP ou GIF), nosniff, Content-Security-Policy: default-src 'none'; frame-ancestors 'self', Content-Disposition: inline, Cache-Control: private, max-age=3600, immutable.

  • trace.illustrations : { offered, cut, picked, shown, dropped, job? } — clés offertes, blocs coupés par la tranche des extraits, clés choisies, empreintes montrées, retraits motivés, et le besoin d'illustration (reused, requested, withdrawn par le lint de neutralité, capped, skipped).

4. person

Joint à chaque message : le moteur n'a pas de table de personnes. Il ne garde du profil que ce qu'un tour en fait — dans l'état de la conversation, les faits du dernier message et les clés qu'ils ont amorcées ; dans l'historique des tours, ces clés seules, avec leur valeur. Un profil modifié vaut dès le message suivant, et libère ce que l'ancien avait amorcé.

  • Chaînes libres de 120 caractères au plus, listes de 10 éléments au plus — sauf groups, qui en admet 100 et dont la forme est celle d'une étiquette d'audience.
  • Un champ inconnu est ignoré ; une valeur vide ou null est absente.
  • Les valeurs suggérées ci-dessous sont proposées par le simulateur, pas imposées : la plateforme garde ses propres libellés.
ChampSuggestionsEffet dans le tour
first_name, job_title—Bloc « Profil de la personne » du composeur : un cadre, jamais une source.
work_location, work_rhythmBureau, Domicile, Site client · Sur site, Hybride, TélétravailFaits déclarés, donnés au composeur comme cadre. Rapprochés seulement des clés obligatoires qu'un thème marque « sur la personne » : user_location l'est mais reste facultative, donc jamais amorcée ; connection_type décrit la connexion du jour et ne l'est pas.
it_levelDébutant, Intermédiaire, Avancé, ExpertRemplace l'audience figée « non technicien » sur le socle : le vocabulaire, jamais le plafond. Repli de expertise quand la plateforme ne l'envoie pas encore.
expertise[][{"scope_id":"socle","level":"expert"}]Le niveau par périmètre de conversation : socle et les identifiants rendus par GET /v1/tenant/scopes, MAX_SCOPES + 1 entrées au plus, sans doublon. level est fermé : novice, intermediate, advanced, expert — sans accent, la valeur est lue par du code. Règle le vocabulaire, le détail, la granularité de l'étape, le nombre de questions, et le choix d'une variante technique quand la documentation en offre deux pour le même résultat. Défaut : novice. Aucun périmètre n'hérite d'un autre.
systems[], hardware, tools[]Windows 11, macOS… · Ordinateur portable, Poste fixe… · Microsoft 365, FortiClient…Faits déclarés, rapprochés des clés obligatoires qu'un thème marque « sur la personne » (about: person) — aujourd'hui vpn_client —, amorcés : les requêtes sont orientées, et l'assistant fait confirmer plutôt que de poser une question ouverte. Jamais une clé de la demande : ni l'opération voulue, ni le périphérique concerné (device_type).
groups[]["compta","agence-lyon"]Accès, pas profil : les documents de l'organisation que la personne a le droit de lire (documents.audience). Étiquettes opaques, jusqu'à 100, de la même forme que l'audience d'un document — lettres, chiffres, _, -, ., :, 64 au plus. Un libellé humain (« Agence de Lyon ») est refusé en 422 plutôt qu'accepté sans jamais rien ouvrir. N'entre dans aucun prompt, aucun souvenir, aucune fiche capitalisée.
sees_all_groupstrueLe rôle d'un administrateur d'espace : il lit les documents de tous les groupes de son organisation sans être membre d'aucun. Un booléen, parce qu'un rôle ne se représente pas en étiquettes — et parce que vous tenir la liste de tous vos groupes à jour dans chaque message serait un annuaire. Jamais les documents personnels des autres.
response_styleConcis, Détaillé, Pas à pasLa forme de la réponse, toujours une seule étape.
languageFrançais, English…Langue de repli quand la demande ne tranche pas (clic d'option, message très court).

Ce que chaque champ a fait dans ce tour-ci se relit dans le panneau Debug du simulateur, section « Mémoire et profil » : la valeur reçue, son rôle, les clés qu'elle a amorcées, et la raison pour laquelle elle n'a pas servi quand elle n'a pas servi (trace.person_profile, §10.4 de docs/ARCHITECTURE.md). Un champ envoyé sans effet s'y voit, plutôt que de se deviner.

Ce que la plateforme ne transmet pas : nom, e-mail, téléphone, préférences d'information et de notification, stockage local, facturation. Rien de cela ne sert une conversation, et le lint de neutralité refuse d'ailleurs e-mails et numéros dans une réponse.

Rien de ce qui entre ne relâche une garde. Le profil règle le vocabulaire et la forme, jamais le plafond d'action ; profil et organisation ne sont jamais donnés à la porte d'entrée — ils ne choisissent pas les thèmes ; aucune de leurs valeurs ne part dans une procédure partagée ; le nom de l'organisation n'entre dans aucun prompt.

Le niveau d'expertise est celui sur lequel cette garde porte le plus, et elle vaut entièrement. Un niveau expert ne donne jamais : une commande d'une classe d'action que le minimum catalogue ∧ installation ∧ tenant a fermée ; un autre thème, puisque la porte d'entrée ne voit pas le profil ; plusieurs manipulations d'affilée ; ni une réponse de connaissance générale qu'un novice n'aurait pas eue. Il change comment une même procédure est écrite, et — quand les extraits décrivent deux chemins vers le même résultat (l'interface graphique et la commande) — lequel des deux est proposé. Deux variantes qui s'excluent (deux clients VPN, deux modèles d'imprimante) restent demandées à la personne.

Le moteur ne modifie jamais un niveau : il observe, et peut rendre un expertise_hint à côté de la réponse quand le déclaré ne ressemble pas à ce que la conversation montre. La plateforme notifie, la personne décide.

5. Exemples

G=http://localhost:8090
ADMIN=…                                   # API_ADMIN_KEY du .env du moteur

# Créer un tenant — la clé n'est rendue qu'ici
curl -s -X POST $G/v1/tenants -H "Authorization: Bearer $ADMIN" \
  -d '{"id":"DEMO_ACME","label":"Démo ACME","policy":{"max_action":"configure"}}'
KEY=DEMO_ACME.…

# Adopter le tenant du .env et son corpus
curl -s -X POST $G/v1/tenants/POC_TENANT/api-key -H "Authorization: Bearer $ADMIN"

# Le cadre de l'organisation ; une politique plus lâche rend 422
curl -s -X PUT $G/v1/tenant -H "Authorization: Bearer $KEY" \
  -d '{"profile":{"industry":"Industrie","escalation_contact":"le centre de services"},"policy":{"local_admin":false}}'

# Un document de l'entreprise, puis son état
curl -s -X PUT "$G/v1/documents/procedure-vpn.pdf" -H "Authorization: Bearer $KEY" --data-binary @procedure-vpn.pdf
curl -s $G/v1/documents -H "Authorization: Bearer $KEY"

# Une page de documentation : 202, lue en tâche de fond, puis son état
curl -s -X POST $G/v1/documents/sources -H "Authorization: Bearer $KEY" \
  -d '{"url":"https://support.microsoft.com/fr-fr/teams","label":"Aide Microsoft Teams"}'
curl -s $G/v1/documents/sources -H "Authorization: Bearer $KEY"

# Les périmètres, en lecture
curl -s $G/v1/tenant/scopes -H "Authorization: Bearer $KEY"

# Un document personnel de Camille, puis ce que la mémoire retient d'elle
curl -s -X PUT "$G/v1/me/documents/mes-notes.md" -H "Authorization: Bearer $KEY" \
  -H "X-AI-Engine-User: usr_camille" --data-binary @mes-notes.md
curl -s $G/v1/me/memory -H "Authorization: Bearer $KEY" -H "X-AI-Engine-User: usr_camille"

# Effacer ce que la mémoire retient d'elle : des comptes, et la fin de la pause
curl -s -X DELETE $G/v1/me/memory -H "Authorization: Bearer $KEY" -H "X-AI-Engine-User: usr_camille"

# Ses conversations (sans l'en-tête : toutes celles du tenant), puis les tours de l'une
curl -s "$G/v1/conversations?limit=20" -H "Authorization: Bearer $KEY" -H "X-AI-Engine-User: usr_camille"
curl -s "$G/v1/usage/runs?conversation_id=conv-001" -H "Authorization: Bearer $KEY"

# Tout effacer d'elle en un geste : 202, et un identifiant pour le journal d'audit
curl -s -X DELETE $G/v1/me -H "Authorization: Bearer $KEY" -H "X-AI-Engine-User: usr_camille"

# Un tour, avec son profil
curl -s -X POST $G/v1/conversations/conv-001/messages -H "Authorization: Bearer $KEY" \
  -H "X-AI-Engine-User: usr_camille" \
  -d '{"message":"Je veux installer mon VPN","person":{"first_name":"Camille","it_level":"Débutant","tools":["FortiClient"]}}'

# La même conversation reprise par une autre personne : 403
curl -s -X POST $G/v1/conversations/conv-001/messages -H "Authorization: Bearer $KEY" \
  -H "X-AI-Engine-User: usr_dominique" -d '{"message":"et ensuite ?"}'

# L'illustration jointe à l'étape (answer.step.illustrations[0].id), puis sa relecture en cache
ID=…
curl -s -D - -o capture.png $G/v1/illustrations/tenant/$ID -H "Authorization: Bearer $KEY"
curl -s -o /dev/null -w "%{http_code}\n" $G/v1/illustrations/tenant/$ID -H "Authorization: Bearer $KEY" \
  -H "If-None-Match: \"$ID\""

GET /v1/health, sans clé, rend { status, contract: "v1", contract_version, restate }. contract_version est la version à comparer — voir CHANGELOG-CONTRAT.md.

On this page

Contrat plateforme v1 — la passerelle du moteur1. Authentification2. StatutsCe qui réussitCe qui échoue2 ter. Le débit est plafonné, et deux des trois plafonds sont approximatifs2 bis. Les listes se paginent3. RoutesSanté et contrat — sans cléAdministration — clé de l'installationRetirer un client, retirer un produitApp — clé de l'appmission, baseline_description, conduct : trois champs, trois rôlesanswer_form : le dépannage en étapes, ou le conseil en recommandationsCe que ce contrat n'expose PAS : le modèle et le fournisseurRéglages — clé de l'app, ou du tenantCatalogue et capitalisation — clé de l'app, et l'écart du tenantSocle documentaire de l'app — clé de l'appDéposer un documentDéclarer une pageLa liste blanche du webConnecteurs — clé du tenantQui est servi — le contrat, et les personnes déclaréesperson_identity : comment le moteur sait qui parleConsommation — clé du tenant, et clé d'appGénération contrainte — clé d'app ou de tenantLe schéma est borné, et le refus nomme le nœudOrganisation — clé du tenantLa fiche de l'organisation — le cadre et les faits ne sont pas la même chosePérimètres — clé du tenantLes périmètres s'écrivent — six routes, et deux niveauxDocuments de l'entreprise — clé du tenantRéserver un document à un groupeSources déclarées — clé du tenantDocuments personnels — clé du tenant + personneMémoire de la personne — clé du tenant + personneEffacer la mémoireEffacer une personne entièrement — DELETE /v1/meIndex des conversations — clé du tenantConversation — clé du tenant + personneGET …/events — la progression, pousséeIllustrations — clé du tenant4. person5. Exemples