1. Da ToolOutput al componente React: flusso dati generale
Nella lezione precedente abbiamo visto come il tool lato server formi ToolOutput — una risposta strutturata per il modello e per il widget. Ora guardiamo alla seconda metà del percorso: come questo ToolOutput finisce nel widget e si trasforma in UI.
Per evitare che tutto sembri magia, ripercorriamo ancora una volta il viaggio dei dati dall’utente al vostro widget. In forma semplificata è così:
- L’utente fa una domanda in chat.
- GPT analizza la richiesta, guarda l’elenco degli strumenti e decide: «Ora mi aiuterà suggest_gifts».
- GPT forma una chiamata allo strumento con nome e argomenti (ToolInput) e la invia al vostro server (MCP o backend).
- Il server esegue la logica dello strumento e restituisce il risultato come ToolOutput — un JSON strutturato con i dati, più un riepilogo testuale per il modello.
- ChatGPT riceve ToolOutput e lo inoltra oltre: al modello (per proseguire il dialogo) e al vostro widget tramite Apps SDK (window.openai.toolOutput o hook).
- Il vostro widget — un normale componente React — legge toolOutput e renderizza la UI.
In modo schematico si può rappresentare così:
flowchart TD U[Utente] -->|richiesta in chat| GPT[GPT] GPT -->|callTool: suggest_gifts| B[Backend/MCP] B -->|"ToolOutput (JSON)"| GPT GPT -->|passa toolOutput| W["Widget (React)"] W -->|card, liste| U
È importante fissare un concetto: ToolOutput non è solo «risposta del server». È anche il vostro comando di rendering per il widget e contemporaneamente il contesto per il modello. Una buona App è quella in cui questo JSON si trasforma in un’interfaccia comoda, invece di essere fatto scorrere con gli occhi dallo sviluppatore in DevTools.
2. Anatomia di ToolOutput: cosa contiene
Il formato del risultato dello strumento in Apps SDK si divide in tre blocchi logici: structuredContent, content e _meta (che arriva al widget con il nome toolResponseMetadata).
In modo approssimativo si può rappresentare così:
{
"structuredContent": { /* dati per UI + modello */ },
"content": "Breve riepilogo testuale per il modello e l’utente",
"_meta": { /* dati di servizio solo per il widget */ }
}
In tabella si vede chi vede cosa:
| Campo | Chi lo vede | A cosa serve |
|---|---|---|
|
Modello + widget | Dati strutturati principali (liste, oggetti, parametri) |
|
Modello + utente (nel testo) | Breve riepilogo che GPT può inserire nella propria risposta |
|
Solo widget | Dati di servizio non necessari al modello (ID, versioni, chiavi, ecc.) |
La documentazione di Apps SDK sottolinea che la coppia structuredContent / content arriva al modello e può essere usata nelle sue risposte successive. Il campo _meta invece resta nascosto ed è disponibile solo all’interno del widget tramite toolResponseMetadata.
Esempio di ToolOutput per GiftGenius
Supponiamo che il nostro strumento suggest_gifts sul server restituisca un corpo simile a questo:
{
"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": "Ho trovato alcune idee regalo. Qui sotto il widget le mostra come card.",
"_meta": {
"giftGenius": {
"catalogVersion": "2025-10-01",
"experimentBucket": "A"
}
}
}
Qui structuredContent.items è ciò che il vostro widget React renderizzerà; il content può essere usato dal modello per spiegare all’utente cosa sta succedendo; _meta.giftGenius sono informazioni interne, utili solo alla vostra UI o all’analitica (per esempio quale versione del catalogo usare per i link).
Proprio structuredContent è l’oggetto che guarderete in JSX invece di fare il parsing manuale di un JSON arbitrario dal server.
3. Ottenere ToolOutput nel widget: window.openai e hook
Passiamo dal parlare di JSON al codice. Come arriva questo ToolOutput nel vostro componente React?
Il template di Apps SDK lo fa in due modi principali: o direttamente tramite window.openai.toolOutput, oppure — cosa comoda — tramite hook React già pronti (useWidgetProps, useToolOutput e simili). L’approccio raccomandato è usare gli hook per non toccare window.openai a mano e avere codice più testabile e sicuro.
Opzione più semplice: direttamente da window.openai
Per capire il meccanismo, guardiamo la versione “grezza”:
'use client';
function RawToolOutputDebug() {
const toolOutput = (window as any).openai?.toolOutput;
return (
<pre>{JSON.stringify(toolOutput, null, 2)}</pre>
);
}
In produzione così non si dovrebbe fare, ma per il debug e per “dare un’occhiata ai primi passi” va benissimo.
Opzione pratica: tramite un hook di React
È molto più comodo incapsulare l’accesso a window.openai in un piccolo hook e lavorare già con un oggetto tipizzato. Supponiamo che il nostro SDK fornisca l’hook useWidgetProps, che restituisce toolOutput e toolResponseMetadata.
'use client';
import { useWidgetProps } from '@/lib/openai-widget';
export function GiftWidgetRoot() {
const { toolOutput, toolResponseMetadata } = useWidgetProps();
// Per ora visualizziamo solo il numero di regali
const items = toolOutput?.structuredContent?.items ?? [];
return (
<div>
Regali trovati: {items.length}
</div>
);
}
Nel template reale il nome dell’hook può variare, ma l’idea è sempre la stessa: l’SDK prende i dati da window.openai e li passa al vostro componente come props o tramite contesto. È molto più semplice che andare ogni volta a leggere l’oggetto globale e, in più, permette nei test di sostituire facilmente la sorgente dei dati (per esempio iniettando una fixture di toolOutput).
4. Renderizzare i regali: da structuredContent a JSX
Passiamo alla parte interessante: prendiamo structuredContent.items e disegniamo delle card. Non dimentichiamo che il nostro widget è un normale componente client di React in Next.js ('use client' in cima al file).
Per prima cosa definiamo il tipo di un regalo:
type GiftItem = {
id: string;
title: string;
price: number;
currency: string;
tags?: string[];
};
Ora scriviamo un piccolo componente card:
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>
);
}
E il componente lista che prende i dati da toolOutput:
'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>
);
}
Nota quanto qui tutto somigli al normale codice React. L’unica “magia” è la sorgente dei dati: invece di props o fetch leggiamo toolOutput dal contenitore di ChatGPT.
E sì, niente di grave se inizialmente aggiungerete as GiftItem[]. Più avanti si potrà tipizzare con cura structuredContent tramite tipi condivisi con il backend (per esempio usando Zod / JSON Schema → tipi TS), ma per una demo questo basta.
5. Stati UI attorno a ToolOutput: caricamento, vuoto, errore
Un’app che mostra solo delle card quando va tutto bene e tace in tutti gli altri casi non è molto amichevole. Bisogna gestire in modo esplicito almeno quattro stati: mentre lo strumento è in esecuzione, quando i dati non ci sono ancora, quando c’è il risultato e quando qualcosa è andato storto.
L’Apps SDK in genere fornisce alcune informazioni sullo stato della chiamata allo strumento: tramite l’elenco delle invocazioni (useToolInvocations) o flag associati a toolOutput. In questa lezione ci basta un modello semplice: se toolOutput non c’è ancora — siamo in stato “caricamento”; se c’è ma la lista è vuota — “vuoto”; se è arrivato un errore — “errore”.
Per semplicità supponiamo che il server, in caso di errore, metta in structuredContent il campo error, e che il flag ok alla radice di toolOutput sia false. Questo schema l’abbiamo già discusso nel tema precedente sull’implementazione server, quando progettavamo il contratto della risposta dello strumento.
type ToolOutput = {
ok: boolean;
structuredContent?: {
items?: GiftItem[];
error?: { code: string; message: string };
};
};
Ora aggiorniamo il nostro componente lista:
'use client';
import { useWidgetProps } from '@/lib/openai-widget';
export function GiftListWithStates() {
const { toolOutput } = useWidgetProps() as { toolOutput?: ToolOutput };
if (!toolOutput) {
return <div>Stiamo cercando i regali…</div>;
}
if (!toolOutput.ok) {
const msg = toolOutput.structuredContent?.error?.message
?? 'Impossibile ottenere le raccomandazioni.';
return <div>Errore: {msg}</div>;
}
const items = toolOutput.structuredContent?.items ?? [];
if (items.length === 0) {
return <div>Nessun regalo corrisponde ai tuoi criteri. Prova a modificare i parametri.</div>;
}
return (
<div className="gift-list">
{items.map(gift => (
<GiftCard key={gift.id} gift={gift} />
))}
</div>
);
}
Un codice del genere offre già un’esperienza adeguata all’utente:
- Finché lo strumento lavora, si vede che qualcosa sta accadendo.
- Se tutto è caduto — c’è un messaggio comprensibile, non uno schermo vuoto.
- Se non si è trovato nulla — non facciamo finta che sia normale, ma spieghiamo onestamente cosa è successo.
In produzione probabilmente sostituirete il testo «Stiamo cercando i regali…» con un piccolo skeleton o uno spinner. Per errori complessi potete dare a GPT la possibilità di formulare una spiegazione leggibile. Ma la struttura di base dei componenti resterà la stessa.
6. Usare _meta e toolResponseMetadata nella UI
Abbiamo già imparato a renderizzare i dati principali da structuredContent e a gestire gli stati di base loading/empty/error. Rimane un altro pezzo importante di ToolOutput, che il modello non usa: il campo _meta.
Torniamo al campo _meta. Non è visibile al modello, ma arriva al vostro widget come toolResponseMetadata (il nome può variare, ma la sostanza è la stessa).
È un ottimo posto per ciò che non deve influenzare il ragionamento di GPT ma è importante per la UI:
- versioni del catalogo o della configurazione;
- ID interni di campagna / esperimenti A/B;
- flag su quali “bottoni” mostrare all’utente;
- qualsiasi elemento tecnico che non volete confondere con i dati di dominio.
Per esempio, il server può restituire questo _meta:
"_meta": {
"giftGenius": {
"catalogVersion": "2025-10-01",
"showExperimentalBadges": true
}
}
Il widget può leggerlo e, per esempio, disegnare un badge “Idea nuova” su alcune card.
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">
Catalogo del {meta.catalogVersion}
</div>
)}
<div className="gift-list">
{items.map(gift => (
<GiftCard
key={gift.id}
gift={gift}
/>
))}
</div>
</div>
);
}
Qui il modello non c’entra affatto: non conosce catalogVersion e showExperimentalBadges, ma la vostra UI può usarli come preferisce.
La documentazione sottolinea proprio questa separazione: i dati importanti per il dialogo e il ragionamento del modello li mettiamo in structuredContent e content; tutto ciò che è puramente tecnico per la UI va in _meta / toolResponseMetadata.
7. Un po’ sugli stati di ToolInvocation e «Sto eseguendo X…»
Mentre lo strumento è in esecuzione, ChatGPT mostra autonomamente all’utente cosa sta accadendo: nella parte alta della chat appare uno stato come «Sto eseguendo GiftGenius…» o «Mi sto rivolgendo a un’app esterna». Non siete voi a stampare queste stringhe, bensì l’ambiente host di ChatGPT che reagisce ai metadati della chiamata allo strumento.
Sotto il cofano questo è descritto tramite chiavi di servizio del tipo _meta["openai/toolInvocation/invoking"] e _meta["openai/toolInvocation/invoked"], che segnalano che l’azione è in corso o completata. Questi campi sono usati dalla piattaforma per visualizzare lo stato e, di norma, non serve toccarli: l’SDK se ne occupa lato server.
Per la UX questo significa un bonus piacevole: anche se il widget non è ancora riuscito a disegnare lo skeleton, l’utente vede già che il sistema sta facendo qualcosa. Il vostro compito è integrare questo stato globale con stati locali come «Stiamo cercando i regali…» e uno skeleton nel widget, come abbiamo fatto sopra.
8. Dimensione dei dati e prestazioni: non carichiamo il mondo intero in structuredContent
Vale la pena parlare separatamente del tema «quanto si può mettere dentro structuredContent». Intuitivamente è allettante: «Ho l’intero catalogo dei regali — diamolo tutto, e il widget filtrerà». In pratica non conviene.
Primo, structuredContent finisce nel contesto del modello (LLM) e il volume totale dei token è limitato. La documentazione e le guide pratiche raccomandano insistentemente di mantenere il volume contenuto: non è un archivio dati, ma il risultato di un’azione.
Secondo, più grande è il payload, più lentamente arriva la risposta e più alto è il rischio di incappare in limiti o in tagli/ errori inattesi.
Un approccio sensato è questo:
- Il backend filtra e ordina i dati in anticipo, restituendo esattamente ciò che serve per lo step corrente: per esempio, i 10–20 migliori regali.
- Se servono le pagine successive, è un’azione separata (nuova chiamata dello strumento, nuovo ToolOutput).
- Per elementi puramente UI (ad esempio, l’elenco di tutti i possibili tag per il filtro) si può usare _meta, ma senza esagerare.
Nel modulo sullo stato abbiamo già discusso il concetto «il backend è la fonte di verità, il widget è cache/rappresentazione». Qui vale lo stesso: il risultato dello strumento è un “ritaglio” pulito dello stato al momento della chiamata, non una copia completa del vostro database.
9. Integrazione con lo stato del widget e il dialogo successivo
Anche se questa lezione è ufficialmente su ToolOutput → UI, non si può non ricordare che nei paraggi vive un altro pezzo importante: widgetState. È proprio lui che permette di ricordare la scelta dell’utente tra i render e di trasformare il vostro widget non in una semplice vetrina, ma in un vero wizard o «configuratore di regali».
Uno scenario tipico è questo:
- Il primo ToolOutput porta un elenco di regali.
- L’utente clicca su una delle card.
- Il widget registra in widgetState quale regalo è stato selezionato e, eventualmente, invia un follow‑up o una nuova chiamata di tool per i dettagli.
- I ToolOutput successivi si basano su questa scelta.
Dal punto di vista del codice, sembra un normale stato di React più la chiamata a setWidgetState, che salva la scelta lato ChatGPT. La differenza è che questo stato è accessibile sia al modello sia al vostro backend, quindi va mantenuto compatto e non bisogna conservarci segreti.
Ne parleremo in dettaglio nei moduli sui workflow multi‑step e sui follow‑up. Già ora è utile pensare così: ToolOutput vi dà un “ritaglio di dati” dal server, e widgetState è il contesto della scelta dell’utente attorno a quel ritaglio.
Errori tipici nel lavoro da ToolOutput → UI
Errore n. 1: «La UI renderizza l’albero JSON grezzo senza adattarlo all’utente».
A volte per il debug viene voglia di fare semplicemente <pre>{JSON.stringify(toolOutput)}</pre> e fermarsi lì. Per lo sviluppo va bene, ma in produzione l’utente vede una struttura di cui voi andate fieri, ma che lui non capisce. È importante incapsulare il prima possibile structuredContent in componenti significativi (liste, card, tabelle), senza costringere la persona a leggere una risposta tokenizzata del server.
Errore n. 2: Mescolare dati di dominio e metadati tecnici in structuredContent.
Il codice è molto più pulito se si separa: «ciò che deve essere visibile al modello e all’utente» da «ciò che serve solo a UI e analitica». I campi tecnici — flag sperimentali, versioni dei cataloghi, idempotency key — appartengono a _meta / toolResponseMetadata. Quando tutto questo è mescolato in structuredContent, è più difficile far evolvere il contratto e testare il comportamento del modello.
Errore n. 3: Mancanza di stati espliciti di caricamento, risultato vuoto ed errori.
Un <div></div> vuoto al posto di «Nessun risultato» o «Qualcosa è andato storto» porta l’utente a pensare: «L’app non funziona». Anche segnaposti testuali minimi e un semplice skeleton migliorano drasticamente la UX. Non fate affidamento solo sullo stato di sistema di ChatGPT «Sto eseguendo X…» — anche il widget deve comunicare cosa gli sta succedendo.
Errore n. 4: Cercare di infilare in un solo ToolOutput il mondo intero.
Restituire l’intero catalogo prodotti, la cronologia dell’utente e persino i log del server in un unico structuredContent è una cattiva idea. Va contro i limiti del modello, rallenta la risposta e complica la UI. Meglio restituire esattamente il volume di dati necessario per lo step corrente (pagina di lista, dettagli dell’elemento selezionato, ecc.) e i passi successivi implementarli con chiamate di tool separate.
Errore n. 5: Collegare rigidamente la UI a una forma instabile della risposta, senza tipi.
Se in tutto il codice scrivete toolOutput.structuredContent.items[0].whatever, senza verificare la presenza dei campi e senza tipi, qualsiasi evoluzione dello schema sul server porterà ai crash del widget. Conviene o sincronizzare i tipi con JSON Schema (generazione dei tipi TS), o almeno descrivere a mano le interfacce (GiftItem, ToolOutput) e lavorare con attenzione con i campi opzionali.
Errore n. 6: Ignorare _meta e sovraccaricare il modello con campi “superflui”.
A volte è forte la tentazione di mettere in structuredContent tutto, perché «tanto è JSON, non c’è niente di superfluo». Ma ogni campo aumenta il contesto del modello e molte cose al modello non servono affatto. Se un’informazione non deve influenzare il ragionamento di GPT e non serve nella risposta testuale, mettetela in _meta e usatela solo nel widget.
Errore n. 7: Accessi diretti a window.openai da una decina di componenti.
Sì, window.openai.toolOutput funziona, ma quando mezza applicazione inizia a pescare da una variabile globale, il debug e i test diventano un incubo. Molto meglio incapsulare una volta in un hook/contesto (useWidgetProps/useToolOutput) e poi usare props normali e oggetti tipizzati. È più pulito e più facile da sostituire con fixture in Storybook/test.
GO TO FULL VERSION