ai-engine · Intégration

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éeCléElle peut
installationAPI_ADMIN_KEYcréer une app et un tenant, (ré)émettre leurs clés, écrire un contrat
appapp_<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
personnela clé du tenant + X-AI-Engine-Usersa 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_KEY

1. 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_urll'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.shapela forme exacte de l'en-tête, avec le préfixe app_ qui distingue une clé d'app d'une clé de tenant
platform_holdsles trois choses que vous tenez, et que le moteur ne tiendra jamais : les personnes et leur profil, les messages à réafficher, la session
next_stepsle parcours, du premier appel au premier tour — les cinq étapes de ce document, en JSON, avec un corps d'exemple
rotate_keyoù réémettre la clé, et sous quelle portée : installation, pas app — une clé ne se réémet pas avec elle-même
catalogueil 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/integration serait surtout un second endroit où base_url pourrait 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_url vient 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 :

RefusPourquoi
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é dans X-AI-Engine-User est 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 enrolled dè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 :

RefusPourquoi
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 :

RefusPourquoi
402 person_subscriptionLa personne n'est pas inscrite, ou son abonnement est suspendu ou échu. message reprend votre motif.
402 tenant_contractLe contrat de l'organisation est suspendu ou échu.
403 sur une conversationElle appartient à une autre personne. Une conversation a une propriétaire, fixée à son premier tour.
422 sur personUn 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 de X-AI-Engine-User. Il n'y a pas de PUT /v1/users/{id}, et il ne doit pas y en avoir.
  • Ce qui a été dit. Le cid est 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ômeCause la plus fréquente
402 sur tous les tours d'un tenant neufpersons: "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 jamaisstatus: "ignored" — il n'entre dans aucun périmètre ouvert. ignored_reason porte le motif.
404 « méthode PUT non servie » sur /v1/connectors/syncLe chemin littéral l'emporte sur le gabarit paramétré. sync n'est pas une source.
401 après une réémissionLa 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.

On this page