CodeGym /課程 /ChatGPT Apps /在沙盒中工作:限制、細節與 window.openai

在沙盒中工作:限制、細節與 window.openai

ChatGPT Apps
等級 3, 課堂 0
開放

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 元件下方直接發生變化。

有兩個建議:

  1. 不要在初始化時做「盲快照」,像是 const toolOutput = window.openai.toolOutput 然後以為它永遠不變。同一個小工具可能會被 ChatGPT 重複使用。
  2. 使用 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
toolInput
呼叫工具時的引數。唯讀。
State & data
toolOutput
你在 MCP 回應中的 structuredContent。小工具與模型都會看到它。
State & data
toolResponseMetadata
回應中的 _meta。只有小工具可見,模型不會讀取。
State & data
widgetState
ChatGPT 在小工具重渲染之間保存的 UI 狀態快照。
State & data
setWidgetState(state)
同步保存新的 widgetState 快照。
Function
callTool(name, args)
從小工具呼叫 MCP 工具。
Function
sendFollowUpMessage({prompt})
要求 ChatGPT 以小工具的身分送出聊天訊息。模型會開始回覆。
Function
requestDisplayMode(...)
向主機請求 inline / fullscreen / pip。
Function
requestModal({title})
請求開啟對話框(modal)。
Function
notifyIntrinsicHeight()
告知內容高度已變更。
Function
requestCheckout(...)
透過 ACP 協議開啟付款對話。
Function
openExternal({href})
在使用者的瀏覽器開啟外部連結。
Context
theme, displayMode, maxHeight, safeArea, view, userAgent, locale
環境訊號:主題、模式、可用高度、語系等。

不必一次背下全部——把它當作「地圖」即可。 接下來我們用更實用的方式理解它,而不是當作單純的「參考手冊」。

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。

重要的兩點:

  1. widgetState 會隨上下文一同傳給模型,因此不要放任何敏感資訊。
  2. 容量有限(約 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/伺服器

為避免把小工具當作系統中的「主角」,我們再確認一次各自的角色。

  1. 使用者輸入:「幫我挑選給女友的禮物,預算 50$」。
  2. ChatGPT 模型決定呼叫你的 MCP 工具 search_gifts,並帶上參數 { recipient: "girlfriend", budget: 50 }。
  3. MCP 伺服器執行商業邏輯並回傳:
    • content:給模型的簡述;
    • structuredContent:包含禮物陣列;
    • _meta:技術細節(例如來源與貨幣)。
  4. ChatGPT:
    • 顯示一段文字訊息給使用者(「我找到幾個選項…」);
    • 建立一個小工具 iframe,並把 structuredContent 與 _meta 傳遞給它,對應到 window.openai.toolOutput 與 toolResponseMetadata。
  5. 你的小工具:
    • 依 toolOutput 渲染 UI;
    • 在互動時呼叫 callTool 或發送 follow-up 訊息;
  6. 模型再決定接下來如何處理這些動作的結果。

重點在於:小工具從來不是流程的唯一主宰。 它是位於模型與 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 審核時被拒。

1
任務
ChatGPT Apps, 等級 3, 課堂 0
上鎖
反應式 OpenAI Globals Inspector(主題、語系、userAgent)
反應式 OpenAI Globals Inspector(主題、語系、userAgent)
1
任務
ChatGPT Apps, 等級 3, 課堂 0
上鎖
SafeExternalLink + 沙箱限制示範(不崩潰)
SafeExternalLink + 沙箱限制示範(不崩潰)
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION