1. Від ToolOutput до React‑компонента: загальний потік даних
У попередній лекції ми розібрали, як серверний інструмент формує ToolOutput — структуровану відповідь для моделі та віджета. Тепер подивімося на другу половину цього шляху: як ToolOutput потрапляє у віджет і перетворюється на UI.
Щоб усе це не здавалося «магією», ще раз окреслимо шлях даних — від користувача до вашого віджета. У спрощеному вигляді процес виглядає так:
- Користувач ставить запитання в чаті.
- GPT аналізує запит, переглядає список інструментів і вирішує: «Зараз мені допоможе suggest_gifts».
- GPT формує виклик інструмента (tool call) з іменем і аргументами (ToolInput) та надсилає його на ваш сервер (MCP або бекенд).
- Сервер виконує логіку інструмента й повертає результат у вигляді ToolOutput — структурований JSON із даними та текстовим резюме для моделі.
- ChatGPT отримує ToolOutput і передає його далі: моделі (щоб продовжити діалог) і вашому віджету через Apps SDK (window.openai.toolOutput або хуки).
- Ваш віджет — звичайний React‑компонент — читає toolOutput і відображає UI.
Схематично це можна показати так:
flowchart TD U[Користувач] -->|запит у чаті| GPT[GPT] GPT -->|callTool: suggest_gifts| B[Backend/MCP] B -->|"ToolOutput (JSON)"| GPT GPT -->|передає toolOutput| W["Віджет (React)"] W -->|картки, списки| U
Важливо зафіксувати думку: ToolOutput — це не просто «відповідь сервера». Для віджета це, по суті, команда на відображення, а для моделі — контекст для продовження діалогу. Хороший застосунок — це той, де цей JSON перетворюється на зручний інтерфейс, а не на те, що розробник розглядає в DevTools.
2. Анатомія ToolOutput: що всередині
Формат результату інструмента в Apps SDK поділяється на три логічні блоки: structuredContent, content і _meta (у віджеті воно приходить під іменем toolResponseMetadata).
Умовно це можна уявити так:
{
"structuredContent": { /* дані для UI + моделі */ },
"content": "Коротка текстова зведена інформація для моделі та користувача",
"_meta": { /* службові дані лише для віджета */ }
}
У таблиці нижче видно, хто що бачить:
| Поле | Хто бачить | Для чого використовується |
|---|---|---|
|
Модель + віджет | Основні структуровані дані (списки, об’єкти, параметри) |
|
Модель + користувач (у тексті) | Коротке резюме, яке GPT може вставити у свою відповідь |
|
Лише віджет | Службові дані, які не потрібні моделі (ID, версії, ключі тощо) |
Документація Apps SDK підкреслює: пара structuredContent / content потрапляє до моделі й може використовуватися в її подальших відповідях. Поле _meta при цьому залишається прихованим і доступним лише всередині віджета — через toolResponseMetadata.
Приклад ToolOutput для GiftGenius
Припустімо, наш інструмент suggest_gifts на сервері повертає приблизно таке тіло:
{
"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": "Знайшов кілька ідей подарунків. Нижче віджет показує їх картками.",
"_meta": {
"giftGenius": {
"catalogVersion": "2025-10-01",
"experimentBucket": "A"
}
}
}
Тут structuredContent.items — це те, що відображатиме ваш React‑віджет. Поле content модель може використати, щоб пояснити користувачеві, що зараз відбувається. А _meta.giftGenius — внутрішня інформація, потрібна лише вашому UI або аналітиці (наприклад, щоб знати, яку версію каталогу використати для посилань).
Саме structuredContent — той об’єкт, на який ви дивитиметеся в JSX, замість того щоб власноруч розбирати довільний JSON від сервера.
3. Отримуємо ToolOutput у віджеті: window.openai і хуки
Час перейти від розмов про JSON до коду. Як узагалі ToolOutput потрапляє у ваш React‑компонент?
Шаблон Apps SDK зазвичай пропонує два основні підходи: або читати дані безпосередньо з window.openai.toolOutput, або (і це зручніше) користуватися готовими React‑хуками (useWidgetProps, useToolOutput тощо). Рекомендований варіант — хуки. Так вам не доведеться вручну «чіпати» window.openai, а код буде і безпечнішим, і зручнішим для тестування.
Найпростіший варіант: безпосередньо з window.openai
Щоб зрозуміти механіку, можна подивитися на «голий» варіант:
'use client';
function RawToolOutputDebug() {
const toolOutput = (window as any).openai?.toolOutput;
return (
<pre>{JSON.stringify(toolOutput, null, 2)}</pre>
);
}
У робочому середовищі так робити, звісно, не варто. Але для налагодження й «першого погляду» — цілком нормально.
Практичний варіант: через React‑хук
Набагато зручніше загорнути доступ до window.openai у невеликий хук і працювати вже з типізованим об’єктом. Нехай наш умовний SDK надає хук useWidgetProps, який повертає toolOutput і toolResponseMetadata.
'use client';
import { useWidgetProps } from '@/lib/openai-widget';
export function GiftWidgetRoot() {
const { toolOutput, toolResponseMetadata } = useWidgetProps();
// Поки просто виведемо кількість подарунків
const items = toolOutput?.structuredContent?.items ?? [];
return (
<div>
Знайдено подарунків: {items.length}
</div>
);
}
У реальному шаблоні назва хука може відрізнятися, але ідея завжди одна: SDK бере дані з window.openai і передає їх вашому компоненту як пропси або через контекст. Це значно простіше, ніж щоразу власноруч лізти в глобальний об’єкт.
До того ж такий підхід дає змогу в тестах легко підміняти джерело даних (наприклад, підставляти фікстуру toolOutput).
4. Рендеримо подарунки: від structuredContent до JSX
Переходимо до найцікавішого: візьмемо structuredContent.items і покажемо їх картками. Не забувайте, що наш віджет — звичайний клієнтський React‑компонент у Next.js ('use client' нагорі файла).
Спочатку визначимо тип одного подарунка:
type GiftItem = {
id: string;
title: string;
price: number;
currency: string;
tags?: string[];
};
Тепер напишемо невеликий компонент картки:
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>
);
}
І компонент списку, який бере дані з 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>
);
}
Зверніть увагу, наскільки це схоже на звичайний React‑код. Єдина «магія» тут — джерело даних: замість props або fetch ми читаємо toolOutput із контейнера ChatGPT.
І так, нічого страшного, якщо спочатку ви додаватимете as GiftItem[]. Згодом можна акуратно типізувати structuredContent через спільні типи з бекендом (наприклад, використати Zod / JSON Schema → TS‑типи). Для демонстрації цього достатньо.
5. Стани UI навколо ToolOutput: завантаження, порожньо, помилка
Застосунок, який просто показує картки, коли «пощастило», а в усіх інших випадках мовчить, — не надто дружній. Потрібно явно обробляти щонайменше чотири стани: коли інструмент ще виконується, коли даних ще немає, коли результат уже є та коли щось пішло не так.
Apps SDK зазвичай надає певну інформацію про статус виклику інструмента: через список викликів (tool invocations, useToolInvocations) або прапори, повʼязані з toolOutput. Для цієї лекції нам достатньо простої моделі: якщо toolOutput ще немає — отже, ми у стані «завантаження»; якщо він є, але список порожній — «порожньо»; якщо прийшла помилка — «помилка».
Для простоти вважатимемо, що сервер у разі помилки додає в structuredContent поле error, а прапор ok у корені toolOutput дорівнює false. Цю схему ми вже обговорювали в попередній темі про серверну реалізацію — коли проєктували контракт відповіді інструмента.
type ToolOutput = {
ok: boolean;
structuredContent?: {
items?: GiftItem[];
error?: { code: string; message: string };
};
};
Тепер оновимо наш компонент списку:
'use client';
import { useWidgetProps } from '@/lib/openai-widget';
export function GiftListWithStates() {
const { toolOutput } = useWidgetProps() as { toolOutput?: ToolOutput };
if (!toolOutput) {
return <div>Підбираємо подарунки…</div>;
}
if (!toolOutput.ok) {
const msg = toolOutput.structuredContent?.error?.message
?? 'Не вдалося отримати рекомендації.';
return <div>Помилка: {msg}</div>;
}
const items = toolOutput.structuredContent?.items ?? [];
if (items.length === 0) {
return <div>За вашими умовами подарунків не знайшлося. Спробуйте змінити параметри.</div>;
}
return (
<div className="gift-list">
{items.map(gift => (
<GiftCard key={gift.id} gift={gift} />
))}
</div>
);
}
Такий код уже дає користувачеві адекватний досвід:
- Поки інструмент працює, видно, що щось відбувається.
- Якщо все зламалося — є зрозуміле повідомлення, а не порожній екран.
- Якщо нічого не знайшлося — ми не робимо вигляд, що це норма, а чесно пояснюємо, що сталося.
У робочому середовищі ви, ймовірно, заміните текст «Підбираємо подарунки…» на невеликий skeleton або spinner. Для складніших помилок можна дати GPT змогу сформулювати зрозуміле для людини пояснення. Базова структура компонентів при цьому залишиться такою самою.
6. Використовуємо _meta і toolResponseMetadata в UI
Ми вже навчилися відображати основні дані з structuredContent і обробляти базові стани loading/empty/error. Залишився ще один важливий шматочок ToolOutput, яким модель не користується, — поле _meta.
Повернімося до _meta. Модель його не бачить, зате воно приходить у ваш віджет як toolResponseMetadata (назва може відрізнятися, але суть та сама).
Це чудове місце для того, що не повинно впливати на міркування GPT, але важливо для UI:
- версії каталогу або конфігурації;
- внутрішні ID кампанії / експерименту A/B;
- прапори, які визначають, які «кнопки» показувати користувачеві;
- будь‑які технічні речі, які не хочеться змішувати з доменними даними.
Наприклад, сервер може повернути таке _meta:
"_meta": {
"giftGenius": {
"catalogVersion": "2025-10-01",
"showExperimentalBadges": true
}
}
Віджет може прочитати це і, скажімо, намалювати бейдж «Нова ідея» на деяких картках.
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">
Каталог від {meta.catalogVersion}
</div>
)}
<div className="gift-list">
{items.map(gift => (
<GiftCard
key={gift.id}
gift={gift}
/>
))}
</div>
</div>
);
}
Модель тут узагалі ні до чого: вона не знає про catalogVersion і showExperimentalBadges, зате ваш UI може використовувати їх як завгодно.
Документація підкреслює саме цей поділ: дані, важливі для діалогу та міркувань моделі, кладемо в structuredContent і content. А все, що суто UI‑технічне, — у _meta / toolResponseMetadata.
7. Трохи про статуси ToolInvocation і «Виконую X…»
Поки інструмент працює, ChatGPT сам показує користувачеві, що відбувається: у верхній частині чату зʼявляється статус на кшталт «Виконую GiftGenius…» або «Звертаюся до зовнішнього застосунку». Це не ви вручну виводите рядки — хост‑середовище ChatGPT реагує на метадані виклику інструмента.
Внутрішньо це описується через службові ключі виду _meta["openai/toolInvocation/invoking"] і _meta["openai/toolInvocation/invoked"], які сигналізують, що дія виконується або завершена. Ці поля використовує сама платформа для відображення статусу. Зазвичай вам не потрібно їх чіпати: SDK робить це за вас на боці сервера.
Для UX це приємний бонус: навіть якщо віджет ще не встиг показати скелетон, користувач уже бачить, що система щось робить. Ваше завдання — доповнювати цей глобальний статус локальними станами на кшталт «Підбираємо подарунки…» і скелетоном у віджеті, як ми робили вище.
8. Розмір даних і продуктивність: не тягнемо весь світ у structuredContent
Окремо варто проговорити тему «скільки взагалі можна напхати в structuredContent». Інтуїтивно це може здаватися привабливим: «У мене ж є весь каталог подарунків — давайте віддамо його цілком, а віджет сам відфільтрує». На практиці так робити не варто.
По‑перше, structuredContent потрапляє в контекст моделі (LLM), а загальний обсяг токенів обмежений. Документація та практичні гіди наполегливо радять тримати обсяг акуратним: це не сховище даних, а результат однієї дії.
По‑друге, що більший обсяг даних, то довше приходить відповідь. Також зростає шанс упертися в ліміти або отримати несподівані обрізання чи помилки.
Здоровий підхід такий:
- Бекенд заздалегідь фільтрує й сортує дані, повертаючи рівно те, що потрібно для поточного кроку: наприклад, 10–20 найкращих подарунків.
- Якщо потрібні наступні сторінки, це окрема дія (новий виклик інструмента, новий ToolOutput).
- Для суто UI‑речей (наприклад, список усіх можливих тегів для фільтрації) можна використати _meta, але теж без фанатизму.
У модулі про стан ми вже обговорювали концепцію «бекенд — джерело істини, а віджет — кеш/представлення». Тут — та сама логіка: результат інструмента — це акуратний «зріз» стану на момент виклику, а не повна копія вашої бази.
9. Звʼязка зі станом віджета і подальшим діалогом
Хоча ця лекція формально про ToolOutput → UI, не можна не згадати ще один важливий елемент — widgetState. Саме він дає змогу запамʼятати вибір користувача між рендерами та перетворити ваш віджет із простої «вітрини» на повноцінний майстер або «конфігуратор подарунка».
Типовий сценарій виглядає так:
- Перший ToolOutput приносить список подарунків.
- Користувач клікає на одну з карток.
- Віджет записує в widgetState, який подарунок обрано, і, можливо, надсилає follow‑up або робить новий виклик інструмента для деталей.
- Наступні ToolOutput‑и спираються на цей вибір.
З погляду коду це виглядає як звичайний React‑стан плюс виклик setWidgetState, який зберігає вибір на боці ChatGPT. Різниця лише в тому, що цей стан доступний і моделі, і вашому бекенду. Тому його потрібно тримати компактним і не зберігати там секрети.
Докладно ми розберемо це в модулях про багатокрокові workflow і follow‑ups. Уже зараз корисно мислити так: ToolOutput дає вам «зріз даних» від сервера, а widgetState — контекст вибору користувача навколо цього зрізу.
Типові помилки під час роботи з ToolOutput → UI
Помилка № 1: «UI відображає сире JSON‑дерево без адаптації для користувача».
Інколи для налагодження хочеться просто зробити <pre>{JSON.stringify(toolOutput)}</pre> — і на цьому зупинитися. Для розробки це нормально, але в робочому середовищі користувач бачить структуру, якою ви пишаєтеся, а не зрозумілий інтерфейс. Важливо якомога раніше загортати structuredContent в осмислені компоненти (списки, картки, таблиці), а не змушувати людину читати токенізовану відповідь сервера.
Помилка № 2: змішування доменних даних і технічних метаданих у structuredContent.
Код стає значно чистішим, якщо розділяти «те, що має бути видно моделі й користувачеві», і «те, що потрібно лише UI та аналітиці». Технічним полям — експериментальні прапори, версії каталогів, idempotency key — місце в _meta / toolResponseMetadata. Коли все це лежить упереміш у structuredContent, складніше розвивати контракт і тестувати поведінку моделі.
Помилка № 3: відсутність явних станів завантаження, порожнього результату та помилок.
Порожній <div></div> замість «Нічого не знайдено» або «Щось пішло не так» — прямий шлях до того, що користувач вирішить: «Застосунок не працює». Навіть мінімальні текстові заглушки й простий skeleton разюче покращують UX. Не покладайтеся лише на системний статус ChatGPT «Виконую X…» — віджет також має показувати, що з ним відбувається.
Помилка № 4: спроба запхнути в один ToolOutput увесь світ.
Повернути цілий каталог товарів, історію користувача та ще й логи сервера в одному structuredContent — погана ідея. Це бʼє по лімітах моделі, сповільнює відповідь і ускладнює UI. Краще повертати рівно той обсяг даних, який потрібен для поточного кроку (сторінка списку, деталі обраного елемента тощо), а наступні кроки оформлювати окремими викликами інструмента.
Помилка № 5: жорстка привʼязка UI до нестабільної форми відповіді без типів.
Якщо всюди в коді писати toolOutput.structuredContent.items[0].whatever, не перевіряючи наявність полів і не маючи типів, будь‑яка еволюція схеми на сервері призведе до падінь віджета. Варто або синхронізувати типи з JSON Schema (генерація TS‑типів), або хоча б вручну описати інтерфейси (GiftItem, ToolOutput) і акуратно працювати з optional‑полями.
Помилка № 6: ігнорування _meta і перевантаження моделі «зайвими» полями.
Буває спокуса складати в structuredContent усе підряд, бо «там же JSON, зайвого не буває». Але кожне поле збільшує контекст моделі, а багато речей моделі взагалі не потрібні. Якщо інформація не повинна впливати на міркування GPT і не потрібна в текстовій відповіді, складайте її в _meta і працюйте з нею лише у віджеті.
Помилка № 7: прямі звернення до window.openai з десятка компонентів.
Так, window.openai.toolOutput працює. Але коли пів застосунку починає лазити в глобальну змінну, налагодження й тестування стають пеклом. Значно краще один раз загорнути це в хук/контекст (useWidgetProps/useToolOutput), а далі використовувати вже нормальні пропси та типізовані обʼєкти. Це і чистіше, і простіше підміняється фікстурами в Storybook/тестах.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