ChatGPT 内に**自分のUI(React など)**を組み込み、会話の流れを壊さずにリッチな体験を提供するためのガイド。
この記事ではwindow.openai
API と MCP サーバ のつなぎこみ、状態管理 と 表示モード切替、サンプルコードを通して、最小構成から実用構成まで一気に理解します。
出典:Build a custom UX / Reference / 公式 Examples repo。 ([OpenAI Developers][1])
0. 何ができるの?(概要)
-
UIはサンドボックス化された iframe 内で実行され、ChatGPT ホストとは
window.openai
でやり取りします(データ受け渡し、ツール呼び出し、表示モード切替、外部リンクなど)。([OpenAI Developers][1]) - コンポーネントは 会話にインライン で差し込まれ、必要に応じて fullscreen / PiP へ切替可能。モバイルでは PiP が fullscreen に強制される場合があります。([OpenAI Developers][1])
- ツール結果は structuredContent / content /
_meta
の3系統で受け渡し。_meta
は モデルに見えず、コンポーネントだけが利用できます。([OpenAI Developers][2])
1. プロジェクトのひな型
app/
server/ # MCP サーバ(Node/Python どちらでも)
web/ # UI(React)をここでバンドル
package.json
tsconfig.json
src/component.tsx
dist/component.js # ← esbuild で出力する単一バンドル
- 依存は最小に、バンドルはできるだけ軽く(iframe なので体感に直結)。([OpenAI Developers][1])
セットアップ例(Node 18+)
cd app/web
npm init -y
npm i react@^18 react-dom@^18
npm i -D typescript esbuild
([OpenAI Developers][1])
esbuild スクリプト(package.json)
{
"scripts": {
"build": "esbuild src/component.tsx --bundle --format=esm --outfile=dist/component.js"
}
}
([OpenAI Developers][1])
2. window.openai
の型と基本API
window.openai
は グローバル値 と 操作API を提供します。主なものだけ抜粋:
-
グローバル値:
theme
,locale
,displayMode
,maxHeight
,safeArea
,toolInput
,toolOutput
,toolResponseMetadata
,widgetState
など。 -
操作API:
-
callTool(name, args)
:MCP のツールを直接呼ぶ -
sendFollowUpMessage({ prompt })
:会話に追記(ユーザーが発言したかのように) -
openExternal({ href })
:外部リンクを開く -
requestDisplayMode({ mode })
:inline | pip | fullscreen
を要求(拒否される可能性あり/モバイルでは PiP→fullscreen) -
setWidgetState(state)
:ウィジェット状態 を保存(モデルにも見える。4k tokens 目安 で小さく)
※ 変更通知はopenai:set_globals
カスタムイベントで飛んできます。([OpenAI Developers][1])
-
3. React フックで“会話とUI”を同期(実装)
3.1 useOpenAiGlobal
:グローバル値を購読
// src/hooks/useOpenAiGlobal.ts
import { useSyncExternalStore } from "react";
export function useOpenAiGlobal<K extends keyof Window["openai"]>(key: K) {
return useSyncExternalStore(
(onChange) => {
const handler = (e: any) => {
const value = e?.detail?.globals?.[key];
if (value !== undefined) onChange();
};
window.addEventListener("openai:set_globals" as any, handler, { passive: true });
return () => window.removeEventListener("openai:set_globals" as any, handler);
},
() => window.openai[key]
);
}
(公式の考え方を踏襲。変更時に UI が即時反映されます)([OpenAI Developers][1])
3.2 よく使うショートカット
export const useToolInput = () => useOpenAiGlobal("toolInput");
export const useToolOutput = () => useOpenAiGlobal("toolOutput");
export const useToolResponseMetadata = () => useOpenAiGlobal("toolResponseMetadata");
export const useDisplayMode = () => useOpenAiGlobal("displayMode");
export const useTheme = () => useOpenAiGlobal("theme");
([OpenAI Developers][1])
3.3 useWidgetState
:ローカル state と永続 state を同期
// src/hooks/useWidgetState.ts
import { useCallback, useEffect, useState } from "react";
import { useOpenAiGlobal } from "./useOpenAiGlobal";
export function useWidgetState<T>(initial?: T | (() => T | null) | null) {
const fromWindow = useOpenAiGlobal("widgetState") as T | null;
const [state, setLocal] = useState<T | null>(() =>
fromWindow ?? (typeof initial === "function" ? (initial as any)() : initial ?? null)
);
useEffect(() => { setLocal(fromWindow); }, [fromWindow]);
const setState = useCallback((next: T | ((prev: T | null) => T | null)) => {
setLocal((prev) => {
const resolved = typeof next === "function" ? (next as any)(prev) : next;
if (resolved != null) window.openai.setWidgetState(resolved as any);
return resolved;
});
}, []);
return [state, setState] as const;
}
(setWidgetState
はモデルにも共有されるため、機微情報は保存しないのがポイント)([OpenAI Developers][1])
4. 最小コンポーネント例(インライン→フルスクリーン、ツール更新、追記)
// src/component.tsx
import React, { useMemo } from "react";
import { createRoot } from "react-dom/client";
import { useToolInput, useToolOutput, useWidgetState, useDisplayMode } from "./hooks";
type Pizza = { id: string; name: string; city: string };
function PizzaListApp() {
const input = useToolInput() as { city?: string };
const output = useToolOutput() as { places?: Pizza[] } | null;
const [favIds, setFavIds] = useWidgetState<string[]>(() => []);
const display = useDisplayMode();
const places = output?.places ?? [];
const toggleFav = (id: string) =>
setFavIds((prev) => {
const set = new Set(prev ?? []);
set.has(id) ? set.delete(id) : set.add(id);
return Array.from(set);
});
const refresh = async () => {
await window.openai.callTool("refresh_pizza_list", { city: input?.city ?? "Tokyo" });
};
const goFullscreen = async () => {
await window.openai.requestDisplayMode({ mode: "fullscreen" }); // 拒否される可能性あり
};
const draftItinerary = async () => {
await window.openai.sendFollowUpMessage({
prompt: "お気に入りのピザ屋で食べ歩きプランを立てて。",
});
};
return (
<div style={{ padding: 12 }}>
<header>
<h3>Pizza in {input?.city ?? "—"}</h3>
<p>display: {display}</p>
<button onClick={refresh}>更新</button>
<button onClick={goFullscreen}>全画面へ</button>
<button onClick={draftItinerary}>会話にプランを追記</button>
</header>
<ul>
{places.map((p) => (
<li key={p.id} style={{ display: "flex", gap: 8, alignItems: "center" }}>
<span>{p.name} / {p.city}</span>
<button onClick={() => toggleFav(p.id)}>
{favIds?.includes(p.id) ? "★" : "☆"}
</button>
</li>
))}
</ul>
</div>
);
}
createRoot(document.getElementById("pizzaz-list-root")!).render(<PizzaListApp />);
上記は公式の Pizza 例の要旨を再構成したもの。
toolOutput
を表示の主ソースに、widgetState
でお気に入りを永続、callTool
で再フェッチ、requestDisplayMode
でレイアウト交渉、sendFollowUpMessage
で会話に追記という、Custom UX の基本動線を一通り押さえています。([OpenAI Developers][1])
HTML テンプレ(サーバから配信する text/html
)
<!-- server 側で配信するテンプレのイメージ(後述の registerResource で埋め込む) -->
<!doctype html>
<html>
<head><meta charset="utf-8" /></head>
<body>
<div id="pizzaz-list-root"></div>
<script type="module" src="/dist/component.js"></script>
</body>
</html>
5. サーバ側:ツール記述(_meta
と出力テンプレート)
ポイント
-
ツール結果は
structuredContent
/content
/_meta
を返せます。-
structuredContent
とcontent
は会話ログに出る(モデルが読める) -
_meta
はモデルに出ないがコンポーネントへ渡る(ハイドレーション用など)
-
-
コンポーネントを表示するには
_meta["openai/outputTemplate"]
に UI テンプレの URI を設定。 -
UI からツールを呼びたいときは
_meta["openai/widgetAccessible"] = true
を付ける(コンポーネント→ツール呼び出しの許可)。([OpenAI Developers][2])
Node 風の registerTool 例(概念コード)
server.registerTool(
"refresh_pizza_list",
{
title: "Refresh Pizza List",
description: "Fetch pizza places by city.",
inputSchema: {
type: "object",
properties: { city: { type: "string" } },
required: ["city"]
},
_meta: {
"openai/outputTemplate": "ui://widget/pizza.html",
"openai/widgetAccessible": true, // ← コンポーネントからの callTool を許可
"openai/toolInvocation/invoking": "Searching…", // 実行中の表示テキスト(短文)
"openai/toolInvocation/invoked": "Results ready"
}
},
async ({ city }) => {
const places = await fetchPizzaPlaces(city);
return {
structuredContent: { places }, // モデル & UI に提示
content: [{ type: "text", text: `Found ${places.length} places in ${city}.` }],
_meta: { allById: Object.fromEntries(places.map(p => [p.id, p])) } // UI 専用の豊富データ
};
}
);
Tool result の三要素の意味は公式 Reference が詳しいです。([OpenAI Developers][2])
6. コンポーネントリソースの登録(CSP・説明文など)
registerResource
で UI テンプレ配信時の _meta
を設定できます:
-
"openai/widgetDescription"
:コンポーネント読込時にモデルへ説明(余計なナレーションを減らす) -
"openai/widgetPrefersBorder"
:カードに枠線を付けたい希望 -
"openai/widgetCSP"
:connect_domains
/resource_domains
の CSP スナップショット -
"openai/widgetDomain"
:ホスティング用の専用サブドメイン(省略時は既定のサンドボックス)([OpenAI Developers][2])
例
server.registerResource("html", "ui://widget/pizza.html", {}, async () => ({
contents: [{
uri: "ui://widget/pizza.html",
mimeType: "text/html",
text: pizzaHtmlTemplate, // 上掲の HTML をここに
_meta: {
"openai/widgetDescription":
"Renders an interactive pizza list with favorites and refresh.",
"openai/widgetPrefersBorder": true,
"openai/widgetCSP": {
connect_domains: ["https://api.example.com"],
resource_domains: ["https://persistent.oaistatic.com"]
}
}
}]
}));
([OpenAI Developers][2])
7. ルーティング(React Router)とホスト連携ナビゲーション
Skybridge(iframe ランタイム)が iframe の history を ChatGPT UI と同期します。
React Router の通常 API を使えば OK。([OpenAI Developers][1])
import { BrowserRouter, Routes, Route, useNavigate } from "react-router-dom";
function AppRouter() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<List />} />
<Route path="place/:placeId" element={<Detail />} />
</Routes>
</BrowserRouter>
);
}
function List() {
const navigate = useNavigate();
return (
<ul>
<li onClick={() => navigate("place/abc123")}>Open detail</li>
</ul>
);
}
(戻る/進むは ChatGPT 側の UI とも整合します)([OpenAI Developers][1])
8. 表示モード(レイアウト)を交渉する
await window.openai.requestDisplayMode({ mode: "fullscreen" });
// 付与された mode は結果で返る。拒否される可能性あり。
// モバイルでは PiP が fullscreen に強制されることがある。
([OpenAI Developers][1])
9. よくある落とし穴とベストプラクティス
-
setWidgetState
はモデルにも見える:機微なユーザーデータを入れない。4k tokens 目安で軽量化。([OpenAI Developers][1]) -
UI→ツール呼び出しの許可を忘れない:ツール定義に
_meta["openai/widgetAccessible"] = true
。([OpenAI Developers][2]) - 冪等なツールに:UI から繰り返し叩かれても安全に。戻り値は次ターンでモデルが推論しやすい構造化に。([OpenAI Developers][1])
- 依存を絞って軽く:チャートやDnD等は必要最小限に。([OpenAI Developers][1])
- Examples repo を丸ごと動かす:Pizza/Carousel/Map/Album/Video などの完成例が揃っている。まずは近いものをコピーしてデータ層だけ差し替えるのが最速。([OpenAI Developers][1])
10. 参考リンク
- Build a custom UX(本記事のベース)([OpenAI Developers][1])
-
Apps SDK Reference(
_meta
、structuredContent、widgetAccessible など)([OpenAI Developers][2]) - Apps SDK Examples(GitHub)(Pizza などの完成例)([GitHub][3])
付録:Tool result の完全例(structuredContent / content / _meta
)
server.registerTool(
"get_zoo_animals",
{
title: "get_zoo_animals",
inputSchema: { type: "object", properties: { count: { type: "number" } } },
_meta: { "openai/outputTemplate": "ui://widget/widget.html" }
},
async ({ count = 10 }) => {
const animals = generateZooAnimals(count);
return {
structuredContent: { animals }, // モデル & UI
content: [{ type: "text", text: `Here are ${animals.length} animals.` }], // 会話に見えるテキスト
_meta: { allAnimalsById: Object.fromEntries(animals.map(a => [a.id, a])) } // UIのみに渡す
};
}
);
(どれが会話ログに出るか、どれが UI 専用かの区別が重要)([OpenAI Developers][2])
まとめ
-
window.openai
を中心に、状態(widgetState)・ツール呼び出し(callTool)・会話追記(sendFollowUpMessage)・表示モード交渉(requestDisplayMode) を把握すれば、ChatGPT らしいリッチ UI を安全に組み込める。([OpenAI Developers][1]) - サーバ側は
_meta["openai/outputTemplate"]
で UI をひも付け、widgetAccessible
で UI→ツール実行を許可。返却は structuredContent / content /_meta
を正しく使い分ける。([OpenAI Developers][2]) - まずは Examples repo を動かして、データ層だけ差し替えるのが最短距離。([GitHub][3])