Blog

Haz que agentes de IA puedan operar tu app React con UI SDK y WebMCP

Deja que agentes de IA lean y usen tu UI React con UI SDK de Vercel: createAgent, snapshot, act, allow, source agent, definePrimitive y registerTools de WebMCP.

Preet Suthar · · 10 min de lectura

  • AI
  • React
  • UI SDK
  • WebMCP

La mayoría de los agentes de IA usan una web app como lo haría una persona con un lector de pantalla lento. Hacen una captura o leen el DOM, adivinan qué elemento es el desplegable "Region", hacen clic, vuelven a leer la página y hacen clic en una opción. Cada ida y vuelta cuesta tokens y tiempo, y cada suposición puede fallar.

UI SDK, que Vercel Labs publicó el 12 de noviembre de 2026, toma otro camino. Sus componentes se registran en un puente que le dice a un agente qué hay en la página, en qué estado está cada componente y qué puede hacer, y luego ejecuta esas acciones a través de la propia API del componente. El mismo puente puede publicar sus herramientas para un agente integrado en el navegador mediante WebMCP.

Este post construye una pequeña página de ajustes en React, le conecta un agente y recorre las partes de la API que importan: createAgent, snapshot(), act(), el callback allow, source: "agent" en los callbacks de cambio, un componente personalizado hecho con definePrimitive y registerTools. Cada salida de abajo está copiada de una ejecución en una app de prueba con React 19.3, Vite 8 y @ui-sdk/react y @ui-sdk/core en 0.1.0-beta.1. UI SDK está en beta, así que los nombres y el comportamiento pueden cambiar.

Cómo funciona el puente

Tu app React

  • Input
  • Select
  • Switch
  • Tus partes de definePrimitive

El puente: createAgent()

  • snapshot() lista componentes, estado y acciones
  • query() encuentra componentes y sus opciones
  • act() ejecuta los pasos a través de la API de cada componente
  • allow aprueba o rechaza cada paso

Tu propio agente

Definiciones de herramientas de agentTools, conectadas al SDK de tu modelo

El agente de un navegador

registerTools() publica las herramientas en document.modelContext (WebMCP), con un segundo allow

Cada cambio que hace un agente llega a tus callbacks de cambio con details.source === "agent", y details.cancel() todavía puede rechazarlo.

Cómo llega un agente a tus componentes a través de UI SDK

El puente es un objeto que devuelve createAgent(). Los componentes de UI SDK de la página se registran en él al montarse. Tiene tres métodos que usa un agente:

  • snapshot() devuelve cada componente registrado con un ref corto como c3, su tipo, su label accesible, su estado y las acciones que acepta en ese momento.
  • query() encuentra componentes por texto o lista las opciones dentro de uno, como las de un Select.
  • act() ejecuta una lista de pasos en orden. Cada paso nombra un componente y una acción, como setValue, y el puente llama a esa acción en el componente.

agentTools describe estos tres métodos como definiciones de herramientas con JSON Schemas, para tu propio bucle de modelo. registerTools(), en cambio, los registra en el navegador.

La decisión de diseño importante es que act() no simula clics. Llama a la misma API que llamaría un controlador, así que el cambio pasa por el callback de cambio del componente con un source de "agent". Tu app ve los cambios del agente en el mismo sitio donde ve los de una persona, y puede rechazarlos ahí.

El benchmark de Vercel

La documentación de Vercel incluye un benchmark. Los agentes renombraron un proyecto, actualizaron los miembros y los ajustes de notificaciones, y luego guardaron. Un agente usó herramientas de navegador en una página hecha con otra biblioteca de componentes. El otro usó el puente para agentes en la misma pantalla hecha con UI SDK.

Herramientas del navegadorPuente para agentes de UI SDK
Llamadas a herramientas−69%
165
Texto leído (caracteres)−83%
39.8k6.8k
Tiempo−61%
41 s16 s
Coste−56%
$0.073$0.032

Fuente: documentación del UI SDK de Vercel. Medianas de tres ejecuciones por enfoque, Sonnet en Claude Code. No reproducido por HextaUI.

Benchmark de ajustes de proyecto de Vercel: herramientas de navegador frente al puente para agentes de UI SDK

