1. Due vie verso l’esterno: navigazione e dati
Se uno sviluppatore Next.js sente «bisogna chiamare il server», istintivamente va verso fetch o verso il client HTTP preferito. Nel mondo delle ChatGPT Apps questa reazione riflessa porta a problemi.
Nella parte del corso dedicata alla sicurezza dei widget nelle ChatGPT Apps, proponiamo fin da subito di rompere il vecchio riflesso. Il widget non vive in Internet libero: è in un isolamento rigido e l’accesso di rete è filtrato e limitato dalle policy dell’host.
Un widget ha solo tre finestre di base verso l’esterno:
- Navigazione: portare l’utente da qualche parte nel mondo esterno. Per questo c’è openExternal.
- Scambio dati: ottenere/inviare JSON, parlare con il backend. Si fa via fetch, ma con forti limitazioni possibili.
- MCP tool call: chiamata di strumenti (MCP / backend) che non hanno alcuna limitazione.
In questa lezione ci focalizziamo sul primo e più sicuro percorso (navigazione) e ci avviciniamo con cautela a fetch in modalità controllata. Nei moduli successivi analizzeremo MCP e gli strumenti come metodo principale per una comunicazione seria con il server.
2. openExternal: il «teletrasporto» sicuro dell’utente
Perché non si può semplicemente usare window.open
In una normale web app potreste fare qualcosa del genere:
window.open("https://example.com", "_blank");
Nella sandbox di ChatGPT questo o non funzionerà, o funzionerà in modo molto strano. Il widget è un iframe isolato con un sandbox rigido, che non ha gli stessi privilegi della scheda del browser.
Inoltre, l’host di ChatGPT vuole controllare dove e quando portate l’utente, per:
- evitare il tracciamento nascosto;
- mostrare all’utente una UI di conferma comprensibile (soprattutto nei client mobile/desktop);
- garantire un comportamento uniforme dei link in diversi ambienti (web, desktop, app mobile).
Per questo è stata ideata una API speciale, openExternal, accessibile tramite window.openai oppure l’hook React più comodo useOpenExternal.
Com’è fatto useOpenExternal
Negli esempi ufficiali dell’Apps SDK l’hook useOpenExternal è implementato più o meno così:
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;
}
L’idea principale è semplice. Per prima cosa proviamo a usare il meccanismo nativo di ChatGPT (window.openai.openExternal). Se il widget viene renderizzato non in ChatGPT (ad esempio lo aprite semplicemente nel browser in sviluppo), facciamo un fallback ordinato al normale window.open.
Nel vostro applicativo questo hook è già presente nel template (se avete preso il repository standard di OpenAI), e va usato proprio così — invece di toccare direttamente window.openai.
Esempio: pulsante «Vedi nel negozio» in GiftGenius
Immaginiamo che in toolOutput del nostro GiftGenius arrivino raccomandazioni con il campo productUrl. Aggiungiamo a ogni card un pulsante che apra il prodotto sul vostro sito:
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>Per ora nessuna raccomandazione…</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)}>
Apri
</button>
<div>
))}
</div>
);
}
Dal punto di vista dell’utente: preme il pulsante, ChatGPT può mostrare una finestra di sistema «Aprire un sito esterno?», quindi aprirà la vostra pagina in una nuova scheda o nel browser predefinito. Non passate alcun segreto, token, ecc.: semplicemente mandate la persona «dal chat al sito».
3. window.fetch nella sandbox: non è il fetch a cui sei abituato
Cosa si aspetta di solito un frontend developer
Di solito il ragionamento è: «Visto che è un browser, posso chiamare qualsiasi URL che abbia CORS configurato. Nel peggiore dei casi otterrò un errore, ma tentar non nuoce.»
Nell’ecosistema delle ChatGPT Apps questo è un malinteso pericoloso. La sandbox intorno al widget non è una «pignoleria», ma un requisito di sicurezza fondamentale: il widget non deve poter tracciare l’utente, chiamare domini arbitrari, scansionare la rete locale o comportarsi in generale come un mini‑browser dentro il browser.
In questo stesso report si sottolinea che nel widget dell’Apps SDK l’accesso di rete arbitrario o è assente o è fortemente limitato — e non è un bug, ma una scelta architetturale consapevole.
Come appare in pratica
In un tipico ambiente ChatGPT:
- fetch può essere disponibile, ma solo verso una lista limitata di domini (di solito il vostro dominio su cui gira l’App e, forse, un paio di API esplicitamente consentite);
- le richieste possono passare attraverso un proxy speciale dell’host, che filtra header e URL;
- alcuni metodi (PUT, DELETE) o header non standard possono essere bloccati dalle policy di sicurezza.
Avete comunque una via comoda: se il vostro widget e il vostro backend vivono sullo stesso dominio (come nel template Next.js, dove sia il server MCP sia la UI sono serviti dalla stessa app), le richieste interne fetch("/api/...") di solito saranno consentite.
La cosa principale è non contare sul fatto che il widget possa chiamare qualsiasi API su Internet. Tutta la comunicazione «pesante» con servizi esterni (Stripe, Notion, CRM, ecc.) deve avvenire lato MCP/backend, che ChatGPT contatta come risorsa fidata.
Insight
Nel widget di ChatGPT bisogna dimenticare subito i percorsi relativi e usare URL assoluti. Motivo semplice: il vostro HTML non gira sullo stesso dominio del backend. ChatGPT legge il vostro HTML, lo mette sul proprio host e lo renderizza dentro un iframe isolato. Qualsiasi "/api/..." o "/static/logo.png" improvvisamente viene risolto rispetto al dominio di ChatGPT, non rispetto alla vostra app — e tutto si rompe.
<base> qui aiuta poco. Sperimentalmente si è visto che se al widget non è impostato widgetCSP, potete dichiarare <base href="https://my-app.dev/">: le risorse verranno caricate dal vostro dominio, ma gli script, per le regole della sandbox, continueranno a non funzionare. Ma funziona solo in Dev Mode.
Non appena impostate un openai/widgetCSP normale (e in produzione dovrete comunque impostarlo per la review), la piattaforma azzera il <base>, e il gioco finisce: risorse e script si caricano solo da domini consentiti nella CSP, e già tramite link assoluti.
Raccomandazione: nel widget di ChatGPT tutto ciò che esce verso l’esterno — fetch, immagini, CSS, le vostre pagine per openExternal — va sempre costruito come URL completo dal dominio base dell’applicazione, che controllate via config/ENV, e non tramite percorsi relativi e <base>.
4. Architettura: UI sottile, backend pesante
Dalle limitazioni di fetch e dalla sandbox discende un principio architetturale più generale, importante per tutto il corso. L’abbiamo già ripetuto più volte, ma è il momento di fissarlo: il widget è uno strato UI sottile. Renderizza ciò che il backend ha già preparato (tramite MCP/tools), mostra reazioni alle azioni dell’utente e, in casi estremi, fa un paio di piccole richieste pubbliche.
Tutto ciò che riguarda autorizzazione, accesso ai dati personali, segreti e logica di business non banale deve vivere lato server. I documenti di sicurezza del corso sottolineano: il frontend (widget React) è un «luogo pubblico», una zona a fiducia zero, e i segreti non devono viverci.
Tutte le mie ricerche sul tema attuale formulano l’obiettivo in modo netto: «piantare l’ultimo chiodo nella bara dell’idea del “client pesante”» per le ChatGPT Apps. Il widget è solo la testa; il corpo e il cervello stanno in MCP/backend.
Quindi:
- openExternal — per portare l’utente sul vostro sito «normale», dove potete far girare la solita SPA, l’area personale e il resto;
- callTool (modulo successivo) — il modo principale per passare al modello un compito che eseguirà il vostro backend;
- fetch dal widget — l’eroe raro per richieste ausiliarie, sicure e preferibilmente pubbliche alla vostra stessa applicazione.
5. Pratica: openExternal nel nostro GiftGenius
Integreremo openExternal nel nostro App didattico con un pizzico di attenzione al UX.
Mini‑regola di UX
Se portate l’utente all’esterno, è utile:
- indicare chiaramente dove arriverà;
- evitare «salti» inattesi senza spiegazioni nel testo (o GPT dice «Aprirò il sito del negozio…», oppure lo scrivete sull’etichetta del pulsante).
Esempio di titolo ed etichetta:
<button onClick={() => openExternal(gift.url)}>
Apri sul sito del negozio
</button>
L’utente capisce che sta per uscire dalla chat confortevole nel mondo reale con carrello e pagamento.
Piccolo refactoring del componente di lista
Abbiamo già fatto un semplice GiftListWidget. Supponiamo che nelle lezioni precedenti abbiate implementato un widget che mostra l’elenco dei regali da toolOutput. Ora facciamo una versione un po’ più curata: aggiungiamo il tipo Gift con il campo url e il pulsante 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>Per ora non ho trovato nulla. Prova a cambiare la richiesta.</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)}>
Vedi
</button>
</div>
))}
</div>
);
}
Continuiamo a non lavorare direttamente con window.openai, ma usiamo l’hook comodo — sa già fare fallback a window.open quando l’ambiente ChatGPT non c’è. La struttura di Gift è indicativa — nel vostro App la adatterete al vostro backend.
6. Pratica: fetch ordinato verso il nostro backend
Ora affrontiamo fetch. Ricordo ancora: operazioni complesse o sensibili è meglio farle via strumenti/MCP. Ma a volte dal widget si vuole recuperare qualcosa di leggero e pubblico dal proprio server, ad esempio l’elenco delle categorie di regali popolari.
Semplice rotta API pubblica in Next.js
Aggiungiamo al nostro progetto Next.js un handler così:
// app/api/public/popular-tags/route.ts
import { NextResponse } from "next/server";
const tags = ["Per bambini", "Per viaggiatori", "Per i gamer"];
export async function GET() {
return NextResponse.json({ tags });
}
Questa rotta non conosce l’utente, non richiede token, non chiama servizi esterni — restituisce solo un array statico. Un codice del genere si può portare quasi senza rischi sia in produzione sia nella sandbox.
Chiamare questa rotta dal widget con fetch
Nel componente del widget aggiungiamo il caricamento di questi tag. Considerate le limitazioni della sandbox, è più comodo fare la richiesta a un URL assoluto: lo stesso dominio su cui gira la vostra App — quello che inoltrate tramite tunnel e registrate in Dev Mode di ChatGPT (lo abbiamo configurato nel modulo su Dev Mode e tunnel).
Importante: il dominio del vostro widget sarà qualcosa tipo https://genius.web-sandbox.oaiusercontent.com, quindi non usate percorsi relativi per caricare i dati, solo assoluti. Esempio:
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("Impossibile caricare le categorie popolari");
}
}
loadTags();
return () => {
cancelled = true;
};
}, []);
if (error) return <p>{error}</p>;
if (!tags) return <p>Carico le categorie popolari…</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>
);
}
È importante che:
- gestiamo con cura gli errori e mostriamo all’utente un messaggio comprensibile;
- non diamo per scontato che fetch «funzionerà di sicuro» — le policy della sandbox possono bloccare l’accesso in qualsiasi momento se cambiate dominio o iniziate a fare richieste strane;
- non passiamo alcun token/segretto; se serve autenticazione — è compito di MCP e dei moduli sull’Auth.
7. openExternal vs fetch vs strumenti (callTool): chi fa cosa
Per evitare confusione, è utile tenere a mente questa «matrice di responsabilità»:
| Scenario | Cosa usare | Perché proprio così |
|---|---|---|
| Aprire landing/prodotto/area personale | openExternal | Transizione esplicita dell’utente, controllata dall’host |
| Ottenere dati pubblici dall’App | fetch("my.com/api/...") | JSON leggero, stesso dominio, senza segreti |
| Recuperare dati utente, DB | callTool/MCP | Serve autorizzazione, logica, backend sicuro |
| Chiamare API esterni (Stripe…) | MCP/server | Il front non vede i segreti, si rispettano le policy |
In questo modulo è importante imparare a scegliere consapevolmente lo strumento. Bisogna abbandonare il pensiero «il widget è frontend, quindi si può fare tutto con fetch», in favore dell’architettura «il widget è uno strato UI gestito sopra un backend LLM+MCP».
Insight
L’interazione con il server in una ChatGPT App ha senso dividerla su due livelli:
- ChatGPT ↔ MCP server: il modello chiama gli strumenti MCP. Ogni tool-call è l’avvio o lo switch di uno scenario di business (selezione dei regali, creazione ordine, calcolo del costo, ecc.). Qui vive la logica «pesante», il lavoro con i dati, le API esterne e l’autorizzazione.
- Widget ↔ server: il widget fa richieste fetch() leggere al proprio backend e/o invoca gli stessi strumenti MCP tramite callTool() già all’interno dello scenario attivo. Sono passi locali: caricare dati ausiliari, aggiornare una parte della UI, precisare lo stato.
Quindi MCP‑tool = avvio/gestione del processo di business, mentre fetch()/callTool() dal widget — piccole operazioni dentro lo scenario già scelto, che non pretendono di cambiare la «storia» generale del dialogo.
8. Piccolo esercizio pratico
Per fissare il tema in pratica, potete implementare una piccola feature in GiftGenius.
Scenario proposto:
- Nell’elenco dei regali aggiungete il pulsante «Procedi al checkout», che tramite openExternal apre la pagina di checkout sul vostro sito di sviluppo.
- Sopra l’elenco dei regali renderizzate PopularTags dall’esempio sopra per mostrare le categorie popolari. In caso di errore di caricamento, mostrate un testo di fallback e non rompete l’intero widget.
- Prestate attenzione al UX: nel testo della risposta GPT o nella UI del widget spiegate all’utente che «cliccando il pulsante aprirò la pagina del negozio in una nuova scheda».
Questa feature in miniatura mostra entrambi i canali:
- openExternal per la navigazione esplicita;
- fetch per una piccola API pubblica, che vive accanto alla vostra App.
9. Errori tipici con window.fetch e openExternal
Errore n. 1: usare il widget come client SPA completo per tutte le vostre API.
Le vecchie abitudini spingono verso «chiamiamo semplicemente il nostro REST/GraphQL direttamente da React». Nel mondo delle ChatGPT Apps ciò porta allo scontro con la sandbox: alcune richieste non passeranno, altre verranno bloccate dalle policy e la sicurezza del progetto sarà a rischio. La logica complessa e l’accesso ai dati dell’utente devono passare via MCP/strumenti, non direttamente dal widget.
Errore n. 2: conservare segreti e token nel codice del widget.
A volte si vuole «prototipare rapidamente» e inserire nel frontend la chiave API di qualche servizio («tanto sto solo testando»). È una cattiva idea anche per una SPA normale, e per le ChatGPT Apps è un no categorico. Il widget è un ambiente pubblico; i segreti devono vivere nella configurazione del server o in sistemi di gestione dei segreti (Vercel env, KMS, ecc.).
Errore n. 3: pensare che fetch verso qualsiasi dominio «funzionerà e basta».
Anche se in Dev Mode qualche richiesta è passata (magari perché il tunnel è inoltrato in modo non standard), in produzione quasi certamente si romperà: ChatGPT limita le richieste in uscita e un dominio esterno arbitrario non è accessibile al widget. Fate conto che il widget possa raggiungere in modo affidabile solo il proprio dominio e una piccola whitelist di risorse esplicitamente consentite.
Errore n. 4: usare window.open invece di openExternal.
Tecnicamente a volte window.open può anche funzionare, soprattutto nella preview del browser, e si crea l’illusione che «tutto ok». Ma nel vero ambiente di ChatGPT, specialmente nei client nativi, il comportamento sarà imprevedibile. L’utente potrebbe non vedere affatto il passaggio o ricevere un errore strano. La via corretta è usare openExternal (tramite l’hook useOpenExternal), che sa come aprire correttamente il link nell’ambiente corrente.
Errore n. 5: non gestire gli errori di fetch e non mostrare lo stato di caricamento.
Nella sandbox gli errori di rete non sono l’eccezione, ma la norma: il tunnel può cadere, il dominio può cambiare, le policy possono bloccare qualcosa. Se fate semplicemente await fetch(...) e poi renderizzate la UI dando per scontato che i dati ci siano, otterrete un’interfaccia mezza rotta che «a volte funziona, a volte no». Mettete sempre try/catch, controllate res.ok, mostrate «Caricamento…» e un messaggio d’errore comprensibile.
Errore n. 6: trasformare openExternal in un redirect nascosto.
A volte c’è la tentazione di portare l’utente subito su un sito esterno al click di qualsiasi pulsante, specialmente sul checkout, senza alcun contesto nel testo. Risulta strano sia per l’utente sia per i revisori dello Store. Buona norma — scrivere esplicitamente cosa succederà: o il modello GPT comunica «Aprirò la pagina del negozio…», oppure il pulsante è etichettato in modo trasparente («Procedi al pagamento sul sito del negozio»).
Errore n. 7: dimenticare che il widget non è l’unico «padrone» del dialogo.
Se la vostra UI cerca di imporre all’utente uno scenario complesso con molte sue link e richieste di rete, ignorando la chat e i follow‑up, il risultato è un UX peggiore e qualità inferiore del lavoro del modello. Ricordate l’architettura: GPT decide quando mostrare l’App, come usare i suoi risultati, e il widget solo suggerisce e visualizza. Navigazione e chiamate di rete vanno progettate in modo che si inseriscano nel dialogo generale, senza tirare tutto il peso su di sé.
GO TO FULL VERSION