1. 什么是沙盒,以及为什么你的小部件被“关在笼子里”
当 ChatGPT 展示你的小部件时,它渲染它并不是像普通的 <iframe src="https://your-site">。 小部件运行在受控的“沙盒”中——一个具有独立 origin 且安全策略严格的隔离 iframe。
从技术上看,大致如下:
flowchart TD
User["ChatGPT 中的用户"]
Chat["ChatGPT UI + 模型"]
Iframe["你的小部件
沙盒 iframe"]
MCP["你的 MCP / 后端"]
User --> Chat
Chat -->|调用 tool| MCP
MCP -->|structuredContent + _meta| Chat
Chat -->|window.openai.*| Iframe
Iframe -->|callTool / 跟进| Chat
Chat --> MCP
你的代码只在该 iframe 内执行,对外界的访问通过由宿主(ChatGPT)提供的严格受控 API进行。小部件不应该:
- 破坏 ChatGPT 本身(DOM、样式、性能);
- 侵犯用户隐私;
- 不受控地访问网络。
这就带来了沙盒的关键限制。
DOM 与 origin 的隔离
小部件运行在沙盒专用域名上(例如 https://sandbox-apps.oaiusercontent.com),其 iframe 带有 sandbox 属性。这意味着:
- 你不能访问 ChatGPT 的 window.parent 或 document —— 会得到 SecurityError;
- 跨域通信(如 postMessage)由宿主控制;
- 任何试图用一段 CSS 去“修理 ChatGPT 的界面”的行为都注定失败。
网络与 CSP 限制
浏览器与宿主的 CSP 策略会限制你的小部件的网络访问:
- fetch 方法只能访问通过审核的白名单域名;
- 可从小部件访问哪些域,你需要在 MCP 响应中通过 openai/widgetCSP 明确声明;否则请求将被拦截;
- 推荐做法:严肃场景尽量不要直接从小部件发网络请求,而是通过 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 只会出现在:
- ChatGPT 为你的小部件创建的那个 iframe 内;
- 当 HTML 模板使用正确的 mimeType(text/html+skybridge)并且通过所有校验时。
你已经在 HelloWorld App 模块中见过这个类型——小部件页面返回的正是它,而不是普通的 text/html。
如果你直接在浏览器中打开小部件页面,那么:
console.log(window.openai); // undefined
这很正常。因此在小部件代码中,如果你希望支持用于本地开发或 Storybook 的“standalone”模式,总是应该检查该对象是否存在。
一个最简单的示例(并非最终代码,仅作演示):
if (typeof window !== "undefined" && (window as any).openai) {
console.log("We are inside ChatGPT sandbox!");
}
初始化的异步性
在底层,ChatGPT 会随着新数据的到来(新的 toolOutput、displayMode 切换等)更新 window.openai,使用内部事件 openai:set_globals。
也就是说其中的“值”不是静态的:模型可能调用 MCP 工具,后端返回新的 structuredContent,于是 window.openai.toolOutput 会在你的 React 组件下方直接发生变化。
因此有两条建议:
- 不要做“盲拍”的快照,如 const toolOutput = window.openai.toolOutput 仅在开始时取一次并认为它永远有效。同一个小部件可能会被 ChatGPT 复用。
- 使用钩子层(稍后介绍),它能订阅变化。
3. window.openai 的剖析:数据、API 与上下文
官方文档给出了 window.openai 的字段与方法的精简表。 这里把它整理成更“接地气”的版本。
主要字段与方法
window.openai = {
// State & data
toolInput, // JSON:模型传给你的 MCP 工具的参数
toolOutput, // JSON:你的 MCP 工具返回给模型的参数
toolResponseMetadata, // MCP 工具响应:_meta 的一部分
widgetState, // 可读取已保存的小部件状态
setWidgetState, // 可在此保存你的小部件状态
// Runtime APIs
callTool, // 可调用 MCP 工具
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 | |
调用工具时的参数。只读。 |
| State & data | |
来自 MCP 响应的 structuredContent。小部件与模型所见的数据。 |
| State & data | |
响应中的 _meta。仅小部件可见,模型不会读取。 |
| State & data | |
在小部件渲染之间由 ChatGPT 保存的 UI 状态快照。 |
| State & data | |
同步保存新的 widgetState 快照。 |
| Function | |
从小部件调用 MCP 工具。 |
| Function | |
请求 ChatGPT 以小部件名义发送消息到聊天,并开始回复。 |
| Function | |
向宿主请求 inline / fullscreen / pip。 |
| Function | |
请求打开模态窗口。 |
| Function | |
告知内容高度已变化。 |
| Function | |
按 ACP 协议打开支付对话框。 |
| Function | |
在用户浏览器中打开外部链接。 |
| Context | |
环境信号:主题、模式、可用高度、语言等。 |
无需一次性记住全部——把它当作一张“地图”。 下面我们不再按“手册”,而是按常规开发经验来理解它。
toolInput 与 toolOutput:数据从何而来
当模型决定调用你的工具时,它会形成 JSON 参数。这些参数会:
- 作为 input 到达 MCP 服务器的处理器;
- 同时进入小部件中的 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 千个 token),不要把它当作迷你数据库。
最简单的用法(直写,不用钩子,原生 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: "展示更贵一些的想法" }) —— 无需让用户手打文字,你放一个跟进按钮,它会在聊天中创建一条新消息;
- 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 检查、重复包装…… 因此,在 Apps SDK 的 Next.js 模板中提供了一组 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 让你像使用普通 React state 一样使用 widgetState: 你会得到 [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 state,也会在 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,并在首次渲染时添加一个简单的 effect:
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>
);
}
并在主 UI 下方临时插入 <DebugPanel />。你会直观看到:
- MCP 在 toolOutput 中到底传来了哪些字段;
- _meta 里有什么(比如 locale、userLocation 等);
- 当你切换显示模式时,displayMode 如何变化。
之后你可以移除该组件,或通过类似 DEBUG_WIDGET 的开关来控制是否显示。
6. 关系梳理:ChatGPT ↔ 小部件 ↔ MCP/服务器
为了避免把小部件当作系统中的“绝对主角”,我们再来明确一下各自的角色。
- 用户发送消息:“帮我为女友挑选礼物,预算 50 美元”。
- ChatGPT 模型决定调用你的 MCP 工具 search_gifts,参数为 { recipient: "girlfriend", budget: 50 }。
- MCP 服务器执行业务逻辑并返回:
- 给模型的 content(简要描述);
- 用于 UI 的 structuredContent(礼物数组);
- 包含技术细节(如 source、货币)的 _meta。
- ChatGPT:
- 向用户展示文本消息(“我找到了几个选项……”);
- 创建小部件 iframe,并将 structuredContent 与 _meta 传入 window.openai.toolOutput 与 toolResponseMetadata。
- 你的小部件:
- 根据 toolOutput 渲染 UI;
- 在交互时调用 callTool 或发送跟进消息。
- 模型据此决定接下来如何处理这些结果。
这引出一个重要观点:小部件从不是整个流程的唯一“主人”。 它是位于模型与 MCP 服务器生态中的 UI 层。 复杂事项(鉴权、访问私密数据、严肃的业务逻辑)应放在服务器端。 小部件负责提供友好的界面与与用户的良好交互。
7. 沙盒中的政策与游戏规则
之所以采用隔离的 iframe 与 window.openai,是出于安全与隐私要求。 OpenAI 的官方指南强调了几个原则。
其一,数据最小化。 你不应通过小部件尽可能多地收集用户的 PII(Personally Identifiable Information,个人可识别信息)并发送到你的服务器。 一切真正必要的数据都应在工具中明确描述,模型与安全层会对这样的调用进行严格审查。
其二,禁止隐蔽追踪与设备指纹。 不得建立“偷窥”用户设备的系统,收集浏览器指纹或尝试绕过限制。 诸如 userAgent、userLocation 等参数——只是用于 UX 的提示,不应用于鉴权或身份识别。
其三,一切放入 structuredContent、_meta、widgetState 的内容,在某种程度上要么被用户看到,要么会被 Store 审核者看到。因此:
- 不要把任何 API key、令牌、密码或管理密钥放进去;
- 需要以让用户看到日志或调试信息时也不会感到意外的方式来设计小部件状态。
其四,网络调用。 从小部件直接访问第三方 API 仅限严格限制的域名且用于非敏感场景。 涉及金钱、账号、私密数据的内容,都应通过 MCP/后端来实现。
8. 在沙盒与 window.openai 中常见的错误
错误 1:把小部件当作“在 iframe 中的普通网站”。
新人常常会下意识地访问 window.parent、修改 ChatGPT 的样式,或像往常一样使用 localStorage。 在沙盒中要么不可用、要么不稳定:origin 不同、存储隔离、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:在 ChatGPT 之外依赖 window.openai 的稳定存在。
有的开发者试图把小部件单独作为 SPA 运行,却没有检查 window.openai 可能为 undefined。 在开发环境中这会导致“Cannot read properties of undefined”的崩溃。 请使用 useIsChatGptApp、对 typeof window !== "undefined" 的检查,以及当不存在小部件时的备用 UI。
错误 6:忽视环境上下文(theme、displayMode、maxHeight、locale)。
当然你可以把高度写死为 2000px、总是深色主题、并只按桌面端来排版——但这会让用户体验很奇怪。 平台提供了可用空间、主题与语言等信号——通过 useOpenAIGlobal、useDisplayMode、useMaxHeight 等来合理使用它们,让小部件在 ChatGPT 中看起来“很原生”。
错误 7:试图通过第三方脚本“绕过”策略。
有时会忍不住引入某个跟踪器、第三方 JS 包,或从其它域执行代码“悄悄地干”。 沙盒与 CSP 正是为此而设:第三方脚本将被拦截,试图绕过系统会让你的 App 在 Store 审核中被拒。
GO TO FULL VERSION