Son números de Vercel, no míos. Son medianas de tres ejecuciones por enfoque, ambos agentes usaron Sonnet en Claude Code con la misma tarea y el mismo system prompt, y todas las ejecuciones completaron la tarea. "Texto leído" cuenta los caracteres que devolvieron las herramientas. No reproduje el benchmark. Como referencia, un snapshot completo de la demo de abajo ocupa 729 caracteres de JSON. El HTML de la página en el servidor de desarrollo de Vite ronda los 28.000 caracteres. El snapshot ARIA de Playwright de la misma página fue aún más corto, unos 370 caracteres, pero solo lista roles y nombres. No dice qué acciones acepta cada control, qué opciones tiene un Select cerrado ni qué botón necesita a una persona. Esa estructura extra es lo que le ahorra a un agente las llamadas de seguimiento, no el tamaño en bruto.

Prepara la demo

Parte de la plantilla de React y TypeScript de Vite y añade los dos paquetes:

npm create vite@latest agent-demo -- --template react-ts
cd agent-demo
npm install
npm install @ui-sdk/react@0.1.0-beta.1 @ui-sdk/core@0.1.0-beta.1

Crea el puente en su propio módulo. Impórtalo en main.tsx antes de que se renderice la app, para que vea los componentes según se crean. La documentación dice que también encuentra los componentes que ya están en la página, así que puedes crearlo más tarde, cuando se abra un asistente.

import { createAgent, registerTools } from "@ui-sdk/react/agent"

const agent = createAgent({
  allow: (step) => {
    if (step.confirm) return window.confirm(step.confirm)
    return step.action !== "clear"
  },
})

const unregister = registerTools(agent, {
  allow: (step) => step.action !== "press",
})

Object.assign(window, { agent })

if (import.meta.hot) import.meta.hot.dispose(unregister)

export { agent }

Los dos callbacks allow vuelven a aparecer más abajo. Object.assign(window, { agent }) pone el puente en window para que puedas probarlo desde la consola.

La página es un formulario de ajustes con un campo de texto, un Select, un Switch, un contador de plazas personalizado y un botón Save. Este extracto quita las clases, el layout y el componente de log, y conserva las partes que le importan al agente:

<Field>
  <FieldLabel>Project name</FieldLabel>
  <Input
    defaultValue="Apollo"
    onValueChange={(value, details) =>
      record(`Project name → ${value}`, details.source)
    }
  />
</Field>

<Select
  defaultValue="europe"
  items={regions}
  onValueChange={(value, details) => {
    if (details.source === "agent" && value === "asia-pacific") {
      details.cancel()
      return
    }
    record(`Region → ${value}`, details.source)
  }}
>
  <SelectLabel>Region</SelectLabel>
  <SelectTrigger>
    <SelectValue />
  </SelectTrigger>
  <SelectContent>
    {regions.map((region) => (
      <SelectItem key={region.value} value={region.value}>
        {region.label}
      </SelectItem>
    ))}
  </SelectContent>
</Select>

<label>
  Email notifications
  <Switch
    defaultChecked
    onCheckedChange={(checked, details) =>
      record(`Email notifications → ${checked}`, details.source)
    }
  >
    <SwitchThumb />
  </Switch>
</label>

<Button
  data-agent-confirm="Saves the settings for every member of Atlas"
  onClick={(_event, { source }) => record("Save pressed", source)}
>
  Save
</Button>

record añade una línea a un log en la página, para que veas quién cambió qué. Aquí los labels importan más de lo habitual. El puente nombra los componentes por su nombre accesible, así que FieldLabel, SelectLabel y el <label> que envuelve son lo que permite a un agente decir "Region" en vez de adivinar.

Lee la página con snapshot()

Con la página renderizada, agent.snapshot() devolvió esto:

[
  { "ref": "c1", "component": "input", "actions": ["setValue", "clear"], "label": "Project name", "value": "Apollo" },
  {
    "ref": "c3",
    "component": "select",
    "actions": ["setOpen", "setValue", "clear", "setQuery"],
    "label": "Region",
    "open": false,
    "value": "europe",
    "query": "",
    "items": [
      { "value": "europe", "label": "Europe" },
      { "value": "north-america", "label": "North America" },
      { "value": "asia-pacific", "label": "Asia Pacific" }
    ]
  },
  { "ref": "c4", "component": "switch", "actions": ["setChecked"], "label": "Email notifications", "checked": true, "value": "on" },
  { "ref": "c5", "component": "seat-stepper", "actions": ["add", "remove"], "label": "Seats", "value": 3 },
  { "ref": "c6", "component": "button", "actions": ["press"], "label": "Save", "confirm": "Saves the settings for every member of Atlas" }
]

