Rendre votre app React utilisable par des agents IA avec UI SDK et WebMCP
Laissez des agents IA lire et utiliser votre UI React avec l’UI SDK de Vercel : createAgent, snapshot, act, allow, source agent, definePrimitive et WebMCP.
Preet Suthar · · 10 min de lecture
- AI
- React
- UI SDK
- WebMCP
La plupart des agents IA utilisent une app web comme une personne avec un lecteur d’écran lent. Ils prennent une capture d’écran ou lisent le DOM, devinent quel élément est la liste déroulante « Region », cliquent dessus, relisent la page, puis cliquent sur une option. Chaque aller-retour coûte des tokens et du temps, et chaque supposition peut être fausse.
UI SDK, que Vercel Labs a publié le 12 novembre 2026, prend une autre voie. Ses composants s’enregistrent auprès d’un pont qui dit à un agent ce qu’il y a sur la page, dans quel état est chaque composant et ce qu’il peut faire, puis exécute ces actions via l’API propre au composant. Le même pont peut publier ses outils vers un agent intégré au navigateur via WebMCP.
Cet article construit une petite page de paramètres en React, y connecte un agent, et passe en revue les parties de l’API qui comptent : createAgent, snapshot(), act(), le callback allow, source: "agent" dans les callbacks de changement, un composant personnalisé construit avec definePrimitive, et registerTools. Chaque sortie ci-dessous est copiée d’une exécution dans une app de test avec React 19.3, Vite 8 et @ui-sdk/react et @ui-sdk/core en 0.1.0-beta.1. UI SDK est en bêta, donc les noms et le comportement peuvent changer.
Comment fonctionne le pont
Votre app React
- Input
- Select
- Switch
- Vos parties definePrimitive
Le pont : createAgent()
snapshot()liste les composants, l’état et les actionsquery()trouve les composants et leurs choixact()exécute les étapes via l’API de chaque composantallowapprouve ou refuse chaque étape
Votre propre agent
Définitions d’outils depuis agentTools, branchées sur le SDK de votre modèle
L’agent d’un navigateur
registerTools() publie les outils sur document.modelContext (WebMCP), avec un second allow
Chaque changement fait par un agent atteint vos callbacks de changement avec details.source === "agent", et details.cancel() peut encore le refuser.
Le pont est un objet renvoyé par createAgent(). Les composants UI SDK de la page s’y enregistrent quand ils sont montés. Il a trois méthodes qu’un agent utilise :
snapshot()renvoie chaque composant enregistré avec unrefcourt commec3, son type, son label accessible, son état et les actions qu’il accepte à ce moment.query()trouve des composants par texte ou liste les choix qu’un composant contient, comme les options d’un Select.act()exécute une liste d’étapes dans l’ordre. Chaque étape désigne un composant et une action, commesetValue, et le pont appelle cette action sur le composant.
agentTools décrit ces trois méthodes comme des définitions d’outils avec des JSON Schemas, pour votre propre boucle de modèle. registerTools() les enregistre plutôt auprès du navigateur.
Le choix de conception important, c’est que act() ne simule pas de clics. Il appelle la même API qu’un contrôleur, donc le changement passe par le callback de changement du composant avec une source valant "agent". Votre app voit les changements d’agent au même endroit que ceux d’une personne, et peut les refuser là.
Le benchmark de Vercel
La doc de Vercel inclut un benchmark. Des agents ont renommé un projet, mis à jour les membres et les paramètres de notification, puis enregistré. Un agent utilisait des outils de navigateur sur une page construite avec une autre bibliothèque de composants. L’autre utilisait le pont agent sur le même écran construit avec UI SDK.
- Appels d’outils−69%
- 165
- Texte lu (caractères)−83%
- 39.8k6.8k
- Temps−61%
- 41 s16 s
- Coût−56%
- $0.073$0.032
Source : doc de l’UI SDK de Vercel. Médianes de trois exécutions par approche, Sonnet via Claude Code. Non reproduit par HextaUI.
Ce sont les chiffres de Vercel, pas les miens. Ce sont des médianes de trois exécutions par approche, les deux agents utilisaient Sonnet via Claude Code avec la même tâche et le même prompt système, et chaque exécution a terminé la tâche. « Texte lu » compte les caractères renvoyés par les outils. Je n’ai pas reproduit le benchmark. Pour l’échelle, un snapshot complet de la démo ci-dessous fait 729 caractères de JSON. Le HTML de la page dans le serveur de dev de Vite fait environ 28 000 caractères. Le snapshot ARIA de Playwright pour la même page était encore plus court, environ 370 caractères, mais il ne liste que les rôles et les noms. Il ne dit pas quelles actions chaque contrôle accepte, quels choix contient un Select fermé, ni quel bouton exige une personne. C’est cette structure en plus qui épargne à un agent ses appels de suivi, pas la taille brute.
Mettre en place la démo
Partez du template React et TypeScript de Vite et ajoutez les deux packages :
Créez le pont dans son propre module. Importez-le dans main.tsx avant le rendu de l’app, pour qu’il voie les composants à leur création. La doc indique qu’il trouve aussi les composants déjà présents sur la page, donc vous pouvez le créer plus tard, quand un assistant s’ouvre.
Les deux callbacks allow reviennent plus bas. Object.assign(window, { agent }) place le pont sur window pour que vous puissiez l’essayer depuis la console.
La page est un formulaire de paramètres avec un champ texte, un Select, un Switch, un compteur de places personnalisé et un bouton Save. Cet extrait retire les classes, la mise en page et le composant de journal, et garde les parties qui intéressent l’agent :
record ajoute une ligne à un journal sur la page, pour voir qui a changé quoi. Les labels comptent ici plus que d’habitude. Le pont nomme les composants par leur nom accessible, donc FieldLabel, SelectLabel et le <label> qui les entoure sont ce qui permet à un agent de dire « Region » au lieu de deviner.
Lire la page avec snapshot()
Une fois la page rendue, agent.snapshot() a renvoyé ceci :
Quelques points à noter :
- Il n’y a pas de
c2. Le Field s’est aussi enregistré, mais un Field n’apparaît dans le snapshot que lorsqu’il a des erreurs, puisque son input porte déjà le label. Le trou dans les refs est sans conséquence. - Le Select liste ses items même fermé, car la démo passe
items. Sans eux,query({ within: "Region" })lit les choix, et le pont peut ouvrir brièvement la liste pour le faire. - Le bouton Save porte le texte
confirmissu dedata-agent-confirm, donc l’agent sait avant d’essayer qu’une personne doit approuver. seat-stepperest mon propre composant. Il listeaddetremoveparce que je l’ai indiqué dans sa définition, présentée plus bas.
Modifier avec act()
Un seul appel act() peut porter toutes les étapes que l’agent connaît déjà. Les étapes désignent les composants par ref ou par label, et le pont fait correspondre des mots entiers sans tenir compte de la casse ni des accents. Un item peut aussi être désigné par son label :
Le résultat donne les changements de chaque étape et la valeur actuelle de chaque champ :
« North America » a été résolu vers la valeur de l’item, north-america. Comme values revient à chaque appel, un agent n’a pas besoin d’un autre snapshot pour vérifier son travail.
Les erreurs sont écrites pour être lues par un modèle. Deux que j’ai provoquées exprès :
Une étape qui échoue arrête l’appel, et les étapes précédentes restent appliquées. Il n’y a pas de rollback.
Une règle de correspondance m’a surpris. Une étape avec ref: "Name" a modifié « Project name », car les labels correspondent par mots entiers. C’est documenté, et le pont refuse un nom qui correspond à plus d’un composant, mais donnez des labels distincts aux composants si un agent doit les deviner.
Permissions : allow, marques de confirmation et cancel
Il y a trois endroits où dire non, et ils ont des rôles différents.
allow sur createAgent voit chaque étape avant son exécution, avec son ref, component, label, action, args et confirm. Renvoyez false pour refuser, ou une promesse pour votre propre UI de confirmation. Ma politique refuse tout clear, et le résultat (sans values ici) est :
data-agent-confirm marque un contrôle qui exige toujours une personne, quelles que soient les règles. Sans callback allow, une étape dessus échoue avec un message « Needs a person ». Avec un callback, step.confirm porte la phrase pour que vous puissiez l’afficher. Mon allow la passe à window.confirm, et pendant l’exécution la boîte de dialogue affichait « Saves the settings for every member of Atlas » avant que Save soit pressé. La marque couvre tout ce qui se trouve dans son élément, donc vous pouvez la mettre sur un formulaire ou une boîte de dialogue entière.
Votre callback de changement a le dernier mot sur une valeur. Le Select Region annule la demande d’un agent pour Asia Pacific :
Notez que ok vaut true. Le pont a exécuté l’étape, et le callback a annulé le changement, donc l’étape renvoie result: false et la valeur est inchangée. La doc le dit clairement : ok: true ne veut pas dire que chaque valeur a été acceptée. Vérifiez changes et values.
Il existe aussi deux attributs pour ce qu’un agent peut voir. data-agent-hidden retire une zone de snapshot et query. data-agent-secret affiche un champ avec sa valeur sous la forme "[redacted]". Les champs de mot de passe, de carte et de code à usage unique sont traités comme secrets sans marque.
Distinguer les changements d’agent avec source
Chaque callback de changement dans UI SDK reçoit details.source : keyboard, mouse, touch, pen, virtual, programmatic ou agent. Après l’exécution ci-dessus, le journal de la démo ressemblait à ceci :

