Parler au chatbot en visiteur — la face navigateur de l’API
Sixieme notebook de la serie « AI Engine par son API ». Les grains precedents ont parle au plugin en administrateur (l’API REST mwai/v1) et en agent (le serveur MCP). Il reste un troisieme consommateur, le plus nombreux de tous : le navigateur d’un visiteur anonyme qui ouvre une page du site et discute avec le chatbot.
Cette face a son propre namespace (mwai-ui/v1), sa propre authentification (un nonce WordPress, pas des credentials), et un mecanisme d’amorcage concu pour les pages mises en cache : la page publique ne contient aucun secret — le visiteur recupere un jeton d’interaction au moment ou il ecrit. Ce notebook demontre le cycle complet : charger la page, prouver qu’elle est vierge de jetons, amorcer la session, puis reellement discuter avec le chatbot sans aucun compte.
Les sorties de ce notebook proviennent d’une execution reelle contre l’instance locale (voir « Provenance et limites », en fin de fichier).
La serie « AI Engine par son API »
Le projet Livres Agites a mis AI Engine au coeur d’une maison d’edition : bot d’accueil, agents d’ateliers, bibliothecaire documentee par RAG, formulaires dynamiques. Cette serie presente le plugin de maniere reproductible — sans jamais exposer de donnees client :
Notebook
Contenu
presenter-ai-engine-par-son-api
instance, API, catalogue des chatbots, premiere completion
configurer-chatbots-par-l-api
lire, dupliquer, ecrire et interroger des chatbots (document JSON global)
administrer-les-formulaires-par-l-api
le formulaire comme contenu : CRUD, publication, rendu public
piloter-wordpress-par-mcp
le serveur MCP : handshake, catalogue d’outils, appels reels
brancher-plusieurs-providers-par-l-api
environnements, matrice d’usages, multi-provider par l’API
la face navigateur : session, nonce, conversation anonyme
notebooks suivants
…
Trois niveaux de lecture, dans chaque notebook :
Decouverte — ce que fait la fonctionnalite, vue par l’API ;
Branchement — comment on l’a branchee dans le projet ;
Exercice — reutiliser le pattern sur un cas voisin.
# Configuration et helpers. Aucune cle ni adresse de provider n'est stockee# dans ce fichier : tout vient de instance-jetable/.env (README, etape 5).import base64import htmlimport jsonimport osimport refrom pathlib import Pathimport requestsfrom dotenv import load_dotenv# Localisation du .env : a cote du notebook (instance-jetable/.env),# sinon dans le repertoire courant.charges = []for candidat in (Path("instance-jetable/.env"), Path(".env")):if candidat.exists(): load_dotenv(candidat) charges.append(str(candidat))print("Fichiers .env charges :", charges or"(aucun)")BASE_URL = os.getenv("VALMONT_BASE_URL", "http://localhost:8093").rstrip("/")ADMIN_USER = os.getenv("VALMONT_ADMIN_USER", "")APP_PASSWORD = os.getenv("VALMONT_APP_PASSWORD", "")print("Base URL :", BASE_URL)creds = base64.b64encode(f"{ADMIN_USER}:{APP_PASSWORD}".encode()).decode()ENTETES = {"Authorization": "Basic "+ creds, "Content-Type": "application/json"}def api(route, method="GET", payload=None):"""Appel a l'API d'administration, en tant qu'admin (app password).""" r = requests.request(method, BASE_URL +"/wp-json"+ route, headers=ENTETES, json=payload, timeout=120) r.raise_for_status()return r.json()def http_get(chemin):"""GET d'une page publique, en simple visiteur (aucun header d'auth).""" r = requests.get(BASE_URL + chemin, timeout=60)return r.status_code, r.textdef clean_text(t, max_len=140):"""Normalise un texte de LLM pour l'affichage (espaces, longueur)."""ifnot t:return"(vide)" t = re.sub(r"\s+", " ", t).strip()return t[:max_len] + ("..."iflen(t) > max_len else"")
Fichiers .env charges : ['instance-jetable\\.env']
Base URL : http://localhost:8093
Trois faces, trois namespaces
L’instance expose plusieurs familles de routes REST, listees par le catalogue racine GET /wp-json/. Trois nous concernent — une par consommateur :
Namespace
Consommateur
Authentification
Grains
mwai/v1
l’administrateur (scripts, tableaux de bord)
application password
1-3, 5
mcp/v1
un agent (Claude, un IDE, un client MCP)
app password ou OAuth
4
mwai-ui/v1
le navigateur du visiteur
nonce WordPress
ce notebook
La face UI est la plus discrete : cinq routes seulement — la conversation (chats/submit), l’editeur (editor/submit), et la gestion de fichiers. C’est pourtant celle qui porte tout le trafic public du chatbot.
# 1. Le catalogue des namespacescatalogue = requests.get(BASE_URL +"/wp-json/", timeout=60).json()noms =sorted(catalogue["namespaces"])print("namespaces de l'instance :", noms)routes_ui =sorted(r for r in catalogue["routes"] if r.startswith("/mwai-ui"))print()print("routes de la face visiteur (mwai-ui/v1) :")for r in routes_ui:print(" ", r)
namespaces de l'instance : ['mcp/v1', 'mwai-ui/v1', 'mwai/v1', 'oembed/1.0', 'wp-block-editor/v1', 'wp-site-health/v1', 'wp/v2']
routes de la face visiteur (mwai-ui/v1) :
/mwai-ui/v1
/mwai-ui/v1/chats/submit
/mwai-ui/v1/editor/submit
/mwai-ui/v1/files/delete
/mwai-ui/v1/files/list
/mwai-ui/v1/files/upload
Trois faces, lues dans le catalogue
Le catalogue de /wp-json/ rend sept namespaces, et trois nous concernent directement : mwai/v1 (l’API d’administration des notebooks precedents), mwai-ui/v1 (la face visiteur, notre sujet) et mcp/v1 (le serveur MCP du quatrieme notebook). Les autres sont la plateforme WordPress elle-meme. Une face = un namespace = un contrat different : meme instance, memes donnees, mais des routes, des authentifications et des garanties distinctes.
La liste des six routes de mwai-ui/v1 se lit comme un cahier des charges du visiteur : soumettre un chat (chats/submit), un editeur (editor/submit), gerer des fichiers (upload, list, delete). Aucune route d’administration ici — ce namespace ne sait PAS configurer le moteur, seulement l’utiliser. C’est la separation que la suite du notebook va mesurer.
Ce que la page publique ne contient pas
Le chatbot s’insere dans une page par son shortcode. Le reflexe du developpeur d’API serait d’y chercher le jeton d’interaction — et il n’y est pas : pour un visiteur non connecte, le conteneur rendu embarque restNonce: null et sessionId: null, explicitement.
C’est un choix d’architecture documente dans le code du plugin : une page mise en cache (WP Rocket, Cloudflare, Varnish…) figerait dans le HTML tout ce qu’on y mettrait — un nonce expire au bout de 12 a 24 heures, et un identifiant de session fige fusionnerait les limites de tous les visiteurs en une seule. La page est donc deliberement vierge de tout jeton ; le navigateur va les chercher a la premiere interaction, par un endpoint concu pour cela.
# 2. Une page chatbot publique, et son contenu exactTITRE ="Discussion Valmont"SLUG ="discussion-valmont"pages = api("/wp/v2/pages?status=publish&per_page=100")existantes = [p for p in pages if p["slug"] == SLUG]if existantes: PAGE_ID = existantes[0]["id"]print("page existante reutilisee :", PAGE_ID)else: rep = api("/wp/v2/pages", method="POST", payload={"title": TITRE, "slug": SLUG, "status": "publish","content": "<!-- wp:shortcode -->\n[mwai_chatbot]\n<!-- /wp:shortcode -->", }) PAGE_ID = rep["id"]print("page creee :", PAGE_ID)# Le visiteur charge la page : aucun header d'authentification.statut, code_html = http_get("/"+ SLUG +"/")print("GET /"+ SLUG +"/ ->", statut, "| taille du HTML :", len(code_html))conteneur = re.search(r"<div[^>]*mwai-chatbot-container[^>]*>", code_html)brut = re.search(r"data-system='([^']*)'", conteneur.group(0)).group(1)systeme = json.loads(html.unescape(brut)) # entites HTML (") -> JSONprint("conteneur present :", bool(conteneur))print("botId embarque :", systeme.get("botId"))print("restNonce embarque :", systeme.get("restNonce"))print("sessionId embarque :", systeme.get("sessionId"))print("stream actif :", systeme.get("stream"))
Le HTML n’est pas « sans jeton par oubli » : le conteneur porte les cles (restNonce, sessionId) avec des valeurs null — le serveur les remplit pour un visiteur connecte, et les laisse vides pour un anonyme. La page est cacheable sans risque ; le jeton, lui, a une duree de vie courte et un perimetre par visiteur. C’est le meme raisonnement qui, dans le projet client, permet de servir les pages du site depuis un cache pendant que chaque conversation reste isolee.
L’amorcage : start_session
L’endpoint d’amorcage est le seul de tout le plugin avec une permission __return_true — reellement public. Il repond a trois besoins d’un coup : un identifiant de session, un nonce frais, et le cookie mwai_session_id qui liera les requetes suivantes. Le serveur derive toujours la session du cookie — le champ du client est ignore s’il est vide.
# 3. Le bootstrap du visiteurvisiteur = requests.Session() # porte le cookie pour la suiterep = visiteur.post(BASE_URL +"/wp-json/mwai/v1/start_session", timeout=60).json()NONCE = rep["restNonce"]print("success :", rep["success"])print("sessionId :", rep["sessionId"][:8] +"... (jeton courte duree)")print("nonce :", NONCE[:6] +"...")print("cookies :", [c.name for c in visiteur.cookies])
Le handshake mwai-ui/v1 rend quatre choses, et chacune a son role dans la conversation a venir. Le sessionId (5024de59...) est un jeton de courte duree : il identifie la conversation cote serveur. Le nonce (dd9b3f...) est la credence anti-CSRF que WordPress exigera sur chaque POST — sans elle, pas d’ecriture. Le cookie mwai_session_id pose par la session requests joue le meme role que le cookie du navigateur : il suit le visiteur d’un appel a l’autre. Et success : True dit juste que le serveur a accepte de les distribuer.
Aucune de ces trois credences n’est une authentification d’utilisateur : un visiteur anonyme les obtient toutes, c’est prevu ainsi. Ce sont des jetons de parole, pas des droits. La frontiere veritable se mesure au palier suivant — et le contraste entre les deux POST (401 sans nonce, 200 avec) montrera exactement ou elle est posee.
La conversation du visiteur
La route est POST /mwai-ui/v1/chats/submit, et l’authentification est le header X-WP-Nonce. La frontiere d’abord : sans le header, la reponse est un refus sec. Puis la conversation reelle — le meme appel que le widget du navigateur emet quand le visiteur ecrit.
# 4. La frontiere, puis la conversationrefus = visiteur.post(BASE_URL +"/wp-json/mwai-ui/v1/chats/submit", json={"botId": "valmont", "newMessage": "Bonjour"}, timeout=60)print("sans nonce ->", refus.status_code, "|", refus.json().get("code"))reponse = visiteur.post(BASE_URL +"/wp-json/mwai-ui/v1/chats/submit", headers={"X-WP-Nonce": NONCE}, json={"botId": "valmont","newMessage": "Bonjour. Repondez en une phrase courte."}, timeout=180).json()print("avec nonce -> 200 | success :", reponse.get("success"))print("reply :", clean_text(reponse.get("reply")))usage = reponse.get("usage") or {}print("usage : prompt", usage.get("prompt_tokens"),"| completion", usage.get("completion_tokens"),"| total", usage.get("total_tokens"))print("champs :", sorted(reponse.keys()))
sans nonce -> 401 | rest_forbidden
avec nonce -> 200 | success : True
reply : Bonjour, je vous réponds avec plaisir.
usage : prompt 77 | completion 1388 | total 1465
champs : ['actions', 'blocks', 'images', 'reply', 'responseId', 'shortcuts', 'success', 'usage']
Nonce n’est pas authentification
La mesure ci-dessus merite d’etre lue deux fois. Le nonce a refuse l’appel sans lui — mais il l’a accepte pour un visiteur qui n’a prouve aucune identite. Un nonce WordPress n’est pas une authentification : c’est un jeton anti-CSRF, delivre a quiconque charge le site, valide 12 a 24 heures. La frontiere qu’il dessine n’est pas « qui etes-vous » mais « venez-vous bien de cette page ».
Le controle d’acces reel de la face visiteur vit ailleurs : le botId demande doit exister et etre autorise (le plugin filtre les bots selon leur perimetre), et les limites de debit s’appliquent par session — le plugin limite par defaut le nombre de requetes quotidiennes des visiteurs non connectes. C’est la difference entre les trois faces du plugin, en une ligne : l’admin prouve une identite, l’agent prouve une delegation, le visiteur ne prouve rien — et c’est borne ailleurs.
Derniere nuance : le texte de reply varie d’une execution a l’autre (c’est un vrai modele de langage) — ce notebook mesure la structure de la reponse (succes, usage, champs), jamais le contenu exact.
Ce qu’on en a fait dans le projet Livres Agites
Dans le projet d’origine, cette face est celle du chatbot public d’accueil : la premiere conversation de chaque visiteur du site passe exactement par le cycle demontre ici — page cachee sans jeton, start_session, chats/submit au nonce. Le site etant derriere un cache et un reverse proxy, le design « aucun secret dans le HTML » n’etait pas un confort : c’etait la condition pour que les pages soient servies vite pendant que les conversations restaient isolees par visiteur.
L’incident qui a fixe la lecon : les appels automatises de test ont un jour echoue en rafale — non pas parce que le nonce manquait, mais parce que la limite de requetes des visiteurs anonymes (trois par jour, par defaut) etait atteinte. Le controle d’acces de cette face n’est pas devant la porte (comme le 401 du serveur MCP, grain 4) : il est au compteur. Un test qui parle a la face visiteur doit surveiller ce compteur — ou le relever consciemment.
# 5. Nettoyage : la page repart, l'instance reste propreapi("/wp/v2/pages/%d"% PAGE_ID, method="DELETE", payload={"force": True})statut, _ = http_get("/"+ SLUG +"/")print("apres suppression : GET /"+ SLUG +"/ ->", statut)
apres suppression : GET /discussion-valmont/ -> 404
Instance rendue propre — et le parcours complet
GET /discussion-valmont/ -> 404 : la page de demonstration a disparu, l’instance redevient celle que son etat initial decrit. Reste a relire ce que le parcours a etabli, car les cinq sections forment une chaine : le catalogue (1) annoncait une face visiteur sans droits d’administration ; la page publique (2) embarquait le bot mais aucun secret (nonce et session a None dans le HTML) ; le bootstrap (3) distribuait ses jetons de parole a quiconque les demande ; la frontiere (4) refusait le POST sans nonce puis acceptait la conversation – reply reelle, usage compte (prompt 77 | completion 1388), neuf champs de reponse dont actions, blocks, images. Le visiteur peut parler au chatbot ; il ne peut pas le configurer. La face navigateur de l’API tient sa promesse de separation, et c’est mesurable.
Exercices
Trois exercices, du plus simple au plus integre. Les fonctions sont a completer ; api(), http_get() et clean_text() sont disponibles. Chaque exercice se verifie d’une ligne de test.
Exercice 1 — decouvrir les faces
Completez decouvrir_les_namespaces() : elle interroge GET /wp-json/ et retourne un dict {namespace: [routes...]} pour les trois namespaces du plugin (mwai/v1, mwai-ui/v1, mcp/v1).
def decouvrir_les_namespaces():"""Retourne {namespace: liste_de_routes} pour les 3 faces du plugin."""# A COMPLETER : GET /wp-json/, filtrer les routes par prefixe de namespace.return {}
Exercice 2 — la preuve par la page
Completez page_sans_secrets(slug) : elle charge la page publique du chatbot en visiteur, extrait le data-system du conteneur, et retourne True seulement si restNonce et sessionId y sont tous deux null (la page est cacheable sans risque).
def page_sans_secrets(slug):"""True si la page chatbot n'embarque ni nonce ni sessionId."""# A COMPLETER : http_get, regex sur le conteneur, json.loads du# data-system, verifier les deux cles a null.returnNone
Exercice 3 — la conversation complete
Completez conversation_visiteur(bot_id, message) : elle execute tout le cycle — session, nonce, chats/submit — et retourne le texte de la reponse (None si un echec survient a quelque etape que ce soit).
def conversation_visiteur(bot_id, message):"""Cycle visiteur complet (session + nonce + chat) ; le texte de reply."""# A COMPLETER : requests.Session, POST start_session, POST# chats/submit avec X-WP-Nonce, retourner reply.returnNone
Provenance et limites
Instance testee : http://localhost:8093, montee via instance-jetable/docker-compose.jetable.example.yml, AI Engine 3.7.0 (version gratuite, wordpress.org), corpus synthetique « Maison Valmont ».
Determinisme : la structure est stable (namespaces, page sans jetons, bootstrap, frontiere 401, champs de la reponse) ; le texte de reply et le compte de tokens varient a chaque execution — le notebook mesure la structure, pas ces litteraux. Une completion LLM reelle est emise (une seule).
Endpoints verifies ici (firsthand) : GET /wp-json/ (catalogue), POST /wp/v2/pages + DELETE .../pages/{id}?force (page ephemeere, purgee en fin de notebook), GET /discussion-valmont/ en visiteur, POST /mwai/v1/start_session, POST /mwai-ui/v1/chats/submit (x2 : refuse sans nonce, accepte avec).
Sources du comportement : check_rest_nonce (core.php) pour le gate a nonce ; build_front_params (modules/chatbot.php) pour les valeurs null embarquees aux visiteurs deconnectes (design anti-cache, commente dans le code) ; rest_start_session (rest.php) pour l’amorcage public.
Frontieres : pas de donnees client, pas de secret, pas d’IP de provider — regles du chantier CoursIA. Le nonce affiche est un jeton anonyme a courte duree d’une instance locale jetable.