CodeGym /Cursos /ChatGPT Apps /Gestión de estado — Widget State, ToolInput, ToolOutput

Gestión de estado — Widget State, ToolInput, ToolOutput

ChatGPT Apps
Nivel 3 , Lección 2
Disponible

1. Por qué merece la pena pensar en el estado del widget

En una aplicación React habitual, estás acostumbrado a lo siguiente: hay estado local, hay peticiones a APIs y, como mucho, algún Zustand/Redux. Todo gira alrededor del navegador del usuario.

En una ChatGPT App la situación es distinta. Tu widget es solo una capa de UI delgada por encima de otras tres entidades:

  • el modelo de ChatGPT, que decide cuándo llamar a tu App y qué argumentos pasarle;
  • el servidor MCP/backend, que almacena los datos reales y ejecuta la lógica de negocio;
  • el contexto del chat, en el que todo esto vive y que puede reabrirse dentro de una hora, un día o una semana.

Por eso, «dónde vive el estado» no es una cuestión académica, sino muy práctica. Si metes todo solo en el estado de React, al mínimo cambio en el chat el usuario perderá su selección. Si lo metes todo en widgetState, el modelo empezará a leer toneladas de JSON y a alucinar con ello. Si, por el contrario, intentas guardar todo en el servidor y volver a pedir cada píxel, será lento y caro.

Las recomendaciones oficiales dividen claramente el estado de una ChatGPT App en tres clases: datos de negocio, estado de UI efímero y estado duradero entre sesiones. Con eso empezamos.

2. Mapa de estados en ChatGPT App

La documentación del Apps SDK describe tres tipos de estado. Es útil tenerlos en mente como una tabla:

Tipo de estado Dónde vive Ciclo de vida Ejemplos
Business data (authoritative) Servidor MCP / tu backend Largo: días, semanas, años tareas, pedidos, productos
UI state (ephemeral) Dentro del widget concreto Mientras viva la instancia del widget tarjeta seleccionada, ordenación, acordeón desplegado
Cross‑session state (durable) Tu backend / almacenamiento Entre sesiones y chats filtros guardados, workspace, pinned board

Importante: los datos autoritativos deben quedarse en el servidor, no en el widget. El widget recibe una instantánea de esos datos a través de herramientas (MCP tools) y la renderiza, superponiendo su estado de UI local.

En esta lección nos centramos en lo que ve el widget:

  • toolInput — argumentos de entrada de la herramienta invocada;
  • toolOutputstructuredContent del servidor (datos principales);
  • toolResponseMetadata — metadatos de servicio _meta, visibles solo para el widget;
  • widgetState — estado de UI persistido que ChatGPT almacena junto con el mensaje.

3. Qué llega exactamente al widget: ToolInput, ToolOutput, Metadata, WidgetState

Estos tres tipos de estado en ChatGPT App se reflejan en campos concretos que la plataforma coloca en window.openai y expone a los hooks del SDK. En la práctica los recibirás mediante hooks de React, pero conviene conocer las definiciones exactas.

toolInput

Es un objeto con los argumentos de la herramienta (tool) que el modelo pasó al invocarla.

Por ejemplo, el usuario escribe:
«Elige ideas de regalos para una mujer de 30 años, presupuesto 100 dólares».
El modelo decide invocar tu herramienta gift_search con los argumentos:

{
  "recipient": "female",
  "age": 30,
  "budget": 100,
  "occasion": "birthday"
}

Ese es exactamente el objeto que verás en toolInput dentro del widget. Allí se guardan los ajustes originales del escenario: lo que motivó el lanzamiento de tu App.

toolOutput

Es el structuredContent que devolvió tu servidor MCP / backend al ejecutar la herramienta.

Normalmente es un JSON como:

{
  "gifts": [
    { "id": "1", "title": "Guía de Islandia", "price": 45 },
    { "id": "2", "title": "Libro electrónico sobre viajes", "price": 20 }
  ],
  "total": 2
}

Precisamente toolOutput es la fuente principal de datos para el renderizado. Se subraya oficialmente: el modelo lee este campo literalmente, así que mantenlo compacto y claro.

toolResponseMetadata

Es el _meta de la respuesta de la herramienta, también accesible a través de window.openai como toolResponseMetadata. La documentación destaca que el contenido de _meta solo lo ve el widget; el modelo no lo recibe.

