Retour au blog

2026-10-01 · Guide · 7 min de lecture · Kouakou Ghislain Boris

Guide : rendre une boutique React pilotable par un agent, pas à pas

Dans ce guide, on part d'une petite boutique React (une liste de produits, une fiche produit, un panier et une page de paiement) et on la rend pilotable par un agent, au clavier comme à la voix. À la fin, l'utilisateur peut dire « montre-moi les casques, ajoute le moins cher au panier et passe commande », et l'agent le fait, en demandant l'accord de l'utilisateur au moment de payer.

On n'écrit aucun sélecteur CSS et aucun clic simulé. Chaque action passe par du code que vous contrôlez.

Ce qu'on va construire

ÉtapeBrique OwlLayerRôle
1OwlLayerServerLe serveur qui parle au modèle
2OwlLayerProviderLa connexion de l'application au serveur, avec le widget
3useAgentContextCe que l'agent sait de l'écran (Shadow Context)
4useAgentToolUne action locale sur la fiche produit
5useAgentToolResolverLes actions du panier, groupées
6useNavigationToolLa navigation entre les pages
7risk: 'critical'Le paiement, soumis à approbation

Prérequis : Node.js 18 ou plus, pnpm 9, une clé d'API Gemini (ou OpenAI, ou Anthropic), et une application React avec react-router-dom.

Étape 1 : le serveur

bash
pnpm add @owllayer/server @owllayer/core @owllayer/adapter-google dotenv
ts
// server.tsimport 'dotenv/config';import { OwlLayerServer } from '@owllayer/server';import { GoogleAdapter, GoogleLiveAdapter } from '@owllayer/adapter-google'; const LANGUAGE = 'fr';const SYSTEM_PROMPT =  'You are the shopping assistant of this store. Use the tools to act for the user. ' +  'Never claim an action is done before its tool result confirms it.'; const server = new OwlLayerServer({  // Modèle texte : répond et choisit les outils  llm: new GoogleAdapter({    model: 'gemini-2.0-flash',    apiKey: process.env.GOOGLE_API_KEY!,    systemPrompt: SYSTEM_PROMPT,    language: LANGUAGE,  }),   // Voix temps réel : écoute, répond à voix haute et appelle les mêmes outils  live: new GoogleLiveAdapter({    apiKey: process.env.GOOGLE_API_KEY!,    model: 'gemini-2.5-flash-native-audio-preview-12-2025',    voice: 'Fenrir',    systemPrompt: SYSTEM_PROMPT,  }),   port: 3000,  path: '/owllayer',  language: LANGUAGE,   // Délai maximal d'exécution d'un outil côté client (30 s par défaut)  toolTimeout: 15_000,  // Messages gardés dans la conversation de chaque session  maxConversationMessages: 50,  // Outils actifs au maximum par session (30 par défaut)  maxActiveTools: 30,  // Ajoute au prompt les consignes d'usage selon le niveau de risque  toolGuidance: true,   // Tableau de bord embarqué : http://localhost:3000/owllayer-ui  ui: { enabled: true, language: LANGUAGE },   // Seules les clés ajoutées avec addApiKey peuvent se connecter  client: { requireApiKey: true },}); server.addApiKey('pk_dev_123');server.listen(() => console.log('OwlLayer on ws://localhost:3000/owllayer'));

Le serveur ne connaît aucun outil à l'avance : c'est l'interface qui les lui annonce, écran par écran. maxActiveTools borne leur nombre ; la valeur est envoyée au navigateur à la connexion.

llm sert au texte, live à la voix temps réel : les deux appellent les mêmes outils, avec les mêmes approbations. Pour la voix, vous pouvez aussi utiliser OpenAI Realtime (OpenAILiveAdapter) ou Deepgram (@owllayer/adapter-deepgram : transcription et synthèse par lots, streaming, ou Voice Agent en temps réel). Avec OpenAIAdapter en modèle texte, l'option timeout borne aussi le temps de réponse du modèle.

Étape 2 : le provider et le widget

bash
pnpm add @owllayer/react @owllayer/core zod
tsx
// main.tsximport { OwlLayerProvider } from '@owllayer/react';import { BrowserRouter } from 'react-router-dom'; createRoot(document.getElementById('root')!).render(  <BrowserRouter>    <OwlLayerProvider      apiKey="pk_dev_123"      endpoint="ws://localhost:3000/owllayer"      config={{        voice: true,        hitl: { ui: 'modal' },        widget: { enabled: true },      }}    >      <App />    </OwlLayerProvider>  </BrowserRouter>,);

Le widget ajoute une bulle de conversation en texte et en voix. hitl: { ui: 'modal' } affiche les demandes d'approbation dans une fenêtre modale, isolée dans un Shadow DOM fermé.

Étape 3 : ce que l'agent sait de l'écran

L'agent n'a pas accès au DOM. Il sait ce que vous lui publiez, avec useAgentContext :

tsx
// ProductPage.tsximport { useAgentContext } from '@owllayer/react'; function ProductPage({ product }: { product: Product }) {  const cart = useCart();   useAgentContext({    page: 'product',    product: { id: product.id, name: product.name, price: product.price, stock: product.stock },    cartCount: cart.count,  });   // …}

Choisissez peu de champs, et utiles. Le prix et le stock aident l'agent à répondre ; l'identifiant lui permet d'agir. Ne publiez jamais de données que l'utilisateur ne doit pas voir : ce contexte part au modèle.

Étape 4 : une action locale sur la fiche produit

