1. Od ToolOutput do komponentu React: ogólny przepływ danych
W poprzednim wykładzie omówiliśmy, jak narzędzie po stronie serwera tworzy ToolOutput — ustrukturyzowaną odpowiedź dla modelu i widżetu. Teraz zobaczmy drugą połowę tej ścieżki: jak ten ToolOutput trafia do widżetu i zamienia się w UI.
Aby nie traktować tego jak magii, omówmy jeszcze raz przepływ danych od użytkownika do Twojego widżetu. W uproszczeniu wygląda to tak:
- Użytkownik zadaje pytanie na czacie.
- GPT analizuje zapytanie, patrzy na listę narzędzi i decyduje: „Teraz pomoże mi suggest_gifts”.
- GPT tworzy wywołanie narzędzia z nazwą i argumentami (ToolInput) i wysyła je na Twój serwer (MCP lub backend).
- Serwer wykonuje logikę narzędzia i zwraca wynik w postaci ToolOutput — ustrukturyzowany JSON z danymi plus tekstowe podsumowanie dla modelu.
- ChatGPT odbiera ToolOutput i przekazuje go dalej: modelowi (do kontynuacji dialogu) oraz Twojemu widżetowi przez Apps SDK (window.openai.toolOutput lub przez hooki).
- Twój widżet — zwykły komponent React — odczytuje toolOutput i renderuje UI.
Schematycznie wygląda to tak:
flowchart TD U[Użytkownik] -->|zapytanie na czacie| GPT[GPT] GPT -->|callTool: suggest_gifts| B[Backend/MCP] B -->|"ToolOutput (JSON)"| GPT GPT -->|przekazuje toolOutput| W["Widżet (React)"] W -->|karty, listy| U
Warto zapamiętać: ToolOutput to nie tylko „odpowiedź serwera”. To także Twoja komenda renderowania dla widżetu i jednocześnie kontekst dla modelu. Dobry App to taki, w którym ten JSON zamienia się w wygodny interfejs, a nie jest tylko przewijany wzrokiem dewelopera w DevTools.
2. Anatomia ToolOutput: co jest w środku
Format wyniku narzędzia w Apps SDK dzieli się na trzy logiczne bloki: structuredContent, content i _meta (które trafia do widżetu pod nazwą toolResponseMetadata).
Umownie można to przedstawić tak:
{
"structuredContent": { /* dane dla UI + modelu */ },
"content": "Krótka tekstowa synteza dla modelu i użytkownika",
"_meta": { /* dane techniczne tylko dla widżetu */ }
}
W tabeli widać, kto co widzi:
| Pole | Kto widzi | Do czego służy |
|---|---|---|
|
Model + widżet | Główne ustrukturyzowane dane (listy, obiekty, parametry) |
|
Model + użytkownik (w tekście) | Krótka synteza, którą GPT może wstawić do swojej odpowiedzi |
|
Tylko widżet | Dane techniczne, których model nie potrzebuje (ID, wersje, klucze itp.) |
Dokumentacja Apps SDK podkreśla, że para structuredContent / content trafia do modelu i może być użyta w jego dalszych odpowiedziach. Pole _meta pozostaje ukryte i jest dostępne tylko w widżecie przez toolResponseMetadata.
Przykład ToolOutput dla GiftGenius
Załóżmy, że nasze narzędzie suggest_gifts po stronie serwera zwraca mniej więcej takie ciało:
{
"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": "Znalazłem kilka pomysłów na prezenty. Poniżej widżet pokazuje je w formie kart.",
"_meta": {
"giftGenius": {
"catalogVersion": "2025-10-01",
"experimentBucket": "A"
}
}
}
Tutaj structuredContent.items — to to, co będzie renderował Twój widżet React; content model może wykorzystać, by wyjaśnić użytkownikowi, co się dzieje; _meta.giftGenius — to informacja wewnętrzna potrzebna wyłącznie Twojemu UI lub analityce (np. jaką wersję katalogu użyć do linków).
Właśnie structuredContent to ten obiekt, na który będziesz patrzeć w JSX zamiast ręcznie parsować dowolny JSON z serwera.
3. Odbieranie ToolOutput w widżecie: window.openai i hooki
Czas przejść od rozmowy o JSON do kodu. Jak ten ToolOutput w ogóle trafia do Twojego komponentu React?
Szablon Apps SDK robi to na dwa główne sposoby: albo bezpośrednio przez window.openai.toolOutput, albo — co wygodniejsze — przez gotowe hooki React (useWidgetProps, useToolOutput i podobne). Zalecane jest korzystanie z hooków, aby nie dotykać window.openai bezpośrednio i mieć kod bezpieczniejszy oraz łatwiejszy w testowaniu.
Najprostszy wariant: bezpośrednio z window.openai
Dla zrozumienia można spojrzeć na „goły” wariant:
'use client';
function RawToolOutputDebug() {
const toolOutput = (window as any).openai?.toolOutput;
return (
<pre>{JSON.stringify(toolOutput, null, 2)}</pre>
);
}
W produkcji oczywiście tak nie róbmy, ale do debugowania i „na pierwszy rzut oka” — w sam raz.
Praktyczny wariant: przez hook React
Znacznie wygodniej jest opakować dostęp do window.openai w mały hook i pracować już z typowanym obiektem. Załóżmy, że nasz SDK udostępnia hook useWidgetProps, zwracający toolOutput i toolResponseMetadata.
'use client';
import { useWidgetProps } from '@/lib/openai-widget';
export function GiftWidgetRoot() {
const { toolOutput, toolResponseMetadata } = useWidgetProps();
// Na razie wyświetlimy liczbę prezentów
const items = toolOutput?.structuredContent?.items ?? [];
return (
<div>
Liczba prezentów: {items.length}
</div>
);
}
W rzeczywistym szablonie nazwa hooka może się różnić, ale idea jest zawsze ta sama: SDK pobiera dane z window.openai i przekazuje je Twojemu komponentowi jako propsy lub przez kontekst. To o wiele prostsze niż za każdym razem sięgać ręcznie do obiektu globalnego, a przy okazji pozwala w testach łatwo podmieniać źródło danych (np. wstrzykiwać fiksturę toolOutput).
4. Renderujemy prezenty: od structuredContent do JSX
Przechodzimy do rzeczy: weźmy structuredContent.items i narysujmy z nich karty. Pamiętajmy, że nasz widżet to zwykły komponent kliencki React w Next.js ('use client' na górze pliku).
Najpierw zdefiniujmy typ jednego prezentu:
type GiftItem = {
id: string;
title: string;
price: number;
currency: string;
tags?: string[];
};
A teraz mały komponent karty:
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>
);
}
I komponent listy, który pobiera dane z 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>
);
}
Zwróć uwagę, jak bardzo przypomina to zwykły kod React. Jedyna „magia” to źródło danych: zamiast props lub fetch czytamy toolOutput z kontenera ChatGPT.
I tak, nic złego, jeśli na początku będziesz dodawać as GiftItem[]. Później można ostrożnie stypizować structuredContent przez wspólne typy z backendem (np. użyć Zod / JSON Schema → typy TS), ale do demonstracji to wystarczy.
5. Stany UI wokół ToolOutput: ładowanie, pusto, błąd
Aplikacja, która pokazuje karty tylko wtedy, gdy się uda, a w pozostałych przypadkach milczy — nie jest przyjazna. Trzeba jawnie obsłużyć co najmniej cztery stany: gdy narzędzie się wykonuje, gdy danych jeszcze nie ma, gdy są wyniki oraz gdy coś poszło nie tak.
Apps SDK zwykle udostępnia pewne informacje o statusie wywołania narzędzia: przez listę wywołań (useToolInvocations) albo flagi związane z toolOutput. Na potrzeby tej lekcji wystarczy prosta logika: jeśli toolOutput jeszcze nie ma — jesteśmy w stanie „ładowanie”; jeśli jest, ale lista pusta — „pusto”; jeśli przyszła awaria — „błąd”.
Dla uproszczenia przyjmijmy, że serwer w razie błędu umieszcza w structuredContent pole error, a flaga ok w korzeniu toolOutput ma wartość false. Ten schemat omawialiśmy już w poprzednim temacie o implementacji serwerowej, projektując kontrakt odpowiedzi narzędzia.
type ToolOutput = {
ok: boolean;
structuredContent?: {
items?: GiftItem[];
error?: { code: string; message: string };
};
};
Teraz zaktualizujmy nasz komponent listy:
'use client';
import { useWidgetProps } from '@/lib/openai-widget';
export function GiftListWithStates() {
const { toolOutput } = useWidgetProps() as { toolOutput?: ToolOutput };
if (!toolOutput) {
return <div>Dobieramy prezenty…</div>;
}
if (!toolOutput.ok) {
const msg = toolOutput.structuredContent?.error?.message
?? 'Nie udało się uzyskać rekomendacji.';
return <div>Błąd: {msg}</div>;
}
const items = toolOutput.structuredContent?.items ?? [];
if (items.length === 0) {
return <div>Nie znaleziono prezentów spełniających Twoje kryteria. Spróbuj zmienić parametry.</div>;
}
return (
<div className="gift-list">
{items.map(gift => (
<GiftCard key={gift.id} gift={gift} />
))}
</div>
);
}
Taki kod zapewnia użytkownikowi sensowne doświadczenie:
- Gdy narzędzie pracuje, widać, że coś się dzieje.
- Jeśli wszystko się wysypało — jest zrozumiały komunikat, a nie pusty ekran.
- Jeśli nic nie znaleziono — nie udajemy, że to norma, tylko jasno tłumaczymy, co zaszło.
W produkcji najpewniej zamienisz tekst „Dobieramy prezenty…” na drobny skeleton albo spinner. Dla złożonych błędów można dać GPT możliwość sformułowania zrozumiałego wyjaśnienia. Ale bazowa struktura komponentów pozostanie taka sama.
6. Korzystanie z _meta i toolResponseMetadata w UI
Nauczyliśmy się już renderować główne dane z structuredContent i obsługiwać podstawowe stany loading/empty/error. Został jeszcze jeden ważny element ToolOutput, którego model nie używa — pole _meta.
Wróćmy do pola _meta. Nie jest ono widoczne dla modelu, ale trafia do widżetu jako toolResponseMetadata (nazwa może się różnić, idea ta sama).
To świetne miejsce na to, co nie powinno wpływać na rozumowanie GPT, ale jest ważne dla UI:
- wersje katalogu lub konfiguracji;
- wewnętrzne ID kampanii / eksperymentu A/B;
- flagi, które „przyciski” pokazywać użytkownikowi;
- dowolne techniczne rzeczy, których nie chcemy mieszać z danymi domenowymi.
Na przykład serwer może zwrócić takie _meta:
"_meta": {
"giftGenius": {
"catalogVersion": "2025-10-01",
"showExperimentalBadges": true
}
}
Widżet może to odczytać i np. narysować odznakę „Nowy pomysł” na niektórych kartach.
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 z {meta.catalogVersion}
</div>
)}
<div className="gift-list">
{items.map(gift => (
<GiftCard
key={gift.id}
gift={gift}
/>
))}
</div>
</div>
);
}
Model nie ma tu nic do rzeczy: nie zna catalogVersion ani showExperimentalBadges, za to Twój UI może z nich korzystać do woli.
Dokumentacja podkreśla właśnie ten podział: dane istotne dla dialogu i rozumowania modelu wkładamy do structuredContent i content; wszystko, co czysto techniczne dla UI — do _meta / toolResponseMetadata.
7. Kilka słów o statusach ToolInvocation i „Wykonuję X…”
Gdy narzędzie pracuje, ChatGPT sam pokazuje użytkownikowi, co się dzieje: u góry czatu pojawia się status w rodzaju „Wykonuję GiftGenius…” albo „Łączę się z aplikacją zewnętrzną”. To nie są ręcznie wypisane ciągi, tylko warstwa hosta ChatGPT reagująca na metadane wywołania narzędzia.
Pod spodem opisują to techniczne klucze w stylu _meta["openai/toolInvocation/invoking"] oraz _meta["openai/toolInvocation/invoked"], które sygnalizują, że akcja trwa albo została zakończona. Tymi polami posługuje się sama platforma do wyświetlenia statusu i na ogół nie trzeba ich dotykać: SDK robi to za Was po stronie serwera.
Dla UX oznacza to miły bonus: nawet jeśli widżet nie zdążył jeszcze narysować skeletonu, użytkownik już widzi, że system coś robi. Twoje zadanie to uzupełnić ten globalny status lokalnymi stanami w stylu „Dobieramy prezenty…” i skeletonem w widżecie, jak zrobiliśmy wyżej.
8. Rozmiar danych i wydajność: nie ładujemy całego świata do structuredContent
Warto omówić jeszcze jedno: „ile w ogóle można wrzucić do structuredContent”. Intuicyjnie kusi: „Mam cały katalog prezentów — oddam go w całości, a widżet sobie odfiltruje”. W praktyce lepiej tak nie robić.
Po pierwsze, structuredContent trafia do kontekstu modelu (LLM), a łączny budżet tokenów jest ograniczony. Dokumentacja i praktyczne poradniki zdecydowanie zalecają trzymać objętość w ryzach: to nie magazyn danych, tylko wynik jednego działania.
Po drugie, im większy payload, tym wolniejsza odpowiedź i tym większe ryzyko limitów lub nieoczekiwanych ucięć/błędów.
Rozsądne podejście:
- Backend wcześniej filtruje i sortuje dane, zwracając dokładnie to, co potrzebne na bieżący krok: np. 10–20 najlepszych prezentów.
- Jeśli potrzebne są kolejne strony, to osobne działanie (nowy tool call, nowy ToolOutput).
- Dla rzeczy czysto UI (np. lista wszystkich możliwych tagów do filtrowania) można użyć _meta, ale też z umiarem.
W module o stanie mówiliśmy już o koncepcji „backend — źródło prawdy, a widżet — cache/warstwa prezentacji”. Tu jest podobnie: wynik narzędzia to zgrabny „wycinek” stanu w momencie wywołania, a nie pełna kopia bazy.
9. Powiązanie ze stanem widżetu i dalszą rozmową
Choć ten wykład formalnie dotyczy ToolOutput → UI, nie można pominąć sąsiadującego elementu — widgetState. To on pozwala zapamiętać wybór użytkownika między renderami i uczynić z widżetu nie tylko witrynę, ale pełnoprawny kreator lub „konfigurator prezentu”.
Typowy scenariusz wygląda tak:
- Pierwszy ToolOutput przynosi listę prezentów.
- Użytkownik klika jedną z kart.
- Widżet zapisuje w widgetState, który prezent wybrano, i być może wysyła follow‑up lub nowe wywołanie narzędzia po szczegóły.
- Kolejne ToolOutput opierają się na tym wyborze.
Z punktu widzenia kodu wygląda to jak zwykły stan React plus wywołanie setWidgetState, które utrwala wybór po stronie ChatGPT. Różnica jest taka, że ten stan jest dostępny zarówno dla modelu, jak i dla backendu, więc trzeba trzymać go zwięźle i nie przechowywać tam sekretów.
Szczegółowo omówimy to w modułach o wieloetapowych workflow i follow‑upach. Już teraz warto myśleć tak: ToolOutput daje Ci „wycinek danych” z serwera, a widgetState — kontekst wyboru użytkownika wokół tego wycinka.
Typowe błędy przy pracy z ToolOutput → UI
Błąd nr 1: „UI renderuje surowe drzewo JSON bez adaptacji do użytkownika”.
Czasem dla debugowania kusi, by po prostu zrobić <pre>{JSON.stringify(toolOutput)}</pre> i na tym poprzestać. W trakcie developmentu to ok, ale w produkcji użytkownik widzi strukturę, z której jesteś dumny, ale której nie rozumie. Warto jak najwcześniej opakowywać structuredContent w sensowne komponenty (listy, karty, tabele), zamiast kazać człowiekowi czytać tokenizowaną odpowiedź serwera.
Błąd nr 2: Mieszanie danych domenowych i metadanych technicznych w structuredContent.
Kod jest znacznie czystszy, gdy rozdzielamy: „to, co ma być widoczne dla modelu i użytkownika” oraz „to, co potrzebne tylko UI i analityce”. Pola techniczne — flagi eksperymentalne, wersje katalogów, idempotency key — mają swoje miejsce w _meta / toolResponseMetadata. Gdy wszystko leży wymieszane w structuredContent, trudniej ewoluować kontrakt i testować zachowanie modelu.
Błąd nr 3: Brak jawnych stanów ładowania, pustego wyniku i błędów.
Pusty <div></div> zamiast „Nic nie znaleziono” lub „Coś poszło nie tak” to prosta droga do wniosku: „App nie działa”. Nawet minimalne tekstowe placeholdery i prosty skeleton dramatycznie poprawiają UX. Nie polegaj wyłącznie na systemowym statusie ChatGPT „Wykonuję X…” — widżet też powinien mówić, co się z nim dzieje.
Błąd nr 4: Próba upchnięcia w jednym ToolOutput całego świata.
Zwracanie całego katalogu produktów, historii użytkownika i jeszcze logów serwera w jednym structuredContent — zły pomysł. Uderza to w limity modelu, spowalnia odpowiedź i komplikuje UI. Lepiej zwracać dokładnie taki zakres danych, jaki jest potrzebny na bieżący krok (strona listy, szczegóły wybranego elementu itp.), a kolejne kroki realizować osobnymi wywołaniami narzędzia.
Błąd nr 5: Sztywne powiązanie UI z niestabilnym kształtem odpowiedzi bez typów.
Jeśli wszędzie w kodzie piszesz toolOutput.structuredContent.items[0].whatever, nie sprawdzając obecności pól i nie mając typów, każda ewolucja schematu po stronie serwera skończy się wywrotką widżetu. Warto albo zsynchronizować typy z JSON Schema (generacja typów TS), albo przynajmniej ręcznie opisać interfejsy (GiftItem, ToolOutput) i ostrożnie obchodzić się z polami opcjonalnymi.
Błąd nr 6: Ignorowanie _meta i przeładowywanie modelu „zbędnymi” polami.
Często kusi, by włożyć do structuredContent wszystko, bo „to tylko JSON, nie zaszkodzi”. Ale każde pole zwiększa kontekst modelu, a wiele rzeczy modelowi w ogóle nie jest potrzebnych. Jeśli informacja nie powinna wpływać na rozumowanie GPT i nie jest potrzebna w odpowiedzi tekstowej, odkładaj ją do _meta i używaj tylko w widżecie.
Błąd nr 7: Bezpośrednie odwołania do window.openai z dziesiątki komponentów.
Tak, window.openai.toolOutput działa, ale gdy pół aplikacji zaczyna grzebać w zmiennej globalnej, debugowanie i testowanie zamienia się w koszmar. O wiele lepiej raz opakować to w hook/kontekst (useWidgetProps/useToolOutput) i dalej używać normalnych propsów oraz typowanych obiektów. To czyściej i łatwiej podmienia się fiksturami w Storybooku/testach.
GO TO FULL VERSION