1. 什麼是沙盒,以及為什麼你的小工具像被關在籠子裡
當 ChatGPT 顯示你的小工具時,它不會把它渲染成普通的 <iframe src="https://your-site">。 小工具會在受控的「沙盒」中啟動——一個具備獨立 origin 且安全性設定嚴格的 iframe。
技術上大致如下:
flowchart TD
User["使用者在 ChatGPT"]
Chat["ChatGPT UI + 模型"]
Iframe["你的小工具
sandboxed iframe"]
MCP["你的 MCP / 後端"]
User --> Chat
Chat -->|呼叫 tool| MCP
MCP -->|structuredContent + _meta| Chat
Chat -->|window.openai.*| Iframe
Iframe -->|callTool / follow-up| 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 中注入一個全域物件——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
這很正常。因此在小工具程式碼中,若你預期要支援「standalone」的本機開發或 Storybook,就應該檢查該物件是否存在。
一個最簡單(僅示意,非最終)的例子:
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 重複使用。
- 使用 hooks 抽象層(稍後介紹),它會訂閱變更。
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, // 請求將小工具切換到另一個模式:fullscreen、pip、inline
requestModal, // 請求將小工具變為對話框(modal)。
requestClose, // 關閉小工具。若為 modal,則關閉後還原為小工具。
requestCheckout, // 開啟付款用的 modal。伺服器需實作 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 | |
請求開啟對話框(modal)。 |
| 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 影像;
- 敏感的憑證或 Token。
重要的兩點:
- widgetState 會隨上下文一同傳給模型,因此不要放任何敏感資訊。
- 容量有限(約 4 千個 token),別把它當作迷你資料庫。
最簡單的用法(直上、無 hooks、原生 JS):
const current = window.openai.widgetState ?? { selectedGiftId: null };
function selectGift(id) {
window.openai.setWidgetState({ ...current, selectedGiftId: id });
}
在實際程式碼中,我們會用 React hooks 把它包起來。
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" })——透過受控通道把使用者帶到外部網站(結帳、個人檔案等)。
所有這些互動都經過 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 等 hooks 來使用,讓小工具在 ChatGPT 內看起來更「原生」。
4. 在 window.openai 之上使用 hooks:別徒手碰全域物件
直接存取 window.openai 做原型很方便,但很快就會讓程式碼變成一鍋粥: 事件訂閱、undefined 檢查、重複包裝。 因此在 Apps SDK 的 Next.js 範本裡,已提供一組 React hooks,幫你隱藏細節並保持反應式。
hooks 索引通常長這樣:
// 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.*, 不如使用 hooks。下面挑重點說明。
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], 而 hook 會在底層與 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,只用 hooks。
5. 實作:體驗沙盒與 window.openai
為了體會「小工具不是一般網站」,做幾個小練習很有幫助。
練習:「摸摸環境」
把你的小工具中的 app/page.tsx,在第一次 render 時加上一個簡單的 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 視窗(若內建的通道檢視器允許),要嘛於本機瀏覽器直接開啟頁面。比較兩種情況:
- 在一般瀏覽器啟動時,isChatGptApp 會是 false,而 window.openai 多半是 undefined;
- 透過 ChatGPT 啟動時,你會看到帶有 toolInput、toolOutput、theme 等欄位的物件。
這能建立直覺:相同的 React 程式碼,會依環境不同而有不同行為, 而 hooks 正是為此而設計。
練習:「把平台給的都印出來」
加上一個暫時用的偵錯元件:
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 下方。你會清楚看到:
- toolOutput 中,來自 MCP 的欄位到底有哪些;
- _meta 裡有什麼(例如 locale、userLocation 等);
- 當你把小工具展開時,displayMode 如何變化。
之後你可以移除它,或保留並透過像 DEBUG_WIDGET 之類的旗標來切換。
6. 關係:ChatGPT ↔ 小工具 ↔ MCP/伺服器
為避免把小工具當作系統中的「主角」,我們再確認一次各自的角色。
- 使用者輸入:「幫我挑選給女友的禮物,預算 50$」。
- ChatGPT 模型決定呼叫你的 MCP 工具 search_gifts,並帶上參數 { recipient: "girlfriend", budget: 50 }。
- MCP 伺服器執行商業邏輯並回傳:
- content:給模型的簡述;
- structuredContent:包含禮物陣列;
- _meta:技術細節(例如來源與貨幣)。
- ChatGPT:
- 顯示一段文字訊息給使用者(「我找到幾個選項…」);
- 建立一個小工具 iframe,並把 structuredContent 與 _meta 傳遞給它,對應到 window.openai.toolOutput 與 toolResponseMetadata。
- 你的小工具:
- 依 toolOutput 渲染 UI;
- 在互動時呼叫 callTool 或發送 follow-up 訊息;
- 模型再決定接下來如何處理這些動作的結果。
重點在於:小工具從來不是流程的唯一主宰。 它是位於模型與 MCP 伺服器生態系中的 UI 層。 複雜的東西(授權、私有資料存取、嚴肅的商業邏輯)應留在伺服器端。 小工具負責友善的介面與與使用者的溝通。
7. 沙盒中的政策與遊戲規則
之所以要有隔離的 iframe 與 window.openai,不是為了麻煩你,而是出於安全與隱私需求。 OpenAI 的官方指南特別強調幾個原則。
第一,資料最小化。 你不應試圖透過小工具收集盡可能多的個資(PII)並傳回自己那邊。 真正必要的東西應在工具中明確描述,模型與安全層會嚴格審視這些呼叫。
第二,禁止隱性追蹤與裝置指紋蒐集(fingerprinting)。 不能建立「偷看」使用者裝置的系統,蒐集瀏覽器指紋,或設法繞過限制。 像 userAgent、userLocation 等參數——它們是為了 UX 的提示,而不是為了授權或辨識身分。
第三,凡是你放進 structuredContent、_meta、widgetState 的內容,某種程度上使用者看得到,或 Store 的審查者看得到。因此:
- API 金鑰、Token、密碼與管理密鑰等,一律不能放進去;
- 小工具狀態的設計要讓使用者在看到它(例如在記錄或除錯畫面)時不會感到意外。
第四,網路呼叫。 小工具直接打第三方 API 只允許針對嚴格限制的網域,且必須是非敏感的情境。 只要牽涉到金錢、帳號、私密資料——一律走 MCP/後端。
8. 在沙盒與 window.openai 中的常見錯誤
錯誤 #1:以為小工具就是「放在 iframe 裡的普通網站」。
新手常習慣去動 window.parent、修改 ChatGPT 的樣式,或像平常一樣使用 localStorage。 在沙盒中,這些不是不能用,就是不穩——因為 origin 不同、儲存空間被隔離、DOM 存取被阻擋。 接受你活在受控環境中的事實,並僅透過 window.openai 與 hooks 與主機溝通。
錯誤 #2:到處直接碰 window.openai。
在十個元件裡到處寫 window.openai.toolOutput, 只會把應用弄得難以除錯,還得自己處理事件、非同步與 undefined 檢查。 更可靠的作法是直接使用 useWidgetProps、useWidgetState、useOpenAIGlobal 等 hooks,它們已包好 openai:set_globals 並同步狀態。
錯誤 #3:把所有東西(尤其是機密)都塞進 widgetState。
有時會想「以防萬一」把龐大的 API 回傳或甚至存取 Token 全塞進去。 結果是上下文暴漲、模型效果變差,還違反基本安全要求。 widgetState 應該很小,只放 UI 訊號,絕不可放機密資料。
錯誤 #4:嘗試從小工具直接連網。
在沙盒中呼叫 fetch("https://api.superbank.com/...") 幾乎必然撞上 CORS, 就算你把一切都設好,也是不安全且難以控管。 凡是涉及真實帳號、金流與個資的內容,都應實作成 MCP 工具並透過 callTool 或後端來處理。
錯誤 #5:在 ChatGPT 之外仍假設 window.openai 穩定可用。
有時開發者會把小工具當作獨立 SPA 來跑,卻沒有檢查 window.openai 可能是 undefined。 在 dev 環境就會直接 crash:「Cannot read properties of undefined」。 請使用 useIsChatGptApp、typeof window !== "undefined" 等檢查,並為沒有小工具環境時提供後備 UI。
錯誤 #6:忽略環境上下文(theme、displayMode、maxHeight、locale)。
你當然可以硬是設定 2000px 高度、永遠深色主題、僅針對桌面版排版——但使用者體驗會很怪。 平台提供可用空間、主題與語系等訊號——請透過 useOpenAIGlobal、useDisplayMode、useMaxHeight 等使用,讓小工具看起來像 ChatGPT 的原生一部分。
錯誤 #7:試圖以第三方腳本「繞過」政策。
有時會想偷偷拉某個追蹤器、外部 JS bundle,或從別的網域執行程式碼。 沙盒與 CSP 政策就是為了阻止這類事:外部腳本會被封鎖,而試圖繞過系統則會讓你的 App 在 Store 審核時被拒。
GO TO FULL VERSION