1. Что за песочница и почему ваш виджет в клетке
Когда ChatGPT показывает ваш виджет, он рендерит его не как обычный <iframe src="https://ваш-сайт">. Виджет запускается в управляемой «песочнице» — изолированном iframe с отдельным origin и жёсткими настройками безопасности.
Технически это выглядит примерно так:
flowchart TD
User["Пользователь в ChatGPT"]
Chat["ChatGPT UI + модель"]
Iframe["Ваш виджет
sandboxed iframe"]
MCP["Ваш MCP / backend"]
User --> Chat
Chat -->|вызов tool| MCP
MCP -->|structuredContent + _meta| Chat
Chat -->|window.openai.*| Iframe
Iframe -->|callTool / follow-up| Chat
Chat --> MCP
Ваш код исполняется только внутри этого iframe, а доступ к остальному миру идёт через узко контролируемый API, который предоставляет хост (ChatGPT). Виджет не должен:
- ломать сам ChatGPT (DOM, стили, перфоманс);
- нарушать приватность пользователя;
- бесконтрольно ходить по сети.
Отсюда следуют ключевые ограничения песочницы.
Изоляция DOM и origin
Виджет живёт на специальном домене песочницы (например, https://sandbox-apps.oaiusercontent.com), с атрибутом sandbox на iframe. Это значит, что:
- вы не можете лезть в window.parent или document ChatGPT — получите SecurityError;
- кросс-доменные штуки вроде postMessage контролируются хостом;
- любые поползновения «починить интерфейс ChatGPT CSS-очкой» обречены.
Сеть и CSP-ограничения
Браузер и CSP-политика от хоста ограничивают доступ в сеть для вашего виджета:
- метод fetch имеет доступ только к доменам из whitelist, которые должны пройти review;
- какие домены можно трогать из виджета, вы явно объявляете через openai/widgetCSP в ответах MCP; иначе запросы просто не пройдут;
- рекомендованный путь для всего серьёзного — вообще не дергать сеть из виджета, а ходить в backend через MCP-инструменты и callTool (об этом подробнее в Модуле 4).
Практически: думайте про виджет как про тонкий UI-слой. Он разговаривает с ChatGPT и вашим сервером через строго определённые каналы, а не как обычное SPA, свободно живущее в интернете.
Хранилища и ресурсы
Локальные хранилища (localStorage, sessionStorage) вам доступны, а вот cookie — нет. Учитывайте это при разработке приложения. Память и CPU ограничены: если вы решите внутри виджета пересчитать все простые числа до миллиарда, хост имеет полное право ваш iframe просто убить.
Отсюда важный вывод: никаких тяжёлых вычислений и долгоживущих «кэшей» в виджете. Сложная логика — на стороне сервера, а не в React-компоненте.
2. window.openai: мост между виджетом и ChatGPT
Чтобы виджет вообще мог о чём-то узнать (результаты инструмента, режим отображения, локаль, состояние), ChatGPT при инициализации встраивает в окно iframe один глобальный объект — window.openai.
Это не ваш код и не npm-пакет, а host object, который предоставляет сама ИИ-платформа. Под капотом он завязан на события и сообщения между хостом и iframe, но вам про это почти не нужно думать. Важно помнить несколько моментов.
Кто и когда создаёт window.openai
window.openai появляется только:
- внутри того iframe, который ChatGPT создал для вашего виджета;
- когда HTML-шаблон отдан с правильным mimeType (text/html+skybridge) и прошёл все проверки.
Этот тип вы уже видели в модуле про HelloWorld App — именно его возвращает страница виджета вместо обычного text/html.
Если вы просто открываете страницу widget’а напрямую в браузере, то:
console.log(window.openai); // undefined
и это нормально. Поэтому в коде виджета всегда стоит проверять, что объект есть, если вы рассчитываете на «standalone»-режим для локальной разработки или сторибука.
Примитивный пример (не финальный, просто иллюстрация):
if (typeof window !== "undefined" && (window as any).openai) {
console.log("We are inside ChatGPT sandbox!");
}
Асинхронность инициализации
Под капотом ChatGPT обновляет window.openai по мере прихода новых данных (новый toolOutput, смена displayMode и т.д.), используя внутреннее событие openai:set_globals.
То есть «значения» в нём не статичны: ИИ-модель может вызвать MCP-инструмент, backend вернёт новые structuredContent, и window.openai.toolOutput поменяется прямо под вашим React-компонентом.
Отсюда две рекомендации:
- Не делать «слепые» snapshot’ы вида const toolOutput = window.openai.toolOutput один раз в начале и думать, что он вечен. Один и тот же виджет может быть переиспользован ChatGPT.
- Использовать слой хуков (чуть позже), который умеет подписываться на изменения.
3. Анатомия window.openai: данные, API и контекст
Официальная документация даёт довольно компактную таблицу по полям и методам window.openai. Сведём её в более «человеческий» вариант.
Основные поля и методы
window.openai = {
// State & data
toolInput, // JSON: параметры которые ИИ передал в ваш MCP-tool
toolOutput, // JSON: параметры которые ваш MCP-tool вернул ИИ
toolResponseMetadata, // Ответ MCP-tool: часть _meta: {...}
widgetState, // Можно прчитать сохраненне состояние виджета
setWidgetState, // Можно соханить состояние вашего видежта сюда
// Runtime APIs
callTool, // Можно вызвать MCP-tool
sendFollowUpMessage, // Скрытно написать сообщения для ИИ в чате: он начнет отвечать.
requestDisplayMode, // Переключить Виджет в другой mode: fullscreen, pip, inline
requestModal, // Превратить виджет в модальное окно.
requestClose, // Закрывает виджет. Закрывает модальное окно - превращается в виджет.
requestCheckout, // Открывает модальное окно для оплаты. Сервер должен реализовать ACP
notifyIntrinsicHeight, // Сообщение об изменени высоты виджета
openExternal, // Открыть ссылку в новом окне.
// Context
theme, // Теманя или светлая тема
displayMode, // Текущий режим отображения виджета, может отличаться от requestDisplayMode
maxHeight, // Максимально допустимая высота виджета
safeArea, // "Безопасная область отрисовки" - актуально для телефонов с "вырезами"
view,
userAgent, // userAgent бразура
locale // locale бразура
}
То же самое в табличке:
| Категория | Свойство / метод | Для чего нужно |
|---|---|---|
| State & data | |
Аргументы, с которыми был вызван инструмент. Read-only. |
| State & data | |
Ваш structuredContent из MCP-ответа. То, что видит виджет и модель. |
| State & data | |
_meta из ответа. Видно только виджету, модель этого не читает. |
| State & data | |
Снимок UI-состояния, который ChatGPT хранит между рендерами вилджета. |
| State & data | |
Сохранить новый снимок widgetState синхронно. |
| Function | |
Вызвать MCP-инструмент из виджета. |
| Function | |
Попросить ChatGPT отправить сообщение в чат от лица виджета. Он начнет отвечать. |
| Function | |
Попросить у хоста inline / fullscreen / pip. |
| Function | |
Попросить открыть модальное окно. |
| Function | |
Сообщить, что высота контента поменялась. |
| Function | |
Открывает диалог оплаты по ACP-протоколу. |
| Function | |
Открыть внешнюю ссылку в браузере пользователя. |
| Context | |
Сигналы окружения: тема, режим, доступная высота, локаль и т.д. |
Необязательно запоминать всё из этой таблицы сразу — воспринимайте её как «карту местности». Теперь разберём это не как «справочник», а как нормальный человек.
toolInput и toolOutput: откуда берутся данные
Когда модель решает вызвать ваш инструмент, она формирует JSON-аргументы. Эти аргументы:
- приходят на MCP-сервер как input в обработчик;
- одновременно попадают в window.openai.toolInput в виджете.
После выполнения инструмента сервер возвращает:
- structuredContent — структурированные данные для UI;
- _meta — приватные данные только для виджета;
- content — текст для самой модели, чтобы она могла «рассказать» пользователю, что произошло.
structuredContent становится window.openai.toolOutput, а _meta становится window.openai.toolResponseMetadata.
Мини-пример (ванильный JS, без React):
const root = document.getElementById("root");
// Можно безопасно использовать nullish-оператор
const gifts = window.openai.toolOutput?.gifts ?? [];
root.textContent = `Найдено подарков: ${gifts.length}`;
widgetState и setWidgetState: память виджета
widgetState — это то, что платформа готова запомнить про ваш UI между рендерами и даже между отдельными ходами диалога.
Пример естественных вещей для widgetState:
- выбранный подарок;
- текущая сортировка (по цене / по популярности);
- номер страницы в списке.
Неестественных:
- сырой ответ стороннего API;
- изображение в base64;
- секретные токены.
Важно помнить две вещи:
- widgetState хранится и передаётся модели вместе с контекстом, поэтому не кладём туда ничего чувствительное.
- Объём ограничен (примерно 4 тысячи токенов), поэтому не делаем из него мини-базу данных.
Простейший пример использования (в лоб, без хуков, на ванильном JS):
const current = window.openai.widgetState ?? { selectedGiftId: null };
function selectGift(id) {
window.openai.setWidgetState({ ...current, selectedGiftId: id });
}
В реальном коде мы будем это оборачивать в React-хуки.
Runtime API: callTool, sendFollowUpMessage и аналоги
Эти методы позволяют виджету не только «рисоваться», но и взаимодействовать с диалогом и сервером.
Несколько типичных сценариев:
- callTool("search_gifts", { budget: 50 }) — пользователь нажал кнопку «Изменить бюджет», вы дернули сервер и обновили UI;
- sendFollowUpMessage({ prompt: "Покажи ещё идеи подороже" }) — вместо того чтобы вручную просить пользователя набрать текст, вы добавляете кнопочку follow-up, которая создаёт новое сообщение в чате;
- requestDisplayMode({ mode: "fullscreen" }) — если inline-режим стал тесен, виджет может вежливо попросить ChatGPT развернуться на весь экран;
- openExternal({ href: "https://myshop.com/checkout?giftId=123" }) — отправка пользователя на внешний сайт (checkout, профиль и т.д.) через проверенный канал.
Все они идут «по проводам» через ChatGPT, а не напрямую в интернет.
Контекст среды: тема, режим, высота, локаль
Поля вроде theme, displayMode, maxHeight, locale дают вам ощущение того, в каком окружении живёт виджет.
Например:
const theme = window.openai.theme; // "light" или "dark"
const mode = window.openai.displayMode; // "inline" | "fullscreen" | "pip"
const maxH = window.openai.maxHeight; // доступная высота
const locale = window.openai.locale; // "en-US", "de-DE", ...
При помощи этих сигналов вы можете:
- подстраивать цвета и отступы под тему;
- менять лэйаут в зависимости от режима (inline vs fullscreen);
- локализовать подписи в UI под язык пользователя (об этом ещё будет целый модуль).
Платформа даёт вам сигналы о том, сколько места есть, какая тема и локаль. Разумно использовать их через useOpenAIGlobal, useDisplayMode, useMaxHeight и другие хуки, чтобы виджет выглядел «родным» в ChatGPT.
4. Хуки поверх window.openai: не трогаем глобальный объект руками
Чистый доступ к window.openai удобен для прототипа, но быстро превращает код в кашу: подписки на события, проверки на undefined, повторяющиеся обёртки. Именно поэтому в Next.js-шаблоне для Apps SDK есть готовый набор React-хуков, которые прячут детали и делают всё реактивным.
Типичный индекс хуков выглядит так:
// app/hooks/openai/index.ts
export { useCallTool } from "./use-call-tool";
export { useSendMessage } from "./use-send-message";
export { useOpenExternal } from "./use-open-external";
export { useRequestDisplayMode, useRequestModal, useRequestClose } from "./use-request-display-mode";
export { useRequestCheckout } from "./use-request-checkout";
// State hooks
export { useDisplayMode } from "./use-display-mode";
export { useWidgetProps } from "./use-widget-props";
export { useWidgetState } from "./use-widget-state";
export { useOpenAIGlobal } from "./use-openai-global";
export { useMaxHeight } from "./use-max-height";
export { useIsChatGptApp } from "./use-is-chatgpt-app";
Названия и точный путь могут слегка отличаться в вашем шаблоне, но идея везде одна: вместо window.openai.* вы используете хуки. Разберём ключевые.
useWidgetProps: вход и выход инструмента
useWidgetProps обычно возвращает объект с данными, которые нужны виджету: toolInput, toolOutput, toolResponseMetadata и иногда дополнительные флаги вроде isLoading.
Пример:
import { useWidgetProps } from "../hooks/openai";
type Gift = { id: string; title: string; price: number };
export function GiftList() {
const { toolOutput } = useWidgetProps<{ gifts: Gift[] }>();
const gifts = toolOutput?.gifts ?? [];
if (!gifts.length) {
return <div>Пока нет вариантов подарков.</div>;
}
return (
<ul>
{gifts.map((g) => (
<li key={g.id}>{g.title} — ${g.price}</li>
))}
</ul>
);
}
Никакого window.openai в коде компонента — и это хорошо.
useWidgetState: «реактивная обёртка» над widgetState
useWidgetState позволяет работать с widgetState как с обычным React-стейтом: вы получаете [state, setState], а хук под капотом синхронизирует его с window.openai.widgetState и setWidgetState.
Пример:
import { useWidgetState } from "../hooks/openai";
type UiState = { selectedGiftId: string | null };
export function SelectedGiftIndicator() {
const [uiState, setUiState] = useWidgetState<UiState>(() => ({
selectedGiftId: null,
}));
if (!uiState?.selectedGiftId) {
return <div>Подарок ещё не выбран.</div>;
}
return (
<div>
Вы выбрали подарок с id={uiState.selectedGiftId}
<button onClick={() => setUiState({ selectedGiftId: null })}>
Сбросить
</button>
</div>
);
}
После клика setUiState не только обновит React-стейт, но и сохранит новое состояние на стороне ChatGPT.
useOpenAIGlobal: доступ к любому полю window.openai
Если нужен доступ к одному глобальному полю (например, теме или режиму), есть универсальный хук useOpenAIGlobal(key). Он подписывается на событие openai:set_globals и возвращает всегда актуальное значение.
Пример:
import { useOpenAIGlobal } from "../hooks/openai";
export function ThemeAwareBlock() {
const theme = useOpenAIGlobal<"light" | "dark">("theme");
const background = theme === "dark" ? "#222" : "#fff";
const color = theme === "dark" ? "#fff" : "#000";
return <div style={{ background, color }}>Я уважаю тему ChatGPT</div>;
}
useCallTool, useSendMessage, useOpenExternal и другие
- useCallTool(name) — возвращает функцию, которая вызывает MCP-инструмент с указанным именем. Это обёртка над callTool.
- useSendMessage() — оборачивает sendFollowUpMessage, чтобы виджет мог инициировать сообщения.
- useOpenExternal() — удобный помощник вокруг openExternal({ href }).
- useRequestDisplayMode() и useRequestModal() — обёртки для запросов смены режима / открытия модалки.
Базовый пример мини-виджета GiftGenius, который использует почти всё сразу:
import {
useWidgetProps,
useWidgetState,
useCallTool,
useSendMessage,
useOpenExternal,
} from "../hooks/openai";
type Gift = { id: string; title: string; url: string; price: number };
export function GiftWidget() {
const { toolOutput } = useWidgetProps<{ gifts: Gift[] }>();
const gifts = toolOutput?.gifts ?? [];
const [ui, setUi] = useWidgetState<{ selectedId: string | null }>(() => ({
selectedId: null,
}));
const callSearch = useCallTool("search_gifts");
const sendMessage = useSendMessage();
const openExternal = useOpenExternal();
if (!gifts.length) {
return <div>Пока нет идей. Попробуйте попросить GPT обновить результаты.</div>;
}
return (
<div>
{gifts.map((g) => (
<button
key={g.id}
style={{
display: "block",
fontWeight: ui?.selectedId === g.id ? "bold" : "normal",
}}
onClick={() => setUi({ selectedId: g.id })}
>
{g.title} — ${g.price}
</button>
))}
<div style={{ marginTop: 12 }}>
<button
onClick={() =>
sendMessage({ prompt: "Покажи подарки подороже текущих." })
}
>
Попросить ещё идеи
</button>
<button
onClick={async () => {
await callSearch({ budget: 200 });
}}
>
Обновить с бюджетом $200
</button>
{ui?.selectedId && (
<button
onClick={() =>
openExternal({
href: `https://giftgenius.example.com/checkout?id=${ui.selectedId}`,
})
}
>
Перейти к покупке
</button>
)}
</div>
</div>
);
}
Эта страничка ещё сырая (мы в следующих модулях доделаем UX, обработку ошибок и т.п.), но уже иллюстрирует подход: никаких прямых обращений к window.openai, только хуки.
5. Практика: изучаем песочницу и window.openai
Чтобы почувствовать, что такое «виджет не как обычный сайт», полезно проделать пару упражнений.
Упражнение: «Пощупай среду»
Возьмите ваш текущий app/page.tsx в виджете и добавьте туда при первом рендере простой эффект:
import { useEffect } from "react";
import { useIsChatGptApp } from "../hooks/openai";
export default function Root() {
const isChatGpt = useIsChatGptApp();
useEffect(() => {
if (typeof window !== "undefined") {
console.log("window.origin =", window.origin);
console.log("window.openai =", (window as any).openai);
}
}, []);
return (
<main>
<h1>GiftGenius widget</h1>
<p>Запущено внутри ChatGPT: {String(isChatGpt)}</p>
</main>
);
}
Откройте DevTools: либо прямо в окне ChatGPT (через встроенный viewer туннеля, если он это позволяет), либо в локальном браузере при прямом открытии страницы. В обоих вариантах сравните:
- при запуске в обычном браузере isChatGptApp будет false, а window.openai скорее всего undefined;
- при запуске через ChatGPT вы увидите объект с полями toolInput, toolOutput, theme и т.д.
Это хорошее интуитивное ощущение: тот же React-код ведёт себя по-разному в зависимости от окружения, и для этого как раз придуманы хуки.
Упражнение: «Выведи всё, что даёт платформа»
Добавьте временный компонент для отладки:
import { useWidgetProps, useOpenAIGlobal } from "../hooks/openai";
export function DebugPanel() {
const { toolInput, toolOutput, toolResponseMetadata } = useWidgetProps();
const theme = useOpenAIGlobal("theme");
const displayMode = useOpenAIGlobal("displayMode");
return (
<pre style={{ fontSize: 10, maxHeight: 200, overflow: "auto" }}>
{JSON.stringify(
{ toolInput, toolOutput, toolResponseMetadata, theme, displayMode },
null,
2
)}
</pre>
);
}
И временно вставьте <DebugPanel /> под основным UI. Так вы наглядно увидите:
- какие именно поля приходят из MCP в toolOutput;
- что живёт в _meta (например, locale, userLocation и прочее);
- как меняется displayMode, если вы разворачиваете виджет.
Потом этот компонент можно убрать или оставить включаемым через какой-нибудь флаг вроде DEBUG_WIDGET.
6. Взаимоотношения: ChatGPT ↔ виджет ↔ MCP/сервер
Чтобы не относиться к виджету как к «главному» участнику системы, полезно ещё раз зафиксировать роли.
- Пользователь пишет сообщение: «Подбери подарок для девушки, бюджет 50$».
- Модель ChatGPT решает вызвать ваш MCP-инструмент search_gifts с аргументами { recipient: "girlfriend", budget: 50 }.
- MCP-сервер выполняет бизнес-логику, возвращает:
- content с кратким описанием для модели;
- structuredContent с массивом подарков;
- _meta с техническими деталями (например, source и валютой).
- ChatGPT:
- показывает пользователю текстовое сообщение («Я нашёл несколько вариантов...»);
- создаёт виджет-iframe и передаёт туда structuredContent и _meta через window.openai.toolOutput и toolResponseMetadata.
- Ваш виджет:
- рендерит UI по toolOutput;
- при взаимодействиях вызывает callTool или отправляет follow-up;
- Модель дальше решает, что делать с результатами этих действий.
Всё это подводит к важной мысли: виджет никогда не является единственным хозяином процесса. Он — UI-слой, который живёт в экосистеме из модели и MCP-сервера. Сложные вещи (авторизация, доступ к приватным данным, серьёзная бизнес-логика) должны оставаться на стороне сервера. Виджет отвечает за удобный интерфейс и аккуратное общение с пользователем.
7. Политики и правила игры в песочнице
Вся эта конструкция с изолированным iframe и window.openai существует не просто так, а из-за требований безопасности и приватности. Официальные гайды OpenAI подчёркивают несколько принципов.
Во-первых, минимизация данных. Вы не должны через виджет пытаться вытащить у пользователя как можно больше PII (personally identifiable information) и тащить её к себе. Всё, что действительно необходимо, должно быть чётко описано в инструментах, и модель, и security-слой будут внимательно смотреть на такие вызовы.
Во-вторых, запрет на скрытый трекинг и fingerprinting. Нельзя строить систему «подглядывания» за устройством пользователя, собирать отпечатки браузера и способы обхода ограничений. Параметры вроде userAgent, userLocation и т.п. — это подсказки для UX, а не для авторизации или идентификации.
В-третьих, всё, что вы запихиваете в structuredContent, _meta, widgetState, в каком-то виде либо видит пользователь, либо может увидеть ревьюер Store. Поэтому:
- никакие API-ключи, токены, пароли и админские секреты туда класть нельзя;
- состояние виджета нужно проектировать так, чтобы пользователь не удивлялся, увидев его в логах или отладке.
В-четвёртых, сетевые вызовы. Прямые запросы из виджета к сторонним API допустимы только к строго ограниченному списку доменов и в не-чувствительных сценариях. Как только речь идёт о деньгах, аккаунтах, приватных данных — всё это должно идти через MCP/backend.
8. Типичные ошибки при работе в песочнице и с window.openai
Ошибка №1: считать, что виджет — это «обычный сайт в iframe».
Новички по привычке пытаются лезть в window.parent, менять стили ChatGPT или использовать localStorage как всегда. В песочнице это либо не работает, либо работает нестабильно: origin другой, storage изолирован, доступ к DOM блокируется. Нужно принять, что вы живёте в управляемой среде и общаетесь с хостом только через window.openai и хуки.
Ошибка №2: трогать window.openai напрямую везде подряд.
Код вида window.openai.toolOutput в десяти компонентах — путь к трудноотлаживаемому приложению. При этом вы сами должны следить за событиями, асинхронностью и проверками undefined. Гораздо надёжнее сразу использовать useWidgetProps, useWidgetState, useOpenAIGlobal и другие хуки, которые уже оборачивают openai:set_globals и синхронизируют состояние.
Ошибка №3: хранить в widgetState всё подряд (и особенно секреты).
Иногда хочется «на всякий случай» запихнуть туда огромный объект с результатами API или даже токеном доступа. В результате растёт контекст, ухудшается работа модели, а вы нарушаете базовые требования безопасности. widgetState должен быть маленьким, содержать только UI-сигналы, и никогда — конфиденциальные данные.
Ошибка №4: пытаться ходить в интернет напрямую из виджета.
Вызовы fetch("https://api.superbank.com/...") из песочницы почти гарантированно наткнутся на CORS, а даже если вы настроите всё идеально, это будет небезопасно и плохо управляемо. Всё, что связано с реальными аккаунтами, деньгами и персональными данными, нужно реализовывать как MCP-инструменты и вызывать их через callTool или серверную часть.
Ошибка №5: полагаться на стабильность window.openai вне ChatGPT.
Иногда разработчики пытаются запускать виджет отдельным SPA и не добавляют проверок на то, что window.openai может быть undefined. В dev-окружении это оканчивается крашем «Cannot read properties of undefined». Используйте useIsChatGptApp, проверки на typeof window !== "undefined" и fallback-UI для случаев, когда виджета как такового нет.
Ошибка №6: игнорировать контекст среды (theme, displayMode, maxHeight, locale).
Можно, конечно, задать жёсткую высоту 2000px, тёмную тему всегда и верстать под десктоп — но это сделает опыт пользователя довольно странным. Платформа даёт вам сигналы о том, сколько места есть, какая тема и локаль — разумно ими пользоваться через useOpenAIGlobal, useDisplayMode, useMaxHeight и др., чтобы виджет выглядел «родным» в ChatGPT.
Ошибка №7: пытаться «обойти» политику через сторонние скрипты.
Иногда приходит соблазн подтянуть какой-нибудь трекер, сторонний JS-бандл или выполнить код с чужого домена «по quietly». Песочница и CSP-политики как раз сделаны, чтобы такого не происходило: сторонние скрипты блокируются, а попытки обойти систему — прямой путь к отклонению вашего App в Store.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