Ejemplos típicos:

  • ID internos de tu sistema;
  • flags para el UI (por ejemplo, «hubo caché»);
  • mensajes de servicio para depuración.

En pocas palabras: toolOutput es «lo que hay que decir al usuario y al modelo», y _meta es «lo que solo necesita el widget y los logs».

widgetState

Es un objeto JSON en el que ChatGPT guarda una instantánea del estado de UI del widget entre renderizados.

Sus propiedades:

  • vive del lado de ChatGPT y está ligado a un message/widgetId concretos;
  • se restaura al reabrir el mismo mensaje;
  • lo ven tanto el widget como el modelo (los datos de widgetState entran en el contexto de la LLM);
  • está limitado en tamaño a aproximadamente 4k tokens, así que no puedes echar allí «todo» ni guardar listas enormes.

Importante: widgetState no es un lugar para secretos. No debes meter tokens ni PII, porque el modelo los verá y la plataforma no lo posiciona como almacenamiento seguro.

4. Estado local de React: dónde sigue siendo necesario

A pesar de toda la magia alrededor de toolOutput y widgetState, dentro del widget sigues escribiendo React normal con useState, useReducer, useRef, etc. La única diferencia es que:

  • el estado local vive tanto como vive el render/iframe concreto;
  • el modelo no lo ve en absoluto;
  • al desmontarse el widget (usuario cambia de chat, re-render, actualización) el estado local desaparece.

El estado local es perfecto para:

  • cosas instantáneas — hover, pestaña seleccionada, desplegable abierto;
  • entrada de formulario antes de pulsar «Continuar»/«Guardar»;
  • flags temporales como isSubmitting o isTooltipOpen.

Mini ejemplo dentro de nuestro App didáctico GiftGenius — un asistente para escoger regalos:

const [selectedGiftId, setSelectedGiftId] = useState<string | null>(null);

return (
  <div>
    {gifts.map(gift => (
      <button
        key={gift.id}
        onClick={() => setSelectedGiftId(gift.id)}
      >
        {gift.title}
      </button>
    ))}
  </div>
);

Mientras no pulsemos «Confirmar selección», esto es un excelente candidato para estado local. Pero en cuanto queramos que la selección «sobreviva» entre actualizaciones del widget, hay que pensar en widgetState.

5. widgetState: memoria del widget entre renderizados

widgetState es esa «memoria» del widget que guarda la propia plataforma. En cada acción importante del UI puedes llamar a setWidgetState, y ChatGPT guardará ese JSON junto con el mensaje. En el siguiente render de ese mismo widget (por ejemplo, el usuario navegó hacia atrás en el historial y volvió), el SDK restaurará ese objeto y te lo pasará.

En rigor, podrías acceder directamente a window.openai.widgetState y window.openai.setWidgetState, pero en la lección seguimos el camino recomendado: hooks de React en la capa del SDK.

Hook useWidgetState

Uno de esos hooks envuelve precisamente widgetState. Este:

  • toma el valor inicial ya sea de window.openai.widgetState o del defaultState pasado;
  • se suscribe a actualizaciones del host;
  • y en cada setWidgetState tuyo sincroniza el nuevo valor hacia arriba mediante window.openai.setWidgetState.

Ejemplo típico de uso dentro del componente del widget (la sintaxis puede variar algo en la plantilla, pero la idea es esta):

import { useWidgetState } from "@openai/chatgpt-apps-sdk/react";

type GiftUiState = { likedIds: string[] };

const [uiState, setUiState] = useWidgetState<GiftUiState>(() => ({
  likedIds: [],
}));

Ahora uiState se restaurará incluso después de que el usuario:

  • pliegue/despliegue el chat;
  • cambie a otro diálogo y vuelva;
  • actualice la página (si la plataforma decide restaurar ese widget).

Ejemplo: recordar el regalo seleccionado

Tomemos la lista de regalos de toolOutput y recordemos el regalo seleccionado en widgetState para que no se pierda.

type Gift = { id: string; title: string; price: number };

const [uiState, setUiState] = useWidgetState<{ selectedId: string | null }>(() => ({
  selectedId: null,
}));