tsx
// ProductPage.tsx (suite)import { useAgentTool } from '@owllayer/react';import { z } from 'zod'; useAgentTool(  {    name: 'add_to_cart',    description: `Add "${product.name}" to the cart.`,    schema: z.object({ quantity: z.number().int().min(1).max(10).default(1) }),    risk: 'low',  },  async ({ quantity }) => {    if (product.stock < quantity) {      return { added: false, reason: `Only ${product.stock} left in stock` };    }    await cart.add(product.id, quantity);    return { added: true, productId: product.id, quantity, cartCount: cart.count + quantity };  },);

Trois choses à noter :

  • L'outil n'existe que sur la fiche produit. Quand l'utilisateur quitte la page, le composant se démonte et l'outil disparaît. Sur la liste des produits, l'agent ne peut pas ajouter « le produit courant » au panier, puisqu'il n'y en a pas.
  • Le schéma est vérifié avant l'exécution. Si le modèle demande quantity: 0, il reçoit une erreur de validation et corrige son appel ; votre handler n'est pas appelé. Avec .default(1), un appel sans quantité arrive avec quantity: 1.
  • Le handler renvoie un résultat utile, y compris quand l'action n'a pas lieu. Le modèle s'appuie sur ce résultat pour répondre à l'utilisateur.

risk: 'low' exécute l'action et affiche une notification : l'utilisateur voit ce que l'agent vient de faire.

Étape 5 : les actions du panier, groupées

Le panier est accessible depuis toutes les pages. Ses actions vont dans le layout, groupées par un resolver :

tsx
// Layout.tsximport { useAgentToolResolver } from '@owllayer/react';import { z } from 'zod'; function Layout({ children }: { children: ReactNode }) {  const cart = useCart();   useAgentToolResolver(    {      cart: {        prefix: 'cart_',        tools: {          view: {            description: 'List the items in the cart with their quantities and the total.',            schema: z.object({}),            risk: 'none',            handler: () => ({ items: cart.items, total: cart.total }),          },          remove: {            description: 'Remove one product from the cart.',            schema: z.object({ productId: z.string() }),            risk: 'high',            handler: async ({ productId }) => {              await cart.remove(productId);              return { removed: productId, cartCount: cart.count - 1 };            },          },        },      },    },    {      onErrorAnyCall: (name, _args, error) => console.error(`[agent] ${name} failed`, error),    },  );   return <>{children}</>;}

Le resolver enregistre cart_view et cart_remove sous un même composant, et ajoute un hook d'erreur commun. cart_remove est en high : retirer un article demande l'accord de l'utilisateur.

Étape 6 : la navigation

Sans navigation, l'agent reste sur la page où il a été appelé. useNavigationTool lui donne un outil navigate global, relié à votre routeur :

tsx
// Layout.tsx (suite)import { useNavigationTool } from '@owllayer/react';import { useNavigate } from 'react-router-dom'; const routerNavigate = useNavigate(); useNavigationTool(({ url }) => routerNavigate(url), {  description:    'Navigate in the store. Routes: / (home), /products?q=<search> (product list), ' +    '/products/<id> (product page), /cart, /checkout.',});

Listez vos routes dans la description : c'est ainsi que le modèle sait où aller. Quand navigate change de page, OwlLayer attend que la nouvelle page ait enregistré ses outils avant de rendre la main au modèle. Il peut donc enchaîner « va sur la fiche du casque X » puis « ajoute-le au panier » dans la même demande.

Étape 7 : le paiement, soumis à approbation

tsx
// CheckoutPage.tsxfunction CheckoutPage() {  const cart = useCart();   useAgentContext({ page: 'checkout', total: cart.total, itemCount: cart.count });   useAgentTool(    {      name: 'confirm_order',      description: 'Place the order and charge the saved payment method. Irreversible.',      schema: z.object({}),      risk: 'critical',    },    async () => {      const order = await api.placeOrder(cart.items);      return { orderId: order.id, total: order.total, status: order.status };    },  );   // …}

Avec risk: 'critical', le handler ne s'exécute qu'après un clic de l'utilisateur dans la fenêtre d'approbation, qui l'avertit que l'action est irréversible. S'il refuse, le modèle reçoit le refus et l'annonce. L'agent peut préparer la commande ; seul l'humain la passe.

Fenêtre d'approbation d'OwlLayer AI : l'utilisateur accepte ou refuse l'action demandée par l'agent

Essayer

Lancez le serveur et l'application, ouvrez le widget, puis essayez :

  • « Montre-moi les casques Bluetooth. » L'agent navigue vers /products?q=casque.
  • « Ouvre le moins cher et ajoutes-en deux. » Navigation vers la fiche, puis add_to_cart avec quantity: 2, et une notification.
  • « Qu'est-ce qu'il y a dans mon panier ? » cart_view, sans confirmation.
  • « Enlève le casque. » cart_remove : la fenêtre d'approbation s'ouvre.
  • « Passe la commande. » Navigation vers /checkout, puis confirm_order avec l'avertissement « action irréversible ».

Avant la production

  • Utilisez une clé par application, ajoutez-la côté serveur avec addApiKey et gardez client.requireApiKey à true. Cette clé est visible dans le navigateur : elle identifie l'application, elle ne protège pas un secret.
  • Relisez les niveaux de risque. Tout ce qui dépense de l'argent, supprime des données ou envoie un message au nom de l'utilisateur mérite high ou critical.
  • Bloquez côté serveur ce qui ne doit jamais être appelé, même si un client le déclare, avec server.blockTool(name).
  • Gardez peu d'outils par écran. Un outil paramétré (add_to_cart({ productId })) vaut mieux que cinquante outils add_product_123. La limite d'outils actifs est là pour vous le rappeler.
  • Vérifiez vos handlers : ils doivent renvoyer une Promise qui se résout quand l'action est vraiment terminée.

Pour aller plus loin