Parte 3 — cómo se estandariza la UI de un agente

En los dos posts anteriores fuimos a implementar estándares ajenos. Con A2A escribimos 250 líneas de traductor para hablar un protocolo que ya existía; con ACP escribimos un relé para conectarnos a un ecosistema que ya estaba armado. En los dos casos el trabajo fue adaptarnos.
Este post cuenta el caso contrario. Llevamos meses emitiendo bloques estructurados desde el agente hacia el chat porque nos hacían falta. La semana pasada leímos la spec de MCP Apps y el post conjunto de Google sobre A2UI, y la sensación no fue "hay que implementar esto": fue reconocer nuestro propio diagrama escrito por otras nueve personas.
Lo interesante no es el halago. Es lo que descubrimos al intentar responder la pregunta honesta que sigue: ¿nuestros bloques son portables? La respuesta corta es que no, y la razón por la que no lo son es lo mejor de este post.
El problema es viejo y siempre se resolvió igual
Un agente que sólo devuelve párrafos obliga a un humano a hacer el trabajo mecánico: copiar, pegar, abrir otra pestaña, volver a capturar. En cuanto un agente hace algo útil, alguien quiere un botón.
La industria ya pasó por esto sin agentes de por medio:
Adaptive Cards es el que más cerca estuvo de ser un estándar multi-vendor de verdad: la misma tarjeta se pinta en Teams, en Outlook y en Webex. Y aun así, los tres comparten la misma limitación estructural: son vocabularios cerrados. Hay una lista de bloques —texto, imagen, columna, input, botón— y lo que no está en la lista no se puede expresar.
La lección histórica, que vale para lo que viene: un vocabulario fijo nunca alcanza. Siempre falta un bloque. Siempre hay alguien intentando dibujar una gráfica con columnas de texto.
Lo que construimos nosotros, antes de saber que era eso
Nuestro agente escribe en el chat de Ghosty Teams usando fences de markdown con etiqueta. El agente emite un bloque; la plataforma decide cómo se pinta. Hoy hay 16 tipos en el código:
El parser vive en un solo archivo (src/lib/ebdoc.ts, ~1,100 líneas) y se ejecuta en
cada render del mensaje, no una vez al guardarlo. Eso tiene una consecuencia que
descubrimos por las malas: el cuerpo del mensaje guarda el fence crudo, así que
cualquier cambio al parser reinterpreta retroactivamente todos los mensajes viejos de la
base. No puedes cambiar la gramática, sólo ampliarla.
Los dos que no se parecen entre sí
Si miras la lista de arriba con atención, gt-alert y eb-artifact no son la misma
clase de cosa. Uno es un aviso de tres líneas; el otro es una página completa.
eb-artifact es HTML que escribe el agente y que servimos en un iframe con
sandbox="allow-scripts allow-forms allow-popups" — deliberadamente sin
allow-same-origin. Esa ausencia es toda la seguridad del asunto: el iframe puede
correr JavaScript, pero su Origin es literalmente null, no alcanza cookies, no
alcanza localStorage, no alcanza nuestra sesión. Es código que escribió un modelo
corriendo con cero credenciales.
Como el agente escribe Tailwind y no podemos meter un CDN dentro de ese iframe, el
servidor hornea el CSS al publicar (bakeTailwind): compila las clases que
realmente aparecen en el HTML y las inyecta. El artefacto queda autocontenido y con una
URL compartible propia.
gt-alert, en cambio, no trae HTML. Trae datos, y nuestro componente de React
decide cómo se ven. Nunca lo pensamos como una decisión de arquitectura; simplemente un
aviso no amerita un iframe.
Esa distinción accidental es, exactamente, la que el estándar formalizó.
MCP Apps: el iframe, estandarizado
MCP-UI era un proyecto de comunidad con una idea simple: un servidor MCP devuelve un
recurso HTML o remote-DOM, y el host lo pinta en un iframe aislado; los dos hablan por
postMessage. Funcionaba, tenía adoptantes reales —Postman, Hugging Face, Shopify,
Goose, ElevenLabs— y no era un estándar.
En paralelo, OpenAI lanzó su Apps SDK en noviembre de 2025 para construir aplicaciones dentro de ChatGPT, también sobre MCP. Ahí estaba el riesgo obvio: dos formas incompatibles de hacer lo mismo, y cada desarrollador manteniendo un adaptador por host.
No divergieron. SEP-1865, "MCP Apps", se creó el 21 de noviembre de 2025, firmado por nueve autores encabezados por los creadores de MCP-UI —Ido Salomon y Liad Yosef— y anunciado en el blog de Model Context Protocol. La spec dice con todas sus letras que la arquitectura de los dos proyectos —Apps SDK y MCP-UI— informó el diseño, y que el objetivo es unificar los dos enfoques en un solo estándar abierto, para que nadie tenga que mantener un adaptador por host. Hoy el SEP está en estado Final, en el Extensions Track.
Las piezas que define:
- Recursos de UI predeclarados bajo el esquema
ui://, no incrustados en el resultado de la tool. El motivo es interesante: predeclarar permite al host precargar la plantilla antes de ejecutar nada, cachear presentación aparte de datos, y revisar el HTML antes de pintarlo. text/html;profile=mcp-appcomo único tipo de contenido del MVP.- JSON-RPC de MCP para el diálogo iframe↔host, en vez de un protocolo propio de mensajes. Así todo lo que cruza es loggeable y auditable.
- Sandbox obligatorio, y consentimiento explícito del usuario para las tools que dispare la UI.
Anthropic lo encendió en Claude el 26 de enero de 2026 con socios de arranque —Amplitude, Asana, Box, Canva, Clay, Figma, Hex, monday.com, Slack— y hoy la matriz de soporte incluye Claude web y escritorio, ChatGPT, VS Code Copilot, Microsoft 365 Copilot, Cursor, Goose, Postman y MCPJam. El spec RC del 28 de julio de 2026 formalizó el framework de Extensions bajo el que vive.
A2UI: lo declarativo, estandarizado
Google publicó A2UI casi en paralelo, y la primera reacción de cualquiera es asumir guerra de estándares. El post conjunto dice lo contrario de forma explícita.
A2UI es un payload JSON que define qué renderizar, y la aplicación anfitriona lo pinta con sus propios componentes nativos. No hay iframe. El resultado se ve como el host, no como el servidor — que es justo lo que MCP Apps sacrifica al dar libertad creativa dentro de un iframe: consistencia visual y rendimiento.
El reparto que proponen:
- A2UI → formularios, datos estructurados, visualizaciones. Lo que quieres que se vea integrado.
- MCP Apps → experiencias muy a la medida y con lógica de cliente compleja. Juegos, flujos intrincados, cosas con estado propio.
Y se pueden anidar en las dos direcciones: servir payloads A2UI desde un servidor MCP, incrustar una MCP App dentro de un componente A2UI, o meter un renderizador de A2UI dentro del iframe de una MCP App. La cita que resume la intención: aprovechar el renderizado nativo para lo estándar y reservar el iframe para lo verdaderamente a la medida.
Con esto, el mapa de protocolos queda limpio por primera vez:
Nuestros 16 bloques se parten solos
Aquí está el hallazgo que nos hizo escribir el post. Si clasificas nuestra lista con el criterio del estándar nuevo, no queda ni un caso ambiguo:
Los de la izquierda son datos; nuestro React decide la forma. Los de la derecha son
superficies con vida propia: un eb-doc tiene versiones, un parámetro ?v para elegir
cuál estás viendo, exportación a .docx y PDF, y edición quirúrgica por
eb-patch/eb-insert/eb-remove con marcatextos sobre lo que cambió.
Nadie diseñó esa frontera. Emergió de la pregunta práctica de siempre: ¿esto tiene estado propio o no? Que dos equipos independientes lleguen al mismo corte es la señal de que el corte es real y no una preferencia de arquitectura.
Ahora la parte incómoda: no son portables
Es fácil terminar aquí con una palmada en la espalda. La realidad es que hoy ninguno de nuestros bloques viaja a un host ajeno, y vale la pena desglosar por qué usando el caso más difícil.
gt-pr es la tarjeta de revisión de un pull request: el agente cierra su análisis con
un fence y la plataforma pinta Aprobar · Pedir cambios · Rechazar · Comentar ·
Mergear. Tres razones por las que no es portable, y ninguna es cosmética:
1. El nombre es una convención nuestra. gt-pr no significa nada fuera de nuestro
parser. Esto es lo único que se arregla renombrando, y es la parte fácil.
2. El fence es un puntero, no contenido. No lleva el estado del PR adentro: lleva la
referencia. El estado —si está aprobado, si CI está roja, qué métodos de merge acepta el
repo— se lee de GitHub en el momento de pintar. Fue una decisión explícita: la
primera versión guardaba el estado en localStorage y sólo lo veía quien había hecho
clic. Un host ajeno que recibiera nuestro fence recibiría una referencia a un estado que
no sabe resolver.
3. Los botones llaman a nuestra API con la sesión de quien hace clic. Y esto es lo más difícil de soltar. Los botones no mandan texto al chat: cada uno es una server function que corre una tool de conector con las credenciales de la persona que hizo clic, no con las del agente. Aprobar un PR tiene que ser atribuible a un humano.
Ese tercer punto tiene un corolario de seguridad que nos costó pensar bien: la lista de
acciones (CARD_ACTIONS) es cerrada y vive en el servidor. Si el modelo pudiera
declarar el nombre de la acción, cualquiera con sesión podría hacer que se invocara
cualquier tool suya con los argumentos que quisiera. De ahí la regla que aplicamos a
todas las tarjetas: el modelo emite datos, la plataforma pone los botones. Si el
modelo declarara los botones, inventaría un "Mergear" que no existe.
Lo que realmente cuesta adoptar el estándar
La conclusión práctica, y es la que no esperábamos: adoptar MCP Apps no es un renombre, es decidir qué parte del estado estás dispuesto a soltar.
Nuestros bloques valen precisamente por lo que los ata a nuestro servidor: la sesión de quien hace clic, la lista cerrada de acciones, el estado que se lee en vivo de la fuente. Un bloque portable de verdad tiene que llevar consigo su contexto de autorización, y ahí el estándar todavía no llega — MCP Apps resuelve el transporte, el sandbox y la auditoría, pero el "con qué credenciales corre esta acción" sigue siendo del host.
Por eso el camino que vemos es asimétrico y honesto:
- Los declarativos son portables casi de inmediato.
gt-steps,gt-alert,gt-testsson datos puros. Ahí A2UI encaja sin pelear. eb-artifactya es una MCP App en todo menos el nombre. HTML autocontenido, CSS horneado, iframe sandbox sinallow-same-origin, URL propia. Lo que falta es declarar el recurso bajoui://y hablar JSON-RPC en vez de nuestropostMessage.gt-prygt-taskprobablemente nunca viajen enteros, y está bien. Lo que viaja es la vista; los botones se quedan donde están las credenciales.
El veredicto
En A2A el estándar llegó primero y nos costó un traductor. En ACP el estándar llegó primero y nos costó un relé. Aquí llegamos nosotros primero, y el costo es distinto: tener que separar, en código que ya funciona, la parte que es UI de la parte que es autoridad.
Si estás construyendo la capa visual de un agente hoy, la única decisión que importa tomar bien desde el principio es esa frontera. Todo lo demás —el nombre del fence, el formato del payload, el mecanismo de transporte— es reversible. Dónde vive el estado y con qué credenciales se ejecuta una acción, no.
Fuentes
- — la spec, en estado Final
- — el anuncio conjunto
- — el post de Google sobre el reparto
- — WorkOS
- — MindStudio
- — OpenUI
- — Inkeep