return (
  <ul>
    {gifts.map(gift => (
      <li
        key={gift.id}
        style={{
          fontWeight: uiState?.selectedId === gift.id ? "bold" : "normal",
        }}
        onClick={() => setUiState({ selectedId: gift.id })}
      >
        {gift.title}
      </li>
    ))}
  </ul>
);

Aquí hay un punto importante: setUiState no solo cambia el estado local de React, sino que también llama a window.openai.setWidgetState por debajo, si está disponible.

Si más tarde el usuario pulsa un follow‑up debajo de este widget, ChatGPT puede continuar el diálogo con el mismo widgetId y el mismo widgetState, y el modelo verá qué regalo se eligió.

6. Lectura de datos de la herramienta en React: useWidgetProps y análogos

Para evitar que cada componente acceda manualmente a window.openai.toolOutput, en el Apps SDK hay otra capa útil: el hook useWidgetProps. Este toma toolOutput del global, te da un objeto tipado y, si se desea, mezcla valores por defecto.

Su firma simplificada se ve así:

export function useWidgetProps<T>(defaultState?: T | () => T): T {
  const toolOutput = useOpenAIGlobal("toolOutput") as T;
  return toolOutput ?? defaultState ?? null;
}

Es decir, te retorna simplemente toolOutput como tipo T.

Supongamos que nuestra herramienta MCP devuelve este structuredContent:

type GiftToolOutput = {
  gifts: { id: string; title: string; price: number }[];
  currency: string;
};

El widget puede leerlo así:

import { useWidgetProps } from "@openai/chatgpt-apps-sdk/react";

export function GiftListWidget() {
  const { gifts, currency } = useWidgetProps<GiftToolOutput>(() => ({
    gifts: [],
    currency: "USD",
  }));

  if (!gifts.length) {
    return <div>De momento no hay ideas adecuadas. Prueba con otra consulta.</div>;
  }

  return (
    <ul>
      {gifts.map(gift => (
        <li key={gift.id}>
          {gift.title} — {gift.price} {currency}
        </li>
      ))}
    </ul>
  );
}

Aquí hay varias buenas prácticas:

  • no damos por hecho que toolOutput ya existe — establecemos un valor por defecto;
  • tratamos con cuidado la lista vacía;
  • nada de acceso directo a window.openai — todo a través del hook.

7. Sincronizar el UI con toolOutput: carga, datos vacíos, errores

En el mundo real, toolOutput no siempre llega al instante y no siempre es «bonito». La documentación del Apps SDK recomienda pensar en tres estados: carga, datos normales y error/vacío.

Patrón básico:

type GiftToolOutput = {
  gifts: { id: string; title: string }[];
  error?: string;
};

const data = useWidgetProps<GiftToolOutput | null>(() => null);

if (data === null) {
  return <div>Cargando ideas de regalos…</div>;
}

if (data.error) {
  return <div>Error: {data.error}</div>;
}

if (!data.gifts.length) {
  return <div>No se ha encontrado nada para tus criterios.</div>;
}

return (
  <ul>
    {data.gifts.map(gift => (
      <li key={gift.id}>{gift.title}</li>
    ))}
  </ul>
);

Este enfoque encaja bien con que el servidor y el modelo puedan re‑invocar la herramienta y recibas un nuevo toolOutput. El widget simplemente recibirá el nuevo valor a través de useWidgetProps y se volverá a renderizar.

En el flujo general se ve así:

Usuario → solicitud
      ↓
Modelo → invoca la herramienta MCP
      ↓
Servidor → calcula, consulta BD/integraciones, devuelve structuredContent y _meta
      ↓
ChatGPT → coloca structuredContent en toolOutput
      ↓
Widget → renderiza el UI desde toolOutput + widgetState

La guía oficial del servidor dibuja un diagrama casi idéntico «User → Model → MCP tool → widget iframe», donde toolOutput es la entrada principal del widget.

8. Escenario de varios pasos: el paso actual en widgetState

Nuestro GiftGenius difícilmente se limitará a una sola tarjeta. A menudo queremos un «asistente» por pasos: primero recopilar preferencias, luego decidir el presupuesto y, al final, sugerir opciones concretas.

Una forma lógica de almacenar el número de paso del asistente es en widgetState. Exactamente así lo recomiendan en la documentación y los ejemplos.