Algunas cosas a tener en cuenta:

  • No hay c2. El Field también se registró, pero un Field solo aparece en el snapshot mientras tiene errores, ya que su input ya lleva el label. El hueco en los refs no tiene importancia.
  • El Select lista sus items aunque esté cerrado, porque la demo pasa items. Sin ellos, query({ within: "Region" }) lee las opciones, y el puente puede abrir la lista un momento para hacerlo.
  • El botón Save lleva el texto confirm de data-agent-confirm, así que el agente sabe antes de intentarlo que una persona tiene que aprobarlo.
  • seat-stepper es mi propio componente. Lista add y remove porque así lo indiqué en su definición, que se explica más abajo.

Cambia cosas con act()

Una llamada a act() puede llevar todos los pasos que el agente ya conoce. Los pasos nombran componentes por ref o por label, y el puente compara palabras completas sin distinguir mayúsculas ni acentos. Un item también puede nombrarse por su label:

const result = await agent.act({
  steps: [
    { ref: "Project name", action: "setValue", args: ["Atlas"] },
    { ref: "Region", action: "setValue", args: ["North America"] },
    { ref: "Email notifications", action: "setChecked", args: [false] },
    { ref: "Seats", action: "add", args: [2] },
  ],
})

El resultado informa de los cambios de cada paso y del valor actual de cada campo:

{
  "ok": true,
  "steps": [
    { "step": 0, "ref": "c1", "changes": [{ "component": "input", "label": "Project name", "key": "value", "value": "Atlas" }] },
    { "step": 1, "ref": "c3", "changes": [{ "component": "select", "label": "Region", "key": "value", "value": "north-america" }] },
    { "step": 2, "ref": "c4", "changes": [{ "component": "switch", "label": "Email notifications", "key": "checked", "value": false }] },
    { "step": 3, "ref": "c5", "changes": [{ "component": "seat-stepper", "label": "Seats", "key": "value", "value": 5 }] }
  ],
  "appeared": [],
  "values": {
    "c1 Project name": "Atlas",
    "c3 Region": "north-america",
    "c4 Email notifications": false,
    "c5 Seats": 5
  }
}

"North America" se resolvió al valor del item, north-america. Como values vuelve con cada llamada, un agente no necesita otro snapshot para comprobar su trabajo.

Los errores están escritos para que los lea un modelo. Dos que provoqué a propósito:

setChecked on Email notifications (c4) takes true or false, not "yes".
seat-stepper c5 takes no keydown now. It takes: add, remove.

Un paso fallido detiene la llamada, y los pasos anteriores se quedan aplicados. No hay rollback.

Una regla de coincidencia me sorprendió. Un paso con ref: "Name" cambió "Project name", porque los labels coinciden por palabras completas. Está documentado, y el puente rechaza un nombre que coincide con más de un componente, pero da a los componentes labels distintos si un agente va a tener que adivinarlos.

Permisos: allow, marcas de confirmación y cancel

Hay tres sitios donde decir que no, y cada uno hace un trabajo distinto.

allow en createAgent ve cada paso antes de que se ejecute, con su ref, component, label, action, args y confirm. Devuelve false para rechazarlo, o una promesa para tu propia UI de confirmación. Mi política rechaza todo clear, y el resultado (sin values aquí) es:

{ "ok": false, "error": { "step": 0, "message": "The app refused clear on c1." }, "steps": [] }

data-agent-confirm marca un control que siempre necesita a una persona, digan lo que digan las reglas. Sin un callback allow, un paso sobre él falla con un mensaje "Needs a person". Con uno, step.confirm lleva la frase para que puedas mostrarla. Mi allow la pasa a window.confirm, y en la ejecución el diálogo decía "Saves the settings for every member of Atlas" antes de pulsar Save. La marca cubre todo lo que hay dentro de su elemento, así que puedes ponerla en un formulario o un diálogo entero.

Tu callback de cambio tiene la última palabra sobre un valor. El Select Region cancela la petición de un agente para Asia Pacific:

{
  "ok": true,
  "steps": [{ "step": 0, "ref": "c3", "result": false }],
  "appeared": [],
  "values": {
    "c1 Project name": "Atlas",
    "c3 Region": "north-america",
    "c4 Email notifications": false,
    "c5 Seats": 5
  }
}

Fíjate en que ok es true. El puente ejecutó el paso y el callback canceló el cambio, así que el paso informa result: false y el valor no cambia. La documentación lo dice claramente: ok: true no significa que se aceptaran todos los valores. Revisa changes y values.

También hay dos atributos para lo que un agente puede ver. data-agent-hidden quita una región de snapshot y query. data-agent-secret muestra un campo con su valor como "[redacted]". Los campos de contraseña, tarjeta y código de un solo uso se tratan como secretos sin necesidad de marca.

