1. De ToolOutput au composant React : flux de données global
Dans le cours précédent, nous avons vu comment l’outil côté serveur forme ToolOutput — une réponse structurée pour le modèle et le widget. Voyons maintenant la deuxième moitié du chemin : comment ce ToolOutput arrive dans le widget et se transforme en UI.
Pour éviter l’effet « magie », reprenons le chemin des données de l’utilisateur jusqu’à votre widget. En version simplifiée, cela ressemble à ceci :
- L’utilisateur pose une question dans le chat.
- GPT analyse la requête, regarde la liste des outils et décide : « À présent, suggest_gifts va m’aider ».
- GPT forme un appel d’outil avec un nom et des arguments (ToolInput) et l’envoie à votre serveur (MCP ou backend).
- Le serveur exécute la logique de l’outil et renvoie le résultat sous forme de ToolOutput — un JSON structuré contenant des données, plus un résumé textuel pour le modèle.
- ChatGPT reçoit ToolOutput et le transmet ensuite : au modèle (pour poursuivre le dialogue) et à votre widget via l’Apps SDK (window.openai.toolOutput ou des hooks).
- Votre widget — un composant React classique — lit toolOutput et rend l’UI.
Schématiquement, on peut représenter cela ainsi :
flowchart TD U[Utilisateur] -->|requête dans le chat| GPT[GPT] GPT -->|callTool: suggest_gifts| B[Backend/MCP] B -->|"ToolOutput (JSON)"| GPT GPT -->|transmet toolOutput| W["Widget (React)"] W -->|cartes, listes| U
Idée clé à retenir : ToolOutput n’est pas juste une « réponse du serveur ». C’est aussi votre ordre de rendu pour le widget et, en même temps, un contexte pour le modèle. Un bon App est celui où ce JSON devient une interface pratique, plutôt qu’un bloc que le développeur survole dans DevTools.
2. Anatomie de ToolOutput : ce qu’il contient
Le format du résultat d’un outil dans l’Apps SDK se divise en trois blocs logiques : structuredContent, content et _meta (qui arrive dans le widget sous le nom toolResponseMetadata).
On peut l’imaginer ainsi :
{
"structuredContent": { /* données pour l’UI + le modèle */ },
"content": "Résumé textuel bref pour le modèle et l’utilisateur",
"_meta": { /* données techniques uniquement pour le widget */ }
}
Le tableau montre qui voit quoi :
| Champ | Qui le voit | À quoi ça sert |
|---|---|---|
|
Modèle + widget | Données structurées principales (listes, objets, paramètres) |
|
Modèle + utilisateur (dans le texte) | Résumé bref que GPT peut insérer dans sa réponse |
|
Widget uniquement | Données techniques dont le modèle n’a pas besoin (ID, versions, clés, etc.) |
La documentation de l’Apps SDK souligne que le couple structuredContent / content est transmis au modèle et peut être exploité dans ses réponses ultérieures. Le champ _meta reste, lui, caché et n’est accessible que dans le widget via toolResponseMetadata.
Exemple de ToolOutput pour GiftGenius
Supposons que notre outil côté serveur suggest_gifts renvoie un corps de réponse de ce type :
{
"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": "J’ai trouvé quelques idées de cadeaux. Le widget ci‑dessous les affiche sous forme de cartes.",
"_meta": {
"giftGenius": {
"catalogVersion": "2025-10-01",
"experimentBucket": "A"
}
}
}
Ici, structuredContent.items correspond à ce que votre widget React va rendre ; content peut être utilisé par le modèle pour expliquer à l’utilisateur ce qui se passe ; _meta.giftGenius fournit des informations internes utiles uniquement à votre UI ou à l’analytics (par exemple, quelle version du catalogue utiliser pour les liens).
C’est bien structuredContent l’objet que vous consulterez dans le JSX au lieu de parser à la main un JSON arbitraire venu du serveur.
3. Récupérer ToolOutput dans le widget : window.openai et hooks
Passons du JSON au code. Comment ce ToolOutput arrive‑t‑il dans votre composant React ?
Le template de l’Apps SDK propose deux approches principales : soit directement via window.openai.toolOutput, soit — préférable — via des hooks React prêts à l’emploi (useWidgetProps, useToolOutput et similaires). L’approche recommandée est d’utiliser des hooks afin d’éviter de manipuler window.openai directement et d’obtenir un code plus testable et plus sûr.
Variante la plus simple : directement depuis window.openai
Pour comprendre, voici la version « brute » :
'use client';
function RawToolOutputDebug() {
const toolOutput = (window as any).openai?.toolOutput;
return (
<pre>{JSON.stringify(toolOutput, null, 2)}</pre>
);
}
À éviter en production, bien sûr, mais très utile pour le debug et un premier coup d’œil.
Variante pratique : via un hook React
Il est beaucoup plus confortable d’envelopper l’accès à window.openai dans un petit hook et de travailler avec un objet typé. Imaginons que notre SDK fournisse un hook useWidgetProps, qui renvoie toolOutput et toolResponseMetadata.
'use client';
import { useWidgetProps } from '@/lib/openai-widget';
export function GiftWidgetRoot() {
const { toolOutput, toolResponseMetadata } = useWidgetProps();
// Pour l’instant, affichons simplement le nombre de cadeaux
const items = toolOutput?.structuredContent?.items ?? [];
return (
<div>
Cadeaux trouvés : {items.length}
</div>
);
}
Dans un template réel, le nom du hook peut différer, mais l’idée est toujours la même : le SDK prend les données depuis window.openai et les expose à votre composant comme des props ou via un contexte. C’est bien plus simple que d’aller chercher à la main dans l’objet global et, en plus, cela permet de remplacer facilement la source de données dans les tests (par exemple en injectant une fixture toolOutput).
4. Rendre les cadeaux : de structuredContent au JSX
Passons au concret : prenons structuredContent.items et dessinons des cartes à partir de ces éléments. N’oublions pas que notre widget est un composant client React dans Next.js ('use client' en haut du fichier).
Commençons par définir le type d’un cadeau :
type GiftItem = {
id: string;
title: string;
price: number;
currency: string;
tags?: string[];
};
Écrivons maintenant un petit composant de carte :
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>
);
}
Et un composant de liste qui récupère les données depuis 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>
);
}
Notez à quel point tout cela ressemble à du code React classique. La seule « magie », c’est la source des données : au lieu de props ou fetch, nous lisons toolOutput depuis le conteneur ChatGPT.
Et oui, rien de dramatique si, au début, vous ajoutez as GiftItem[]. Plus tard, vous pourrez typer proprement structuredContent avec des types partagés avec le backend (par exemple, Zod / JSON Schema → types TS), mais pour une démo ceci suffit.
5. États d’UI autour de ToolOutput : chargement, vide, erreur
Une application qui se contente d’afficher des cartes quand tout va bien, et qui reste muette dans les autres cas, n’est pas très conviviale. Il faut gérer explicitement au moins quatre états : pendant que l’outil s’exécute, quand il n’y a pas encore de données, quand il y a un résultat et quand quelque chose s’est mal passé.
L’Apps SDK fournit généralement des informations sur le statut d’un appel d’outil : via la liste des tool invocations (useToolInvocations) ou des indicateurs liés à toolOutput. Pour ce cours, un modèle simple suffit : si toolOutput n’est pas encore là — nous sommes en « chargement » ; s’il est là mais que la liste est vide — « vide » ; si une erreur arrive — « erreur ».
Pour simplifier, supposons que le serveur, en cas d’erreur, place dans structuredContent un champ error, et que le drapeau ok à la racine de toolOutput vaille false. Nous en avons déjà parlé dans le sujet précédent sur l’implémentation côté serveur lorsque nous avons conçu le contrat de réponse de l’outil.
type ToolOutput = {
ok: boolean;
structuredContent?: {
items?: GiftItem[];
error?: { code: string; message: string };
};
};
Mise à jour de notre composant de liste :
'use client';
import { useWidgetProps } from '@/lib/openai-widget';
export function GiftListWithStates() {
const { toolOutput } = useWidgetProps() as { toolOutput?: ToolOutput };
if (!toolOutput) {
return <div>Nous cherchons des cadeaux…</div>;
}
if (!toolOutput.ok) {
const msg = toolOutput.structuredContent?.error?.message
?? 'Impossible d’obtenir des recommandations.';
return <div>Erreur : {msg}</div>;
}
const items = toolOutput.structuredContent?.items ?? [];
if (items.length === 0) {
return <div>Aucun cadeau ne correspond à vos critères. Essayez de modifier les paramètres.</div>;
}
return (
<div className="gift-list">
{items.map(gift => (
<GiftCard key={gift.id} gift={gift} />
))}
</div>
);
}
Un tel code offre déjà une expérience correcte à l’utilisateur :
- Pendant l’exécution de l’outil, on voit qu’il se passe quelque chose.
- Si tout a échoué — un message clair s’affiche au lieu d’un écran vide.
- Si rien n’a été trouvé — on l’explique honnêtement au lieu de faire comme si c’était normal.
En production, vous remplacerez sans doute le texte « Nous cherchons des cadeaux… » par un skeleton ou un spinner. Pour des erreurs complexes, vous pouvez laisser GPT formuler une explication lisible. Mais la structure de base des composants restera la même.
6. Utiliser _meta et toolResponseMetadata dans l’UI
Nous savons déjà rendre les données principales depuis structuredContent et gérer les états de base loading/empty/error. Il reste un élément important de ToolOutput dont le modèle ne se sert pas — le champ _meta.
Revenons au champ _meta. Il n’est pas visible par le modèle, mais arrive dans votre widget sous toolResponseMetadata (le nom peut varier, le principe reste le même).
C’est un excellent endroit pour ce qui ne doit pas influencer le raisonnement de GPT mais qui est important pour l’UI :
- versions de catalogue ou de configuration ;
- ID internes de campagne / bucket d’A/B test ;
- indicateurs sur les « boutons » à afficher à l’utilisateur ;
- toute information technique à ne pas mélanger aux données métier.
Par exemple, le serveur peut renvoyer ce _meta :
"_meta": {
"giftGenius": {
"catalogVersion": "2025-10-01",
"showExperimentalBadges": true
}
}
Le widget peut le lire et, par exemple, afficher un badge « Nouvelle idée » sur certaines cartes.
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">
Catalogue du {meta.catalogVersion}
</div>
)}
<div className="gift-list">
{items.map(gift => (
<GiftCard
key={gift.id}
gift={gift}
/>
))}
</div>
</div>
);
}
Le modèle n’intervient pas ici : il ne connaît ni catalogVersion ni showExperimentalBadges, alors que votre UI peut les utiliser comme bon lui semble.
La documentation insiste sur cette séparation : les données importantes pour le dialogue et le raisonnement du modèle vont dans structuredContent et content ; tout ce qui est purement technique côté UI va dans _meta / toolResponseMetadata.
7. Un mot sur les statuts ToolInvocation et « J’exécute X… »
Pendant l’exécution d’un outil, ChatGPT indique de lui‑même à l’utilisateur ce qui se passe : en haut du chat, un statut du type « J’exécute GiftGenius… » ou « Appel à une application externe » apparaît. Ce n’est pas vous qui affichez ces chaînes, c’est l’environnement hôte de ChatGPT qui réagit aux métadonnées de l’appel d’outil.
Sous le capot, cela se décrit via des clés techniques du type _meta["openai/toolInvocation/invoking"] et _meta["openai/toolInvocation/invoked"], qui signalent qu’une action est en cours ou terminée. La plateforme s’en sert pour afficher le statut et, en général, vous n’avez pas à les manipuler : le SDK gère cela côté serveur.
Pour l’UX, c’est un bonus appréciable : même si le widget n’a pas encore rendu un skeleton, l’utilisateur voit déjà que le système travaille. À vous d’enrichir ce statut global avec des états locaux comme « Nous cherchons des cadeaux… » et un skeleton dans le widget, comme nous l’avons fait ci‑dessus.
8. Taille des données et performance : n’inondez pas structuredContent
Abordons un point important : « jusqu’où peut‑on remplir structuredContent ? ». Intuitivement, la tentation est grande : « J’ai tout le catalogue de cadeaux — renvoyons tout, le widget filtrera ». En pratique, c’est une mauvaise idée.
Premièrement, structuredContent part dans le contexte du modèle (LLM), et le volume total de tokens est limité. La documentation et les guides pratiques recommandent fermement de rester raisonnable : ce n’est pas un stockage de données, mais le résultat d’une action.
Deuxièmement, plus le payload est gros, plus la réponse est lente et plus vous risquez de heurter des limites ou de subir des coupures/erreurs inattendues.
Approche pragmatique :
- Le backend filtre et trie en amont pour ne renvoyer que ce qui est nécessaire à l’étape courante : par exemple, 10–20 meilleurs cadeaux.
- Si des pages supplémentaires sont nécessaires, cela fait l’objet d’une action séparée (nouvel appel d’outil, nouveau ToolOutput).
- Pour des éléments purement UI (par exemple, la liste de tous les tags possibles pour filtrer), vous pouvez utiliser _meta, mais là aussi avec modération.
Dans le module sur l’état, nous avons vu la notion « le backend est la source de vérité, le widget est un cache/une vue ». Ici, c’est pareil : le résultat de l’outil est un « instantané » propre de l’état au moment de l’appel, pas une copie complète de votre base.
9. Lien avec l’état du widget et poursuite du dialogue
Même si ce cours est officiellement consacré à ToolOutput → UI, il ne faut pas oublier un autre élément important : le widgetState. C’est lui qui permet de mémoriser les choix de l’utilisateur entre les rendus et de transformer votre widget en assistant/moteur de configuration complet.
Scénario typique :
- Le premier ToolOutput apporte une liste de cadeaux.
- L’utilisateur clique sur l’une des cartes.
- Le widget enregistre dans widgetState le cadeau sélectionné et, éventuellement, envoie un follow‑up ou un nouvel appel d’outil pour les détails.
- Les ToolOutput suivants s’appuient sur ce choix.
Côté code, cela ressemble à un état React habituel plus un appel à setWidgetState, qui persiste le choix côté ChatGPT. La différence, c’est que cet état est accessible au modèle et à votre backend, donc gardez‑le compact et n’y stockez pas de secrets.
Nous détaillerons cela dans les modules sur les workflows multi‑étapes et les follow‑ups. Dès maintenant, pensez ainsi : ToolOutput vous donne un « instantané de données » venant du serveur, et widgetState fournit le contexte des choix de l’utilisateur autour de cet instantané.
Erreurs courantes dans le flux ToolOutput → UI
Erreur n° 1 : « L’UI rend un arbre JSON brut sans adaptation pour l’utilisateur ».
Parfois, pour le debug, on a envie de faire <pre>{JSON.stringify(toolOutput)}</pre> et de s’arrêter là. Pour le développement, d’accord ; en production, l’utilisateur voit une structure dont vous êtes fier, mais qu’il ne comprend pas. Il est essentiel d’envelopper au plus tôt structuredContent dans des composants parlants (listes, cartes, tableaux), au lieu de forcer quelqu’un à lire une réponse serveur tokenisée.
Erreur n° 2 : Mélanger données métier et métadonnées techniques dans structuredContent.
Le code reste bien plus propre si l’on sépare « ce qui doit être visible du modèle et de l’utilisateur » de « ce qui ne sert qu’à l’UI et à l’analytics ». Les champs techniques — flags d’expérimentation, versions de catalogue, idempotency key — ont leur place dans _meta / toolResponseMetadata. Quand tout est mélangé dans structuredContent, il devient plus difficile de faire évoluer le contrat et de tester le comportement du modèle.
Erreur n° 3 : Absence d’états explicites de chargement, de résultat vide et d’erreurs.
Un <div></div> vide au lieu de « Rien trouvé » ou « Un problème est survenu » conduit l’utilisateur à penser : « L’app ne marche pas ». Même des messages de base et un simple skeleton améliorent fortement l’UX. Ne vous fiez pas uniquement au statut système de ChatGPT « J’exécute X… » — le widget doit aussi indiquer ce qui lui arrive.
Erreur n° 4 : Vouloir mettre le monde entier dans un seul ToolOutput.
Renvoyer tout un catalogue de produits, l’historique utilisateur et les logs serveur dans un seul structuredContent est une mauvaise idée. Cela explose les limites du modèle, ralentit la réponse et complique l’UI. Mieux vaut renvoyer exactement le volume nécessaire pour l’étape en cours (page de liste, détails de l’élément sélectionné, etc.), et traiter les étapes suivantes via des appels d’outil séparés.
Erreur n° 5 : Couplage rigide de l’UI à une forme de réponse instable, sans types.
Si l’on écrit partout toolOutput.structuredContent.items[0].whatever sans vérifier l’existence des champs et sans types, la moindre évolution du schéma côté serveur fera tomber le widget. Synchronisez les types avec un JSON Schema (génération de types TS) ou, au minimum, décrivez des interfaces à la main (GiftItem, ToolOutput) et traitez prudemment les champs optionnels.
Erreur n° 6 : Ignorer _meta et surcharger le modèle avec des champs « inutiles ».
La tentation est de tout mettre dans structuredContent parce que « c’est du JSON, rien n’est en trop ». Mais chaque champ augmente le contexte du modèle, et beaucoup d’informations ne lui sont pas utiles. Si une information ne doit pas influencer le raisonnement de GPT ni apparaître dans une réponse textuelle, mettez‑la dans _meta et ne l’utilisez que dans le widget.
Erreur n° 7 : Accéder directement à window.openai depuis une dizaine de composants.
Oui, window.openai.toolOutput fonctionne, mais si la moitié de l’app va piocher dans une variable globale, le debug et les tests deviennent pénibles. Il est bien préférable d’envelopper cela une fois dans un hook/contexte (useWidgetProps/useToolOutput) puis d’utiliser des props normales et des objets typés. C’est plus propre et plus facile à substituer par des fixtures dans Storybook/tests.
GO TO FULL VERSION