Ejemplo de mini‑asistente de dos pasos:

type GiftWizardState = {
  step: 1 | 2;
  budget?: number;
};

const [state, setState] = useWidgetState<GiftWizardState>(() => ({ step: 1 }));

if (state.step === 1) {
  return (
    <div>
      <label>
        Presupuesto, $
        <input
          type="number"
          defaultValue={state.budget ?? 50}
          onBlur={e =>
            setState({ step: 2, budget: Number(e.target.value) || 50 })
          }
        />
      </label>
    </div>
  );
}

return (
  <div>
    <div>Busco regalos hasta {state.budget} $…</div>
    {/* aquí ya podríamos renderizar el toolOutput con los regalos */}
  </div>
);

Aspectos interesantes aquí:

  • en la primera visualización, step es 1, el usuario introduce el presupuesto;
  • después de onBlur actualizamos widgetState a { step: 2, budget:};
  • en el siguiente render (incluido al reabrir este mensaje) el widget estará directamente en el paso 2 con el presupuesto guardado.

En una versión más avanzada, en el segundo paso ya lanzarías la herramienta mediante useCallTool, le pasarías el budget y leerías el resultado desde toolOutput. Pero eso ya remite al módulo sobre herramientas (Módulo 4); hoy lo principal es dónde guardamos la información del paso.

9. Dónde poner cada cosa: patrón «UI delgado, backend grueso»

Resumamos el reparto de responsabilidades:

  • los datos autoritativos (lista de regalos, estados de pedidos) viven en el servidor y llegan en toolOutput;
  • lo visual temporal (si un acordeón está desplegado, el contenido de una entrada aún no enviada) vive en el estado local de React;
  • decisiones de UI duraderas dentro de un widget (paso actual, elemento seleccionado, ordenación) viven en widgetState;
  • ajustes a largo plazo del usuario entre chats (categoría favorita de regalos, última divisa) viven en tu backend como estado persistente.

A veces apetece crear «un gran objeto con todo», meterlo en widgetState y vivir tranquilo. Pero es mala idea. La documentación subraya que el estado que pasas mediante widgetState entra por completo en el contexto del modelo y debería ser ligero y principalmente sobre el UI.

Lo mismo con toolOutput: conviene poner allí exactamente los datos que necesitan el widget y el modelo para explicar al usuario lo que ha pasado. Árboles enormes, blobs binarios, respuestas crudas de otras APIs — todo eso conduce directamente a respuestas del modelo extrañas y caras.

Insight

Dentro de un widget de ChatGPT es imposible apoyarse en mecanismos clásicos de identificación del cliente. Las cookies son prácticamente inaccesibles: el widget se carga como un recurso de terceros en la sandbox de ChatGPT, y los navegadores modernos bloquean las third‑party cookies por defecto. Por ello, cualquier intento de guardar estado mediante cookies no funciona.

Comprobado experimentalmente: localStorage funciona perfectamente; puedes contar con él al diseñar tus aplicaciones.

10. Pequeño ejemplo de extremo a extremo: GiftGenius con selección duradera

Juntemos todo en un mini‑widget que:

  • lee datos desde toolOutput;
  • guarda la selección del usuario en widgetState;
  • maneja con cuidado los datos vacíos.
import {
  useWidgetProps,
  useWidgetState,
} from "@openai/chatgpt-apps-sdk/react";

type Gift = { id: string; title: string; price: number };
type GiftToolOutput = { gifts: Gift[]; currency: string; error?: string };

export function GiftWidget() {
  const data = useWidgetProps<GiftToolOutput | null>(() => null);
  const [uiState, setUiState] = useWidgetState<{ selectedId: string | null }>(
    () => ({ selectedId: null })
  );

  if (data === null) {
    return <div>Un segundo, buscando ideas…</div>;
  }
  if (data.error) {
    return <div>Error: {data.error}</div>;
  }
  if (!data.gifts.length) {
    return <div>Por desgracia, no encontramos nada. Prueba con otra consulta.</div>;
  }

  return (
    <ul>
      {data.gifts.map(gift => (
        <li
          key={gift.id}
          style={{
            fontWeight: uiState?.selectedId === gift.id ? "bold" : "normal",
            cursor: "pointer",
          }}
          onClick={() => setUiState({ selectedId: gift.id })}
        >
          {gift.title} — {gift.price} {data.currency}
        </li>
      ))}
    </ul>
  );
}