Distingue los cambios de agentes con source

Cada callback de cambio en UI SDK recibe details.source: keyboard, mouse, touch, pen, virtual, programmatic o agent. Tras la ejecución de arriba, el log de la demo quedó así:

Un formulario Project settings con Project name Atlas, Region North America, notificaciones desactivadas y 5 seats, sobre un log donde cada línea termina en source: agent
La demo tras la ejecución del agente. Cada línea del log viene de un callback de cambio, y cada una indica source: agent.

Al pulsar después el botón de más a mano se registró Seats → 2 (source: mouse). Ese único campo basta para crear un badge de "editado por tu asistente", enviar los cambios de agentes a una cola de revisión u omitir eventos de analítica que no causó una persona.

Un matiz de la documentación: un manejador de Button que espera algo con await y cambia el estado después informa "programmatic", porque para entonces la pulsación ya ha terminado. Envuelve el cambio posterior en withSource(source, …) de @ui-sdk/react/observe para mantener al agente como origen.

Tu propio componente: definePrimitive y la clave agent

Los componentes integrados se unen al puente solos. Los componentes que escribes con definePrimitive también. Este es el contador de plazas de la demo, con una clave agent que nombra los dos métodos que un agente puede llamar:

import { createChange, definePrimitive, type ChangeDetails } from "@ui-sdk/core"

type SeatStepperOptions = {
  value?: number
  defaultValue?: number
  max?: number
  onValueChange?: (
    value: number,
    details: ChangeDetails<"add" | "remove">,
  ) => void
}

const seatStepper = definePrimitive({
  name: "seat-stepper",
  options: {
    value: "number",
    defaultValue: "number",
    max: "number",
    onValueChange: "callback",
  },
  controlled: ["value"],
  state: (options: SeatStepperOptions) => ({
    value: options.value ?? options.defaultValue ?? 1,
  }),
  sync: (options) =>
    options.value === undefined ? {} : { value: options.value },
  api: (context) => {
    const change = createChange(context)
    const max = () => context.options().max ?? 50
    return {
      add: (count = 1, event?: Event) =>
        change("value", Math.min(max(), context.store.get().value + count), {
          reason: "add",
          event,
        }),
      remove: (count = 1, event?: Event) =>
        change("value", Math.max(1, context.store.get().value - count), {
          reason: "remove",
          event,
        }),
    }
  },
  slots: (slot) => ({
    root: slot<{}, { value: number }>({
      role: "group",
      state: (state) => ({ value: state.value }),
    }),
    remove: slot({
      attrs: (state) => ({
        type: "button",
        "aria-label": "Remove a seat",
        disabled: state.value <= 1 ? "" : undefined,
      }),
      press: ({ api }, event) => api.remove(1, event),
    }),
    add: slot({
      attrs: () => ({ type: "button", "aria-label": "Add a seat" }),
      press: ({ api }, event) => api.add(1, event),
    }),
  }),
  agent: { actions: ["add", "remove"] },
})

export { seatStepper }

createParts(seatStepper, { root: "div", remove: "button", add: "button" }) de @ui-sdk/react/primitive lo convierte en componentes React. La raíz acepta aria-labelledby como cualquier elemento, y de ahí salió el label "Seats" del snapshot.

Por qué importa la clave agent: sin ella, el puente deduce las acciones a partir de los nombres de los métodos. Reconoce set seguido de una mayúscula, como setValue, y verbos como open, toggle y clear. No deduciría add ni remove, así que el stepper no mostraría ninguna acción. Con la clave, solo se ofrecen los métodos listados y cualquier otro se rechaza, como mostró el error de keydown de arriba. La documentación pide poner agent al final, después de slots, para que TypeScript ya haya leído la API, y marca la clave como experimental.

Como el cambio pasa por createChange, el onValueChange de la app sigue viendo source: "agent" y puede cancelarlo. Cuando el agente pidió quitar cinco plazas de tres, el valor se limitó a 1 y el botón de quitar se desactivó, igual que lo habrían dejado los clics de una persona.

Dos notas de cuando lo escribí. Los valores de atributos en attrs son strings, así que disabled es "" o undefined, no un booleano; TypeScript lo detectó. Y la documentación de Core dice que nunca pongas role en attrs y que uses la clave role del slot, mientras que un ejemplo de Core sí pone role en attrs. Yo usé la clave role.

Registra las herramientas para WebMCP

