1. Два пути наружу: навигация и данные
Если обычный Next.js‑разработчик слышит «нужно сходить на сервер», рука автоматически тянется к fetch или к любимому HTTP‑клиенту. В мире ChatGPT Apps такая рефлекторная реакция приводит к боли.
В части курса, посвящённой безопасности виджетов в ChatGPT Apps, мы предлагаем с самого начала сломать старый рефлекс. Виджет не живёт в свободном интернете: он сидит в жёсткой изоляции, а сетевой доступ к нему фильтруется и ограничивается политиками хоста.
У виджета есть всего три базовых окошка наружу:
- Навигация: отправить пользователя куда‑то во внешний мир. Для этого есть openExternal.
- Обмен данными: получить/отправить JSON, поговорить с backend’ом. Это делается через fetch, но возможны сильные ограничения.
- MCP tool call: вызов инструментов (MCP / backend), которые не имеют никаких ограничений.
В этой лекции мы фокусируемся на первом и самом безопасном пути (навигация) и аккуратно знакомимся с контролируемым fetch. В следующих модулях мы разберём MCP и инструменты как основной способ серьёзного общения с сервером.
2. openExternal: безопасный «телепорт» пользователя
Почему нельзя просто сделать window.open
В обычном веб‑приложении вы бы сделали примерно так:
window.open("https://example.com", "_blank");
В песочнице ChatGPT это либо не сработает, либо сработает как‑то очень странно. Виджет — это заизолированный iframe с жёстким sandbox, который не имеет тех же прав, что вкладка браузера.
Кроме того, ChatGPT хост хочет контролировать, куда и когда вы ведёте пользователя, чтобы:
- не допустить скрытого трекинга;
- показать пользователю понятный UI подтверждения (особенно в мобильных/десктоп‑клиентах);
- обеспечить одинаковое поведение ссылок в разных окружениях (web, десктоп, мобильное приложение).
Поэтому был придуман специальный 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 приходят рекомендации с полем productUrl. Добавим к каждой карточке кнопку, которая откроет товар на вашем сайте:
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 может быть доступен, но только к ограниченному списку доменов (обычно вашему домену, на котором крутится App, и, возможно, к паре явно разрешённых API);
- запросы могут идти через специальный прокси хоста, который фильтрует заголовки и URL;
- некоторые методы (PUT, DELETE) или нестандартные заголовки могут блокироваться политиками безопасности.
При этом у вас всё ещё есть удобный путь: если ваш виджет и ваш backend живут на одном домене (как в шаблоне на Next.js, где и MCP‑сервер, и UI обслуживаются одним приложением), внутренние запросы fetch("/api/...") обычно будут разрешены.
Главное — не рассчитывать на то, что виджет сможет ходить на любой api в интернете. Всё «толстое» общение с внешними сервисами (Stripe, Notion, CRM и т.п.) должно происходить на стороне MCP/бэкенда, куда ChatGPT обращается как к доверенному ресурсу.
Insight
В виджете ChatGPT нужно сразу забыть про относительные пути и жить на абсолютных URL. Причина простая: ваш HTML не работает на том же домене, что backend. 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, толстый backend
Из ограничений fetch и общей песочницы вытекает более общий архитектурный принцип, который важен для всего курса. Мы уже несколько раз говорили эту мантру, но сейчас самое время закрепить: виджет — это тонкий UI‑слой. Он рендерит то, что уже подготовил backend (через MCP/tools), показывает реакции на действия пользователя и в крайнем случае делает пару небольших публичных запросов.
Всё, что связано с авторизацией, доступом к персональным данным, секретами и небанальной бизнес‑логикой, должно жить на стороне сервера. Документы по безопасности курса отдельно подчёркивают: фронтенд (React‑виджет) — «public place», зона нулевого доверия, и секреты там жить не должны.
Все мои исследования по текущей теме формулируют цель жёстко: «забить последний гвоздь в крышку гроба идеи “толстого клиента”» для ChatGPT Apps. Виджет — только голова, а тело и мозги — в MCP/бэкенде.
Поэтому:
- openExternal — для навигации пользователя на ваш «нормальный» сайт, где можно крутить уже привычный SPA, личный кабинет и остальное;
- callTool (следующий модуль) — основной способ передать модели задачу, которую выполнит ваш backend;
- fetch из виджета — редкий герой для вспомогательных, безопасных и желательно публичных запросов к вашему же приложению.
5. Практика: openExternal в нашем GiftGenius
Давайте чуть аккуратнее встроим openExternal в наш учебный App и заодно подумаем про 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 здесь примерная — в своём App вы подстроите её под свой backend.
6. Практика: аккуратный fetch к нашему backend’у
Теперь давайте разберёмся с 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: на тот же домен, где крутится ваш App — тот, который вы пробрасываете через туннель и регистрируете в 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 | Явный переход пользователя, контролируемый хостом |
| Получить публичные данные с App | fetch("my.com/api/...") | Лёгкий JSON, тот же домен, без секретов |
| Достать данные пользователя, БД | callTool/MCP | Нужна авторизация, логика, безопасный backend |
| Ходить во внешние API (Stripe…) | MCP/сервер | Фронт не видит секретов, соблюдаем политики |
В текущем модуле нам важно научиться сознательно выбирать инструмент. Нужно уйти от мышления «виджет — это фронтенд, значит всё можно сделать через fetch», к архитектуре «виджет — это управляемый UI‑слой поверх LLM+MCP‑бэкенда».
Insight
Взаимодействие с сервером в ChatGPT App логично делить на два уровня:
- ChatGPT ↔ MCP-сервер: модель вызывает MCP-инструменты. Каждый tool-call — это запуск или переключение бизнес-сценария (подбор подарков, создание заказа, расчёт стоимости и т.п.). Здесь живёт «тяжёлая» логика, работа с данными, внешние API и авторизация.
- Виджет ↔ сервер: виджет делает лёгкие fetch()-запросы к своему backend’у и/или дергает те же MCP-инструменты через callTool() уже внутри активного сценария. Это локальные шаги: подзагрузить вспомогательные данные, обновить кусок UI, уточнить состояние.
То есть MCP-tool = запуск/управление бизнес-процессом, а fetch()/callTool() из виджета — мелкие операции внутри уже выбранного сценария, не претендующие на изменение общей «истории» диалога.
8. Небольшое практическое упражнение
Чтобы закрепить тему на практике, можно сделать небольшую фичу в GiftGenius.
Предложенный сценарий:
- В списке подарков добавьте кнопку «Перейти к оформлению», которая через openExternal открывает страницу оформления заказа на вашем dev‑сайте.
- Над списком подарков отрендерите PopularTags из примера выше, чтобы показать популярные категории. При ошибке загрузки сделайте fallback‑текст и не ломайте весь виджет.
- Обратите внимание на UX: в тексте GPT‑ответа или в UI виджета объясните пользователю, что «при нажатии кнопки я открою страницу магазина в новой вкладке».
Эта фича в миниатюре показывает оба канала:
- openExternal для явной навигации;
- fetch для маленького публичного API, живущего рядом с вашим App.
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 пытается навязать пользователю сложный сценарий с кучей своих ссылок и сетевых запросов, игнорируя сам чат и follow‑up’ы, получается хуже UX и хуже качество работы модели. Вспоминайте архитектуру: GPT решает, когда показать App, как использовать его результаты, а виджет лишь подсказывает и визуализирует. Навигацию и сетевые вызовы нужно проектировать так, чтобы они вписывались в общий диалог, а не перетягивали всё одеяло на себя.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