1. Dónde aparecen realmente «flujos» en la arquitectura de ChatGPT App
Antes de debatir qué es mejor — SSE o HTTP-stream — conviene entender dónde existen los flujos en nuestro stack.
De forma aproximada, podemos distinguir tres niveles.
Primero, el nivel de ChatGPT y el modelo. El propio modelo ya va haciendo streaming de la respuesta por tokens: ves cómo el texto de la respuesta se «escribe» letra a letra. Esto también es un flujo, pero está completamente gestionado por OpenAI y no se relaciona directamente con tu código.
Segundo, el nivel MCP. Cuando ChatGPT se conecta a tu servidor MCP, normalmente mantiene una conexión SSE: el servidor envía por push mensajes MCP JSON‑RPC (respuestas y notificaciones), y ChatGPT, en respuesta, envía peticiones a un endpoint HTTP separado, por ejemplo /messages. En términos de MCP, este es el transporte básico.
Tercero, el nivel del Apps SDK y tu backend. Tu widget React GiftGenius se ejecuta en la sandbox de ChatGPT y se comunica con tu backend/MCP‑gateway por HTTP: mediante un fetch normal, mediante fetch con flujo (ReadableStream) o mediante una suscripción SSE (EventSource).
Es importante no mezclar estos niveles. Los eventos MCP son el «cable» entre ChatGPT y los servidores; y SSE/HTTP-stream entre el widget y tu HTTP‑backend — ese ya es tu tramo de la carretera.
Podemos dibujar un diagrama.
flowchart TD
subgraph ChatGPT
UI[ChatGPT UI + modelo]
W[GiftGenius Widget]
end
subgraph YourInfra[Infraestructura del desarrollador]
GW[MCP Gateway / Backend]
MCP[MCP Server]
end
UI -- "llamada de herramienta / respuestas\n(flujo interno de tokens)" --> W
UI <-- "MCP over SSE\n(/sse + /messages)" --> MCP
W <-- "HTTP / fetch / SSE / stream" --> GW
GW <-- "JSON-RPC MCP" --> MCP
Hoy nos centraremos en la flecha Widget ↔ Backend y recordaremos de pasada que el propio transporte MCP también se basa en SSE.
Precisamente en este tramo — Widget ↔ Backend — es donde tenemos que elegir cómo comunicarnos: con peticiones HTTP simples o con flujos. En la siguiente sección veremos por qué el HTTP «normal» se queda corto aquí rápidamente.
2. Por qué una petición HTTP normal no es suficiente
El modelo estándar de HTTP es «petición → una respuesta». El cliente pregunta algo, el servidor responde una vez y se cierra la conexión.
Para muchas tareas es suficiente: obtener el estado actual de un job, guardar la configuración de usuario, o recuperar una lista de regalos ya preparada que está en la base de datos.
Pero en cuanto haces una operación larga, todo empieza a chirriar.
Imagina GiftGenius, que:
- recopila señales de varias fuentes (historial de compras, wishlist, redes sociales),
- pasa eso por un par de consultas LLM,
- construye un ranking personalizado a partir de un centenar de candidatos.
Todo esto puede tardar decenas de segundos. Si mantienes una petición HTTP normal durante 40 segundos sin enviar nada, la UX será como en los navegadores antiguos: el usuario mira un spinner sin saber si la app se ha roto o si todavía «está pensando».
Además de la UX, hay problemas puramente técnicos:
- timeouts en ChatGPT, en Vercel y en los proxies;
- imposibilidad de enviar progreso, resultados parciales, etc.;
- imposibilidad de manejar correctamente un corte de conexión y recuperarse.
De ahí surge la solución natural: pasar de una gran respuesta a un flujo de trozos pequeños, que el servidor puede enviar conforme estén listos.
Esos trozos pueden ser:
- eventos (job.progress, job.completed) — esto va de SSE;
- fragmentos de una única carga útil grande (texto del informe, líneas NDJSON con regalos) — esto va de HTTP-stream.
3. SSE (Server‑Sent Events): suscripción a eventos
Empecemos por SSE, porque en gran medida es «más afín» a MCP: el propio MCP sobre HTTP usa una conexión SSE para hacer push de eventos del servidor al cliente.
El modelo SSE en pocas palabras
SSE es un protocolo sobre HTTP normal:
- el cliente abre una petición GET a un endpoint que responde con Content-Type: text/event-stream;
- el servidor no cierra la conexión y va escribiendo periódicamente líneas del tipo:
event: job.progress
data: {"jobId":"123","percent":40}
event: job.completed
data: {"jobId":"123","resultCount":12}
- en el lado del navegador usamos EventSource, que:
- gestiona por sí mismo los reintentos de conexión;
- parsea el formato event: + data: + doble salto de línea;
- invoca los handlers onmessage / addEventListener("job.progress", ...).
Punto clave: el canal es unidireccional. Solo el servidor envía eventos al cliente. El cliente no envía datos a través de esa conexión.
Para ChatGPT Apps, este modelo encaja muy bien cuando el widget solo quiere «suscribirse» a eventos por jobId y reaccionar al progreso y a la finalización de la tarea.
Mini‑ejemplo de endpoint SSE en Next.js 16
Supongamos que tenemos un route handler para eventos de progreso de un Job:
app/api/gift-jobs/[jobId]/events/route.ts
import { NextRequest } from "next/server";
export async function GET(req: NextRequest, { params }: { params: { jobId: string } }) {
const jobId = params.jobId;
const stream = new ReadableStream({
start(controller) {
// Utilidad para enviar un evento SSE
const send = (event: string, data: unknown) => {
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
controller.enqueue(new TextEncoder().encode(payload));
};
send("job.started", { jobId });
let percent = 0;
const interval = setInterval(() => {
percent += 20;
if (percent >= 100) {
send("job.completed", { jobId, totalGifts: 10 });
clearInterval(interval);
controller.close();
} else {
send("job.progress", { jobId, percent });
}
}, 1000);
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream", // aquí configuramos SSE
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
Esto es una simulación de ejemplo: el porcentaje aumenta cada segundo y al final llega job.completed. Más adelante sustituirás este temporizador por eventos reales de un worker/cola, pero el esquema en sí seguirá siendo el mismo.
Cliente: suscripción a SSE en el widget GiftGenius
Dentro del widget React podemos suscribirnos a este flujo cuando tenemos un jobId. Recuerda que la API del widget se ejecuta en la sandbox de ChatGPT, pero EventSource está disponible igual que en un navegador normal.
import { useEffect, useState } from "react";
export function GiftJobProgress({ jobId }: { jobId: string }) {
const [percent, setPercent] = useState(0);
useEffect(() => {
const url = `/api/gift-jobs/${jobId}/events`;
const es = new EventSource(url);
es.addEventListener("job.progress", (event) => {
const data = JSON.parse((event as MessageEvent).data);
setPercent(data.percent);
});
es.addEventListener("job.completed", () => {
setPercent(100);
es.close();
});
es.onerror = () => {
// aquí podemos mostrar "Problemas de conexión, intentando reconectar"
};
return () => es.close();
}, [jobId]);
return <div>Progreso de la selección de regalos: {percent}%</div>;
}
Ahora puedes enlazar esto con una herramienta MCP. La herramienta start_gift_job devuelve jobId, y en el ToolOutput de tu widget simplemente renderizas GiftJobProgress.
Reconexión automática y Last‑Event‑ID
EventSource, por estándar, intenta reconectar automáticamente cuando se corta la conexión. El servidor puede usar el campo estándar de SSE id: en los eventos, y el cliente — la cabecera Last-Event-ID, para recuperar los eventos perdidos tras reconectar.
Para un GiftGenius sencillo puedes no implementar aún id: ni un identificador propio de eventos y simplemente aceptar una pequeña «caída» de progreso al reconectar. Pero en producción, especialmente con carga alta, necesitarás:
- añadir el campo estándar id: a cada evento SSE para que el cliente pueda enviar Last-Event-ID al reconectar;
- introducir un event_id aplicativo en el payload del evento y basarte en él para el procesamiento idempotente en cliente/backend.
Esto encaja directamente con la idempotencia: incluso si un mismo job.progress llega dos veces, el handler, al ver un event_id conocido, no repetirá los efectos secundarios.
En resumen, SSE nos da una suscripción cómoda a eventos alrededor de jobId con reconexión automática y control de duplicados mediante identificadores de eventos. Ahora veamos el segundo tipo de flujos — cuando tenemos una sola petición, pero una respuesta muy grande que queremos devolver por partes.
4. HTTP‑streaming: responder poco a poco a una única petición
Si SSE es «suscripción a eventos independientes», HTTP‑streaming es «una petición, una respuesta, pero la respuesta se reparte en el tiempo y llega en chunks».
Es exactamente el mecanismo que ves cuando usas OpenAI API con stream : true: el servidor envía chunks JSON (a menudo en formato SSE, pero la lógica es «una petición ↔ flujo de respuesta parcial»), y el cliente los va ensamblando en el texto final.
En tus APIs puedes hacer lo mismo para:
- informes de texto grandes (por ejemplo, la explicación de la lógica de los regalos elegidos),
- listas largas de regalos (hacer streaming por partes, en lugar de mantener al usuario esperando).
Un endpoint HTTP‑stream sencillísimo en Next.js
Supongamos que necesitamos generar una «explicación» del resultado de la selección, donde la LLM escribe un texto largo. Queremos hacer streaming hacia el widget conforme se genera.
app/api/gift-report/route.ts
import { NextRequest } from "next/server";
export async function POST(req: NextRequest) {
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
controller.enqueue(encoder.encode("Empezamos el análisis...\n"));
// Aquí podría ir una generación real de la LLM por chunks
for (const line of ["Recopilamos preferencias...\n", "Calculamos el presupuesto...\n", "Recomendaciones finales...\n"]) {
await new Promise((r) => setTimeout(r, 1000));
controller.enqueue(encoder.encode(line));
}
controller.close();
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Transfer-Encoding": "chunked", // Aquí indicamos que esto es HTTP/stream
},
});
}
Técnicamente, Next gestiona por sí mismo la codificación chunked; a ti solo te importa devolver un ReadableStream.
Leer un stream HTTP en el widget con fetch
En el cliente (dentro del widget) podemos leer el flujo así:
async function fetchReport(setText: (s: string) => void) {
const res = await fetch("/api/gift-report", { method: "POST" });
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let acc = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
acc += decoder.decode(value, { stream: true });
setText(acc); // actualizamos la UI a medida que llegan los datos
}
}
Y el componente contenedor:
import { useState } from "react";
export function GiftReport() {
const [text, setText] = useState("");
return (
<div>
<button onClick={() => fetchReport(setText)}>Generar informe</button>
<pre style={{ whiteSpace: "pre-wrap" }}>{text}</pre>
</div>
);
}
Es un patrón clásico: una petición POST a /api/gift-report, y como respuesta — un flujo de texto que se muestra progresivamente.
Hacer streaming de JSON, no de texto
A menudo querrás hacer streaming no de cadenas, sino de objetos JSON. El formato más popular es NDJSON (Newline‑delimited JSON): cada evento es una línea JSON y termina con el carácter \n.
Ejemplo en el servidor:
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
for (let i = 0; i < 5; i++) {
const chunk = { type: "gift", index: i, name: `Regalo #${i}` };
controller.enqueue(encoder.encode(JSON.stringify(chunk) + "\n"));
await new Promise((r) => setTimeout(r, 500));
}
controller.close();
},
});
El cliente lee con TextDecoder, divide por \n y parsea los objetos JSON individuales.
5. SSE vs HTTP‑stream: en qué se diferencian y cómo elegir
A estas alturas, la idea ya debería ser intuitiva, pero fijémosla igualmente en forma de una pequeña tabla.
| Característica | SSE (Server‑Sent Events) | HTTP‑stream (chunked) |
|---|---|---|
| Iniciador | El cliente hace GET y se suscribe | El cliente hace una petición (GET/POST), el servidor hace streaming de la respuesta |
| Dirección | Solo servidor → cliente | Respuesta del servidor a una petición concreta |
| Semántica | Suscripción a un flujo de eventos (pub/sub) | Respuesta parcial a una única petición |
| Protocolo incorporado | Sí (event:, data:, id:, etc.) | No, tú defines el formato (líneas, NDJSON, JSON) |
| API del cliente | EventSource | fetch + ReadableStream / response.body |
| Soporte de reconexión | Integrado (EventSource, Last-Event-ID) | Hay que implementarlo a mano |
| Casos típicos | Progreso, estados, notificaciones por jobId | Streaming de texto, respuestas JSON grandes, salida de LLM |
Si lo simplificamos a «reglas orientativas» (con cuidado, sin fanatismo):
- tienes un job y muchos eventos alrededor → SSE;
- una llamada de herramienta devuelve un resultado grande que quieres mostrar por partes → HTTP‑stream.
Para GiftGenius, esto significa: SSE — para la barra de progreso en vivo y los estados de la selección; HTTP‑stream — para el resumen textual largo o para cargar progresivamente una lista larga de regalos.
6. Cómo encaja esto con MCP y GiftGenius
Recordemos nuestro esquema del inicio de la lección: modelo ↔ MCP ↔ widget ↔ backend. Ya hemos visto los flujos en el nivel widget ↔ backend, y ahora volvamos un paso atrás y distingamos con cuidado dónde está MCP en esta historia y dónde es «solo HTTP».
MCP define cómo ChatGPT (como cliente MCP) se comunica con tu servidor MCP. Para ello hay un transporte en el que:
- ChatGPT abre una conexión SSE /sse y recibe por ella mensajes MCP (respuestas, notificaciones, eventos);
- ChatGPT envía peticiones MCP (call_tool, list_tools, etc.) a /messages, normalmente como POST con JSON‑RPC.
Este nivel ya lo has cubierto cuando conectaste GiftGenius a ChatGPT.
Ahora, cuando añadimos tareas asíncronas y flujos de UX en el widget, aparecen dos variantes de arquitectura.
Primera variante — «MCP puro»: El servidor MCP genera por sí mismo eventos job.progress y job.completed; ChatGPT los recibe por MCP‑SSE; luego el modelo invoca tu widget con el contexto actualizado, y el widget renderiza el progreso sin hablar directamente con el backend. Es el camino más «canónico» de MCP‑events.
Segunda variante — híbrida: La herramienta MCP start_gift_job crea la tarea y devuelve jobId; el widget recibe jobId y a partir de ahí se comunica por sí mismo con el backend vía HTTP, suscribiéndose al endpoint SSE /api/gift-jobs/{jobId}/events y, si hace falta, solicitando el stream HTTP del informe. Desde el lado MCP, no pasa nada especial.
En el curso seguimos el camino híbrido: se integra mejor con App Router/Next y es más simple para depuración local. Luego ya podrás migrar a «notificaciones MCP puras» cuando le cojas el truco.
7. Reconexión, timeouts y otras realidades de la red
Hasta ahora todo sonaba ideal: abrimos un SSE o un stream, todo fluye, llegan eventos, la UX brilla. En la vida real, la red suele cortar conexiones en momentos inesperados y la infraestructura pone timeouts.
Qué puede salir mal
Con SSE y HTTP-stream tarde o temprano te toparás con:
- timeouts de inactividad en proxies: «si por la conexión no pasó nada durante N segundos — la cerramos»;
- reinicios de tu backend (deploy, incidente);
- red inestable en el lado del usuario (especialmente en móviles).
Es normal; lo importante es estar preparado, no confiar en «ya pasará».
Estrategia para SSE
SSE tiene muchas ventajas precisamente en esta zona:
- EventSource se reconecta solo con cierto retardo;
- tienes id: y Last-Event-ID para recuperar eventos.
Kit mínimo de prácticas:
- En el servidor, enviar periódicamente algo tipo heartbeat para que la conexión no se considere completamente inactiva. Puede ser un evento separado event: ping o simplemente un comentario : keep-alive.
- En el cliente, en onerror mostrar al usuario un estado comprensible como «Problemas de conexión, intentando reconectar…», en lugar de romper todo el widget.
- Al reconectar, si usas id:, devolver desde el servidor solo los eventos nuevos posteriores a ese ID. Para GiftGenius se puede empezar sin id: y simplemente «reconstruir» el estado con el último job.progress/job.completed recibido.
Estrategia para HTTP‑stream
El stream HTTP es una sola petición, así que si se corta, básicamente te toca empezar de nuevo:
- si haces streaming de un informe de texto, puedes simplemente decir al usuario «No se pudo obtener el informe completo, inténtalo de nuevo» y empezar otra vez;
- si haces streaming de datos estructurados (NDJSON), puedes plantearte un mecanismo de resume: por ejemplo, pasando en la petición un offset o cursor desde el que continuar.
Para empezar, no hace falta complicarlo: si el stream de la respuesta se corta antes del final — muestra lo que haya llegado y un botón «Continuar la generación del informe», que enviará una nueva petición.
Lo principal es no dejar al usuario en un estado de «espera eterna».
8. Lo aplicamos a GiftGenius: un escenario de principio a fin
Ahora juntemos todo lo que hemos hablado sobre SSE, HTTP‑stream y las dos variantes de arquitectura con MCP, en un escenario real de GiftGenius — desde la petición del usuario hasta el informe listo.
El usuario en ChatGPT escribe: «Busca un regalo para un fan de los juegos de mesa, presupuesto hasta 100 dólares». El modelo decide invocar GiftGenius. La aplicación/agente hace una llamada de herramienta start_gift_job en tu servidor MCP. El servidor:
- registra el job en la BD;
- la envía a una cola interna (los detalles de colas y workers — en la próxima lección; por ahora asumimos que «alguien» la procesa);
- devuelve sincrónicamente jobId en respuesta a la llamada de herramienta.
El widget GiftGenius recibe un ToolOutput con jobId y renderiza el componente:
function GiftGeniusRoot({ jobId }: { jobId: string }) {
return (
<div>
<h2>Buscamos los regalos ideales...</h2>
<GiftJobProgress jobId={jobId} />
<GiftReport />
</div>
);
}
El componente GiftJobProgress se suscribe por SSE a /api/gift-jobs/{jobId}/events y dibuja el progreso. Cada job.progress actualiza el porcentaje, job.completed pone 100% y, quizá, habilita un botón «Mostrar informe detallado».
El componente GiftReport, al hacer clic en el botón, envía un POST a /api/gift-report (pasando allí el jobId) y va mostrando el informe textual mientras el servidor envía los chunks del stream HTTP.
Si se corta la conexión SSE, el widget muestra un aviso suave y EventSource intenta reconectar. Si hay problemas con el stream del informe, el usuario ve parte del informe y un botón «Continuar la generación» o «Intentar de nuevo».
Desde el punto de vista de ChatGPT y MCP:
- MCP ve la llamada de herramienta start_gift_job y, quizá, después notificaciones sobre los estados del job;
- La UX alrededor de los flujos se implementa principalmente en el nivel HTTP entre el widget y tu backend.
9. Errores típicos al trabajar con SSE y HTTP‑stream
Error n.º 1: considerar que SSE y HTTP‑stream «son lo mismo».
Sí, en el fondo ambos usan HTTP y respuestas chunked, pero la semántica es muy distinta. SSE es una suscripción a eventos independientes, que pueden llegar en cualquier momento y el cliente no los conoce de antemano. HTTP‑stream es una respuesta concreta, repartida en el tiempo. Si intentas implementar una suscripción a múltiples jobId por un único stream HTTP, tendrás que inventarte un protocolo sobre los bytes, reconstruyendo de facto la mitad de SSE.
Error n.º 2: ignorar la reconexión automática de SSE y no pensar en la idempotencia.
Muchos escriben un servidor SSE «simple»: envían data: ... y no añaden ni el id: estándar (para Last-Event-ID), ni un event_id aplicativo en el cuerpo del evento. Luego, a la primera desconexión y reconexión, empiezan a multiplicarse los duplicados. Sin un event_id bien pensado y la lógica de «ya he visto este evento», el handler del cliente corre el riesgo de actualizar el estado dos veces, mostrar dos veces el mismo job.completed o, peor aún, cobrar/abonar dos veces.
Error n.º 3: enviar cada «suspiro» del worker como un evento SSE separado.
Si envías el progreso de la tarea por SSE cada milisegundo, probablemente matarás la red y al cliente, en vez de alegrar al usuario con una animación fluida. Es mucho más sensato agregar actualizaciones y enviar el progreso, por ejemplo, una vez cada 200–500 ms o cuando cambie la fase del proceso. El tema de throttling y backpressure lo veremos más adelante, pero ya ahora conviene pensar en la frecuencia de eventos.
Error n.º 4: crear protocolos complejos sobre HTTP‑stream sin un formato explícito.
Anti‑patrón típico: hacer streaming de JSON sin delimitadores e intentar «adivinar» dónde termina un objeto y empieza otro. O mezclar en un mismo flujo texto y JSON. La mejor vía es elegir un formato sencillo y claro: texto por líneas, o NDJSON (un objeto JSON por línea), o delimitadores explícitos. Así el parser en el cliente seguirá siendo razonable.
Error n.º 5: olvidarse de los timeouts y de los streams «eternos».
A veces se hacen endpoints SSE que no envían nada durante 5–10 minutos, y luego sorprende que las conexiones se corten en el camino del usuario al servidor (balanceadores, pasarelas API, proxies corporativos). Eventos heartbeat regulares o comentarios permiten mantener viva la conexión y detectar a tiempo los cortes. Y los streams HTTP no deben convertirse en respuestas interminables — para suscripciones interminables está SSE.
Error n.º 6: intentar hacer por HTTP‑stream un pub/sub complejo en lugar de eventos normales.
A veces surge la tentación: «Hagamos un único stream y por él enviaremos progreso, resultados parciales y logs aleatorios». El resultado en el cliente será un multiplexor complejo que analiza cada chunk y decide a qué jobId pertenece. En la mayoría de los casos es más simple y fiable usar SSE con eventos del tipo job.progress, job.completed y un canal separado por job, que inventar un megaprotocol personalizado sobre HTTP‑stream.
Error n.º 7: atar la UX rígidamente a que el flujo «nunca se cae».
Cualquier flujo se cortará alguna vez. Si tu widget se queda entonces con una barra de progreso animada eternamente y sin posibilidad de acción — la UX se percibe como «rota». Incluso un mensaje sencillo como «Parece que se ha perdido la conexión. Intenta reiniciar la selección de regalos» con un botón «Reintentar» es mucho mejor que el silencio.
GO TO FULL VERSION