registerTools(agent, { allow }) publica snapshot, query y act en document.modelContext, la API del navegador de la propuesta WebMCP. Un navegador que la ofrezca puede pasar las herramientas a su agente integrado. En un navegador sin ella, registerTools no hace nada.

El Chromium headless con el que probé no ofrece document.modelContext, así que para ver qué se registra instalé un objeto sustituto antes de que cargara la página. Las tres herramientas llegaron con estas anotaciones:

[
  { "name": "snapshot", "annotations": { "readOnlyHint": true, "untrustedContentHint": true } },
  { "name": "act", "annotations": { "readOnlyHint": false, "untrustedContentHint": true } },
  { "name": "query", "annotations": { "readOnlyHint": true, "untrustedContentHint": true } }
]

untrustedContentHint está ahí porque los resultados de las herramientas llevan texto de la página escrito por usuarios, como un nombre en una fila de una tabla. Las descripciones de las herramientas también le dicen al modelo que trate ese texto como datos, nunca como instrucciones. Es una pista, no una garantía, así que deja todo lo arriesgado detrás de allow y data-agent-confirm.

El allow de registerTools es obligatorio, y se ejecuta después del allow propio del puente, solo para los pasos que vienen del agente del navegador. Eso te da dos políticas: una para el agente que escribiste tú y otra más estricta para un agente que no escribiste.

  1. Encuentra el objetivorefUn ref como c3, luego un label, un item y después un tipo.
  2. allow del puentecreateAgentTu política para cualquier llamante. False detiene la llamada.
  3. allow de WebMCPregisterToolsUna segunda política, solo para el agente del navegador.
  4. API del componentesetValueEl mismo método que llamaría un controlador.
  5. Tu callbackdetails.sourceVe "agent" y puede llamar a cancel().
Qué le pasa a un paso de act

La guía de UI SDK explica cómo usar las herramientas desde el navegador integrado de la app de escritorio de ChatGPT, donde las herramientas de sitio hay que activarlas y solo algunos modelos y workspaces las soportan. No probé esa vía. Comprueba typeof document.modelContext?.registerTool en el navegador al que apuntas. Devuelve "function" donde el registro está soportado.

Un hallazgo de la beta: un rechazo que culpa a la persona

En mi configuración, el allow del puente pedía a la persona confirmar Save, y el allow de WebMCP rechaza todo press. Cuando llamé a la herramienta registrada act para pulsar Save, apareció el diálogo de confirmación, lo acepté, y después la política de WebMCP rechazó el paso. El error decía:

The person said no to "Saves the settings for every member of Atlas".

La persona dijo que sí. El código dijo que no. El paso se rechazó, lo cual es correcto, pero un modelo que lea ese mensaje podría decirle al usuario que rechazó algo que aprobó. Además, preguntó a la persona antes de comprobar una política que iba a rechazarlo de todos modos. Si combinas marcas de confirmación con un allow de WebMCP que rechaza pulsaciones, rechaza antes: comprueba step.action también en el allow del puente, para que nunca se pregunte a la persona. Lo reporto contra 0.1.0-beta.1 y espero que cambie.

Cuándo merece la pena

El puente se gana su sitio cuando un agente forma parte del producto: un panel de asistente que rellena formularios, una herramienta de soporte que cambia ajustes por el usuario o un agente de navegador que quieres que funcione bien en tu sitio. También es útil en tests, ya que act() te da una forma tipada de manejar componentes sin selectores.

No es motivo por sí solo para migrar una app estable. El puente solo ve componentes de UI SDK, así que una página que mezcla bibliotecas le da al agente una vista parcial. Y es una beta, con la clave agent y los helpers de historial marcados como experimentales.

Si construyes sobre él, algunos hábitos mantienen a los agentes útiles y seguros:

  • da a cada control un nombre accesible, porque es lo que escribe el agente;
  • pasa items a los Selects y Comboboxes para que las opciones se puedan leer con la lista cerrada;
  • rechaza en allow lo que un agente nunca debería hacer, y marca con data-agent-confirm lo que necesita a una persona;
  • valida los valores en los callbacks de cambio, porque setValue llama directamente a la API y se salta tus manejadores de teclado y puntero;
  • lee values del resultado de cada act() en vez de fiarte de ok.

HextaUI está trabajando en una variante con estilos de sus componentes sobre UI SDK, así que estos son los patrones en torno a los que estoy construyendo. Para ver cómo se compara UI SDK con Radix y Base UI más allá de los agentes, lee UI SDK vs Base UI vs Radix. Para estilizar los componentes con Tailwind, lee Estilizar el UI SDK de Vercel con Tailwind.