1. Zwei Wege nach außen: Navigation und Daten
Wenn ein gewöhnlicher Next.js‑Entwickler „muss zum Server gehen“ hört, greift man automatisch zu fetch oder zum Lieblings‑HTTP‑Client. In der Welt der ChatGPT‑Apps führt diese reflexartige Reaktion zu Problemen.
Im Kursteil zur Sicherheit von Widgets in ChatGPT‑Apps empfehlen wir von Anfang an, diesen alten Reflex zu durchbrechen. Das Widget lebt nicht im freien Internet: Es sitzt in strikter Isolation, und sein Netzwerkzugriff wird durch Richtlinien des Hosts gefiltert und begrenzt.
Ein Widget hat im Grunde nur drei Fenster nach außen:
- Navigation: den Nutzer irgendwohin in die Außenwelt schicken. Dafür gibt es openExternal.
- Datenaustausch: JSON holen/senden, mit dem Backend sprechen. Das geschieht über fetch, jedoch oft mit starken Einschränkungen.
- MCP‑Tool‑Call: Aufruf von Tools (MCP/Backend), die den Widget‑Beschränkungen nicht unterliegen.
In dieser Lektion fokussieren wir uns auf den ersten und sichersten Weg (Navigation) und nähern uns behutsam dem kontrollierten fetch. In den nächsten Modulen behandeln wir MCP und Tools als den Hauptweg für ernsthafte Serverkommunikation.
2. openExternal: der sichere „Teleport“ des Nutzers
Warum nicht einfach window.open verwenden
In einer normalen Web‑App würden Sie etwa Folgendes tun:
window.open("https://example.com", "_blank");
In der ChatGPT‑Sandbox funktioniert das entweder gar nicht oder sehr merkwürdig. Das Widget ist ein isoliertes iframe mit striktem sandbox, das nicht die gleichen Rechte hat wie ein Browser‑Tab.
Außerdem möchte der ChatGPT‑Host kontrollieren, wohin und wann Sie den Nutzer führen, damit:
- kein verstecktes Tracking möglich ist;
- dem Nutzer eine verständliche Bestätigungs‑UI gezeigt wird (besonders in Mobil‑/Desktop‑Clients);
- sichergestellt ist, dass sich Links in verschiedenen Umgebungen (Web, Desktop, mobile App) gleich verhalten.
Deshalb gibt es die spezielle API openExternal, erreichbar über window.openai oder den bequemeren React‑Hook useOpenExternal.
Wie useOpenExternal aussieht
In den offiziellen Apps‑SDK‑Beispielen ist der Hook useOpenExternal ungefähr so implementiert:
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;
}
Die Hauptidee ist simpel: Zuerst nutzen wir den nativen Mechanismus von ChatGPT (window.openai.openExternal). Falls das Widget unerwartet nicht in ChatGPT gerendert wird (z. B. lokale Entwicklung im Browser), fällt der Hook sauber auf window.open zurück.
In Ihrer App ist dieser Hook im Template bereits vorhanden (wenn Sie das Standard‑Repository von OpenAI verwendet haben) – und genau so sollten Sie ihn auch nutzen, anstatt direkt auf window.openai zuzugreifen.
Beispiel: Button „Im Shop ansehen“ in GiftGenius
Nehmen wir an, im toolOutput unseres GiftGenius kommen Empfehlungen mit dem Feld productUrl. Fügen wir zu jeder Karte einen Button hinzu, der das Produkt auf Ihrer Website öffnet:
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>Noch keine Empfehlungen …</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)}>
Öffnen
</button>
<div/>
))}
</div>
);
}
Aus Sicht des Nutzers: Er klickt den Button, ChatGPT kann ein systemeigenes Fenster „Externe Website öffnen?“ anzeigen und öffnet anschließend Ihre Seite in einem neuen Tab oder im Standardbrowser. Sie geben keine Secrets, Tokens o. Ä. weiter – Sie schicken den Menschen lediglich „aus dem Chat auf die Website“.
3. window.fetch in der Sandbox: nicht das fetch, das Sie gewohnt sind
Was ein Frontender üblicherweise erwartet
Normalerweise lautet die Logik: „Wenn es ein Browser ist, kann ich jeden URL mit konfiguriertem CORS aufrufen. Im schlimmsten Fall bekomme ich einen Fehler, aber versuchen kann man’s.“
Im Ökosystem der ChatGPT‑Apps ist das ein gefährlicher Irrtum. Die Sandbox um das Widget ist nicht nur eine „kleine Marotte“, sondern ein fundamentales Sicherheitsprinzip: Das Widget soll den Nutzer nicht tracken, nicht auf beliebige Domains zugreifen, nicht das lokale Netz scannen und sich generell nicht wie ein Mini‑Browser im Browser verhalten.
Ebenso wird betont, dass willkürlicher Netzwerkzugang im Apps‑SDK‑Widget entweder fehlt oder stark eingeschränkt ist – das ist kein Bug, sondern eine bewusste Architekturentscheidung.
Wie das in der Praxis aussieht
In einer typischen ChatGPT‑Umgebung gilt:
- fetch kann verfügbar sein, jedoch nur zu einer eingeschränkten Liste von Domains (meist Ihre eigene App‑Domain und ggf. ein paar explizit erlaubte APIs);
- Anfragen können über einen speziellen Host‑Proxy laufen, der Header und URLs filtert;
- einige Methoden (PUT, DELETE) oder unübliche Header können durch Sicherheitsrichtlinien blockiert werden.
Gleichzeitig haben Sie weiterhin einen bequemen Weg: Wenn Ihr Widget und Ihr Backend auf derselben Domain leben (wie im Next.js‑Template, wo MCP‑Server und UI von einer App bedient werden), sind interne Anfragen fetch("/api/...") in der Regel erlaubt.
Wichtig ist, nicht davon auszugehen, dass das Widget jeden beliebigen API im Internet erreichen kann. Sämtliche „schwere“ Kommunikation mit externen Diensten (Stripe, Notion, CRM etc.) sollte auf der Seite von MCP/Backend stattfinden, die ChatGPT als vertrauenswürdige Ressource anspricht.
Insight
Im ChatGPT‑Widget sollte man relative Pfade sofort vergessen und mit absoluten URLs leben. Der Grund ist einfach: Ihr HTML läuft nicht auf derselben Domain wie das Backend. ChatGPT liest Ihr HTML, legt es auf seinem Host ab und rendert es in einem isolierten iframe. Jeder "/api/..." oder "/static/logo.png" wird plötzlich relativ zur ChatGPT‑Domain aufgelöst – nicht zu Ihrer App – und alles bricht.
<base> hilft hier kaum. Experimentell wurde festgestellt: Wenn für das Widget kein widgetCSP gesetzt ist, können Sie <base href="https://my-app.dev/"> setzen: Ressourcen werden von Ihrer Domain geladen, aber Skripte funktionieren wegen der Sandbox‑Regeln trotzdem nicht. Das funktioniert allerdings nur im Dev Mode.
Sobald Sie eine reguläre openai/widgetCSP konfigurieren (und im Prod müssen Sie sie für das Review ohnehin setzen), setzt die Plattform <base> außer Kraft: Ressourcen und Skripte werden nur noch von in der CSP erlaubten Domains geladen – und zwar über absolute Links.
Empfehlung: In einem ChatGPT‑Widget sollte alles, was nach außen geht – fetch, Bilder, CSS, Ihre Seiten für openExternal – stets als vollständige URL vom Basis‑Domainnamen Ihrer App aufgebaut werden, den Sie via Config/ENV steuern – nicht über relative Pfade und <base>.
4. Architektur: dünnes UI, dickes Backend
Aus den Einschränkungen von fetch und der Sandbox ergibt sich ein allgemeiner Architekturgrundsatz, der für den gesamten Kurs wichtig ist. Wir haben das Mantra schon mehrfach erwähnt, jetzt verankern wir es: Das Widget ist eine dünne UI‑Schicht. Es rendert, was das Backend bereits vorbereitet hat (über MCP/Tools), zeigt Reaktionen auf Nutzeraktionen und macht im äußersten Fall ein paar kleine öffentliche Requests.
Alles, was mit Autorisierung, Zugriff auf personenbezogene Daten, Secrets und nichttrivialer Business‑Logik zu tun hat, muss serverseitig leben. Die Sicherheitsunterlagen des Kurses betonen: Das Frontend (React‑Widget) ist ein „public place“, eine Zero‑Trust‑Zone – dort dürfen keine Secrets liegen.
Meine gesamte Recherche zum Thema formuliert das Ziel klar: „Den letzten Nagel in den Sargdeckel der Idee vom ‚fetten Client‘“ für ChatGPT‑Apps schlagen. Das Widget ist nur der Kopf, Körper und Gehirn liegen im MCP/Backend.
Daher:
- openExternal – für die Navigation des Nutzers auf Ihre „normale“ Website, wo dann die gewohnte SPA, das Konto usw. laufen;
- callTool (nächstes Modul) – der Hauptweg, dem Modell eine Aufgabe zu übergeben, die Ihr Backend ausführt;
- fetch aus dem Widget – der seltene Held für unterstützende, sichere und möglichst öffentliche Anfragen an Ihre eigene App.
5. Praxis: openExternal in unserem GiftGenius
Integrieren wir openExternal etwas sorgfältiger in unsere Übungs‑App und denken gleich über UX nach.
Mini‑UX‑Regel
Wenn Sie den Nutzer nach außen führen, ist es hilfreich:
- klar anzukündigen, wo er landet;
- keine überraschenden „Sprünge“ ohne Erklärung zu machen (entweder teilt GPT mit „Ich öffne die Website des Shops …“, oder Sie beschriften den Button entsprechend).
Beispiel für Titel und Beschriftung:
<button onClick={() => openExternal(gift.url)}>
Auf der Shop‑Website öffnen
</button>
Der Nutzer versteht, dass er gleich aus dem gemütlichen Chat in die echte Welt mit Warenkorb und Bezahlung wechselt.
Kleines Refactoring der Listen‑Komponente
Zuvor hatten wir bereits ein einfaches GiftListWidget. Angenommen, in den vorherigen Lektionen haben Sie ein Widget implementiert, das eine Geschenkeliste aus dem toolOutput anzeigt. Jetzt machen wir eine etwas sauberere Version: Wir fügen den Typ Gift mit dem Feld url und den Button openExternal hinzu.
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>Ich habe noch nichts gefunden. Versuchen Sie, die Anfrage anzupassen.</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)}>
Ansehen
</button>
</div>
))}
</div>
);
}
Wir arbeiten weiterhin nicht direkt mit window.openai, sondern nutzen den bequemen Hook – er kann in Fällen, in denen die ChatGPT‑Umgebung fehlt, auf window.open zurückfallen. Die Struktur von Gift ist hier beispielhaft – in Ihrer App passen Sie sie Ihrem Backend an.
6. Praxis: ein sorgfältiges fetch zu unserem Backend
Schauen wir uns nun fetch an. Zur Erinnerung: Komplexe oder sensitive Operationen sollten besser über Tools/MCP laufen. Manchmal möchte man aber aus dem Widget etwas Leichtes und Öffentliches vom eigenen Server nachladen, etwa eine Liste beliebter Geschenk‑Kategorien.
Eine einfache öffentliche API‑Route in Next.js
Fügen wir in unser Next.js‑Projekt folgenden Handler hinzu:
// app/api/public/popular-tags/route.ts
import { NextResponse } from "next/server";
const tags = ["Für Kinder", "Für Reisende", "Für Gamer"];
export async function GET() {
return NextResponse.json({ tags });
}
Diese Route kennt den Nutzer nicht, benötigt keine Tokens und ruft keine externen Dienste auf – sie liefert einfach ein statisches Array. Solchen Code kann man mit geringem Risiko in Produktion und in der Sandbox einsetzen.
Aufruf dieser Route aus dem Widget per fetch
Nun fügen wir im Widget‑Component das Laden dieser Tags hinzu. Angesichts der Sandbox‑Einschränkungen ist es am bequemsten, die Anfrage an eine absolute URL zu stellen: an die Domain, auf der Ihre App läuft – jene, die Sie durch den Tunnel reichen und im Dev Mode von ChatGPT registrieren (das haben wir im Modul zu Dev Mode und Tunnel konfiguriert).
Wichtig: Die Domain Ihres Widgets wird etwas wie https://genius.web-sandbox.oaiusercontent.com sein, verwenden Sie daher keine relativen Pfade zum Laden von Daten, sondern nur absolute. Beispiel:
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("Beliebte Kategorien konnten nicht geladen werden");
}
}
loadTags();
return () => {
cancelled = true;
};
}, []);
if (error) return <p>{error}</p>;
if (!tags) return <p>Lade beliebte Kategorien …</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>
);
}
Wichtig ist dabei:
- wir behandeln Fehler sorgfältig und zeigen dem Nutzer eine verständliche Meldung;
- wir verlassen uns nicht darauf, dass fetch „sicher funktioniert“ – die Sandbox‑Richtlinien können den Zugriff jederzeit kappen, wenn Sie die Domain ändern oder ungewöhnliche Requests schicken;
- wir senden keine Tokens/Secrets mit; falls Authentifizierung nötig ist – das ist Aufgabe von MCP und den Modulen zu Auth.
7. openExternal vs fetch vs Tools (callTool): wofür ist was zuständig
Damit es nicht durcheinandergeht, hilft es, sich folgende „Zuständigkeitsmatrix“ zu merken:
| Szenario | Was verwenden wir | Warum so |
|---|---|---|
| Landing/Produkt/Konto öffnen | openExternal | Expliziter Nutzer‑Wechsel, vom Host kontrolliert |
| Öffentliche Daten aus der App holen | fetch("my.com/api/...") | Leichter JSON, gleiche Domain, ohne Secrets |
| Nutzerdaten, DB abrufen | callTool/MCP | Benötigt Autorisierung, Logik, sicheres Backend |
| Externe APIs aufrufen (Stripe …) | MCP/Server | Frontend sieht keine Secrets, Richtlinien werden eingehalten |
In diesem Modul ist es wichtig, das Werkzeug bewusst zu wählen. Wir wollen weg vom Denken „Das Widget ist Frontend, also geht alles mit fetch“, hin zur Architektur „Das Widget ist eine gesteuerte UI‑Schicht über einem LLM+MCP‑Backend“.
Insight
Die Server‑Interaktion in einer ChatGPT‑App lässt sich sinnvoll in zwei Ebenen teilen:
- ChatGPT ↔ MCP‑Server: Das Modell ruft MCP‑Tools auf. Jeder Tool‑Call startet oder schaltet ein Business‑Szenario um (Geschenkauswahl, Bestellung anlegen, Kosten berechnen usw.). Hier leben „schwere“ Logik, Datenarbeit, externe APIs und Autorisierung.
- Widget ↔ Server: Das Widget macht leichte fetch()‑Requests an sein eigenes Backend und/oder triggert dieselben MCP‑Tools via callTool() bereits innerhalb des aktiven Szenarios. Das sind lokale Schritte: Hilfsdaten nachladen, ein Stück UI aktualisieren, Zustand präzisieren.
Das heißt, MCP‑Tool = Start/Steuerung eines Geschäftsprozesses, während fetch()/callTool() aus dem Widget kleine Operationen im bereits gewählten Szenario sind, ohne die gesamte „Story“ des Dialogs zu verändern.
8. Kleine praktische Übung
Um das Thema praktisch zu festigen, bauen wir ein kleines Feature in GiftGenius.
Vorgeschlagenes Szenario:
- Fügen Sie in der Geschenkeliste einen Button „Zur Kasse gehen“ hinzu, der über openExternal die Checkout‑Seite auf Ihrer Dev‑Site öffnet.
- Rendern Sie oberhalb der Geschenkeliste die PopularTags aus dem obigen Beispiel, um beliebte Kategorien zu zeigen. Bei Ladefehlern zeigen Sie einen Fallback‑Text und bringen das gesamte Widget nicht zum Absturz.
- Achten Sie auf den UX‑Text: Im GPT‑Antworttext oder im Widget‑UI erklären Sie dem Nutzer, dass „beim Klick die Shop‑Seite in einem neuen Tab geöffnet wird“.
Dieses Feature zeigt im Kleinen beide Kanäle:
- openExternal für die explizite Navigation;
- fetch für ein kleines öffentliches API, das nahe bei Ihrer App lebt.
9. Typische Fehler im Umgang mit window.fetch und openExternal
Fehler Nr. 1: Das Widget als vollwertigen SPA‑Client für all Ihre APIs benutzen.
Alte Gewohnheiten verleiten dazu, „einfach unser REST/GraphQL direkt aus React“ aufzurufen. In ChatGPT‑Apps führt das zur Kollision mit der Sandbox: Ein Teil der Requests geht schlicht nicht durch, ein Teil wird durch Richtlinien blockiert, und die Sicherheit der App gerät in Gefahr. Komplexe Logik und Zugriff auf Nutzerdaten müssen über MCP/Tools laufen, nicht direkt aus dem Widget.
Fehler Nr. 2: Secrets und Tokens im Widget‑Code speichern.
Manchmal möchte man „schnell prototypen“ und einen API‑Key ins Frontend schreiben („ist ja nur ein Test“). Schlechte Idee schon bei einer normalen SPA, und für ChatGPT‑Apps ein kategorisches Nein. Das Widget ist eine öffentliche Umgebung; Secrets gehören in die Server‑Konfiguration oder Secret‑Management (Vercel Env, KMS etc.).
Fehler Nr. 3: Glauben, dass fetch zu jeder Domain „einfach funktioniert“.
Auch wenn im Dev Mode irgendeine Anfrage durchging (z. B. wegen unüblichem Tunnel‑Setup), wird sie in Produktion nahezu sicher scheitern: ChatGPT beschränkt ausgehende Requests, und eine beliebige externe Domain ist für das Widget nicht erreichbar. Rechnen Sie damit, dass das Widget zuverlässig nur zur eigenen Domain und zu einer sehr kleinen Whitelist explizit erlaubter Ressourcen gehen kann.
Fehler Nr. 4: window.open statt openExternal verwenden.
Technisch kann window.open manchmal funktionieren, besonders in der Browser‑Vorschau, und es entsteht der Eindruck, „alles ok“. In der echten ChatGPT‑Umgebung, vor allem in nativen Clients, ist das Verhalten jedoch unvorhersehbar. Der Nutzer sieht den Übergang womöglich gar nicht oder erhält einen seltsamen Fehler. Der richtige Weg ist openExternal (über den Hook useOpenExternal), der weiß, wie man den Link in der aktuellen Umgebung korrekt öffnet.
Fehler Nr. 5: fetch‑Fehler nicht behandeln und keinen Ladezustand anzeigen.
In der Sandbox sind Netzwerkfehler keine Ausnahme, sondern normal: Der Tunnel kann ausfallen, die Domain sich ändern, Richtlinien etwas abschneiden. Wenn Sie einfach await fetch(...) machen und danach UI rendern, als ob Daten sicher da wären, erhalten Sie eine halb kaputte Oberfläche, die „manchmal geht, manchmal nicht“. Setzen Sie stets try/catch, prüfen Sie res.ok, zeigen Sie „Lade …“ und eine saubere Fehlermeldung.
Fehler Nr. 6: openExternal als versteckten Redirect missbrauchen.
Mitunter besteht die Versuchung, bei jedem Klick den Nutzer sofort auf eine externe Site zu ziehen, besonders direkt zum Checkout, ohne Kontext im Text. Das wirkt für Nutzer und Store‑Reviewer merkwürdig. Gute Praxis ist, explizit zu schreiben, was gleich passiert: Entweder die GPT‑Modellantwort kündigt an „Ich öffne die Shop‑Seite …“, oder der Button ist klar genug beschriftet („Zur Bezahlung auf der Shop‑Website“).
Fehler Nr. 7: Vergessen, dass das Widget nicht der einzige Treiber des Dialogs ist.
Wenn Ihr UI dem Nutzer ein komplexes Szenario mit vielen eigenen Links und Netzwerkaufrufen aufzwingt und dabei den Chat und Follow‑ups ignoriert, leiden UX und Modellqualität. Denken Sie an die Architektur: GPT entscheidet, wann die App gezeigt wird und wie sie genutzt wird; das Widget unterstützt und visualisiert. Navigation und Netzwerkaufrufe sollten so gestaltet sein, dass sie sich in den Gesamtdialog einfügen – und ihm nicht die Show stehlen.
GO TO FULL VERSION