1. What the sandbox is and why your widget is in a cage
When ChatGPT displays your widget, it renders it not as a regular <iframe src="https://your-site">. The widget runs in a managed “sandbox”—an isolated iframe with a separate origin and restrictive security settings.
Technically, it looks roughly like this:
flowchart TD
User["User in ChatGPT"]
Chat["ChatGPT UI + model"]
Iframe["Your widget
sandboxed iframe"]
MCP["Your MCP / backend"]
User --> Chat
Chat -->|tool call| MCP
MCP -->|structuredContent + _meta| Chat
Chat -->|window.openai.*| Iframe
Iframe -->|callTool / follow-up| Chat
Chat --> MCP
Your code executes only inside this iframe, and access to the rest of the world goes through a tightly controlled API provided by the host (ChatGPT). The widget must not:
- break ChatGPT itself (DOM, styles, performance);
- violate user privacy;
- roam the network uncontrolled.
Hence the key sandbox constraints.
DOM and origin isolation
The widget lives on a special sandbox domain (for example, https://sandbox-apps.oaiusercontent.com) with the sandbox attribute on the iframe. This means:
- you cannot access window.parent or the ChatGPT document — you’ll get a SecurityError;
- cross-domain things like postMessage are controlled by the host;
- any attempts to “fix ChatGPT’s interface with a CSS tweak” are doomed.
Network and CSP limitations
The browser and the host’s CSP policy restrict network access for your widget:
- the fetch method has access only to allowlisted domains that must pass review;
- you explicitly declare which domains can be touched from the widget via openai/widgetCSP in MCP responses; otherwise requests will simply fail;
- the recommended path for anything serious is to avoid direct network calls from the widget and instead go to your backend via MCP tools and callTool (more on this in Module 4).
Practically speaking: think of the widget as a thin UI layer. It talks to ChatGPT and your server through strictly defined channels, not like a regular SPA freely roaming the internet.
Storage and resources
Local storage (localStorage, sessionStorage) is available, but cookies are not. Keep this in mind when building your app. Memory and CPU are limited: if you decide to compute all prime numbers up to a billion inside the widget, the host is fully entitled to just kill your iframe.
The key takeaway: no heavy computations or long-lived “caches” in the widget. Complex logic belongs on the server side, not in a React component.
2. window.openai: a bridge between the widget and ChatGPT
For the widget to learn anything (tool results, display mode, locale, state), ChatGPT injects one global object into the iframe window during initialization — window.openai.
This isn’t your code or an npm package; it’s a host object provided by the AI platform itself. Under the hood it’s wired to events and messages between the host and the iframe, but you rarely need to think about that. A few points to remember.
Who creates window.openai and when
window.openai appears only:
- inside the iframe that ChatGPT created for your widget;
- when the HTML template is served with the correct mimeType (text/html+skybridge) and passes all checks.
You’ve seen this type in the HelloWorld App module — it’s exactly what the widget page returns instead of plain text/html.
If you just open the widget page directly in the browser, then:
console.log(window.openai); // undefined
and that’s fine. Therefore, the widget code should always check that the object exists if you rely on a “standalone” mode for local development or Storybook.
Primitive example (not final, just an illustration):
if (typeof window !== "undefined" && (window as any).openai) {
console.log("We are inside ChatGPT sandbox!");
}
Asynchronous initialization
Under the hood, ChatGPT updates window.openai as new data arrives (new toolOutput, a displayMode change, etc.) using the internal event openai:set_globals.
In other words, its “values” aren’t static: the AI model can call an MCP tool, the backend returns new structuredContent, and window.openai.toolOutput changes right under your React component.
Two recommendations follow:
- Don’t take “blind” snapshots like const toolOutput = window.openai.toolOutput once at the start and assume they’re permanent. The same widget instance can be reused by ChatGPT.
- Use the hooks layer (coming up) that knows how to subscribe to changes.
3. Anatomy of window.openai: data, APIs, and context
The official documentation provides a fairly compact table of fields and methods on window.openai. Let’s turn it into a more “human” version.
Main fields and methods
window.openai = {
// State & data
toolInput, // JSON: parameters the AI passed into your MCP tool
toolOutput, // JSON: data your MCP tool returned to the AI
toolResponseMetadata, // MCP tool response: the _meta portion: {...}
widgetState, // You can read the saved widget state
setWidgetState, // You can save your widget state here
// Runtime APIs
callTool, // Call an MCP tool
sendFollowUpMessage, // Silently post a message to the AI in chat; it will start responding.
requestDisplayMode, // Switch the widget to another mode: fullscreen, pip, inline
requestModal, // Turn the widget into a modal dialog.
requestClose, // Close the widget. If it's a modal, close it and return to the inline widget.
requestCheckout, // Open the payment modal. The server must implement ACP.
notifyIntrinsicHeight, // Notify about a change to the widget's intrinsic height
openExternal, // Open a link in a new window.
// Context
theme, // Dark or light theme
displayMode, // Current widget display mode; may differ from requestDisplayMode
maxHeight, // Maximum allowed widget height
safeArea, // "Safe drawing area" — relevant on phones with notches
view,
userAgent, // Browser userAgent
locale // Browser locale
}
The same in a table:
| Category | Property / method | What it is for |
|---|---|---|
| State & data | |
Arguments with which the tool was called. Read-only. |
| State & data | |
Your structuredContent from the MCP response. What both the widget and the model see. |
| State & data | |
_meta from the response. Visible to the widget only; the model does not read it. |
| State & data | |
A snapshot of UI state that ChatGPT persists between widget renders. |
| State & data | |
Persist a new snapshot of widgetState synchronously. |
| Function | |
Call an MCP tool from the widget. |
| Function | |
Ask ChatGPT to send a chat message on behalf of the widget. It will start replying. |
| Function | |
Request inline / fullscreen / pip from the host. |
| Function | |
Request opening a modal dialog. |
| Function | |
Notify that the content height has changed. |
| Function | |
Opens a payment dialog via the ACP protocol. |
| Function | |
Open an external link in the user’s browser. |
| Context | |
Environment signals: theme, mode, available height, locale, etc. |
You don’t need to memorise everything in this table at once—treat it as a “map of the territory.” Now let’s go through it like a normal person, not as a “reference.”
toolInput and toolOutput: where the data comes from
When the model decides to call your tool, it forms JSON arguments. These arguments:
- arrive at the MCP server as input to the handler;
- simultaneously land in window.openai.toolInput in the widget.
After the tool finishes, the server returns:
- structuredContent — structured data for the UI;
- _meta — private data for the widget only;
- content — text for the model itself so it can “tell” the user what happened.
structuredContent becomes window.openai.toolOutput, and _meta becomes window.openai.toolResponseMetadata.
Mini example (vanilla JS, no React):
const root = document.getElementById("root");
// Safe to use the nullish operator
const gifts = window.openai.toolOutput?.gifts ?? [];
root.textContent = `Gifts found: ${gifts.length}`;
widgetState and setWidgetState: the widget’s memory
widgetState is what the platform is willing to remember about your UI between renders and even between separate turns of the conversation.
Examples of natural things for widgetState:
- the selected gift;
- current sorting (by price / by popularity);
- page number in a list.
Not natural:
- a raw response from a third-party API;
- a base64 image;
- secret tokens.
Two important things to remember:
- widgetState is stored and passed to the model along with context, so don’t put anything sensitive in it.
- The size is limited (approximately 4,000 tokens), so don’t turn it into a mini database.
Simplest usage (head-on, no hooks, vanilla JS):
const current = window.openai.widgetState ?? { selectedGiftId: null };
function selectGift(id) {
window.openai.setWidgetState({ ...current, selectedGiftId: id });
}
In real code, we’ll wrap this in React hooks.
Runtime API: callTool, sendFollowUpMessage, and friends
These methods let the widget not only “render” but also interact with the dialogue and the server.
Some typical scenarios:
- callTool("search_gifts", { budget: 50 }) — the user clicked the “Change budget” button, you called the server, and updated the UI;
- sendFollowUpMessage({ prompt: "Show more expensive ideas" }) — instead of asking the user to type manually, you add a follow-up button that creates a new chat message;
- requestDisplayMode({ mode: "fullscreen" }) — if inline mode feels cramped, the widget can politely ask ChatGPT to go fullscreen;
- openExternal({ href: "https://myshop.com/checkout?giftId=123" }) — send the user to an external site (checkout, profile, etc.) through a vetted channel.
All of these go “over the wire” through ChatGPT, not directly to the internet.
Environment context: theme, mode, height, locale
Fields like theme, displayMode, maxHeight, locale give you a sense of the environment the widget is living in.
For example:
const theme = window.openai.theme; // "light" or "dark"
const mode = window.openai.displayMode; // "inline" | "fullscreen" | "pip"
const maxH = window.openai.maxHeight; // available height
const locale = window.openai.locale; // "en-US", "de-DE", ...
With these signals you can:
- adjust colours and spacing for the theme;
- change the layout depending on the mode (inline vs fullscreen);
- localize UI labels to the user’s language (we’ll have a whole module on this).
The platform gives you signals about available space, theme, and locale. It’s sensible to consume them via useOpenAIGlobal, useDisplayMode, useMaxHeight and other hooks so the widget looks “native” inside ChatGPT.
4. Hooks on top of window.openai: don’t touch the global by hand
Direct access to window.openai is fine for a prototype but quickly turns code into a mess: event subscriptions, undefined checks, repeated wrappers. That’s why the Apps SDK Next.js template ships with a set of React hooks that hide the details and make everything reactive.
A typical hooks index looks like this:
// 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";
Names and exact paths may differ slightly in your template, but the idea is the same everywhere: instead of window.openai.* you use hooks. Let’s cover the key ones.
useWidgetProps: tool input and output
useWidgetProps typically returns an object with the data the widget needs: toolInput, toolOutput, toolResponseMetadata and sometimes additional flags like isLoading.
Example:
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>No gift options yet.</div>;
}
return (
<ul>
{gifts.map((g) => (
<li key={g.id}>{g.title} — ${g.price}</li>
))}
</ul>
);
}
No window.openai in the component code — and that’s good.
useWidgetState: a “reactive wrapper” over widgetState
useWidgetState lets you work with widgetState like a regular React state: you get [state, setState], and the hook synchronizes it under the hood with window.openai.widgetState and setWidgetState.
Example:
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>No gift selected yet.</div>;
}
return (
<div>
You selected a gift with id={uiState.selectedGiftId}
<button onClick={() => setUiState({ selectedGiftId: null })}>
Reset
</button>
</div>
);
}
After clicking, setUiState not only updates the React state but also saves the new state on ChatGPT’s side.
useOpenAIGlobal: access a specific window.openai field
If you need access to a single global field (for example, the theme or mode), there’s a universal hook useOpenAIGlobal(key). It subscribes to the openai:set_globals event and always returns the up-to-date value.
Example:
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 }}>I respect ChatGPT’s theme</div>;
}
useCallTool, useSendMessage, useOpenExternal, and others
- useCallTool(name) — returns a function that calls the MCP tool with the specified name. A wrapper over callTool.
- useSendMessage() — wraps sendFollowUpMessage so the widget can initiate messages.
- useOpenExternal() — a handy helper around openExternal({ href }).
- useRequestDisplayMode() and useRequestModal() — wrappers for requesting a mode change / opening a modal.
A basic example of a mini widget, GiftGenius, that uses almost everything at once:
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>No ideas yet. Try asking GPT to refresh the results.</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: "Show gifts more expensive than the current ones." })
}
>
Ask for more ideas
</button>
<button
onClick={async () => {
await callSearch({ budget: 200 });
}}
>
Refresh with a $200 budget
</button>
{ui?.selectedId && (
<button
onClick={() =>
openExternal({
href: `https://giftgenius.example.com/checkout?id=${ui.selectedId}`,
})
}
>
Proceed to checkout
</button>
)}
</div>
</div>
);
}
This page is still rough (we’ll improve UX, error handling, etc., in later modules), but it already illustrates the approach: no direct calls to window.openai, hooks only.
5. Practice: exploring the sandbox and window.openai
To get a feel for “a widget is not a regular site,” it helps to do a couple of exercises.
Exercise: “Probe the environment”
Take your current app/page.tsx in the widget and add a simple effect on first render:
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>Running inside ChatGPT: {String(isChatGpt)}</p>
</main>
);
}
Open DevTools: either directly in ChatGPT (via the tunnel’s embedded viewer if it allows it) or in a local browser when opening the page directly. Compare both variants:
- when launched in a regular browser, isChatGptApp will be false, and window.openai will likely be undefined;
- when launched via ChatGPT, you’ll see an object with fields like toolInput, toolOutput, theme, etc.
This is a good gut feel: the same React code behaves differently depending on the environment, and hooks are exactly what we use for this.
Exercise: “Print everything the platform provides”
Add a temporary debugging component:
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>
);
}
And temporarily insert <DebugPanel /> under the main UI. This way you’ll clearly see:
- which fields come from MCP into toolOutput;
- what lives in _meta (for example, locale, userLocation, and so on);
- how displayMode changes when you expand the widget.
You can later remove this component or keep it behind a flag like DEBUG_WIDGET.
6. Relationships: ChatGPT ↔ widget ↔ MCP/server
To avoid treating the widget as the “main” actor in the system, it helps to fix the roles one more time.
- The user writes a message: “Find a gift for my girlfriend, budget $50.”
- The ChatGPT model decides to call your MCP tool search_gifts with arguments { recipient: "girlfriend", budget: 50 }.
- The MCP server executes the business logic and returns:
- content with a short description for the model;
- structuredContent with an array of gifts;
- _meta with technical details (for example, source and currency).
- ChatGPT:
- shows the user a text message (“I found a few options...”);
- creates a widget iframe and passes structuredContent and _meta into it via window.openai.toolOutput and toolResponseMetadata.
- Your widget:
- renders the UI based on toolOutput;
- on interactions, calls callTool or sends a follow-up;
- The model then decides what to do with the results of those actions.
All of this leads to an important idea: the widget is never the sole owner of the process. It’s a UI layer that lives in an ecosystem with the model and the MCP server. The heavy stuff (authentication, access to private data, serious business logic) should remain on the server side. The widget is responsible for a convenient interface and careful communication with the user.
7. Policies and rules of the sandbox
This whole construct with an isolated iframe and window.openai exists for a reason: security and privacy requirements. OpenAI’s official guides emphasise several principles.
First, data minimization. You shouldn’t try to extract as much PII (personally identifiable information) as possible from the user through the widget and drag it to your side. Everything that’s truly necessary should be clearly described in your tools, and both the model and the security layer will scrutinize such calls.
Second, a ban on hidden tracking and fingerprinting. You must not build a “peeking” system to profile the user’s device, collect browser fingerprints, or find ways around the restrictions. Parameters like userAgent, userLocation, etc., are hints for UX, not for authorization or identification.
Third, anything you put into structuredContent, _meta, widgetState is, in some form, either visible to the user or can be seen by a Store reviewer. Therefore:
- no API keys, tokens, passwords, or admin secrets should ever go there;
- design widget state so the user wouldn’t be surprised to see it in logs or debugging.
Fourth, network calls. Direct requests from the widget to third-party APIs are allowed only to a strictly limited list of domains and in non-sensitive scenarios. As soon as money, accounts, or private data are involved — everything should go via MCP/backend.
8. Common mistakes when working in the sandbox and with window.openai
Mistake #1: assuming the widget is “just a normal site in an iframe.”
Beginners tend to try accessing window.parent, changing ChatGPT styles, or using localStorage as usual. In the sandbox this either doesn’t work or is unstable: the origin is different, storage is isolated, access to the host DOM is blocked. Accept that you live in a managed environment and talk to the host only through window.openai and hooks.
Mistake #2: touching window.openai directly all over the codebase.
Code like window.openai.toolOutput in ten components leads to a hard-to-debug app. You’ll have to handle events, asynchrony, and undefined checks yourself. It’s much more reliable to use useWidgetProps, useWidgetState, useOpenAIGlobal, and other hooks that already wrap openai:set_globals and synchronize state.
Mistake #3: storing everything (especially secrets) in widgetState.
Sometimes you’re tempted to shove a huge object with API results or even an access token “just in case.” As a result, the context grows, the model’s performance suffers, and you violate basic security requirements. widgetState should be small, contain UI signals only, and never include confidential data.
Mistake #4: trying to go to the internet directly from the widget.
fetch("https://api.superbank.com/...") calls from the sandbox will almost certainly hit CORS, and even if you configure everything perfectly, it will be unsafe and poorly controlled. Anything involving real accounts, money, and personal data should be implemented as MCP tools and invoked via callTool or your server layer.
Mistake #5: relying on window.openai being stable outside ChatGPT.
Sometimes developers try to run the widget as a standalone SPA and forget to check that window.openai can be undefined. In dev environments this ends with a crash: “Cannot read properties of undefined.” Use useIsChatGptApp, checks like typeof window !== "undefined", and fallback UI for cases where the widget doesn’t exist.
Mistake #6: ignoring environment context (theme, displayMode, maxHeight, locale).
You could hardcode a 2000px height, always-on dark theme, and design only for desktop — but that would make for a strange user experience. The platform gives you signals about available space, theme, and locale — use them via useOpenAIGlobal, useDisplayMode, useMaxHeight, etc., so the widget feels “native” in ChatGPT.
Mistake #7: trying to “work around” policy via third-party scripts.
It can be tempting to pull in a tracker, a third-party JS bundle, or execute code from someone else’s domain “quietly.” The sandbox and CSP policies exist specifically to prevent this: third-party scripts are blocked, and attempts to bypass the system are a straight path to your App being rejected in the Store.
GO TO FULL VERSION