Este código ya está bastante cerca de un widget real:

  • si la herramienta aún se está ejecutando, vemos «buscando ideas»;
  • si el servidor devuelve un error, lo mostramos honestamente;
  • si no hay regalos, tratamos correctamente el resultado vacío;
  • el regalo seleccionado se recuerda en widgetState y el modelo puede usarlo en pasos posteriores del diálogo.

Después podrás añadir botones de «Continuar con este regalo» (follow‑up), lanzar nuevas herramientas, etc., basándote en que la selección ya está en el estado.

En definitiva, una buena arquitectura de estado en una ChatGPT App se reduce a una idea sencilla: los datos de negocio viven en el servidor, la instantánea actual llega por toolOutput, lo temporal del UI va en el useState local, y el contexto del widget que debe ser duradero pero atado a un único mensaje va en widgetState. Si mantienes este esquema en mente y no intentas meter «todo a la vez» en una sola capa, el widget se mantiene predecible tanto para el usuario como para el modelo.

11. Errores típicos al trabajar con Widget State, ToolInput y ToolOutput

Error n.º 1: guardar datos de negocio en widgetState en vez de en el servidor.
A veces apetece guardar una lista completa de entidades en widgetState para no volver a llamar al servidor. Es malo por dos razones: duplicas los datos autoritativos (servidor y widget pueden desincronizarse) y engordas el contexto del modelo, porque widgetState entra en él íntegro. Es mejor guardar los datos reales en el servidor y devolver un toolOutput fresco como instantánea.

Error n.º 2: meter en widgetState secretos o PII.
Puesto que el contenido de widgetState lo ve el modelo y no está pensado como almacenamiento seguro, no puedes meter ahí tokens, logins, e‑mails, teléfonos u otra información confidencial. Esas cosas deben vivir en el servidor y, como mucho, en widgetState guardas un ID con el que luego trabajas vía MCP.

Error n.º 3: suponer que toolOutput siempre existe y siempre es correcto.
Un widget que accede sin comprobar a toolOutput.gifts[0] tarde o temprano fallará: la herramienta puede devolver un error, un array vacío o cambiar la estructura. Se recomienda tratar explícitamente los estados «carga», «vacío», «error» y solo después renderizar normalmente.

Error n.º 4: copiar toolOutput al estado local sin necesidad.
Es tentador hacer const [data, setData] = useState(toolOutput) y luego vivir solo con ese data. El resultado es una fuente de verdad duplicada: cuando llegue un nuevo toolOutput, el estado local no se enterará y el UI seguirá mostrando datos antiguos. Mejor lee toolOutput directamente desde useWidgetProps o deriva estado (mapping, filtrado) en el render, sin duplicar todo el objeto.

Error n.º 5: usar solo el useState local donde hace falta widgetState.
Bug clásico: haces un pequeño asistente, guardas currentStep en el estado local, lo pruebas — funciona. Luego el usuario se desplaza por el chat, vuelve — y de repente aparece de nuevo el primer paso. La razón es simple: el estado local no sobrevive al desmontaje del widget. Para pasos importantes en el escenario, usa widgetState; la plataforma lo restaurará junto con el mensaje.

Error n.º 6: intentar acceder a window.openai directamente en cada componente.
Formalmente funciona, pero te atas al global, obtienes un código difícil de depurar y suscripciones a eventos escritas a mano. Los materiales y ejemplos oficiales aconsejan usar la capa de hooks (useWidgetProps, useWidgetState, useOpenAiGlobal), que encapsulan los detalles y son más fáciles de testear.

Error n.º 7: no tener en cuenta la naturaleza message‑scoped de los widgets.
Si el usuario no pulsa follow‑up y simplemente escribe un nuevo mensaje en el chat, ChatGPT crea un nuevo ejemplar del widget con un nuevo widgetId y un widgetState vacío. Los escenarios que dependen de una «memoria eterna» de un widget empiezan a comportarse de forma extraña. Aquí necesitas o bien guardar el contexto entre sesiones en el servidor, o bien diseñar el UX alrededor de los follow‑ups y la continuación explícita del escenario.

Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION