Intégrer une app sur le moteur
POST /v1/apps promet ce document à tout créateur d'app depuis le premier jour. Le
voici : le parcours complet, du premier appel au premier tour qui cite un document, un
curl par étape — et le seul endroit où est écrit en clair ce qui est refusé, et
pourquoi.
Le contrat lui-même — toutes les routes, tous les champs — est dans
API.md. Ce document-ci est le chemin, pas la référence.
Les quatre portées, et ce que chacune peut
| Portée | Clé | Elle peut |
|---|---|---|
| installation | API_ADMIN_KEY | créer une app et un tenant, (ré)émettre leurs clés, écrire un contrat |
| app | app_<APP_ID>.<secret> | créer et lister SES tenants, écrire sa mission, son socle, ses périmètres |
| tenant | <TENANT_ID>.<secret> | son cadre, ses périmètres, ses documents, ses connecteurs, ses personnes |
| personne | la clé du tenant + X-AI-Engine-User | sa conversation, ses documents, sa mémoire, son profil |
Une portée ne se déduit pas d'une autre : la clé désigne l'objet. Aucune route ne
prend un identifiant de tenant dans son URL ou son corps, et aucune ne prend celui d'une
personne — /v1/tenant et /v1/person désignent ceux de la clé et de l'en-tête.
BASE=https://moteur.example # API_PUBLIC_URL
ADMIN=… # API_ADMIN_KEY1. Créer l'app — la clé n'est rendue qu'ici
curl -sS -X POST "$BASE/v1/apps" \
-H "authorization: Bearer $ADMIN" -H 'content-type: application/json' \
-d '{
"id": "CAVISTE",
"label": "Assistant caviste",
"mission": "conseil en vins",
"baseline_label": "Le vin et sa vente",
"baseline_description": "Les cépages, les accords mets-vins, les millésimes, la conservation, et la vente au comptoir."
}'201, et la réponse porte api_key — une seule fois. Le moteur n'en garde que
l'empreinte : elle ne se relit nulle part. La perdre oblige à
POST /v1/apps/{id}/api-key, qui fait cesser de valoir la précédente (replaced: true) — et donc casse toute intégration qui la détenait.
Le bloc integration — ce que vous emportez en créant l'app
La réponse porte un bloc integration. C'est un geste de remise, pas un état : il
n'existe qu'ici, une fois, et il répond aux questions qu'on se pose avant d'avoir ouvert
la référence.
{
"integration": {
"base_url": "https://moteur.example",
"contract": "v1",
"app_id": "CAVISTE",
"auth": {
"header": "Authorization",
"shape": "Bearer app_CAVISTE.<secret>",
"person_header": "X-AI-Engine-User",
"shown_once": "la clé en clair n'est rendue qu'ici ; perdue, elle se réémet et la précédente cesse de valoir"
},
"platform_holds": ["…", "…", "…"],
"next_steps": [{ "title": "…", "method": "POST", "path": "/v1/tenants", "auth": "app", "body": {} }],
"rotate_key": { "method": "POST", "path": "/v1/apps/CAVISTE/api-key", "auth": "installation" },
"catalogue": "vide à la création : l'opérateur l'amorce avant que l'assistant ne cite quoi que ce soit",
"docs": { "integration": "docs/INTEGRATION.md", "reference": "docs/API.md" }
}
}| Clé | Ce qu'elle dit |
|---|---|
base_url | l'URL publique du moteur, telle que le moteur la connaît (API_PUBLIC_URL). Omise si elle n'est pas réglée, jamais devinée |
auth.shape | la forme exacte de l'en-tête, avec le préfixe app_ qui distingue une clé d'app d'une clé de tenant |
platform_holds | les trois choses que vous tenez, et que le moteur ne tiendra jamais : les personnes et leur profil, les messages à réafficher, la session |
next_steps | le parcours, du premier appel au premier tour — les cinq étapes de ce document, en JSON, avec un corps d'exemple |
rotate_key | où réémettre la clé, et sous quelle portée : installation, pas app — une clé ne se réémet pas avec elle-même |
catalogue | il est vide à la création. Un assistant sans catalogue ne cite rien |
Trois bornes, et elles sont structurelles plutôt que disciplinaires :
- le bloc ne prend jamais la clé en paramètre : il n'existe aucun chemin par lequel elle puisse y entrer, donc aucun journal ne la verra passer là ;
- il n'a pas de route à lui.
GET /v1/app/integrationserait surtout un second endroit oùbase_urlpourrait diverger. Réémettre une clé ne le rend pas non plus : ce n'est pas une première intégration ; - il ne lit pas l'en-tête
Host:base_urlvient de la configuration du moteur, jamais de ce qu'un relais a réécrit.
Il est rendu même si le provisionnement de la base a échoué : l'app existe, sa clé
vaut, et provision_error dit déjà ce qui manque.
platform_holds mérite d'être lu en entier, parce qu'il vous évite de chercher une
route qui n'existe pas et ne doit pas exister : il n'y a pas de PUT /v1/users/{id}.
L'objet person voyage avec chaque message, un profil modifié vaut dès le message
suivant, et le moteur tient l'index de ses conversations (GET /v1/conversations) et
l'état de chacune — jamais les messages à réafficher.
Ce que créer une app ne fait pas : ni thèmes, ni périmètres ne s'écrivent. Chacun
coûterait un embedding, et créer une app ne doit jamais coûter un embedding par surprise.
Le catalogue s'amorce ensuite par pnpm app:import, ou depuis la console.
Ce qui est refusé, et pourquoi :
| Refus | Pourquoi |
|---|---|
id commençant par app_ (422) | <ID>.<secret> et app_<ID>.<secret> ont la même forme : le préfixe est ce qui distingue une clé d'app d'une clé de tenant. Un identifiant en app_ ferait naître l'ambiguïté par l'autre bout. |
id en collision, même normalisé (422) | Deux apps ne peuvent pas partager une base. Le message nomme celle qui existe, et la route qui lui émet une clé. |
mission vide (422) | Elle entre dans les prompts à la place de « support informatique ». Sans elle, l'agent n'a pas de métier. |
baseline_label sans baseline_description (422) | C'est sur la description que la porte d'entrée et l'analyse d'ingestion jugent. Un libellé seul laisserait croire à un socle qui ne filtre rien. |
policy.max_action plus lâche que l'installation (422) | Une politique ne peut que durcir. Au-dessus, elle ne durcirait rien et laisserait croire à un plafond qui n'existe pas. |
2. Créer une organisation
Sous la clé de l'app — le tenant naît dans cette app :
APP_KEY=app_CAVISTE.…
curl -sS -X POST "$BASE/v1/tenants" \
-H "authorization: Bearer $APP_KEY" -H 'content-type: application/json' \
-d '{ "id": "ACME", "label": "ACME SA" }'Sous la clé d'installation, app_id est accepté — l'installation est au-dessus de
toutes les apps, et c'est ce qui évite de faire tourner la clé d'une app (donc de casser
son intégration) juste pour y créer un tenant :
curl -sS -X POST "$BASE/v1/tenants" \
-H "authorization: Bearer $ADMIN" -H 'content-type: application/json' \
-d '{ "id": "ACME", "label": "ACME SA", "app_id": "CAVISTE" }'Sous une clé d'app, app_id est en revanche refusé en 422 : une app ne crée pas chez
une autre.
provisioned: false n'est pas un échec de création : le tenant existe et sa clé vaut. Sa
base manque, et la console la crée d'un geste.
Un tenant créé aujourd'hui sert TOUTE personne (
contract.persons: "open"), et il faut le savoir dans les deux sens. Aucun 402 ne vous attend à l'étape 3 : vous pouvez faire votre premier tour tout de suite. Mais n'importe quel identifiant présenté dansX-AI-Engine-Userest alors servi, y compris un identifiant forgé — et il aurait accès à la mémoire et aux documents personnels rangés sous cette empreinte.Le défaut est ouvert pour ne casser aucune intégration existante, pas parce que c'est le bon réglage. Basculez en
enrolleddès que vos comptes sont stables, et déclarez vos personnes : c'est l'étape 3, et c'est celle qu'on oublie.
3. Déclarer qui est servi
TENANT_KEY=ACME.…
curl -sS -X PUT "$BASE/v1/person" \
-H "authorization: Bearer $TENANT_KEY" \
-H 'X-AI-Engine-User: usr_camille' -H 'content-type: application/json' \
-d '{ "subscription": "active", "plan": "standard" }'Pour n'accepter QUE les personnes déclarées — et donc refuser un identifiant forgé — basculez son contrat :
curl -sS -X PUT "$BASE/v1/tenants/ACME/contract" \
-H "authorization: Bearer $ADMIN" -H 'content-type: application/json' \
-d '{ "status": "active", "persons": "enrolled" }'Le même geste sert à suspendre une organisation qui ne paie plus
("status": "suspended"), avec reason : chaque tour rend alors 402 et le message
reprend votre motif, mot pour mot.
Ce qu'un 402 ne ferme jamais. PUT /v1/person, d'abord — sinon l'inscription serait
un verrou dont la clé est à l'intérieur. Puis tout ce qui sert à récupérer ses données ou
à les faire supprimer : lire et effacer sa mémoire, lister ses documents personnels
(la procédure d'effacement en dépend), en retirer un, effacer une conversation. Un impayé
n'est pas un moyen de retenir des données personnelles. Le 402 ferme le service — le tour,
ses pièces jointes, le dépôt d'un document —, pas les droits d'une personne.
Ce qui est refusé, et pourquoi :
| Refus | Pourquoi |
|---|---|
subscription, plan, expires_at, reason sur /v1/me/profile (422) | Ces champs disent ce qui est dû, et c'est la plateforme qui le déclare. Refusés plutôt qu'ignorés : une tentative se lit, un silence se répète. |
PUT /v1/tenants/{id}/contract sous la clé du tenant (404) | Le cadre et la politique s'écrivent sous la clé du tenant ; un tenant qui s'accorderait son propre contrat n'en serait pas un. |
| un tenant d'une autre app, sous une clé d'app (404) | Une app n'écrit que chez les siens. 404 et non 403 : la réponse ne dit pas qu'il existe. |
PUT /v1/tenants/{id}/contract sur un tenant sans ligne de registre (422) | Un tenant de .env qui n'a jamais reçu de clé n'a rien où écrire son contrat. Le message nomme la route qui lui en crée une. |
4. Le cadre, les périmètres, les documents
# Le cadre : il règle le vocabulaire, jamais le plafond d'action
curl -sS -X PUT "$BASE/v1/tenant" -H "authorization: Bearer $TENANT_KEY" \
-H 'content-type: application/json' \
-d '{ "profile": { "industry": "Commerce de détail" }, "policy": { "max_action": "observe" } }'
# Lire les périmètres AVANT d'en ouvrir : un tour ne cite que ce qu'un périmètre admet
curl -sS "$BASE/v1/tenant/scopes" -H "authorization: Bearer $TENANT_KEY"
# En ouvrir un — la description est ce que l'analyse d'ingestion lira pour juger
curl -sS -X POST "$BASE/v1/tenant/scopes" -H "authorization: Bearer $TENANT_KEY" \
-H 'content-type: application/json' \
-d '{ "label": "Notes de frais",
"description": "Barèmes, justificatifs et circuit de validation des notes de frais." }'
# Un document : les octets bruts dans le corps, le nom dans le chemin
curl -sS -X PUT "$BASE/v1/documents/procedure-vpn.pdf" \
-H "authorization: Bearer $TENANT_KEY" --data-binary @procedure-vpn.pdf
# L'indexation est ASYNCHRONE : le 202 ci-dessus a rendu { "job_id": "…" }
curl -sS "$BASE/v1/documents/jobs/<job_id>" -H "authorization: Bearer $TENANT_KEY"
# Ou l'état de toute la documentation, un status par document
curl -sS "$BASE/v1/documents" -H "authorization: Bearer $TENANT_KEY"Déclarer n'indexe pas pendant la requête, et un document hors de tout périmètre
ouvert n'est pas indexé — il ressort ignored, avec le motif du juge. C'est la cause
numéro un d'un « pourquoi l'assistant ne trouve rien » : le document est là, il n'entre
dans aucun périmètre. D'où l'ordre de ces appels : ouvrir le périmètre avant de
déposer, sinon il faut attendre le passage suivant de la source pour que le document
soit rejugé.
Deux façons de suivre l'indexation, et elles ne disent pas la même chose.
GET /v1/documents/jobs/{job_id} suit le geste — le job_id que le 202 du dépôt a
rendu —, avec une ligne par document dans items[]. C'est ce qu'une boucle d'attente
interroge, et elle surveille finished_at. GET /v1/documents dit l'état de la
documentation : c'est ce qu'un écran affiche. La liste ne relit que les derniers
dépôts, donc un job_id peut en sortir avant d'avoir été lu.
Les périmètres s'écrivent par le contrat — POST, PATCH, DELETE sur
/v1/tenant/scopes, et les trois mêmes sur /v1/app/scopes pour ceux que l'app fait
hériter à tous ses clients. Deux choses à savoir avant de câbler un bouton : PATCH
fusionne ({"enabled": false} seul suffit), et un périmètre hérité ne se supprime pas
depuis l'organisation — DELETE y annule seulement son durcissement (reverted: true),
et rend 422 s'il n'a jamais été durci. Le détail est dans API.md §3.
5. Le premier tour
curl -sS -X POST "$BASE/v1/conversations/c-001/messages" \
-H "authorization: Bearer $TENANT_KEY" \
-H 'X-AI-Engine-User: usr_camille' -H 'content-type: application/json' \
-d '{
"message": "Comment je me connecte au VPN ?",
"person": { "first_name": "Camille", "job_title": "Comptable", "it_level": "Débutant" }
}'person accompagne le message et l'emporte EN BLOC sur le profil rangé, pour ce
tour-là. Jamais champ par champ : le contrat écarte déjà les valeurs vides, donc « champ
absent » et « champ effacé » seraient indiscernables, et une fusion par champ ferait
remonter un outil que vous auriez précisément cessé d'envoyer. Un frontal qui joint
person à chaque message se comporte exactement comme si les routes de profil
n'existaient pas.
Un tour n'écrit jamais le profil rangé. PUT /v1/person et PUT /v1/me/profile sont
les deux seules écritures.
Ce qui est refusé, et pourquoi :
| Refus | Pourquoi |
|---|---|
402 person_subscription | La personne n'est pas inscrite, ou son abonnement est suspendu ou échu. message reprend votre motif. |
402 tenant_contract | Le contrat de l'organisation est suspendu ou échu. |
| 403 sur une conversation | Elle appartient à une autre personne. Une conversation a une propriétaire, fixée à son premier tour. |
422 sur person | Un champ hors contrat, ou une valeur trop longue. Le message nomme le champ. |
Un tour refusé ne coûte rien : aucune ligne n'entre au registre des coûts, et rien n'est écrit dans la conversation.
6. Ce que vous tenez, et que le moteur ne tiendra jamais
- Les personnes. Le moteur n'en tient aucun annuaire : ni nom, ni e-mail, ni
téléphone. Il ne connaît que l'empreinte de l'identifiant que vous donnez —
u-+ les 40 premiers caractères du SHA-256 hexadécimal deX-AI-Engine-User. Il n'y a pas dePUT /v1/users/{id}, et il ne doit pas y en avoir. - Ce qui a été dit. Le
cidest le vôtre, et le moteur en tient désormais l'index (GET /v1/conversations: dates, compte de tours, empreinte de la personne) — ce qui rend l'historique énumérable et l'effacement complet possible. Mais aucune route ne rend une question ni une réponse passée : les messages à réafficher sont à vous. - La session. Les appels sont de serveur à serveur : le navigateur ne voit jamais
une clé. Un
Authorization: Bearer <clé de tenant>posé dans du JavaScript de page donne à qui ouvre les devtools les documents et les conversations de tout le tenant. - L'abonnement et le contrat. C'est vous qui déclarez qui est servi ; le moteur ne fait que refuser quand ce n'est plus le cas.
7. Quand quelque chose ne marche pas
| Symptôme | Cause la plus fréquente |
|---|---|
| 402 sur tous les tours d'un tenant neuf | persons: "enrolled" et aucune personne déclarée. PUT /v1/person, ou basculer le contrat en open. |
| 503 « tenant non provisionné » | La base existe au registre, pas dans StarRocks. La créer depuis la console, ou pnpm db:bootstrap. |
| Un document déposé que l'assistant ne cite jamais | status: "ignored" — il n'entre dans aucun périmètre ouvert. ignored_reason porte le motif. |
404 « méthode PUT non servie » sur /v1/connectors/sync | Le chemin littéral l'emporte sur le gabarit paramétré. sync n'est pas une source. |
| 401 après une réémission | La clé précédente a cessé de valoir à l'instant de l'émission. replaced: true le disait. |
| Un profil qui « revient » après avoir été retiré | Vous envoyez encore person dans le message : il l'emporte en bloc. Le profil rangé n'est lu qu'à défaut. |