Mach deine React-App mit UI SDK und WebMCP für KI-Agenten bedienbar
Lass KI-Agenten deine React-UI mit Vercels UI SDK lesen und bedienen: createAgent, snapshot, act, allow, Source agent, definePrimitive und WebMCP registerTools.
Preet Suthar · · 10 Min. Lesezeit
- AI
- React
- UI SDK
- WebMCP
Die meisten KI-Agenten nutzen eine Web-App so, wie es ein Mensch mit einem langsamen Screenreader täte. Sie machen einen Screenshot oder lesen das DOM, raten, welches Element das Dropdown „Region“ ist, klicken darauf, lesen die Seite erneut und klicken eine Option an. Jeder Durchlauf kostet Tokens und Zeit, und jede Vermutung kann falsch sein.
UI SDK, das Vercel Labs am 12. November 2026 veröffentlicht hat, geht einen anderen Weg. Seine Komponenten registrieren sich bei einer Bridge, die einem Agenten sagt, was auf der Seite ist, in welchem Zustand jede Komponente ist und was sie kann, und führt diese Aktionen dann über die eigene API der Komponente aus. Dieselbe Bridge kann ihre Tools über WebMCP an einen Agenten veröffentlichen, der in den Browser eingebaut ist.
Dieser Post baut eine kleine Einstellungsseite in React, verbindet einen Agenten damit und geht die Teile der API durch, auf die es ankommt: createAgent, snapshot(), act(), den allow-Callback, source: "agent" in Change-Callbacks, eine eigene Komponente mit definePrimitive und registerTools. Jede Ausgabe unten ist aus einem Lauf in einer Test-App mit React 19.3, Vite 8 sowie @ui-sdk/react und @ui-sdk/core in 0.1.0-beta.1 kopiert. UI SDK ist in der Beta, Namen und Verhalten können sich also ändern.
Wie die Bridge funktioniert
Deine React-App
- Input
- Select
- Switch
- Deine definePrimitive-Parts
Die Bridge: createAgent()
snapshot()listet Komponenten, Zustand und Aktionenquery()findet Komponenten und ihre Optionenact()führt Schritte über die API jeder Komponente ausallowgenehmigt oder lehnt jeden Schritt ab
Dein eigener Agent
Tool-Definitionen aus agentTools, angebunden an dein Model-SDK
Der Agent eines Browsers
registerTools() veröffentlicht die Tools auf document.modelContext (WebMCP), mit einem zweiten allow
Jede Änderung, die ein Agent macht, erreicht deine Change-Callbacks mit details.source === "agent", und details.cancel() kann sie weiterhin ablehnen.
Die Bridge ist ein Objekt, das createAgent() zurückgibt. UI-SDK-Komponenten auf der Seite registrieren sich beim Mounten bei ihr. Sie hat drei Methoden, die ein Agent nutzt:
snapshot()gibt jede registrierte Komponente mit einer kurzenrefwiec3zurück, dazu ihre Art, ihr barrierefreies Label, ihren Zustand und die Aktionen, die sie gerade akzeptiert.query()findet Komponenten per Text oder listet die Optionen in einer Komponente auf, etwa die Optionen eines Select.act()führt eine Liste von Schritten der Reihe nach aus. Jeder Schritt nennt eine Komponente und eine Aktion, etwasetValue, und die Bridge ruft diese Aktion an der Komponente auf.
agentTools beschreibt diese drei Methoden als Tool-Definitionen mit JSON Schemas, für deinen eigenen Model-Loop. registerTools() registriert sie stattdessen beim Browser.
Die wichtige Designentscheidung ist, dass act() keine Klicks simuliert. Es ruft dieselbe API auf, die ein Controller aufrufen würde, also geht die Änderung durch den Change-Callback der Komponente, mit einer source von "agent". Deine App sieht Agent-Änderungen an derselben Stelle wie die eines Menschen und kann sie dort ablehnen.
Vercels Benchmark
Vercels Doku enthält einen Benchmark. Agenten haben ein Projekt umbenannt, Mitglieder und Benachrichtigungseinstellungen aktualisiert und dann gespeichert. Ein Agent nutzte Browser-Tools auf einer Seite, die mit einer anderen Komponentenbibliothek gebaut war. Der andere nutzte die Agent-Bridge auf demselben Screen, gebaut mit UI SDK.
- Tool-Aufrufe−69%
- 165
- Gelesener Text (Zeichen)−83%
- 39.8k6.8k
- Zeit−61%
- 41 s16 s
- Kosten−56%
- $0.073$0.032
Quelle: Vercels UI-SDK-Doku. Mediane aus drei Läufen pro Ansatz, Sonnet über Claude Code. Nicht von HextaUI nachgemessen.
Das sind Vercels Zahlen, nicht meine. Es sind Mediane aus drei Läufen pro Ansatz, beide Agenten nutzten Sonnet über Claude Code mit derselben Aufgabe und demselben System-Prompt, und jeder Lauf hat die Aufgabe erledigt. „Gelesener Text“ zählt die Zeichen, die die Tools zurückgeben. Ich habe den Benchmark nicht nachgemessen. Zur Einordnung: Ein vollständiger Snapshot der Demo unten ist 729 Zeichen JSON. Das HTML der Seite im Dev-Server von Vite hat etwa 28.000 Zeichen. Der ARIA-Snapshot von Playwright für dieselbe Seite war noch kürzer, etwa 370 Zeichen, aber er listet nur Rollen und Namen. Er sagt nicht, welche Aktionen jedes Control akzeptiert, welche Optionen ein geschlossenes Select enthält oder welcher Button einen Menschen braucht. Diese zusätzliche Struktur spart einem Agenten seine Folgeaufrufe, nicht die reine Größe.
Die Demo einrichten
Starte mit dem React- und TypeScript-Template von Vite und füge die zwei Pakete hinzu:
Erzeuge die Bridge in einem eigenen Modul. Importiere es in main.tsx, bevor die App rendert, damit sie Komponenten sieht, während sie erzeugt werden. Laut Doku findet sie auch Komponenten, die schon auf der Seite sind, du kannst sie also auch später erzeugen, wenn sich ein Assistent öffnet.
Die zwei allow-Callbacks kommen weiter unten noch einmal vor. Object.assign(window, { agent }) legt die Bridge auf window, damit du sie in der Konsole ausprobieren kannst.
Die Seite ist ein Einstellungsformular mit einem Textfeld, einem Select, einem Switch, einem eigenen Seat-Zähler und einem Save-Button. Dieser Ausschnitt lässt Klassen, Layout und die Log-Komponente weg und behält die Parts, die für den Agenten zählen:
record hängt eine Zeile an ein Log auf der Seite an, damit du siehst, wer was geändert hat. Labels zählen hier mehr als sonst. Die Bridge benennt Komponenten nach ihrem barrierefreien Namen, also sind es FieldLabel, SelectLabel und das umschließende <label>, die einen Agenten „Region“ sagen lassen, statt zu raten.
Die Seite mit snapshot() lesen
Mit der gerenderten Seite gab agent.snapshot() das hier zurück:
Ein paar Dinge, die auffallen:
- Es gibt kein
c2. Das Field hat sich auch registriert, aber ein Field erscheint im Snapshot nur, solange es Fehler hat, weil sein Input das Label schon trägt. Die Lücke in den Refs ist harmlos. - Das Select listet seine Items auch geschlossen auf, weil die Demo
itemsübergibt. Ohne sie liestquery({ within: "Region" })die Optionen, und die Bridge öffnet dafür eventuell kurz die Liste. - Der Save-Button trägt den
confirm-Text ausdata-agent-confirm, damit der Agent schon vor dem Versuch weiß, dass ein Mensch zustimmen muss. seat-stepperist meine eigene Komponente. Sie listetaddundremove, weil ich es in ihrer Definition so festgelegt habe, dazu weiter unten mehr.
Dinge mit act() ändern
Ein act()-Aufruf kann jeden Schritt enthalten, den der Agent schon kennt. Schritte benennen Komponenten per ref oder per Label, und die Bridge vergleicht ganze Wörter, unabhängig von Groß- und Kleinschreibung und Akzenten. Auch ein Item lässt sich über sein Label benennen:
Das Ergebnis meldet die Änderungen jedes Schritts und den aktuellen Wert jedes Felds:
„North America“ wurde in den Wert des Items aufgelöst, north-america. Weil values bei jedem Aufruf zurückkommt, braucht ein Agent keinen weiteren Snapshot, um seine Arbeit zu prüfen.
Fehlermeldungen sind so geschrieben, dass ein Modell sie lesen kann. Zwei, die ich absichtlich ausgelöst habe:
Ein fehlgeschlagener Schritt stoppt den Aufruf, und frühere Schritte bleiben angewendet. Es gibt kein Rollback.
Eine Matching-Regel hat mich überrascht. Ein Schritt mit ref: "Name" hat „Project name“ geändert, weil Labels nach ganzen Wörtern verglichen werden. Das ist dokumentiert, und die Bridge lehnt einen Namen ab, der auf mehr als eine Komponente passt, aber gib Komponenten eindeutige Labels, wenn ein Agent sie erraten muss.
Berechtigungen: allow, Confirm-Markierungen und cancel
Es gibt drei Stellen, an denen du Nein sagen kannst, und sie haben unterschiedliche Aufgaben.
allow auf createAgent sieht jeden Schritt, bevor er läuft, mit ref, component, label, action, args und confirm. Gib false zurück, um abzulehnen, oder ein Promise für deine eigene Bestätigungs-UI. Meine Policy lehnt jedes clear ab, und das Ergebnis (hier ohne values) ist:
data-agent-confirm markiert ein Control, das immer einen Menschen braucht, egal was die Regeln sagen. Ohne allow-Callback schlägt ein Schritt darauf mit der Meldung „Needs a person“ fehl. Mit einem trägt step.confirm den Satz, damit du ihn anzeigen kannst. Mein allow gibt ihn an window.confirm weiter, und im Lauf zeigte der Dialog „Saves the settings for every member of Atlas“, bevor Save gedrückt wurde. Die Markierung gilt für alles in ihrem Element, du kannst sie also auf ein ganzes Formular oder einen Dialog setzen.
Dein Change-Callback hat bei einem Wert das letzte Wort. Das Region-Select lehnt die Anfrage eines Agenten nach Asia Pacific ab:
Beachte, dass ok gleich true ist. Die Bridge hat den Schritt ausgeführt, und der Callback hat die Änderung abgebrochen, also meldet der Schritt result: false und der Wert bleibt unverändert. Die Doku sagt das klar: ok: true heißt nicht, dass jeder Wert angenommen wurde. Prüfe changes und values.
Es gibt außerdem zwei Attribute dafür, was ein Agent sehen kann. data-agent-hidden entfernt einen Bereich aus snapshot und query. data-agent-secret zeigt ein Feld mit dem Wert "[redacted]". Passwort-, Karten- und Einmalcode-Felder gelten auch ohne Markierung als geheim.
Agent-Änderungen über die Source unterscheiden
Jeder Change-Callback in UI SDK bekommt details.source: keyboard, mouse, touch, pen, virtual, programmatic oder agent. Nach dem Lauf oben sah das Log der Demo so aus:

Ein Klick von Hand auf den Plus-Button danach loggte Seats → 2 (source: mouse). Dieses eine Feld reicht, um ein Badge „von deinem Assistenten bearbeitet“ zu bauen, Agent-Änderungen in eine Review-Queue zu schicken oder Analytics-Events zu überspringen, die kein Mensch ausgelöst hat.
Ein Vorbehalt aus der Doku: Ein Button-Handler, der auf etwas wartet und danach den State ändert, meldet "programmatic", weil der Druck dann schon vorbei ist. Pack die spätere Änderung in withSource(source, …) aus @ui-sdk/react/observe, damit der Agent die Source bleibt.
Deine eigene Komponente: definePrimitive und der agent-Key
Eingebaute Komponenten schließen sich der Bridge von selbst an. Komponenten, die du mit definePrimitive schreibst, ebenfalls. Hier ist der Seat-Zähler aus der Demo, mit einem agent-Key, der die zwei Methoden nennt, die ein Agent aufrufen darf:
createParts(seatStepper, { root: "div", remove: "button", add: "button" }) aus @ui-sdk/react/primitive macht daraus React-Komponenten. Der Root nimmt aria-labelledby wie jedes Element, und daher kam das Label „Seats“ im Snapshot.
Warum der agent-Key wichtig ist: Ohne ihn rät die Bridge Aktionen anhand von Methodennamen. Sie erkennt set gefolgt von einem Großbuchstaben, etwa setValue, und Verben wie open, toggle und clear. add oder remove würde sie nicht erraten, der Stepper würde also keine Aktionen zeigen. Mit dem Key werden nur die gelisteten Methoden angeboten und alles andere abgelehnt, wie der keydown-Fehler oben gezeigt hat. Die Doku bittet dich, agent zuletzt zu setzen, nach slots, damit TypeScript die API bis dahin gelesen hat, und markiert den Key als experimentell.
Weil die Änderung durch createChange geht, sieht onValueChange der App weiterhin source: "agent" und kann abbrechen. Als der Agent bei drei Seats fünf entfernen wollte, wurde der Wert auf 1 begrenzt und der Entfernen-Button deaktiviert, genau so, wie es die Klicks eines Menschen hinterlassen hätten.
Zwei Anmerkungen vom Schreiben. Attributwerte in attrs sind Strings, also ist disabled gleich "" oder undefined, kein Boolean; TypeScript hat das bemerkt. Und die Core-Doku sagt, man solle role nie in attrs setzen, sondern den role-Key des Slots nutzen, während ein Core-Beispiel role doch in attrs setzt. Ich habe den role-Key genutzt.
Die Tools für WebMCP registrieren
registerTools(agent, { allow }) veröffentlicht snapshot, query und act auf document.modelContext, der Browser-API aus dem WebMCP-Vorschlag. Ein Browser, der sie bereitstellt, kann die Tools an seinen eingebauten Agenten übergeben. In einem Browser ohne sie tut registerTools nichts.
Das Headless Chromium, mit dem ich getestet habe, stellt document.modelContext nicht bereit. Um zu sehen, was registriert wird, habe ich also vor dem Laden der Seite ein Ersatzobjekt installiert. Die drei Tools kamen mit diesen Annotations an:
untrustedContentHint gibt es, weil Tool-Ergebnisse Seitentext enthalten, den Nutzer geschrieben haben, etwa einen Namen in einer Tabellenzeile. Die Tool-Beschreibungen sagen dem Modell außerdem, diesen Text als Daten zu behandeln, nie als Anweisungen. Das ist ein Hinweis, keine Garantie, also halte alles Riskante hinter allow und data-agent-confirm.
Das allow auf registerTools ist Pflicht, und es läuft nach dem eigenen allow der Bridge, nur für Schritte, die vom Agenten des Browsers kommen. Das gibt dir zwei Policies: eine für den Agenten, den du geschrieben hast, und eine strengere für einen Agenten, den du nicht geschrieben hast.
- Das Ziel finden
refEine Ref wie c3, dann ein Label, ein Item, dann eine Art. - Bridge-allow
createAgentDeine Policy für jeden Aufrufer. False stoppt den Aufruf. - WebMCP-allow
registerToolsEine zweite Policy, nur für den Agenten des Browsers. - Komponenten-API
setValueDieselbe Methode, die ein Controller aufrufen würde. - Dein Callback
details.sourceSieht"agent"und kanncancel()aufrufen.
Der Guide von UI SDK zeigt, wie man die Tools aus dem eingebauten Browser der ChatGPT-Desktop-App nutzt, wo Site-Tools aktiviert werden müssen und nur manche Modelle und Workspaces sie unterstützen. Diesen Weg habe ich nicht getestet. Prüfe typeof document.modelContext?.registerTool in dem Browser, auf den du zielst. Wo die Registrierung unterstützt wird, gibt es "function" zurück.
Ein Beta-Befund: eine Ablehnung, die dem Menschen angelastet wird
In meinem Setup bat das allow der Bridge den Menschen, Save zu bestätigen, und das WebMCP-allow lehnt jedes press ab. Als ich das registrierte act-Tool aufrief, um Save zu drücken, erschien der Bestätigungsdialog, ich habe ihn akzeptiert, und dann lehnte die WebMCP-Policy den Schritt ab. Der Fehler lautete:
Der Mensch hat Ja gesagt. Der Code hat Nein gesagt. Der Schritt wurde abgelehnt, was korrekt ist, aber ein Modell, das diese Meldung liest, könnte dem Nutzer erzählen, er habe etwas abgelehnt, dem er zugestimmt hat. Außerdem wurde der Mensch gefragt, bevor eine Policy geprüft wurde, die ohnehin ablehnen würde. Wenn du Confirm-Markierungen mit einem WebMCP-allow kombinierst, das Presses ablehnt, lehne früher ab: Prüfe step.action auch im allow der Bridge, damit der Mensch gar nicht erst gefragt wird. Ich melde das für 0.1.0-beta.1 und gehe davon aus, dass es sich ändert.
Wann sich das lohnt
Die Bridge lohnt sich, wenn ein Agent Teil des Produkts ist: ein Assistenten-Panel, das Formulare ausfüllt, ein Support-Tool, das Einstellungen für den Nutzer ändert, oder ein Browser-Agent, der auf deiner Seite gut funktionieren soll. Sie ist auch in Tests nützlich, weil act() dir einen typisierten Weg gibt, Komponenten ohne Selektoren zu steuern.
Allein ist das kein Grund, eine stabile App zu migrieren. Die Bridge sieht nur UI-SDK-Komponenten, eine Seite, die Bibliotheken mischt, gibt dem Agenten also nur eine Teilansicht. Und es ist eine Beta, bei der der agent-Key und die Verlaufs-Helfer als experimentell markiert sind.
Wenn du darauf aufbaust, halten ein paar Gewohnheiten Agenten nützlich und sicher:
- gib jedem Control einen barrierefreien Namen, denn den tippt der Agent;
- übergib
itemsan Selects und Comboboxes, damit die Optionen auch geschlossen lesbar sind; - lehne in
allowab, was ein Agent nie tun sollte, und markiere mitdata-agent-confirm, was einen Menschen braucht; - validiere Werte in Change-Callbacks, denn
setValueruft die API direkt auf und überspringt deine Tastatur- und Pointer-Handler; - lies
valuesaus jedemact()-Ergebnis, stattokzu vertrauen.
HextaUI arbeitet an einer gestylten UI-SDK-Variante seiner Komponenten, also sind das die Patterns, um die herum ich baue. Wie UI SDK sich jenseits von Agenten mit Radix und Base UI vergleicht, steht in UI SDK vs Base UI vs Radix. Zum Stylen der Komponenten mit Tailwind siehe Vercels UI SDK mit Tailwind stylen.
