1. Warum überhaupt über den Widget‑Zustand nachdenken
In einer gewöhnlichen React‑Anwendung sind Sie es gewohnt: Es gibt lokalen State, es gibt API‑Requests, höchstens noch irgendein Zustand‑Store wie Zustand/Redux. Alles dreht sich um den Browser der Nutzerin/des Nutzers.
In einer ChatGPT App ist die Situation anders. Ihr Widget ist lediglich eine dünne UI‑Schicht über drei anderen Entitäten:
- dem ChatGPT‑Modell, das entscheidet, wann Ihre App überhaupt aufgerufen wird und welche Argumente es übergibt;
- einem MCP‑Server/Backend, das die echten Daten speichert und die Geschäftslogik ausführt;
- dem Chat‑Kontext, in dem all das lebt und der in einer Stunde, einem Tag oder einer Woche erneut geöffnet werden kann.
Daher ist die Frage „wo liegt der Zustand“ nicht akademisch, sondern sehr praktisch. Wenn Sie alles nur im React‑State ablegen, verliert die Nutzerin/der Nutzer bei der kleinsten Chat‑Änderung ihre/seine Auswahl. Wenn Sie alles in widgetState stopfen, liest das Modell Tonnen von JSON und halluziniert fröhlich darüber. Wenn Sie hingegen versuchen, alles auf dem Server zu speichern und jeden Pixel neu anzufordern, wird es langsam und teuer.
Die offiziellen Empfehlungen teilen den Zustand einer ChatGPT App klar in drei Klassen auf: Geschäftsdaten, flüchtiger UI‑State und langlebiger, sitzungsübergreifender Zustand. Damit fangen wir an.
2. Zustandslandkarte in der ChatGPT App
Die Dokumentation zum Apps SDK beschreibt drei State‑Typen. Praktisch ist es, sie als eine Tabelle im Kopf zu behalten:
| Zustandstyp | Wo liegt er | Lebenszyklus | Beispiele |
|---|---|---|---|
| Business data (authoritative) | MCP‑Server / Ihr Backend | Lange: Tage, Wochen, Jahre | Aufgaben, Bestellungen, Produkte |
| UI state (ephemeral) | Innerhalb des konkreten Widgets | Solange die Widget‑Instanz lebt | ausgewählte Karte, Sortierung, aufgeklappter Spoiler |
| Cross‑session state (durable) | Ihr Backend / Speicher | Über Sitzungen und Chats hinweg | gespeicherte Filter, Workspace, angeheftetes Board |
Wichtig: Maßgebliche Daten müssen auf dem Server bleiben, nicht im Widget. Das Widget erhält einen Snapshot dieser Daten über Tools (MCP‑Tools) und rendert ihn, wobei es seinen lokalen UI‑State darüberlegt.
In dieser Vorlesung fokussieren wir auf das, was das Widget konkret sieht:
- toolInput — Eingabeargumente des aufgerufenen Tools;
- toolOutput — structuredContent vom Server (Kerndaten);
- toolResponseMetadata — technische Metadaten _meta, die nur das Widget sieht;
- widgetState — gespeicherter UI‑Zustand, den ChatGPT zusammen mit der Nachricht aufbewahrt.
3. Was genau im Widget ankommt: ToolInput, ToolOutput, Metadata, WidgetState
Diese drei State‑Typen in der ChatGPT App spiegeln sich in konkreten Feldern wider, die die Plattform in window.openai legt und in die SDK‑Hooks einspeist. In der Praxis holen Sie sie über React‑Hooks ab, aber es ist hilfreich, die genauen Definitionen zu kennen.
toolInput
Das ist ein Objekt mit Tool‑Argumenten, die das Modell beim Aufruf übergeben hat.
Beispiel: Die Nutzerin/der Nutzer schreibt:
„Finde Geschenkideen für eine 30‑jährige Frau, Budget 100 US‑Dollar.“
Das Modell entscheidet, Ihr Tool gift_search mit folgenden Argumenten aufzurufen:
{
"recipient": "female",
"age": 30,
"budget": 100,
"occasion": "birthday"
}
Genau dieses Objekt sehen Sie im toolInput innerhalb des Widgets. Dort liegen die ursprünglichen Einstellungen des Szenarios – also der Grund, weshalb Ihre App überhaupt gestartet wurde.
toolOutput
Das ist das structuredContent, das Ihr MCP‑Server/Backend bei der Tool‑Ausführung zurückgegeben hat.
Üblicherweise ist das JSON etwa so:
{
"gifts": [
{ "id": "1", "title": "Reiseführer für Island", "price": 45 },
{ "id": "2", "title": "E‑Book über Reisen", "price": 20 }
],
"total": 2
}
Genau toolOutput ist die Hauptdatenquelle für das Rendering. Offiziell wird betont: Das Modell liest dieses Feld wörtlich; halten Sie es daher kompakt und verständlich.
toolResponseMetadata
Das ist das _meta aus der Tool‑Antwort, ebenfalls über window.openai als toolResponseMetadata verfügbar. Die Dokumentation hebt hervor, dass den Inhalt von _meta nur das Widget sieht; das Modell erhält ihn nicht.
Typische Beispiele:
- interne IDs aus Ihrem System;
- Flags für das UI (z. B. „gab es einen Cache“);
- Service‑Nachrichten für das Debugging.
Kurz gesagt: toolOutput ist „was man Nutzerin/Nutzer und Modell sagt“, und _meta ist „was nur das Widget und die Logs brauchen“.
widgetState
Das ist ein JSON‑Objekt, in dem ChatGPT den Snapshot des UI‑Zustands eines konkreten Widgets zwischen Rendern speichert.
Seine Eigenschaften:
- lebt auf der ChatGPT‑Seite und ist an eine konkrete message/widgetId gebunden;
- wird beim erneuten Öffnen derselben Nachricht wiederhergestellt;
- ist sowohl für das Widget als auch für das Modell sichtbar (Daten aus widgetState fließen in den LLM‑Kontext ein);
- ist in der Größe auf ungefähr 4k Token begrenzt – dort darf man also nicht alles Mögliche abladen oder riesige Listen speichern.
Wichtig: widgetState ist kein Ort für Geheimnisse. Weder Tokens noch PII‑Daten dürfen dort landen, weil das Modell sie sieht und die Plattform das nicht als sicheres Speicher‑Backend versteht.
4. Lokaler React‑State: wofür er weiterhin nötig ist
Trotz all der Magie rund um toolOutput und widgetState schreiben Sie im Widget weiterhin normales React mit useState, useReducer, useRef usw. Der Unterschied ist nur:
- der lokale State lebt so lange, wie der konkrete Render/iframe lebt;
- das Modell sieht ihn überhaupt nicht;
- beim Unmounten des Widgets (Nutzerin/Nutzer wechselt den Chat, Neurendering, Aktualisierung) verschwindet der lokale State.
Lokaler State eignet sich hervorragend für:
- sofortige Dinge — Hover, ausgewählter Tab, geöffnetes Dropdown;
- Formulareingaben bis zum Klick auf „Weiter“/„Speichern“;
- temporäre Flags wie isSubmitting oder isTooltipOpen.
Mini‑Beispiel innerhalb unserer Lern‑App GiftGenius — einem Assistenten zur Geschenkfindung:
const [selectedGiftId, setSelectedGiftId] = useState<string | null>(null);
return (
<div>
{gifts.map(gift => (
<button
key={gift.id}
onClick={() => setSelectedGiftId(gift.id)}
>
{gift.title}
</button>
))}
</div>
);
Solange wir nicht auf „Auswahl bestätigen“ klicken, ist das ein idealer Kandidat für lokalen State. Sobald wir wollen, dass die Auswahl „zwischen Widget‑Updates mitzieht“, sollten wir über widgetState nachdenken.
5. widgetState: Gedächtnis des Widgets zwischen Rendern
widgetState ist das „Gedächtnis“ des Widgets, das die Plattform selbst speichert. Bei jeder wichtigen UI‑Aktion können Sie setWidgetState aufrufen, und ChatGPT speichert dieses JSON zusammen mit der Nachricht. Beim nächsten Render desselben Widgets (z. B. Nutzerin/Nutzer scrollt in der Chat‑Historie zurück und kommt wieder) stellt das SDK dieses Objekt wieder her und gibt es an Sie weiter.
Streng genommen könnten Sie auch direkt window.openai.widgetState und window.openai.setWidgetState aufrufen, in der Vorlesung halten wir uns aber an den empfohlenen Weg — React‑Hooks in der SDK‑Schicht.
Hook useWidgetState
Einer dieser Hooks kapselt widgetState. Er:
- nimmt den Anfangswert entweder aus window.openai.widgetState oder aus dem übergebenen defaultState;
- abonniert Updates vom Host;
- synchronisiert bei jedem setWidgetState den neuen Wert nach oben über window.openai.setWidgetState.
Beispiel für die typische Verwendung in einer Widget‑Komponente (Syntax kann im Template leicht abweichen, die Idee ist die gleiche):
import { useWidgetState } from "@openai/chatgpt-apps-sdk/react";
type GiftUiState = { likedIds: string[] };
const [uiState, setUiState] = useWidgetState<GiftUiState>(() => ({
likedIds: [],
}));
Jetzt wird uiState sogar dann wiederhergestellt, wenn die Nutzerin/der Nutzer später:
- den Chat minimiert/expandiert;
- in einen anderen Dialog wechselt und zurückkommt;
- die Seite aktualisiert (sofern die Plattform dieses Widget wiederherstellt).
Beispiel: gewähltes Geschenk merken
Nehmen wir die Geschenk‑Liste aus toolOutput und merken das gewählte Geschenk im widgetState, damit es nicht verloren geht.
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>
);
Wichtiger Punkt: setUiState ändert nicht nur den lokalen React‑State, sondern ruft unter der Haube auch window.openai.setWidgetState auf, sofern verfügbar.
Wenn die Nutzerin/der Nutzer später ein Follow‑up unter diesem Widget klickt, kann ChatGPT den Dialog mit derselben widgetId und demselben widgetState fortsetzen, und das Modell sieht, welches Geschenk ausgewählt wurde.
6. Tool‑Daten in React lesen: useWidgetProps und Co.
Damit nicht jede Komponente direkt in window.openai.toolOutput greift, gibt es im Apps SDK eine weitere nützliche Schicht — den Hook useWidgetProps. Er nimmt toolOutput aus dem Global, gibt Ihnen ein typisiertes Objekt und mischt bei Bedarf Default‑Werte ein.
Die vereinfachte Signatur sieht so aus:
export function useWidgetProps<T>(defaultState?: T | () => T): T {
const toolOutput = useOpenAIGlobal("toolOutput") as T;
return toolOutput ?? defaultState ?? null;
}
Das heißt, intern bekommen Sie einfach toolOutput als Typ T zurück.
Nehmen wir an, unser MCP‑Tool liefert folgendes structuredContent:
type GiftToolOutput = {
gifts: { id: string; title: string; price: number }[];
currency: string;
};
Das Widget kann das so lesen:
import { useWidgetProps } from "@openai/chatgpt-apps-sdk/react";
export function GiftListWidget() {
const { gifts, currency } = useWidgetProps<GiftToolOutput>(() => ({
gifts: [],
currency: "USD",
}));
if (!gifts.length) {
return <div>Noch keine passenden Ideen. Versuchen Sie eine andere Anfrage.</div>;
}
return (
<ul>
{gifts.map(gift => (
<li key={gift.id}>
{gift.title} — {gift.price} {currency}
</li>
))}
<ul>
);
}
Hier stecken gleich mehrere Best Practices:
- wir nehmen nicht an, dass toolOutput bereits sicher vorhanden ist — wir setzen einen Default‑Wert;
- wir behandeln die leere Liste sauber;
- kein direkter Zugriff auf window.openai — alles über den Hook.
7. UI mit toolOutput synchronisieren: Laden, leere Daten, Fehler
In der Realität kommt toolOutput nicht immer sofort und nicht immer „schön“. Die Apps‑SDK‑Doku empfiehlt explizit, über drei Zustände nachzudenken: Laden, normale Daten, Fehler/leer.
Ein einfaches Muster:
type GiftToolOutput = {
gifts: { id: string; title: string }[];
error?: string;
};
const data = useWidgetProps<GiftToolOutput | null>(() => null);
if (data === null) {
return <div>Wir laden Geschenkideen…</div>;
}
if (data.error) {
return <div>Fehler: {data.error}</div>;
}
if (!data.gifts.length) {
return <div>Es wurde nichts gefunden, das Ihren Kriterien entspricht.</div>;
}
return (
<ul>
{data.gifts.map(gift => (
<li key={gift.id}>{gift.title}</li>
))}
</ul>
);
Dieser Ansatz passt gut dazu, dass Server und Modell das Tool erneut aufrufen können und Sie ein neues toolOutput erhalten. Das Widget bekommt dann einfach den neuen Wert über useWidgetProps und rendert neu.
Im Gesamtfluss sieht das so aus:
Nutzerin/Nutzer → Anfrage
↓
Modell → ruft MCP-Tool auf
↓
Server → rechnet, greift auf DB/Integrationen zu, gibt structuredContent und _meta zurück
↓
ChatGPT → legt structuredContent in toolOutput
↓
Widget → rendert UI aus toolOutput + widgetState
Der offizielle Server‑Guide zeichnet fast dasselbe Diagramm „User → Model → MCP tool → widget iframe“, wobei toolOutput der Haupteingang für das Widget ist.
8. Mehrschritt‑Szenario: aktueller Schritt im widgetState
Unser GiftGenius wird sich kaum auf eine einzige Karte beschränken. Meist möchte man einen „Wizard“ aus Schritten: erst Präferenzen einsammeln, dann das Budget festlegen und am Ende konkrete Optionen vorschlagen.
Eine naheliegende Art, die Step‑Nummer zu speichern, ist der widgetState. Genau so wird es in Doku und Beispielen empfohlen.
Beispiel eines Mini‑Wizards mit zwei Schritten:
type GiftWizardState = {
step: 1 | 2;
budget?: number;
};
const [state, setState] = useWidgetState<GiftWizardState>(() => ({ step: 1 }));
if (state.step === 1) {
return (
<div>
<label>
Budget, $
<input
type="number"
defaultValue={state.budget ?? 50}
onBlur={e =>
setState({ step: 2, budget: Number(e.target.value) || 50 })
}
/>
</label>
</div>
);
}
return (
<div>
<div>Suche Geschenke bis {state.budget} $…</div>
{/* hier könnten wir bereits toolOutput mit Geschenken rendern */}
</div>
);
Interessante Punkte hier:
- bei der ersten Anzeige ist step gleich 1, die Nutzerin/der Nutzer gibt ein Budget ein;
- nach onBlur aktualisieren wir den widgetState auf { step: 2, budget: … };
- beim nächsten Render (auch eine Minute später oder beim erneuten Öffnen derselben Nachricht) landet das Widget direkt in Schritt 2 mit gespeichertem Budget.
In der erweiterten Version starten Sie im zweiten Schritt bereits das Tool über useCallTool, übergeben budget und lesen das Ergebnis aus toolOutput. Das gehört jedoch in das Modul über Tools (Modul 4) — heute geht es vor allem darum, wo wir die Information über den Schritt halten.
9. Wohin was gehört: Muster „dünnes UI, dickes Backend“
Fassen wir die Rollenverteilung zusammen:
- maßgebliche Daten (Geschenk‑Liste, Bestellstatus) leben auf dem Server und kommen als toolOutput;
- temporäre visuelle Dinge (ob ein Spoiler geöffnet ist, aktueller Inhalt einer unvollendeten Eingabe) leben im lokalen React‑State;
- persistente UI‑Entscheidungen innerhalb eines Widgets (aktueller Schritt, ausgewähltes Element, Sortierung) leben im widgetState;
- langlebige Nutzungseinstellungen zwischen Chats (Lieblingskategorie der Geschenke, letzte Währung) leben in Ihrem Backend als persistent state.
Man möchte manchmal einen „großen Objekt‑Klumpen“ bauen, ihn in den widgetState legen und sich entspannt zurücklehnen. Das ist jedoch eine schlechte Idee. Die Doku betont, dass der Zustand, den Sie über widgetState übergeben, vollständig in den Modell‑Kontext eingeht und leichtgewichtig sowie überwiegend UI‑bezogen sein sollte.
Gleiches gilt für toolOutput: Dort sollten genau die Daten liegen, die Widget und Modell benötigen, um der Nutzerin/dem Nutzer zu erklären, was passiert ist. Große Bäume, binäre Blobs, rohe Antworten anderer APIs — das führt direkt zu seltsamen und teuren Modell‑Antworten.
Insight
Innerhalb eines ChatGPT‑Widgets kann man sich nicht auf klassische Mechanismen der Client‑Identifikation stützen. Cookies sind faktisch nicht verfügbar: Das Widget wird als Third‑Party‑Ressource in der ChatGPT‑Sandbox geladen, und moderne Browser blockieren Third‑Party‑Cookies standardmäßig. Daher funktionieren Versuche, Zustand via Cookie zu speichern, nicht.
Experimentell verifiziert: localStorage funktioniert einwandfrei; darauf können Sie bei der Gestaltung Ihrer Anwendungen setzen.
10. Kleines End‑to‑End‑Beispiel: GiftGenius mit persistenter Auswahl
Setzen wir alles zu einem Mini‑Widget zusammen, das:
- Daten aus toolOutput liest;
- die Auswahl der Nutzerin/des Nutzers im widgetState speichert;
- leere Daten sauber behandelt.
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>Einen Moment, wir suchen Ideen…</div>;
}
if (data.error) {
return <div>Fehler: {data.error}</div>;
}
if (!data.gifts.length) {
return <div>Leider nichts gefunden. Versuchen Sie eine andere Anfrage.</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>
);
}
Dieser Code ist bereits recht nah an einem echten Widget:
- wenn das Tool noch läuft, sehen wir „wir suchen Ideen“;
- wenn der Server einen Fehler zurückgibt, zeigen wir ihn ehrlich an;
- wenn es keine Geschenke gibt, behandeln wir das leere Ergebnis korrekt;
- das ausgewählte Geschenk wird im widgetState gemerkt, und das Modell kann es in den nächsten Dialogschritten verwenden.
Als Nächstes können Sie „Mit diesem Geschenk fortfahren“‑Buttons (Follow‑up), neue Tool‑Aufrufe usw. hinzufügen — mit der Gewissheit, dass die Auswahl bereits im Zustand liegt.
Am Ende läuft eine gute Zustandsarchitektur in der ChatGPT App auf eine einfache Idee hinaus: Geschäftsdaten leben auf dem Server, der aktuelle Snapshot kommt über toolOutput, temporäres UI in das lokale useState, und der robuste, aber an eine Nachricht gebundene Widget‑Kontext in den widgetState. Hält man dieses Schema im Kopf und versucht nicht, „alles auf einmal“ in eine Schicht zu stopfen, bleibt das Widget sowohl für Nutzerin/Nutzer als auch für das Modell vorhersehbar.
11. Typische Fehler beim Umgang mit Widget State, ToolInput und ToolOutput
Fehler Nr. 1: Geschäftsdaten im widgetState statt auf dem Server speichern.
Manchmal möchte man eine ganze Liste von Entitäten im widgetState aufbewahren, um den Server nicht erneut zu rufen. Das ist aus zwei Gründen schlecht: Sie duplizieren maßgebliche Daten (Server und Widget können auseinanderlaufen) und blasen den Modell‑Kontext auf, weil widgetState vollständig darin landet. Besser: Echte Daten auf dem Server halten und frisches toolOutput als Snapshot zurückgeben.
Fehler Nr. 2: Geheimnisse oder PII in widgetState stecken.
Da der Inhalt von widgetState vom Modell gesehen wird und es kein geschützter Speicher ist, dürfen dort keine Tokens, Logins, E‑Mails, Telefonnummern und andere vertrauliche Informationen liegen. Solche Dinge gehören auf den Server; im widgetState steht höchstens die ID eines Datensatzes, mit dem Sie dann über MCP weiterarbeiten.
Fehler Nr. 3: Davon ausgehen, dass toolOutput immer vorhanden und korrekt ist.
Ein Widget, das ohne Prüfung in toolOutput.gifts[0] greift, bricht früher oder später: Das Tool kann einen Fehler liefern, ein leeres Array oder seine Struktur ändern. Es wird empfohlen, die Zustände „Laden“, „Leer“, „Fehler“ explizit zu behandeln und erst dann normal zu rendern.
Fehler Nr. 4: toolOutput ohne Not in lokalen State kopieren.
Es ist verlockend, so etwas zu machen wie const [data, setData] = useState(toolOutput) und dann nur noch mit diesem data zu leben. Ergebnis: ein doppelter Single Source of Truth. Wenn ein neues toolOutput kommt, erfährt der lokale State nichts davon und das UI zeigt alte Daten weiter an. Besser: toolOutput direkt aus useWidgetProps lesen oder abgeleiteten Zustand (Mapping, Filter) im Render erzeugen, ohne das ganze Objekt zu duplizieren.
Fehler Nr. 5: Nur lokalen useState nutzen, wo widgetState nötig ist.
Klassischer Bug: Sie bauen einen kleinen Wizard, speichern currentStep im lokalen State, testen — funktioniert. Dann scrollt die Nutzerin/der Nutzer im Chat, kommt zurück — und plötzlich ist wieder der erste Schritt. Der Grund ist einfach: Lokaler State überlebt das Unmounten des Widgets nicht. Für schritt‑kritische Szenarien lohnt sich widgetState; dann stellt die Plattform ihn zusammen mit der Nachricht wieder her.
Fehler Nr. 6: In jedem Component direkt auf window.openai zugreifen.
Formal funktioniert das, aber Sie koppeln hart an den Global, erzeugen schwer debugbaren Code und handgeschriebene Event‑Subscriptions. Offizielle Materialien und Beispiele raten dazu, die Hook‑Schicht zu verwenden (useWidgetProps, useWidgetState, useOpenAiGlobal), die alle Details kapselt und leichter zu testen ist.
Fehler Nr. 7: Die message‑scoped Natur von Widgets nicht berücksichtigen.
Wenn die Nutzerin/der Nutzer kein Follow‑up klickt, sondern einfach eine neue Nachricht in den Chat schreibt, erstellt ChatGPT eine neue Widget‑Instanz mit neuer widgetId und leerem widgetState. Szenarien, die auf die „ewige“ Erinnerung eines Widgets bauen, verhalten sich dann merkwürdig. Hier muss man entweder sitzungsübergreifenden Kontext auf dem Server halten oder das UX um Follow‑ups und eine explizite Szenario‑Fortsetzung herum bauen.
GO TO FULL VERSION