1. Два шляхи назовні: навігація та дані
Коли розробник Next.js чує «треба сходити на сервер», рука автоматично тягнеться до fetch або до улюбленого HTTP‑клієнта. У світі ChatGPT Apps така рефлекторна реакція швидко обертається суцільною морокою.
У частині курсу, присвяченій безпеці віджетів у ChatGPT Apps, ми пропонуємо від самого початку позбутися цього старого рефлексу. Віджет не живе у «вільному інтернеті»: він працює в жорсткій ізоляції, а його мережевий доступ фільтрується й обмежується політиками хоста.
У віджета є лише три базові канали назовні:
- Навігація: скерувати користувача кудись у зовнішній світ. Для цього є openExternal.
- Обмін даними: отримати або надіслати JSON, поспілкуватися з бекендом. Це робиться через fetch, але можливі суттєві обмеження.
- Виклик інструментів MCP: звернення до інструментів (MCP/бекенд), які не мають жодних обмежень.
У цій лекції ми зосередимося на першому й найбезпечнішому шляху (навігації) та обережно познайомимося з контрольованим fetch. У наступних модулях розберемо MCP та інструменти як основний спосіб серйозної взаємодії із сервером.
2. openExternal: безпечний «телепорт» користувача
Чому не можна просто викликати window.open
У вебзастосунку ви зробили б приблизно так:
window.open("https://example.com", "_blank");
У пісочниці ChatGPT це або не спрацює, або спрацює якось дуже дивно. Віджет — це ізольований iframe із жорстким sandbox, який не має тих самих прав, що й вкладка браузера.
Крім того, хост ChatGPT хоче контролювати, куди й коли ви ведете користувача, щоб:
- не допустити прихованого трекінгу;
- показати користувачеві зрозумілий UI підтвердження (особливо в мобільних і десктоп‑клієнтах);
- забезпечити однакову поведінку посилань у різних середовищах (веб, десктоп, мобільний застосунок).
Саме тому створили спеціальний API openExternal, доступний через window.openai, або зручніший React‑хук useOpenExternal.
Як виглядає useOpenExternal
В офіційних прикладах Apps SDK хук useOpenExternal реалізовано приблизно так:
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;
}
Головна думка тут проста: спочатку ми намагаємося скористатися нативним механізмом ChatGPT (window.openai.openExternal). Якщо віджет раптом рендериться поза ChatGPT (наприклад, ви відкрили його просто в браузері під час розробки), ми акуратно повертаємося до звичайного window.open.
У вашому застосунку цей хук уже є в шаблоні (якщо ви взяли стандартний репозиторій від OpenAI). Користуватися ним варто саме так — замість того, щоб звертатися напряму до window.openai.
Приклад: кнопка «Переглянути в магазині» в GiftGenius
Уявімо, що в toolOutput нашого GiftGenius приходять рекомендації з полем url. Додамо до кожної картки кнопку, яка відкриватиме товар на вашому сайті:
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>Поки немає рекомендацій…</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)}>
Відкрити
</button>
</div>
))}
</div>
);
}
З погляду користувача це виглядає так: він натискає кнопку, ChatGPT може показати системне вікно «Відкрити зовнішній сайт?», а тоді відкриє вашу сторінку в новій вкладці або в браузері за замовчуванням. Ви не передаєте туди жодних секретів, токенів тощо — просто скеровуєте людину «з чату на сайт».
3. window.fetch у пісочниці: це не той fetch, до якого ви звикли
Що зазвичай очікує фронтенд‑розробник
Зазвичай логіка така: «Раз це браузер, значить, можна спокійно зробити запит на будь‑який URL, для якого налаштовано CORS. У гіршому разі отримаю помилку, але ж спробувати можна».
В екосистемі ChatGPT Apps це небезпечна омана. Пісочниця навколо віджета — не просто «дрібна прискіпливість», а фундаментальна вимога безпеки. Вона потрібна, щоб віджет не міг відстежувати користувача, ходити на довільні домени, сканувати локальну мережу й узагалі поводитися як мінібраузер усередині браузера.
У документації також підкреслюють, що в Apps SDK довільний мережевий доступ для віджета або відсутній, або суттєво обмежений. Це не помилка, а свідоме архітектурне рішення.
Як це виглядає на практиці
У типовому середовищі ChatGPT:
- fetch може бути доступний, але лише для обмеженого списку доменів (зазвичай це ваш домен, на якому працює застосунок, і, можливо, кілька явно дозволених API);
- запити можуть іти через спеціальний проксі хоста, який фільтрує заголовки та URL;
- деякі методи (PUT, DELETE) або нестандартні заголовки можуть блокуватися політиками безпеки.
Водночас у вас лишається зручний варіант. Якщо віджет і бекенд живуть на одному домені (як у шаблоні на Next.js, де і MCP‑сервер, і UI обслуговуються одним застосунком), внутрішні запити fetch("/api/...") зазвичай дозволені.
Головне — не розраховувати на те, що віджет зможе ходити на будь‑який API в інтернеті. Усе «важке» спілкування із зовнішніми сервісами (Stripe, Notion, CRM тощо) має відбуватися на боці MCP/бекенду. Саме туди ChatGPT звертається як до довіреного ресурсу.
Інсайт
У віджеті ChatGPT варто одразу відмовитися від відносних шляхів і використовувати абсолютні URL. Причина проста: ваш HTML не працює на тому самому домені, що й бекенд. ChatGPT читає ваш HTML, розміщує його на своєму хості та рендерить усередині ізольованого iframe. Будь‑який "/api/..." або "/static/logo.png" раптом починає визначатися відносно домену ChatGPT, а не вашого застосунку — і все розвалюється.
<base> тут майже не допомагає. Практика показує: якщо у віджета не задано widgetCSP, ви можете прописати <base href="https://my-app.dev/">. Ресурси підтягнуться з вашого домену, але скрипти за правилами пісочниці все одно не працюватимуть. Утім, це працює лише в Dev Mode.
Щойно ви задаєте коректний openai/widgetCSP (а в продакшн‑середовищі його все одно доведеться задати для review), платформа скидає <base>. На цьому «гра закінчується»: ресурси та скрипти завантажуються тільки з доменів, дозволених у CSP, причому вже за абсолютними посиланнями.
Рекомендація: у ChatGPT‑віджеті все, що виходить назовні, — fetch, зображення, CSS, ваші сторінки для openExternal — завжди будуйте як повний URL від базового домену застосунку. Цей домен варто контролювати через конфіг/ENV, а не через відносні шляхи та <base>.
4. Архітектура: тонкий UI, товстий бекенд
З обмежень fetch і самої пісочниці випливає ширший архітектурний принцип, важливий для всього курсу. Ми вже кілька разів повторювали цю мантру, але тепер саме час її закріпити.
Віджет — це тонкий UI‑шар. Він рендерить те, що вже підготував бекенд (через MCP/tools), показує реакції на дії користувача й у крайньому разі робить кілька невеликих публічних запитів.
Усе, що повʼязано з авторизацією, доступом до персональних даних, секретами та нетривіальною бізнес‑логікою, має жити на боці сервера. Документи з безпеки курсу окремо підкреслюють: фронтенд (React‑віджет) — це «public place», зона нульової довіри, і секретів там бути не повинно.
У матеріалах на цю тему мету формулюють доволі жорстко: остаточно відмовитися від ідеї «товстого клієнта» для ChatGPT Apps. Віджет — лише «голова», а «тіло й мозок» — у MCP/бекенді.
Тому:
- openExternal — для навігації користувача на ваш «звичайний» сайт, де можна запускати вже звичний SPA, особистий кабінет тощо;
- callTool (наступний модуль) — основний спосіб передати моделі задачу, яку виконає ваш бекенд;
- fetch із віджета — рідкісний гість: для допоміжних, безпечних і, бажано, публічних запитів до вашого ж застосунку.
5. Практика: openExternal у нашому GiftGenius
Давайте трохи охайніше вбудуємо openExternal у наш навчальний застосунок і водночас подумаємо про UX.
Міні‑UX‑правило
Якщо ви ведете користувача назовні, корисно:
- чітко підказати, куди саме він потрапить;
- не робити несподіваних «стрибків» без пояснення в тексті (або GPT повідомляє «Відкрию сайт магазину…», або ви підписуєте кнопку).
Приклад заголовка та підпису:
<button onClick={() => openExternal(gift.url)}>
Відкрити на сайті магазину
</button>
Користувач розуміє, що зараз його «виведе» з чату в реальний світ із кошиком та оплатою.
Невеликий рефакторинг компонента списку
Раніше ми вже робили простий GiftListWidget. Припустімо, що в попередніх лекціях ви вже реалізували віджет, який показує список подарунків за toolOutput. Тепер зробимо трохи охайнішу версію: додамо тип Gift із полем url і кнопку з 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>Поки нічого не знайшов. Спробуйте змінити запит.</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)}>
Переглянути
</button>
</div>
))}
</div>
);
}
Як і раніше, ми не працюємо напряму з window.openai, а користуємося зручним хуком. Він уже вміє повертатися до window.open у тих випадках, коли ChatGPT‑середовища немає. Структура Gift тут приблизна — у своєму застосунку ви підлаштуєте її під свій бекенд.
6. Практика: акуратний fetch до нашого бекенду
Тепер розберімося з fetch. Нагадаю ще раз: складні або чутливі операції краще робити через інструменти/MCP. Але іноді хочеться з віджета підтягнути щось легке й публічне з вашого ж сервера — наприклад, список популярних категорій подарунків.
Простий публічний API‑роут у Next.js
Додамо до нашого Next.js‑проєкту такий обробник:
// app/api/public/popular-tags/route.ts
import { NextResponse } from "next/server";
const tags = ["Для дітей", "Для мандрівників", "Для геймерів"];
export async function GET() {
return NextResponse.json({ tags });
}
Цей роут нічого не знає про користувача, не вимагає токенів і не ходить до зовнішніх сервісів — він просто віддає статичний масив. Такий код можна майже без ризику переносити і в продакшн, і в пісочницю.
Виклик цього роута з віджета через fetch
Тепер у компоненті віджета додамо завантаження цих тегів. З огляду на обмеження пісочниці, найзручніше робити запит на абсолютний URL: на той самий домен, де працює ваш застосунок. Саме його ви передаєте через тунель і реєструєте в Dev Mode ChatGPT (ми налаштовували це в модулі про Dev Mode і тунель).
Важливо: домен у вашого віджета буде щось на кшталт https://genius.web-sandbox.oaiusercontent.com. Тому не використовуйте відносні шляхи для завантаження даних — лише абсолютні. Приклад:
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("Не вдалося завантажити популярні категорії");
}
}
loadTags();
return () => {
cancelled = true;
};
}, []);
if (error) return <p>{error}</p>;
if (!tags) return <p>Завантажую популярні категорії…</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>
);
}
Важливо, що:
- ми акуратно обробляємо помилки й показуємо користувачеві зрозуміле повідомлення;
- не покладаємося на те, що fetch «точно спрацює» — політики пісочниці можуть будь‑коли перекрити доступ, якщо ви зміните домен або почнете робити підозрілі запити;
- не передаємо сюди жодних токенів або секретів; якщо знадобиться автентифікація — це вже завдання MCP і модулів про Auth.
7. openExternal vs fetch vs інструменти (callTool): хто за що відповідає
Щоб не плутатися, зручно тримати в голові таку «матрицю обовʼязків»:
| Сценарій | Що використовуємо | Чому саме так |
|---|---|---|
| Відкрити посадкову сторінку/товар/кабінет | openExternal | Явний перехід користувача, контрольований хостом |
| Отримати публічні дані із застосунку | fetch("https://my.com/api/...") | Легкий JSON, той самий домен, без секретів |
| Отримати дані користувача, БД | callTool/MCP | Потрібна авторизація, логіка, безпечний бекенд |
| Звертатися до зовнішніх API (Stripe…) | MCP/сервер | Фронтенд не бачить секретів, дотримуємося політик |
У поточному модулі нам важливо навчитися свідомо обирати інструмент. Варто відійти від мислення «віджет — це фронтенд, значить, усе можна зробити через fetch» і перейти до архітектури «віджет — це керований UI‑шар поверх LLM+MCP‑бекенду».
Інсайт
Взаємодію із сервером у ChatGPT Apps логічно ділити на два рівні:
- ChatGPT ↔ MCP‑сервер: модель викликає MCP‑інструменти. Кожен tool‑call — це запуск або перемикання бізнес‑сценарію (підбір подарунків, створення замовлення, розрахунок вартості тощо). Тут живе «важка» логіка, робота з даними, зовнішні API та авторизація.
- Віджет ↔ сервер: віджет робить легкі fetch()‑запити до свого бекенду та/або викликає ті самі MCP‑інструменти через callTool() уже всередині активного сценарію. Це локальні кроки: дозавантажити допоміжні дані, оновити шматок UI, уточнити стан.
Тобто MCP‑tool = запуск або керування бізнес‑процесом, а fetch()/callTool() із віджета — дрібні операції всередині вже обраного сценарію. Вони не претендують на зміну загальної «історії» діалогу.
8. Невелика практична вправа
Щоб закріпити тему на практиці, можна додати невелику можливість у GiftGenius.
Запропонований сценарій:
- У списку подарунків додайте кнопку «Перейти до оформлення», яка через openExternal відкриває сторінку оформлення замовлення на вашому тестовому сайті.
- Над списком подарунків відрендеріть PopularTags із прикладу вище, щоб показати популярні категорії. У разі помилки завантаження зробіть fallback‑текст і не ламайте весь віджет.
- Зверніть увагу на UX: у тексті GPT‑відповіді або в UI віджета поясніть користувачеві, що «після натискання кнопки я відкрию сторінку магазину в новій вкладці».
Ця можливість у мініатюрі показує обидва канали:
- openExternal для явної навігації;
- fetch для невеликого публічного API, що живе поруч із вашим застосунком.
9. Типові помилки під час роботи з window.fetch і openExternal
Помилка № 1: намагатися використовувати віджет як повноцінний SPA‑клієнт для всіх ваших API.
Старі звички сильно тягнуть у бік «а давайте просто викличемо наш REST/GraphQL прямо з React». У світі ChatGPT Apps це призводить до лобового зіткнення з пісочницею: частина запитів просто не пройде, частину заблокують політики, а безпека проєкту опиниться під питанням. Складна логіка й доступ до даних користувача мають іти через MCP/інструменти, а не безпосередньо з віджета.
Помилка № 2: зберігати секрети й токени в коді віджета.
Іноді хочеться «швидко зробити прототип» і вписати в код фронтенда API‑ключ до якогось сервісу («ну я ж тільки тестую»). Це погана ідея навіть для звичайного SPA, а для ChatGPT Apps — категоричне ні. Віджет — публічне середовище; секрети мають жити в конфігурації сервера або в системах керування секретами (Vercel env, KMS тощо).
Помилка № 3: вважати, що fetch до будь‑якого домену «просто спрацює».
Навіть якщо в Dev Mode якийсь запит пройшов (наприклад, тому що тунель прокладено нестандартно), у продакшн‑середовищі він майже напевно зламається. ChatGPT обмежує вихідні запити, і довільний зовнішній домен для віджета недоступний. Орієнтуйтеся на те, що віджет надійно може ходити лише до свого домену та до дуже малого білого списку явно дозволених ресурсів.
Помилка № 4: використовувати window.open замість openExternal.
Технічно іноді window.open може спрацювати, особливо в браузерному превʼю, і створюється ілюзія, що «все гаразд». Але в реальному середовищі ChatGPT, особливо в нативних клієнтах, поведінка буде непередбачуваною. Користувач може взагалі не побачити переходу або отримати дивну помилку. Правильний шлях — використовувати openExternal (через хук useOpenExternal), який знає, як коректно відкрити посилання в поточному середовищі.
Помилка № 5: не обробляти помилки fetch і не показувати користувачеві стан завантаження.
У пісочниці мережеві помилки — не виняток, а радше норма: тунель може впасти, домен може змінитися, політики можуть щось відрізати. Якщо ви просто робите await fetch(...) і далі рендерите UI, припускаючи, що дані є, — отримаєте дивний напівробочий інтерфейс, який «іноді працює, іноді ні». Завжди ставте try/catch, перевіряйте res.ok, показуйте «Завантажую…» і акуратне повідомлення про помилку.
Помилка № 6: перетворювати openExternal на прихований редирект.
Іноді виникає бажання після кліку на будь‑яку кнопку одразу відводити користувача на зовнішній сайт — особливо на checkout — без жодного контексту в тексті. Це виглядає дивно і для користувача, і для ревʼюерів Store. Добрий тон — чітко написати, що зараз станеться: або GPT‑модель повідомляє «Я відкрию сторінку магазину…», або сама кнопка підписана достатньо прозоро («Перейти до оплати на сайті магазину»).
Помилка № 7: забувати, що віджет — не єдиний «господар» діалогу.
Якщо ваш UI намагається навʼязати користувачеві складний сценарій із купою своїх посилань і мережевих запитів, ігноруючи сам чат і подальші уточнювальні запитання, виходить гірший UX і гірша якість роботи моделі. Памʼятайте архітектуру: GPT вирішує, коли показати застосунок і як використати його результати, а віджет лише підказує та візуалізує. Навігацію й мережеві виклики потрібно проєктувати так, щоб вони вписувалися в загальний діалог, а не «перетягували ковдру» на себе.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