1. Dos salidas: navegación y datos
Si un desarrollador de Next.js oye «hay que ir al servidor», la mano se va automáticamente a fetch o a su cliente HTTP favorito. En el mundo de ChatGPT Apps, esa reacción refleja trae dolor.
En la parte del curso dedicada a la seguridad de los widgets en ChatGPT Apps, proponemos romper ese viejo reflejo desde el principio. El widget no vive en Internet libre: está fuertemente aislado y su acceso de red es filtrado y limitado por las políticas del host.
El widget solo tiene tres ventanitas básicas hacia fuera:
- Navegación: llevar al usuario a algún lugar externo. Para esto existe openExternal.
- Intercambio de datos: obtener/enviar JSON, hablar con el backend. Se hace a través de fetch, pero puede tener fuertes restricciones.
- MCP tool call: invocación de herramientas (MCP / backend) que no tienen restricciones.
En esta lección nos centramos en la primera y más segura vía (navegación) y nos familiarizamos con fetch de forma controlada. En módulos posteriores veremos MCP y las herramientas como la forma principal de comunicación seria con el servidor.
2. openExternal: el «teletransporte» seguro del usuario
Por qué no se puede simplemente hacer window.open
En una aplicación web normal harías algo así:
window.open("https://example.com", "_blank");
En la sandbox de ChatGPT esto o no funcionará, o funcionará de forma muy extraña. El widget es un iframe aislado con un sandbox estricto, que no tiene los mismos privilegios que una pestaña del navegador.
Además, el host de ChatGPT quiere controlar a dónde y cuándo llevas al usuario, para:
- impedir el tracking oculto;
- mostrar al usuario una IU de confirmación comprensible (especialmente en clientes móviles/de escritorio);
- garantizar el mismo comportamiento de los enlaces en distintos entornos (web, escritorio, app móvil).
Por eso se inventó la API especial openExternal, accesible vía window.openai o el hook de React más cómodo useOpenExternal.
Cómo es useOpenExternal
En los ejemplos oficiales del Apps SDK, el hook useOpenExternal está implementado aproximadamente así:
export function useOpenExternal() {
const openExternal = useCallback((href: string) => {
if (typeof window === "undefined") return;
if (window?.openai?.openExternal) {
try {
window.openai.openExternal({ href });
return;
} catch (error) {
console.warn("openExternal failed, falling back to window.open", error);
}
}
window.open(href, "_blank", "noopener,noreferrer");
}, []);
return openExternal;
}
La idea principal es simple. Primero intentamos usar el mecanismo nativo de ChatGPT (window.openai.openExternal). Si el widget de repente no se renderiza en ChatGPT (por ejemplo, lo abriste en el navegador durante el desarrollo), hacemos un fallback a window.open.
En tu aplicación, este hook ya está en la plantilla (si tomaste el repositorio estándar de OpenAI), y debes usarlo exactamente así, no accediendo directamente a window.openai.
Ejemplo: botón «Ver en la tienda» en GiftGenius
Imagina que en el toolOutput de nuestro GiftGenius llegan recomendaciones con el campo productUrl. Añadimos a cada tarjeta un botón que abrirá el producto en tu sitio:
import { useWidgetProps } from "../hooks/use-widget-props";
import { useOpenExternal } from "../hooks/use-open-external";
export function GiftListWidget() {
const { toolOutput } = useWidgetProps<{
recommendations: { id: string; title: string; price: string; url: string }[];
}>();
const openExternal = useOpenExternal();
if (!toolOutput) return <p>Por ahora no hay recomendaciones…</p>;
return (
<div>
{toolOutput.recommendations.map((gift) => (
<div key={gift.id} className="flex justify-between gap-2">
<div>
<div>{gift.title}</div>
<div className="text-sm text-muted-foreground">{gift.price}</div>
</div>
<button onClick={() => openExternal(gift.url)}>
Abrir
</button>
<div>
))}
</div>
);
}
Desde el punto de vista del usuario: pulsa el botón, ChatGPT puede mostrar una ventana del sistema del tipo «¿Abrir sitio externo?» y luego abrirá tu página en una nueva pestaña o en el navegador por defecto. No pasas secretos, tokens, etc.; simplemente llevas a la persona «del chat al sitio».
3. window.fetch en la sandbox: no es el fetch al que estás acostumbrado
Qué espera normalmente un frontend
Normalmente la lógica es: «Si es navegador, entonces puedo llamar con tranquilidad a cualquier URL con CORS configurado. En el peor de los casos tendré un error, pero al menos lo habré intentado».
En el ecosistema de ChatGPT Apps esto es una suposición peligrosa. La sandbox alrededor del widget no es una «pequeña manía», sino un requisito de seguridad fundamental: para que el widget no pueda trackear al usuario, llamar a dominios arbitrarios, escanear la red local y, en general, comportarse como un mini navegador dentro del navegador.
En esa misma documentación se subraya que el acceso de red arbitrario del widget en Apps SDK o bien está ausente, o bien muy limitado, y no es un bug, sino una decisión arquitectónica consciente.
Cómo se ve en la práctica
En un entorno típico de ChatGPT:
- fetch puede estar disponible, pero solo hacia una lista limitada de dominios (normalmente tu dominio donde corre la App y, quizá, un par de APIs explícitamente permitidas);
- las peticiones pueden pasar por un proxy especial del host, que filtra cabeceras y URLs;
- algunos métodos (PUT, DELETE) o cabeceras no estándar pueden ser bloqueados por políticas de seguridad.
Aun así, tienes un camino cómodo: si tu widget y tu backend viven en el mismo dominio (como en la plantilla de Next.js, donde el servidor MCP y la UI son servidos por la misma aplicación), las peticiones internas fetch("/api/...") suelen estar permitidas.
Lo principal es no contar con que el widget podrá llamar a cualquier API de Internet. Toda la comunicación «pesada» con servicios externos (Stripe, Notion, CRM, etc.) debe ocurrir del lado de MCP/backend, al que ChatGPT accede como recurso de confianza.
Idea clave
En el widget de ChatGPT hay que olvidarse de las rutas relativas y vivir con URLs absolutas. La razón es simple: tu HTML no funciona en el mismo dominio que el backend. ChatGPT lee tu HTML, lo aloja en su host y lo renderiza dentro de un iframe aislado. Cualquier "/api/..." o "/static/logo.png" de repente se resuelve relativo al dominio de ChatGPT, y no al de tu aplicación — y todo se rompe.
<base> apenas ayuda aquí. Experimentalmente se ha comprobado que si el widget no tiene definido widgetCSP, puedes establecer <base href="https://my-app.dev/">: los recursos se cargarán desde tu dominio, pero los scripts, por las reglas de la sandbox, seguirán sin funcionar. Esto solo funciona en Dev Mode.
Pero en cuanto defines un openai/widgetCSP normal (y en producción habrá que definirlo para el review), la plataforma ignora/restablece <base>, y se acabó el juego: los recursos y scripts se cargan solo desde dominios permitidos en la CSP, y además con enlaces absolutos.
Recomendación: en un widget de ChatGPT, todo lo que salga hacia fuera — fetch, imágenes, CSS, tus páginas para openExternal — debe construirse siempre como URL completa a partir del dominio base de tu aplicación, que controlas vía configuración/ENV, y no mediante rutas relativas y <base>.
4. Arquitectura: UI ligero, backend pesado
De las limitaciones de fetch y de la sandbox en general se deriva un principio arquitectónico más amplio, importante para todo el curso. Ya hemos repetido este mantra varias veces, pero ahora toca fijarlo: el widget es una capa de UI ligera. Renderiza lo que ya preparó el backend (mediante MCP/tools), muestra reacciones a acciones del usuario y, en el extremo, hace un par de peticiones públicas pequeñas.
Todo lo relacionado con autorización, acceso a datos personales, secretos y lógica de negocio no trivial debe vivir en el servidor. La documentación de seguridad del curso recalca: el frontend (widget de React) es un «lugar público», zona de confianza cero, y los secretos no deben vivir ahí.
Todas mis investigaciones sobre este tema formulan el objetivo con firmeza: «clavar el último clavo en el ataúd de la idea de “cliente pesado”» para ChatGPT Apps. El widget es solo la cabeza; el cuerpo y el cerebro están en MCP/backend.
Por tanto:
- openExternal: para llevar al usuario a tu sitio «normal», donde ya puedes ejecutar tu SPA, área privada, etc.;
- callTool (siguiente módulo): la forma principal de pasar a la modelo una tarea que ejecutará tu backend;
- fetch desde el widget: un invitado raro para peticiones auxiliares, seguras y, a ser posible, públicas a tu propia aplicación.
5. Práctica: openExternal en nuestro GiftGenius
Integramos openExternal en nuestra App de aprendizaje con un poco más de cuidado y de paso pensamos en el UX.
Mini-regla de UX
Si llevas al usuario fuera, conviene:
- indicar explícitamente a dónde irá;
- no hacer «saltos» inesperados sin explicarlo en el texto (o bien GPT informa «Voy a abrir el sitio de la tienda…», o tú lo indicas en el botón).
Ejemplo de título y etiqueta:
<button onClick={() => openExternal(gift.url)}>
Abrir en el sitio de la tienda
</button>
El usuario entiende que ahora saldrá del chat calentito al mundo real con carrito y pago.
Pequeño refactor del componente de lista
Antes ya hicimos un GiftListWidget sencillo. Supongamos que en lecciones anteriores ya implementaste un widget que muestra una lista de regalos a partir de toolOutput. Ahora haremos una versión un poco más cuidada: añadiremos el tipo Gift con el campo url y el botón openExternal.
type Gift = {
id: string;
title: string;
priceLabel: string;
url: string;
};
export function GiftListWidget() {
const { toolOutput } = useWidgetProps<{ gifts: Gift[] }>();
const openExternal = useOpenExternal();
if (!toolOutput || toolOutput.gifts.length === 0) {
return <p>De momento no he encontrado nada. Prueba a cambiar la consulta.</p>;
}
return (
<div>
{toolOutput.gifts.map((gift) => (
<div key={gift.id} className="flex justify-between gap-2">
<div>
<div>{gift.title}</div>
<div className="text-sm text-muted-foreground">
{gift.priceLabel}
</div>
</div>
<button onClick={() => openExternal(gift.url)}>
Ver
</button>
</div>
))}
</div>
);
}
Seguimos sin trabajar directamente con window.openai, y usamos el hook conveniente — ya sabe hacer fallback a window.open cuando no hay entorno ChatGPT. La estructura de Gift aquí es aproximada: en tu App la adaptarás a tu backend.
6. Práctica: fetch cuidadoso a nuestro backend
Ahora veamos fetch. Recuerdo una vez más: las operaciones complejas o sensibles mejor hacerlas mediante herramientas/MCP. Pero a veces quieres desde el widget traer algo ligero y público de tu propio servidor, por ejemplo, una lista de categorías populares de regalos.
Ruta de API pública simple en Next.js
Añadimos a nuestro proyecto Next.js este handler:
// app/api/public/popular-tags/route.ts
import { NextResponse } from "next/server";
const tags = ["Para niños", "Para viajeros", "Para jugadores"];
export async function GET() {
return NextResponse.json({ tags });
}
Esta ruta no sabe nada del usuario, no requiere tokens, no llama a servicios externos — simplemente devuelve un array estático. Este código se puede llevar casi sin riesgo tanto a producción como a la sandbox.
Llamada a esta ruta desde el widget mediante fetch
Ahora en el componente del widget añadimos la carga de estas etiquetas. Teniendo en cuenta las limitaciones de la sandbox, lo más cómodo es hacer la petición a una URL absoluta: al mismo dominio donde corre tu App — el que expones por túnel y registras en el Dev Mode de ChatGPT (esto lo configuramos en el módulo sobre Dev Mode y túnel).
Importante: el dominio de tu widget será algo como https://genius.web-sandbox.oaiusercontent.com, así que no uses rutas relativas para cargar datos, solo absolutas. Ejemplo:
import { useEffect, useState } from "react";
export function PopularTags() {
const [tags, setTags] = useState<string[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function loadTags() {
try {
const res = await fetch("https://giftgenius.app/api/public/popular-tags");
if (!res.ok) throw new Error("Bad status");
const data: { tags: string[] } = await res.json();
if (!cancelled) setTags(data.tags);
} catch (e) {
if (!cancelled) setError("No se pudieron cargar las categorías populares");
}
}
loadTags();
return () => {
cancelled = true;
};
}, []);
if (error) return <p>{error}</p>;
if (!tags) return <p>Cargando categorías populares…</p>;
return (
<div className="flex flex-wrap gap-2 text-sm">
{tags.map((tag) => (
<span key={tag} className="rounded border px-2 py-1">
{tag}
</span>
))}
</div>
);
}
Es importante que:
- tratamos los errores con cuidado y mostramos al usuario un mensaje comprensible;
- no asumimos que fetch «seguro que funciona» — las políticas de la sandbox pueden cortar el acceso en cualquier momento si cambias el dominio o haces peticiones extrañas;
- no pasamos tokens/secretos; si necesitas autenticación — esa ya es tarea de MCP y de los módulos sobre Auth.
7. openExternal vs fetch vs herramientas (callTool): quién hace qué
Para no confundirse, es útil mantener en mente esta «matriz de responsabilidades»:
| Escenario | Qué usamos | Por qué así |
|---|---|---|
| Abrir landing/producto/área privada | openExternal | Transición explícita del usuario, controlada por el host |
| Obtener datos públicos de la App | fetch("my.com/api/...") | JSON ligero, mismo dominio, sin secretos |
| Obtener datos del usuario, base de datos | callTool/MCP | Se necesita autorización, lógica, backend seguro |
| Acceder a APIs externas (Stripe…) | MCP/servidor | El front no ve secretos, cumplimos políticas |
En este módulo es importante aprender a elegir conscientemente la herramienta. Hay que pasar de «el widget es frontend, así que todo se hace con fetch» a una arquitectura donde «el widget es una capa de UI gestionada sobre un backend LLM+MCP».
Idea clave
La interacción con el servidor en una ChatGPT App se divide lógicamente en dos niveles:
- ChatGPT ↔ servidor MCP: el modelo invoca herramientas MCP. Cada tool-call es el arranque o el cambio de un escenario de negocio (selección de regalos, creación de pedido, cálculo de costes, etc.). Aquí vive la lógica «pesada», trabajo con datos, APIs externas y autorización.
- Widget ↔ servidor: el widget hace peticiones fetch() ligeras a su backend y/o invoca las mismas herramientas MCP mediante callTool() ya dentro del escenario activo. Son pasos locales: cargar datos auxiliares, actualizar una parte de la UI, concretar estado.
Es decir, herramienta MCP = arranque/gestión del proceso de negocio, y fetch()/callTool() desde el widget son operaciones pequeñas dentro del escenario ya elegido, sin pretender cambiar la «historia» general del diálogo.
8. Pequeño ejercicio práctico
Para fijar el tema en la práctica, puedes implementar una pequeña funcionalidad en GiftGenius.
Escenario propuesto:
- En la lista de regalos añade un botón «Ir a la tramitación», que mediante openExternal abra la página de checkout en tu sitio de desarrollo.
- Encima de la lista de regalos renderiza PopularTags del ejemplo anterior para mostrar categorías populares. En caso de error de carga, muestra un mensaje de respaldo y no rompas todo el widget.
- Presta atención al UX: en el texto de la respuesta de GPT o en la UI del widget explica al usuario que «al pulsar el botón abriré la página de la tienda en una nueva pestaña».
Esta funcionalidad en miniatura muestra ambos canales:
- openExternal para la navegación explícita;
- fetch para una pequeña API pública que vive junto a tu App.
9. Errores típicos al trabajar con window.fetch y openExternal
Error n.º 1: intentar usar el widget como cliente SPA completo para todas tus APIs.
Los viejos hábitos empujan fuerte hacia «vamos a llamar nuestro REST/GraphQL directamente desde React». En el mundo de ChatGPT Apps esto conduce a un choque frontal con la sandbox: parte de las peticiones no pasarán, parte serán bloqueadas por políticas y la seguridad del proyecto quedará en entredicho. La lógica compleja y el acceso a datos de usuario deben ir por MCP/herramientas, no directamente desde el widget.
Error n.º 2: guardar secretos y tokens en el código del widget.
A veces apetece «prototipar rápido» e incluir en el frontend una API key de algún servicio («solo estoy probando»). Es mala idea incluso para un SPA normal; para ChatGPT Apps es un no rotundo. El widget es un entorno público; los secretos deben vivir en la config del servidor o en sistemas de gestión de secretos (Vercel env, KMS, etc.).
Error n.º 3: pensar que fetch a cualquier dominio «simplemente funcionará».
Aunque en Dev Mode alguna petición haya pasado (por ejemplo, por un túnel inusual), en producción casi seguro se romperá: ChatGPT limita las salidas y un dominio externo arbitrario no está disponible para el widget. Cuenta con que el widget solo puede ir con fiabilidad a su propio dominio y a una lista blanca muy pequeña de recursos explícitamente permitidos.
Error n.º 4: usar window.open en lugar de openExternal.
Técnicamente, a veces window.open puede funcionar, sobre todo en la vista previa del navegador, y se crea la ilusión de que «todo va bien». Pero en el entorno real de ChatGPT, especialmente en clientes nativos, el comportamiento será impredecible. El usuario puede no ver la transición o recibir un error extraño. La forma correcta es usar openExternal (a través del hook useOpenExternal), que sabe abrir el enlace correctamente en el entorno actual.
Error n.º 5: no manejar los errores de fetch ni mostrar estados de carga al usuario.
En la sandbox los errores de red no son la excepción, sino la norma: el túnel puede caerse, el dominio puede cambiar, las políticas pueden cortar algo. Si simplemente haces await fetch(...) y luego renderizas la UI suponiendo que hay datos, obtendrás una interfaz a medio romper que «a veces funciona y a veces no». Pon siempre try/catch, comprueba res.ok, muestra «Cargando…» y un mensaje de error claro.
Error n.º 6: convertir openExternal en un redireccionamiento oculto.
A veces aparece la tentación de, al hacer clic en cualquier botón, llevar directamente al usuario a un sitio externo, especialmente al checkout, sin contexto en el texto. Esto resulta extraño tanto para el usuario como para los revisores del Store. Es buena práctica escribir explícitamente lo que va a suceder: o bien la modelo GPT indica «Voy a abrir la página de la tienda…», o el propio botón está etiquetado de forma transparente («Ir al pago en el sitio de la tienda»).
Error n.º 7: olvidar que el widget no es el único «dueño» del diálogo.
Si tu UI intenta imponer al usuario un escenario complejo con un montón de enlaces y peticiones de red, ignorando el propio chat y los follow-ups, el resultado es peor UX y peor calidad de la modelo. Recuerda la arquitectura: GPT decide cuándo mostrar la App, cómo usar sus resultados, y el widget solo sugiere y visualiza. La navegación y las llamadas de red deben diseñarse para encajar en el diálogo general, no para acapararlo.
GO TO FULL VERSION