1. Von ToolOutput bis zur React‑Komponente: allgemeiner Datenfluss
In der letzten Vorlesung haben wir besprochen, wie ein serverseitiges Tool den ToolOutput bildet — eine strukturierte Antwort für Modell und Widget. Jetzt schauen wir uns die zweite Hälfte an: wie dieser ToolOutput im Widget landet und zur UI wird.
Damit das Ganze nicht wie Magie wirkt, sprechen wir den Datenweg vom Benutzer bis zu Ihrem Widget noch einmal durch. Vereinfacht sieht es so aus:
- Der Benutzer stellt im Chat eine Frage.
- GPT analysiert die Anfrage, schaut auf die Liste der Tools und entscheidet: „Jetzt hilft mir suggest_gifts.“
- GPT erzeugt einen Tool‑Aufruf mit Name und Argumenten (ToolInput) und sendet ihn an Ihren Server (MCP oder Backend).
- Der Server führt die Tool‑Logik aus und liefert das Ergebnis als ToolOutput — ein strukturierter JSON mit Daten plus eine textuelle Zusammenfassung für das Modell.
- ChatGPT erhält den ToolOutput und gibt ihn weiter: an das Modell (zur Fortsetzung des Dialogs) und an Ihr Widget via Apps SDK (window.openai.toolOutput oder Hooks).
- Ihr Widget — eine ganz normale React‑Komponente — liest toolOutput und rendert die UI.
Schematisch lässt sich das so darstellen:
flowchart TD U[Benutzer] -->|Chat‑Anfrage| GPT[GPT] GPT -->|callTool: suggest_gifts| B[Backend/MCP] B -->|"ToolOutput (JSON)"| GPT GPT -->|übergibt toolOutput| W["Widget (React)"] W -->|Karten, Listen| U
Wichtig ist die folgende Erkenntnis: ToolOutput ist nicht nur eine „Serverantwort“. Es ist zugleich Ihre Render‑Anweisung für das Widget und gleichzeitig Kontext für das Modell. Eine gute App ist eine, in der dieses JSON in eine nutzerfreundliche Oberfläche verwandelt wird, statt dass es lediglich in den DevTools überflogen wird.
2. Anatomie von ToolOutput: Was steckt drin
Das Ergebnisformat eines Tools im Apps SDK gliedert sich in drei logische Blöcke: structuredContent, content und _meta (das im Widget unter dem Namen toolResponseMetadata ankommt).
Vereinfacht sieht es so aus:
{
"structuredContent": { /* Daten für UI + Modell */ },
"content": "Kurze textuelle Zusammenfassung für Modell und Nutzer",
"_meta": { /* Servicedaten nur für das Widget */ }
}
In der Tabelle sieht man, wer was sieht:
| Feld | Wer sieht es | Wofür wird es verwendet |
|---|---|---|
|
Modell + Widget | Zentrale strukturierte Daten (Listen, Objekte, Parameter) |
|
Modell + Benutzer (im Text) | Kurze Zusammenfassung, die GPT in seine Antwort einfügen kann |
|
Nur Widget | Servicedaten, die das Modell nicht braucht (IDs, Versionen, Schlüssel usw.) |
Die Apps‑SDK‑Dokumentation betont, dass das Paar structuredContent / content im Modell landet und in dessen weiteren Antworten genutzt werden kann. Das Feld _meta bleibt dagegen verborgen und ist nur innerhalb des Widgets über toolResponseMetadata verfügbar.
Beispiel eines ToolOutput für GiftGenius
Nehmen wir an, unser Tool suggest_gifts liefert serverseitig in etwa diesen Body:
{
"structuredContent": {
"items": [
{
"id": "boardgame-cozy-strategy",
"title": "Cozy Strategy Board Game",
"price": 39.99,
"currency": "USD",
"score": 0.92,
"tags": ["board_game","strategy","2-4_players"]
}
]
},
"content": "Ich habe ein paar Geschenkideen gefunden. Unten zeigt das Widget sie als Karten.",
"_meta": {
"giftGenius": {
"catalogVersion": "2025-10-01",
"experimentBucket": "A"
}
}
}
Hier sind structuredContent.items das, was Ihr React‑Widget rendert; content kann das Modell verwenden, um dem Benutzer zu erklären, was gerade passiert; _meta.giftGenius ist interne Information, die nur für Ihre UI oder Analytics relevant ist (z. B. welche Katalogversion für Links zu verwenden ist).
Genau structuredContent ist das Objekt, das Sie in JSX betrachten werden, anstatt beliebiges JSON vom Server manuell zu parsen.
3. ToolOutput im Widget empfangen: window.openai und Hooks
Jetzt wechseln wir von JSON‑Gesprächen zum Code. Wie kommt dieser ToolOutput überhaupt in Ihre React‑Komponente?
Das Apps‑SDK‑Template bietet zwei Hauptwege: entweder direkt über window.openai.toolOutput oder — angenehm — über fertige React‑Hooks (useWidgetProps, useToolOutput u. ä.). Der empfohlene Ansatz ist, Hooks zu verwenden, um window.openai nicht direkt anzufassen und testbareren sowie sichereren Code zu haben.
Einfachste Variante: direkt aus window.openai
Zum Verständnis kann man sich die „nackte“ Variante ansehen:
'use client';
function RawToolOutputDebug() {
const toolOutput = (window as any).openai?.toolOutput;
return (
<pre>{JSON.stringify(toolOutput, null, 2)}</pre>
);
}
So sollte man es in Produktion natürlich nicht machen, aber zum Debuggen und „erst mal draufschauen“ reicht es völlig.
Praktische Variante: über einen React‑Hook
Viel bequemer ist es, den Zugriff auf window.openai in einen kleinen Hook zu kapseln und mit einem typisierten Objekt zu arbeiten. Nehmen wir an, unser hypothetisches SDK liefert den Hook useWidgetProps, der toolOutput und toolResponseMetadata zurückgibt.
'use client';
import { useWidgetProps } from '@/lib/openai-widget';
export function GiftWidgetRoot() {
const { toolOutput, toolResponseMetadata } = useWidgetProps();
// Wir geben vorerst nur die Anzahl der Geschenke aus
const items = toolOutput?.structuredContent?.items ?? [];
return (
<div>
Gefundene Geschenke: {items.length}
</div>
);
}
Im echten Template kann der Hook anders heißen, aber die Idee ist stets dieselbe: Das SDK holt die Daten aus window.openai und übergibt sie Ihrer Komponente als Props oder via Context. Das ist viel einfacher, als jedes Mal manuell in das globale Objekt zu greifen, und erlaubt in Tests, die Datenquelle leicht zu ersetzen (z. B. eine toolOutput‑Fixture einzuspeisen).
4. Geschenke rendern: von structuredContent zu JSX
Kommen wir zum Spannenden: Wir nehmen structuredContent.items und zeichnen daraus Karten. Nicht vergessen: Unser Widget ist eine normale React‑Client‑Komponente in Next.js ('use client' am Anfang der Datei).
Zuerst definieren wir den Typ eines Geschenks:
type GiftItem = {
id: string;
title: string;
price: number;
currency: string;
tags?: string[];
};
Jetzt schreiben wir eine kleine Karten‑Komponente:
function GiftCard({ gift }: { gift: GiftItem }) {
return (
<div className="gift-card">
<div className="gift-title">{gift.title}</div>
<div className="gift-price">
{gift.price} {gift.currency}
</div>
</div>
);
}
Und eine Listen‑Komponente, die die Daten aus toolOutput holt:
'use client';
import { useWidgetProps } from '@/lib/openai-widget';
export function GiftList() {
const { toolOutput } = useWidgetProps();
const items = (toolOutput?.structuredContent?.items ?? []) as GiftItem[];
return (
<div className="gift-list">
{items.map(gift => (
<GiftCard key={gift.id} gift={gift} />
))}
</div>
);
}
Beachten Sie, wie sehr das hier an normalen React‑Code erinnert. Die einzige „Magie“ ist die Datenquelle: Anstelle von props oder fetch lesen wir toolOutput aus dem ChatGPT‑Container.
Und ja, es ist völlig okay, wenn Sie am Anfang as GiftItem[] ergänzen. Später kann man structuredContent sauber typisieren (z. B. gemeinsame Typen mit dem Backend verwenden — etwa Zod / JSON Schema → TS‑Typen), aber für die Demo reicht das.
5. UI‑Zustände rund um ToolOutput: Laden, leer, Fehler
Eine App, die nur Karten zeigt, wenn es klappt, und sonst schweigt, ist nicht sonderlich freundlich. Man sollte mindestens vier Zustände explizit behandeln: solange das Tool läuft, wenn noch keine Daten da sind, wenn ein Ergebnis vorliegt, und wenn etwas schiefgeht.
Das Apps SDK liefert üblicherweise Status‑Infos zum Tool‑Aufruf: über die Liste der Tool‑Invocations (useToolInvocations) oder Flags in Zusammenhang mit toolOutput. Für diese Vorlesung reicht ein einfaches Modell: Wenn toolOutput noch nicht da ist — Zustand „Laden“; wenn vorhanden, aber die Liste leer — „leer“; wenn ein Fehler kam — „Fehler“.
Der Einfachheit halber nehmen wir an, dass der Server im Fehlerfall in structuredContent das Feld error setzt und das Flag ok an der Wurzel von toolOutput false ist. Dieses Schema hatten wir im vorherigen Thema zur Server‑Implementierung diskutiert, als wir den Antwort‑Kontrakt des Tools entworfen haben.
type ToolOutput = {
ok: boolean;
structuredContent?: {
items?: GiftItem[];
error?: { code: string; message: string };
};
};
Jetzt aktualisieren wir unsere Listen‑Komponente:
'use client';
import { useWidgetProps } from '@/lib/openai-widget';
export function GiftListWithStates() {
const { toolOutput } = useWidgetProps() as { toolOutput?: ToolOutput };
if (!toolOutput) {
return <div>Wir suchen Geschenke…</div>;
}
if (!toolOutput.ok) {
const msg = toolOutput.structuredContent?.error?.message
?? 'Empfehlungen konnten nicht abgerufen werden.';
return <div>Fehler: {msg}</div>;
}
const items = toolOutput.structuredContent?.items ?? [];
if (items.length === 0) {
return <div>Für Ihre Bedingungen wurden keine Geschenke gefunden. Versuchen Sie, die Parameter zu ändern.</div>;
}
return (
<div className="gift-list">
{items.map(gift => (
<GiftCard key={gift.id} gift={gift} />
))}
</div>
);
}
Solcher Code liefert dem Nutzer bereits eine vernünftige Erfahrung:
- Solange das Tool läuft, ist ersichtlich, dass etwas passiert.
- Wenn etwas abstürzt, gibt es eine verständliche Meldung statt eines leeren Bildschirms.
- Wenn nichts gefunden wurde, tun wir nicht so, als wäre das normal, sondern erklären ehrlich, was passiert ist.
In Produktion ersetzen Sie „Wir suchen Geschenke…“ vermutlich durch ein Skeleton oder einen Spinner. Für komplexe Fehler kann man GPT die Möglichkeit geben, eine gut lesbare Erklärung zu formulieren. Die grundlegende Struktur der Komponenten bleibt dabei aber dieselbe.
6. _meta und toolResponseMetadata im UI verwenden
Wir können nun die Kerndaten aus structuredContent rendern und die Basiszustände loading/empty/error behandeln. Es bleibt noch ein wichtiger Teil des ToolOutput, den das Modell nicht nutzt — das Feld _meta.
Zurück zum Feld _meta. Es ist für das Modell unsichtbar, kommt aber in Ihrem Widget als toolResponseMetadata an (der Name kann variieren, die Idee ist dieselbe).
Das ist ein hervorragender Ort für Dinge, die das Denken von GPT nicht beeinflussen sollen, aber für die UI wichtig sind:
- Katalog‑ oder Konfigurationsversionen;
- interne Kampagnen‑IDs / A/B‑Experiment‑Bucket;
- Flags, welche „Buttons“ dem Benutzer angezeigt werden sollen;
- beliebige technische Details, die nicht mit Fachdaten vermischt werden sollen.
Beispielsweise könnte der Server dieses _meta zurückgeben:
"_meta": {
"giftGenius": {
"catalogVersion": "2025-10-01",
"showExperimentalBadges": true
}
}
Das Widget kann das lesen und z. B. einen „Neu“-Badge auf einigen Karten rendern.
type GiftMeta = {
giftGenius?: {
catalogVersion: string;
showExperimentalBadges?: boolean;
};
};
export function GiftListWithMeta() {
const { toolOutput, toolResponseMetadata } = useWidgetProps() as {
toolOutput?: ToolOutput;
toolResponseMetadata?: GiftMeta;
};
const meta = toolResponseMetadata?.giftGenius;
const items = toolOutput?.structuredContent?.items ?? [];
return (
<div>
{meta && (
<div className="catalog-version">
Katalog vom {meta.catalogVersion}
</div>
)}
<div className="gift-list">
{items.map(gift => (
<GiftCard
key={gift.id}
gift={gift}
/>
))}
</div>
</div>
);
}
Das Modell spielt hier gar keine Rolle: Es kennt catalogVersion und showExperimentalBadges nicht — Ihre UI kann sie jedoch nach Belieben nutzen.
Die Dokumentation betont genau diese Trennung: Daten, die für den Dialog und das Denken des Modells wichtig sind, kommen in structuredContent und content; alles, was rein UI‑technisch ist, kommt in _meta / toolResponseMetadata.
7. Ein wenig zu ToolInvocation‑Status und „Führe X aus …“
Solange ein Tool läuft, zeigt ChatGPT dem Benutzer selbst an, was passiert: Am oberen Rand des Chats erscheint ein Status wie „Führe GiftGenius aus …“ oder „Greife auf eine externe Anwendung zu“. Das geben nicht Sie manuell aus, sondern die ChatGPT‑Hostumgebung reagiert auf Metadaten des Tool‑Aufrufs.
Unter der Haube wird das über Servicestichworte wie _meta["openai/toolInvocation/invoking"] und _meta["openai/toolInvocation/invoked"] beschrieben, die signalisieren, dass eine Aktion läuft bzw. abgeschlossen ist. Diese Felder nutzt die Plattform selbst zur Statusanzeige und in der Regel müssen Sie sie nicht anrühren: Das SDK erledigt das serverseitig für Sie.
Für die UX bedeutet das einen angenehmen Bonus: Selbst wenn das Widget seinen Skeleton noch nicht gezeichnet hat, sieht der Benutzer bereits, dass das System etwas tut. Ihre Aufgabe ist es, diesen globalen Status durch lokale Zustände wie „Wir suchen Geschenke…“ und einen Skeleton im Widget zu ergänzen, wie wir es oben getan haben.
8. Datenmenge und Performance: nicht die ganze Welt in structuredContent packen
Ein eigenes Thema ist die Frage „Wie viel kann man überhaupt in structuredContent stecken?“. Intuitiv ist es verlockend: „Ich habe doch den ganzen Geschenkkatalog — geben wir ihn komplett zurück und das Widget filtert schon.“ In der Praxis sollte man das nicht tun.
Erstens landet structuredContent im Kontext des Modells (LLM), und der Gesamttokenumfang ist begrenzt. Dokumentation und Praxisguides empfehlen ausdrücklich, den Umfang schlank zu halten: Es ist kein Datenspeicher, sondern das Ergebnis einer Aktion.
Zweitens: Je größer das Payload, desto langsamer die Antwort und desto höher die Chance, Limits zu reißen oder unerwartete Kürzungen/Fehler zu bekommen.
Der pragmatische Ansatz:
- Das Backend filtert und sortiert die Daten vorab und liefert genau das, was für den aktuellen Schritt nötig ist — z. B. 10–20 der besten Geschenke.
- Wenn weitere Seiten nötig sind, ist das eine separate Aktion (neuer Tool‑Aufruf, neuer ToolOutput).
- Für reine UI‑Dinge (z. B. die Liste aller möglichen Filter‑Tags) kann man _meta verwenden — aber ebenfalls mit Maß.
Im Modul zum Zustand hatten wir bereits die Idee „Backend = Source of Truth, Widget = Cache/Ansicht“ besprochen. Hier genauso: Das Tool‑Ergebnis ist ein schlanker „Schnitt“ des Zustands zum Zeitpunkt des Aufrufs — keine vollständige Kopie Ihrer Datenbank.
9. Verknüpfung mit dem Widget‑Zustand und dem weiteren Dialog
Auch wenn diese Vorlesung offiziell ToolOutput → UI behandelt, lohnt der Blick auf ein benachbartes Element — den widgetState. Er ermöglicht es, die Auswahl des Benutzers zwischen Rendern zu merken und aus Ihrem Widget mehr als nur eine Vitrine zu machen — z. B. einen Wizard oder einen „Geschenkkonfigurator“.
Ein typisches Szenario:
- Der erste ToolOutput bringt eine Liste von Geschenken.
- Der Benutzer klickt auf eine der Karten.
- Das Widget schreibt in den widgetState, welches Geschenk gewählt wurde, und sendet ggf. ein Follow‑up oder einen neuen Tool‑Aufruf für Details.
- Die folgenden ToolOutputs stützen sich auf diese Auswahl.
Im Code sieht das wie normaler React‑State plus ein Aufruf von setWidgetState aus, der die Auswahl auf der ChatGPT‑Seite speichert. Der Unterschied ist nur, dass dieser Zustand sowohl dem Modell als auch Ihrem Backend zugänglich ist — daher sollte er kompakt bleiben und keine Geheimnisse enthalten.
Wir gehen darauf ausführlich in den Modulen zu mehrschrittigen Workflows und Follow‑ups ein. Schon jetzt ist die Denkweise hilfreich: ToolOutput liefert den „Datenschnitt“ vom Server, und widgetState ist der Kontext der Benutzerwahl rund um diesen Schnitt.
Häufige Fehler beim Arbeiten mit ToolOutput → UI
Fehler Nr. 1: „Die UI rendert den rohen JSON‑Baum ohne Anpassung für den Nutzer.“
Manchmal möchte man fürs Debugging einfach <pre>{JSON.stringify(toolOutput)}</pre> machen — und es dabei belassen. Für die Entwicklung okay, in Produktion sieht der Benutzer aber eine Struktur, auf die Sie stolz sind, die er jedoch nicht versteht. Es ist wichtig, structuredContent frühzeitig in sinnvolle Komponenten (Listen, Karten, Tabellen) zu überführen, statt Menschen einen tokenisierten Server‑Output lesen zu lassen.
Fehler Nr. 2: Vermischung von Fachdaten und technischen Metadaten in structuredContent.
Der Code bleibt deutlich sauberer, wenn man trennt: „was Modell und Benutzer sehen sollen“ vs. „was nur für UI und Analytics gebraucht wird“. Technische Felder — Experiment‑Flags, Katalogversionen, Idempotency‑Key — gehören in _meta / toolResponseMetadata. Liegt das alles vermischt in structuredContent, wird es schwerer, den Vertrag zu entwickeln und das Modellverhalten zu testen.
Fehler Nr. 3: Fehlende explizite Zustände für Laden, leeres Ergebnis und Fehler.
Ein leerer <div></div> statt „Nichts gefunden“ oder „Etwas ist schiefgelaufen“ führt direkt dazu, dass der Benutzer denkt: „Die App funktioniert nicht.“ Selbst minimale Text‑Platzhalter und ein einfaches Skeleton verbessern die UX drastisch. Verlassen Sie sich nicht nur auf den Systemstatus von ChatGPT „Führe X aus …“ — auch das Widget sollte sagen, was gerade mit ihm passiert.
Fehler Nr. 4: Der Versuch, die ganze Welt in einen ToolOutput zu packen.
Den kompletten Produktkatalog, die Benutzerhistorie und dann noch Server‑Logs in einem structuredContent zurückzugeben, ist keine gute Idee. Das belastet die Limits des Modells, verlangsamt die Antwort und verkompliziert die UI. Besser ist es, genau den Umfang zurückzugeben, der für den aktuellen Schritt nötig ist (Listen‑Seite, Details zum gewählten Element usw.), und die folgenden Schritte als separate Tool‑Aufrufe zu gestalten.
Fehler Nr. 5: Harte Kopplung der UI an eine instabile Antwortform ohne Typen.
Wenn man überall im Code toolOutput.structuredContent.items[0].whatever schreibt, ohne Felder zu prüfen und ohne Typen, führt jede Schema‑Änderung auf dem Server zu Widget‑Abstürzen. Entweder die Typen mit JSON Schema synchronisieren (TS‑Typen generieren) oder zumindest Interfaces manuell beschreiben (GiftItem, ToolOutput) und sorgfältig mit optionalen Feldern arbeiten.
Fehler Nr. 6: _meta ignorieren und das Modell mit „unnötigen“ Feldern überladen.
Es ist verlockend, in structuredContent alles Mögliche unterzubringen, weil „ist ja JSON, da stört nichts“. Aber jedes Feld vergrößert den Modellkontext, und vieles davon braucht das Modell gar nicht. Wenn Informationen das Denken von GPT nicht beeinflussen sollen und nicht in die Textantwort gehören, legen Sie sie in _meta ab und arbeiten Sie nur im Widget damit.
Fehler Nr. 7: Direkte Zugriffe auf window.openai aus einem Dutzend Komponenten.
Ja, window.openai.toolOutput funktioniert — aber wenn ein halbes Projekt in die globale Variable greift, werden Debugging und Tests zur Hölle. Kapseln Sie das lieber einmal in Hook/Context (useWidgetProps/useToolOutput) und nutzen Sie danach normale Props und typisierte Objekte. Das ist sauberer und lässt sich in Storybook/Tests leichter mit Fixtures ersetzen.
GO TO FULL VERSION