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.mdetFRONTEND.mdvont 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/), surAPI_PORT(8090). - La seule porte vers l'extérieur. L'ingress Restate ne vérifie rien : il reste
privé, comme StarRocks. La console
/adminest 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 v1les 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 etPOST /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 --checkles confronte à la table des routes, etpnpm typecheckrefuse de passer s'ils divergent. Mode d'emploi :../postman/README.md, etESSAIS.mdpour 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-Userse vérifie quand l'app le demande (person_identity: signed), et la progression d'un tour se pousse (GET …/events, entext/event-stream) autant qu'elle se sonde. Ce qui reste vrai : les jetons ne streament pas, la réponse arrive sur lePOST. - Savoir que le contrat a changé. Chaque réponse porte
X-AI-Engine-Contract, etGET /v1/healthrendcontract_version. Le majeur est le préfixe d'URL (1tant 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 dansCHANGELOG-CONTRAT.md.
1. Authentification
| Niveau | En-têtes | Routes |
|---|---|---|
| installation | Authorization: Bearer <API_ADMIN_KEY> | créer une app et (ré)émettre sa clé, créer un tenant et (ré)émettre la sienne |
| app | Authorization: Bearer app_<APP_ID>.<secret> | créer et lister SES tenants, lire sa configuration et ses périmètres |
| tenant | Authorization: Bearer <TENANT_ID>.<secret> | organisation et ses périmètres (lecture), documents et pages déclarées de l'entreprise |
| personne | la 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_iddans le corps est refusé en 422. - Le préfixe
app_lève l'ambiguïté.<ID>.<secret>etapp_<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 parapp_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, tablestenantsetappsde 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-Userest 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_KEYvide : 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
| Statut | Quand |
|---|---|
| 200 | la 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é |
| 201 | une ressource est née et son identifiant est rendu : une app, un tenant, une pièce jointe, un document personnel indexé |
| 202 | c'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 |
| 304 | sur 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.
| Statut | code | Quand |
|---|---|---|
| 401 | unauthorized | clé absente, mal formée, inconnue ou renouvelée |
| 402 | payment_required | la personne n'est pas inscrite, ou le contrat de l'organisation est suspendu ou échu. message reprend votre motif |
| 403 | forbidden | routes d'administration coupées ; conversation d'une autre personne |
| 404 | not_found | route, tenant, document, page déclarée ou pièce jointe inconnus |
| 413 | payload_too_large | corps au-delà de la limite de la route |
| 415 | unsupported_media_type | format refusé, reconnu par les octets, jamais par l'extension seule |
| 422 | invalid_request | valeur invalide ; politique plus lâche que l'installation ; person hors contrat ; page déclarée locale ou privée |
| 429 | rate_limited | trop d'appels, trop vite. Toujours accompagné de Retry-After, en secondes |
| 502 | engine_error | le moteur a échoué pendant le traitement |
| 503 | unavailable | Restate, 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ée | Réglage de l'installation | Ce qu'un tenant peut faire |
|---|---|---|---|
| Tours par heure | l'organisation | RATE_TURNS_PER_HOUR | policy.turns_per_hour, plus bas seulement |
| Tours par heure | une personne | RATE_TURNS_PER_HOUR_PERSON | — |
POST /v1/generate par heure | le payeur (app ou tenant) | RATE_GENERATE_PER_HOUR | — |
| Dépense d'un jour UTC | l'organisation | RATE_DAILY_COST_USD | policy.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/usageet 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-Afterest en secondes, toujours présent sur un 429, et jamais0. 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.
| Route | Ce qui est paginé |
|---|---|
GET /v1/apps | apps |
GET /v1/tenants | tenants |
GET /v1/documents | documents — 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/sources | sources — 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/documents | documents |
GET /v1/conversations | conversations |
GET /v1/usage/runs | runs |
| Paramètre | Valeur |
|---|---|
limit | 1 à 500, 100 par défaut. Hors bornes : 422 qui nomme le paramètre |
cursor | le 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
documentsen 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. Passerlimit=500et suivrenext_cursorrestitue 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é
| Route | Corps | Réponse |
|---|---|---|
GET /v1/health | — | { status, contract: "v1", contract_version, restate } |
GET /v1/openapi.json | — | un document OpenAPI 3.1 |
GET /v1/health—restate: trueest 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) etx-ai-engine-template(le gabarit exact du moteur, quand il diffère du chemin du document — voir ci-dessous). La copie versionnée estdocs/openapi.json;pnpm contract:openapi --check, danspnpm 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
| Route | Corps | Ré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/appsliste les apps de l'installation, celle d'APP_IDen tête (is_default). C'est le pendant deGET /v1/tenantspour 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/appsvalide 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é) etcatalogue_source(l'app d'où il vient). Si l'installation n'en déclare aucun,catalogue_skillsvaut 0 et le blocintegrationle 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 parPUT /v1/app/skills/{id}(§ Catalogue et capitalisation), parpnpm app:import --file=…, ou depuis la console.missionest 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_actionne 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/tenantsvalide 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 rendprovisioned: false: le tenant existe, l'écran Tenants de/adminle montre « base à créer ». Servie aussi sous clé d'app : le tenant naît alors dans cette app. -
POST /v1/tenants/{id}/provisionreprend un provisionnement en échec, et c'est le geste qui manquait : un tenant néprovisioned: falserendait chaque tour en erreur, et la plateforme n'avait qu'un courriel à écrire à l'exploitant. Rejouable par construction — il n'existe aucune colonneprovisioned: 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 rendcreated: 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: falseen général : c'est l'état d'un ÉCHEC, et le chemin ordinaire rendtrue. 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 », etpnpm db:bootstraprejoue 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_TENANTet son corpus compris : sa ligne naît avec sa clé, sans cadre ni politique, et.envgarde 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 :
- l'état Restate de ses objets — il ne vit pas en base, et aucun
DROP DATABASEne 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 ; - sa base : documents, passages, historique des tours, index des conversations, documents personnels, périmètres, connecteurs ;
- ses octets : pièces jointes, documents déposés, illustrations ;
- ce que la base de contrôle garde de lui : personnes déclarées, réglages ;
- 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 reste | Pourquoi |
|---|---|
usage_events | le 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_audit | retirer 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
.envrend 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_clearedcompte 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
| Route | Corps | Ré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.
| Champ | Ce qu'il dit | Où il va | Effet 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 toujours | prompt de la porte d'entrée et juge d'ingestion | le 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éserves | prompt du composeur, et lui seul | aucun : 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é.
steps | recommendations | |
|---|---|---|
answer.step | l'unique étape du tour | null la plupart du temps, pas toujours — voir ci-dessous |
answer.message | souvent vide : l'étape se suffit | la réponse entière, 3 à 6 phrases, jamais vide |
answer.recommendations | absent | 2 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.envfigé au démarrage ; SECRET_KEYécarte tout*_API_KEYde la tablesettings, à 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
| Route | Corps | Ré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 refus | Ce qu'il protège |
|---|---|
la clé reste dans .env | un secret, une adresse de service, ce qui sert à joindre la base |
| elle ne se change pas à chaud | elle périmerait des données déjà écrites (EMBEDDING_*, ILLUSTRATIONS_ENABLED) |
| cette portée n'a pas d'effet | LLM_* et WEB_ALLOWLIST se règlent par app, jamais par tenant |
| la valeur ne passe pas son schéma | une faute de frappe ne s'installe pas en base |
| la clé appartient à l'exploitant | le 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
| Route | Corps | Ré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."
}
}status | Sens | Statut HTTP |
|---|---|---|
indexed | indexé, 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 |
unchanged | mê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 juge | 200 |
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.
status | Sens |
|---|---|
indexed | la 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 |
failed | la lecture a échoué : detail dit pourquoi (HTTP 404, page vide, redirection refusée) |
pending | pas encore lue |
disabled | dé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
| Route | Corps | Ré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 :
mode | Qui détient le jeton |
|---|---|
env | l'exploitant du moteur, dans son .env. La plateforme ne fournit rien |
delegated | la 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/syncest 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
| Route | Corps | Ré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é.
contract | Effet |
|---|---|
status | active, suspended ou expired. Autre chose qu'active : 402 tenant_contract sur chaque route de personne |
persons | open (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_at | 2026-12-31 ou un instant ISO, lu et rendu en UTC. Passé, le contrat est échu quel que soit status |
reason | ce 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/person | c'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/memory | effacer, et vérifier que l'effacement a abouti (residue_pending) — le fermer rendrait l'effacement invérifiable |
GET /v1/me/documents | la 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/me | l'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 :
| Refus | Pourquoi |
|---|---|
| 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
| Route | Corps | Ré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ètre | Valeur |
|---|---|
from, to | 2026-09-01 ou un instant ISO complet, lus en UTC. À défaut, les 30 derniers jours. 400 jours au plus |
group_by | activity, day, model, provider, source, user_key — et tenant_id sous clé d'app seulement. Absent : une seule ligne, le total |
activity | pour 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ètre | Valeur |
|---|---|
limit | 1 à 500, 100 par défaut |
cursor | le next_cursor de la page précédente, opaque : à rendre tel quel |
conversation_id | pour 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
| Route | Corps | Ré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.
| Champ | Règle |
|---|---|
name | requis, 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é |
schema | JSON Schema, borné — voir ci-dessous. 32 Kio au plus |
system | 8 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 |
input | une chaîne, ou une liste de { role, content } (user, assistant, system). 100 000 caractères en tout |
max_output_tokens | 16 à 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 |
effort | low, 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ègle | Pourquoi |
|---|---|
racine { "type": "object" } | une sortie est un objet nommé |
"additionalProperties": false sur chaque objet | sans lui, l'appelant croit tenir un contrat, il tient une suggestion |
required nommant toutes les propriétés | les 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 tableau | un tableau sans plafond est une réponse dont on ne peut pas prévoir le coût |
ni $ref, $defs, oneOf, anyOf, allOf, not, if | la 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és | au-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
| Route | Corps | Ré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é.
| Champ | Effet 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_action | observe, 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_admin | Ne 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 cadre | facts — les faits | |
|---|---|---|
| Ce que c'est | comment s'adresser à la personne, vers qui passer la main | ce que l'organisation tourne : messagerie, sauvegarde, applications métier, effectif |
| Dans le prompt | bloc « Organisation », annoncé comme un cadre, pas une source | famille de preuves [O…], annoncée comme faisant autorité sur son environnement |
| Se cite ? | non, jamais | oui — le modèle la nomme dans used_document_ids, et elle ressort dans answer.sources avec source: "organisation" |
| Fonde une étape ? | non | non non plus — voir ci-dessous |
| Taille | 5 champs, ~1 080 caractères | 60 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" }
]
}| Champ | Règle |
|---|---|
key | requise. 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. |
label | requis, 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. |
value | 600 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. |
restricted | true : le fait ne part qu'à une personne dont le tour déclare facts_clearance: "full". Absent vaut « visible par tout le monde ». |
| en tout | 60 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
| Route | Corps | Ré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
}| Champ | Sens |
|---|---|
answers | ce 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 |
enabled | false : fermé sans être oublié ; ses documents sont rejugés au passage suivant de leur source |
documents | documents admis sous ce périmètre par l'analyse d'ingestion |
admission | l'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 :
| Niveau | Clé | Ce qu'il écrit |
|---|---|---|
| App | clé d'app | un périmètre hérité par tous ses tenants : la veille éditoriale du produit, faite une fois |
| Organisation | clé du tenant | un périmètre propre à ce client, ou le durcissement d'un hérité |
| Champ | Règle à l'écriture |
|---|---|
label | requis à la création, 80 caractères au plus. C'est lui qui produit scope_id (Notes de frais → notes-de-frais) |
description | requise à la création, 600 caractères au plus. C'est elle que l'analyse d'ingestion lit pour juger un document |
enabled | défaut true. false ferme sans oublier — les documents sont rejugés au passage suivant de leur source |
answers | documentation (défaut) ou general |
scope_id | refusé 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 :
PATCHfusionne, il ne remplace pas.{"enabled": false}seul suffit : le libellé et la description sont relus avant l'écriture. C'est l'inverse dePUT /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 ;DELETElibère un emplacement et efface les lignes que les tenants avaient écrites pour durcir ce périmètre —tenants_clearedles 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_scopesrendu 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 tour | person.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 tour | person.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/documents | audience 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: trueet prend effet tout de suite. groupsest refusé parPUT /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.
| Route | Corps | Ré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/documentsdit l'état de la documentation — unstatuspar 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 : unjob_idpouvait en sortir avant d'avoir été lu une seule fois.phasevautqueued,ingesting,covering,readyoufailed— etfinished_atest renseigné dès qu'elle ne bougera plus ; un identifiant inconnu rend 404, un identifiant malformé 422.
documents[].status | Sens |
|---|---|
indexing | un dépôt en cours le traite |
indexed | indexé, theme et theme_name renseignés |
ignored | écarté par l'analyse de périmètre : ignored_reason porte le motif du juge |
failed | error dit pourquoi |
stored | déposé, jamais indexé |
Redéposer un même nom remplace la version précédente.
Sources déclarées — clé du tenant
| Route | Corps | Ré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
urldu 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 ressortignoredavec le motif du juge. - Une plateforme ne déclare que des pages publiques :
httpouhttps, sans identifiants dans l'adresse, nilocalhost, ni adresse IP privée ou de lien local, ni nom en.localou.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"
}status | Sens |
|---|---|
indexing | un passage lancé par la passerelle est en cours |
indexed | le 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 |
failed | la lecture a échoué : detail dit pourquoi (HTTP 404, page vide, redirection refusée) |
pending | pas encore lue, ou lue par un passage qui n'a pas écrit son issue |
disabled | dé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
| Route | Corps | Ré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, Markdown | 201 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 :
- 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"). - 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.
- 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
| Route | Corps | Ré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_pendingest ABSENT des deux routes quandUSER_MEMORYn'est pastdam. La réponse se réduit alors à{ "enabled": false, "facts": [], "profile": "" }pourGET, et à{ "enabled": false }pourDELETE: niresidue_pending, nipaused_until, nierasure_id, nierased. Un client qui litresidue_pendingsans vérifierenabledlitundefined, ce qui n'est pasfalse— et une purge de maintenance ne s'attend pas sur un moteur qui n'a pas de mémoire. Soustdam, en revanche, il vaut toujours un booléen, y compris pour une personne jamais effacée.profileest 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 desfactsa 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, etDELETE /v1/me/memoryles efface.
Effacer la mémoire
DELETE /v1/me/memory efface ce que la mémoire retient de la personne, sur toutes ses
conversations :
| Niveau | Ce qui est effacé | Compté dans erased |
|---|---|---|
| L0 | les messages enregistrés, de toutes ses conversations | messages |
| L1 | les faits extraits | facts |
| L2 | les scènes que la mémoire en dérive | scenes |
| L3 | le profil que la mémoire en dérive | profile : 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/memoryrendpaused_untilet 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 installationstandalone) etrecords/<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_pendingle dit :truedu premier passage de l'effacement jusqu'à ce passage de maintenance,falseensuite, 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 —, etresidue_pendingrestetruejusqu'à un passage où sa mémoire est vide, après un nouvel effacement. - les copies JSONL en ajout seul :
-
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
| Route | Corps | Ré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 :
- la mémoire — sa pause est ce qui empêche un tour encore en vol de réenregistrer pendant qu'on vide le reste ;
- 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 ;
- les documents personnels et les thèmes personnels qu'ils portaient ;
- 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.
conversationscompte 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/conversationsdit ce qu'il en reste. - Vérifier que c'est fini :
GET /v1/me/memoryrendresidue_pending: falseune fois le passage de maintenance passé, etGET /v1/conversationsne 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 (objetperson, 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_MEMORYautre quetdam:memoryvaut{ 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
| Route | Corps | Ré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ètre | Valeur |
|---|---|
limit | 1 à 500, 100 par défaut |
cursor | le 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-Userest 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 routepersonlui aurait interdit de voir sa propre installation. Elle n'est donc pas soumise au 402, ni au jeton signé deperson_identity.idest 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_keyest l'empreinteu-…de la personne, vide pour une conversation ouverte sans personne. Jamais l'identifiant en clair : le moteur ne le garde pas.created_atest le PREMIER TOUR, pas l'ouverture : une clé de conversation sans tour n'existe pas pour le moteur.turnscompte 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.
503si la table manque : le tenant attend un provisionnement.
Conversation — clé du tenant + personne
| Route | Corps | Ré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 quePUT …/attachmentsa rendu, au plusATTACHMENT_MAX_PER_TURN.hrefn'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 leGETdu 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 dansattachmentsau 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 commeperson, 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": []etfactsabsent 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 jointfactsà 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/factsetPUT /v1/tenantsont les deux seules écritures. -
answer_form:stepsourecommendations, 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 à laconductde l'app, sous les mêmes règles de préséance. Une consigne durable se déclare une fois, parPUT /v1/app: la répéter à chaque message la fait payer à chaque message. -
facts_clearance:shared(défaut) oufull. Sousshared, les faitsrestrictedne partent pas dans le prompt. C'est une affirmation de la plateforme : elle seule connaît les rôles de ses comptes.sharedpar 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) ounaive_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.persondit ce que le profil a amorcé. -
Le corps ne rend que ce que
AskResultdéclare :answer,trace, etexpertise_hintquand il y en a un. Il portait quatre clés de plus —state,illustration_request,refresh_request,scope_refusal—, dontstate, 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 formerecommendationsseulement :{ title, description, sources }, trois au plus.sourcesne porte que desdocument_id, tous présents dansanswer.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 restePUT /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.labeletcurrent_valueviennent 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.messagepeut être vide. Au-dessus d'une étape, il ne dit que ce queanswer.stepne 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
messagecomme dansstep.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.optionsest du texte brut : un clic le renvoie tel quel commemessage. Les adresses des sources ne sont pas recopiées dans le texte : elles sont dansanswer.sources[].url, sans paramètresutm_*. -
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 autrecid: 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.
stageetlabelsont ceux de…/progress, aux mêmes libellés, déjà rédigés pour être affichés tels quels.turnest 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
EventSourcese reconnectera : c'est au consommateur de fermer quand la réponse duPOSTest arrivée. Sur réception d'unevent: error, il faut appelerclose()— 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 unevent: errorportant le mêmecodeque 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 :401sans clé,422sansX-AI-Engine-Userou sur uncidmal formé,402souspersons: "enrolled",403sur la conversation d'une autre personne,503si le moteur est injoignable. X-Accel-Buffering: noest 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 sonPOST— c'est l'affichage qui manque, pas la réponse. Aucun contournement côté appelant : sonder…/progressa 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.
| Route | Corps | Ré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 étapedocumentation(ougeneral_knowledgepour une image de la bibliothèque). Chaque entrée porteid(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
webougeneratedvient 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.illustrationsetanswer.illustration_jobportent les mêmes objets, aux mêmes champs, quand le tour rend une réponse SANS étape — une réponse de conseil est unmessageet dessources, 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.srcest l'adresse sous laquelle la plateforme sert l'image en relayant leGET: 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 :doneporte l'illustration,failedou 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_HOSTSl'emporte surILLUSTRATION_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écialenonerend 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 unelicense_note. La règle « un écran ne s'invente pas » (ILLUSTRATION_GENERATE_SCREENS=false) n'a pas eu à être tordue : ces sujets-là sont dumateriel, 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é parILLUSTRATION_DAILY_GENERATIONS(20) etILLUSTRATION_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, doncAuthorization: Bearer <API_ADMIN_KEY>et aucun tenant. Mêmes octets, mêmes en-têtes, même 404 unique, mêmerecord.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 ; etrow.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-Typelu 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,withdrawnpar 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
nullest absente. - Les valeurs suggérées ci-dessous sont proposées par le simulateur, pas imposées : la plateforme garde ses propres libellés.
| Champ | Suggestions | Effet dans le tour |
|---|---|---|
first_name, job_title | — | Bloc « Profil de la personne » du composeur : un cadre, jamais une source. |
work_location, work_rhythm | Bureau, Domicile, Site client · Sur site, Hybride, Télétravail | Faits 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_level | Débutant, Intermédiaire, Avancé, Expert | Remplace 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_groups | true | Le 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_style | Concis, Détaillé, Pas à pas | La forme de la réponse, toujours une seule étape. |
language | Franç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.