Cliquer ensuite à la main sur le bouton plus a enregistré Seats → 2 (source: mouse). Ce seul champ suffit pour construire un badge « modifié par votre assistant », envoyer les changements d’agent dans une file de revue, ou ignorer les événements analytics qu’une personne n’a pas causés.
Une réserve tirée de la doc : un gestionnaire de Button qui attend quelque chose puis change l’état ensuite renvoie "programmatic", car la pression est déjà terminée à ce moment-là. Enveloppez le changement tardif dans withSource(source, …) de @ui-sdk/react/observe pour garder l’agent comme source.
Votre propre composant : definePrimitive et la clé agent
Les composants intégrés rejoignent le pont d’eux-mêmes. Les composants que vous écrivez avec definePrimitive le rejoignent aussi. Voici le compteur de places de la démo, avec une clé agent qui nomme les deux méthodes qu’un agent peut appeler :
createParts(seatStepper, { root: "div", remove: "button", add: "button" }) de @ui-sdk/react/primitive le transforme en composants React. La racine accepte aria-labelledby comme n’importe quel élément, et c’est de là que vient le label « Seats » du snapshot.
Pourquoi la clé agent compte : sans elle, le pont devine les actions à partir des noms de méthodes. Il reconnaît set suivi d’une majuscule, comme setValue, et des verbes comme open, toggle et clear. Il ne devinerait pas add ni remove, donc le compteur n’afficherait aucune action. Avec la clé, seules les méthodes listées sont proposées et tout le reste est refusé, comme l’a montré l’erreur keydown plus haut. La doc demande de mettre agent en dernier, après slots, pour que TypeScript ait déjà lu l’API, et marque la clé comme expérimentale.
Comme le changement passe par createChange, le onValueChange de l’app voit toujours source: "agent" et peut annuler. Quand l’agent a demandé de retirer cinq places sur trois, la valeur s’est bloquée à 1 et le bouton de retrait est devenu désactivé, exactement comme l’auraient laissé les clics d’une personne.
Deux remarques en l’écrivant. Les valeurs d’attributs dans attrs sont des chaînes, donc disabled vaut "" ou undefined, pas un booléen ; TypeScript l’a repéré. Et la doc Core dit de ne jamais mettre role dans attrs et d’utiliser la clé role du slot, alors qu’un exemple Core met bien role dans attrs. J’ai utilisé la clé role.
Enregistrer les outils pour WebMCP
registerTools(agent, { allow }) publie snapshot, query et act sur document.modelContext, l’API navigateur de la proposition WebMCP. Un navigateur qui la fournit peut passer les outils à son agent intégré. Dans un navigateur qui ne la fournit pas, registerTools ne fait rien.
Le Chromium headless avec lequel j’ai testé ne fournit pas document.modelContext, donc pour voir ce qui est enregistré, j’ai installé un objet de substitution avant le chargement de la page. Les trois outils sont arrivés avec ces annotations :
untrustedContentHint est là parce que les résultats des outils contiennent du texte de page écrit par des utilisateurs, comme un nom dans une ligne de tableau. Les descriptions des outils disent aussi au modèle de traiter ce texte comme des données, jamais comme des instructions. C’est une indication, pas une garantie, donc gardez tout ce qui est risqué derrière allow et data-agent-confirm.
Le allow de registerTools est obligatoire, et il s’exécute après le allow du pont, uniquement pour les étapes venues de l’agent du navigateur. Cela vous donne deux politiques : une pour l’agent que vous avez écrit, et une plus stricte pour un agent que vous n’avez pas écrit.
- Trouver la cible
refUn ref comme c3, puis un label, un item, puis un type. - allow du pont
createAgentVotre politique pour chaque appelant. False arrête l’appel. - allow WebMCP
registerToolsUne seconde politique, uniquement pour l’agent du navigateur. - API du composant
setValueLa même méthode qu’un contrôleur appellerait. - Votre callback
details.sourceVoit"agent"et peut appelercancel().
Le guide d’UI SDK montre comment utiliser les outils depuis le navigateur intégré de l’app de bureau ChatGPT, où les outils de site doivent être activés et où seuls certains modèles et espaces de travail les prennent en charge. Je n’ai pas testé ce chemin. Vérifiez typeof document.modelContext?.registerTool dans le navigateur que vous ciblez. Il renvoie "function" là où l’enregistrement est pris en charge.
Un constat de bêta : un refus imputé à la personne
Dans ma configuration, le allow du pont demandait à la personne de confirmer Save, et le allow WebMCP refuse tout press. Quand j’ai appelé l’outil act enregistré pour presser Save, la boîte de confirmation est apparue, je l’ai acceptée, puis la politique WebMCP a refusé l’étape. L’erreur disait :
La personne a dit oui. Le code a dit non. L’étape a été refusée, ce qui est correct, mais un modèle qui lit ce message pourrait dire à l’utilisateur qu’il a refusé quelque chose qu’il avait approuvé. Il a aussi demandé à la personne avant de vérifier une politique qui allait de toute façon refuser. Si vous combinez des marques de confirmation avec un allow WebMCP qui refuse les pressions, refusez plus tôt : vérifiez aussi step.action dans le allow du pont, pour que la personne ne soit jamais sollicitée. Je le signale sur 0.1.0-beta.1 et je m’attends à ce que ça change.
Quand ça vaut le coup
Le pont trouve sa place quand un agent fait partie du produit : un panneau d’assistant qui remplit des formulaires, un outil de support qui change des paramètres pour l’utilisateur, ou un agent de navigateur que vous voulez voir bien fonctionner sur votre site. Il est aussi utile dans les tests, puisque act() vous donne un moyen typé de piloter les composants sans sélecteurs.
Ce n’est pas, à lui seul, une raison de migrer une app stable. Le pont ne voit que les composants UI SDK, donc une page qui mélange des bibliothèques donne à l’agent une vue partielle. Et c’est une bêta, avec la clé agent et les helpers d’historique marqués expérimentaux.
Si vous construisez dessus, quelques habitudes gardent les agents utiles et sûrs :
- donnez à chaque contrôle un nom accessible, car c’est ce que l’agent tape ;
- passez
itemsaux Selects et aux Comboboxes pour que les choix soient lisibles même fermés ; - refusez dans
allowce qu’un agent ne doit jamais faire, et marquez avecdata-agent-confirmce qui exige une personne ; - validez les valeurs dans les callbacks de changement, car
setValueappelle directement l’API et contourne vos gestionnaires clavier et pointeur ; - lisez
valuesdans chaque résultat deact()au lieu de faire confiance àok.
HextaUI prépare une version stylisée UI SDK de ses composants, donc ce sont les patterns autour desquels je construis. Pour comparer UI SDK avec Radix et Base UI au-delà des agents, voir UI SDK vs Base UI vs Radix. Pour styliser les composants avec Tailwind, voir Styliser l’UI SDK de Vercel avec Tailwind.
