1. 外への2つの経路: ナビゲーションとデータ
通常の Next.js 開発者は「サーバーへ行く必要がある」と聞くと、反射的に fetch やお気に入りの HTTP クライアントに手が伸びます。ChatGPT Apps の世界では、その反射が痛みの原因になります。
本コースの「ChatGPT Apps におけるウィジェットの安全性」のセクションでは、その古い反射を最初から壊すことを提案します。ウィジェットは自由なインターネットで生きているわけではありません。厳しく隔離され、ネットワークアクセスはホストのポリシーでフィルタ・制限されます。
ウィジェットが外部とつながる基本の窓は3つだけです。
- ナビゲーション: ユーザーを外部に連れて行く。これには openExternal があります。
- データのやり取り: JSON の取得/送信、バックエンドとの会話。fetch を使いますが、強い制約があり得ます。
- MCP tool call: 制約のない(MCP / バックエンドの)ツール呼び出し。
この講義では、まず最初で最も安全な経路(ナビゲーション)に焦点を当て、制御された fetch に慎重に触れます。次のモジュールで、サーバーとの本格的なやり取りの主役として MCP とツールを扱います。
2. openExternal: ユーザーを安全に「テレポート」する
なぜ単に window.open してはいけないのか
通常の Web アプリなら、だいたい次のように書くでしょう。
window.open("https://example.com", "_blank");
ChatGPT のサンドボックスでは、これは動かないか、動いても非常に奇妙な振る舞いになります。ウィジェットは厳格な sandbox が付与された分離 iframe であり、ブラウザタブと同じ権限を持ちません。
さらに、ChatGPT のホストは、次の理由から「どこへ」「いつ」ユーザーを連れて行くかをコントロールしたいのです。
- 秘匿されたトラッキングを防ぐため
- ユーザーに分かりやすい確認 UI を表示するため(特にモバイル/デスクトップクライアント)
- 異なる環境(Web、デスクトップ、モバイルアプリ)でリンクの挙動を揃えるため
そのため、特別な API openExternal が用意されており、 window.openai 経由、またはより便利な React フック useOpenExternal から利用できます。
useOpenExternal はどう見えるか
Apps SDK の公式サンプルでは、フック useOpenExternal はおおむね次のように実装されています。
export function useOpenExternal() {
const openExternal = useCallback((href: string) => {
if (typeof window === "undefined") return;
if (window?.openai?.openExternal) {
try {
window.openai.openExternal({ href });
return;
} catch (error) {
console.warn("openExternal failed, falling back to window.open", error);
}
}
window.open(href, "_blank", "noopener,noreferrer");
}, []);
return openExternal;
}
ここでの要点はシンプルです。まず ChatGPT のネイティブ機構 (window.openai.openExternal) を試みます。もしウィジェットが ChatGPT 以外でレンダリングされている(たとえば開発中にブラウザで直接開いている)場合は、 window.open へのフォールバックに丁寧に落とします。
あなたのアプリ(OpenAI の標準リポジトリを基にしている場合)には、このフックがテンプレートとしてすでに含まれています。使い方はこれに従い、window.openai を直接触らないようにしましょう。
例: GiftGenius の「ストアで見る」ボタン
toolOutput に productUrl フィールド付きのレコメンドが届くとします。各カードに、あなたのサイトで商品を開くボタンを追加してみましょう。
import { useWidgetProps } from "../hooks/use-widget-props";
import { useOpenExternal } from "../hooks/use-open-external";
export function GiftListWidget() {
const { toolOutput } = useWidgetProps<{
recommendations: { id: string; title: string; price: string; url: string }[];
}>();
const openExternal = useOpenExternal();
if (!toolOutput) return <p>まだおすすめはありません…</p>;
return (
<div>
{toolOutput.recommendations.map((gift) => (
<div key={gift.id} className="flex justify-between gap-2">
<div>
<div>{gift.title}</div>
<div className="text-sm text-muted-foreground">{gift.price}</div>
</div>
<button onClick={() => openExternal(gift.url)}>
開く
</button>
</div>
))}
</div>
);
}
ユーザー視点では、ボタンを押すと ChatGPT が「外部サイトを開きますか?」のようなシステムダイアログを出すことがあり、その後に新しいタブまたは既定のブラウザであなたのページが開きます。秘密やトークンなどは持ち出さず、ただ「チャットからサイトへ」ユーザーを移すだけです。
3. サンドボックスにおける window.fetch: いつもの fetch ではありません
フロントエンド開発者が通常期待すること
ふつうは「ブラウザなんだから、CORS を設定してある任意の URL に気軽に投げられる。最悪エラーにはなるが、とりあえず試せる」と考えます。
ChatGPT Apps のエコシステムでは、これは危険な思い込みです。ウィジェットを取り巻くサンドボックスは、単なる「細かいお行儀」ではなく、根本的なセキュリティ要件です。ウィジェットがユーザーをトラッキングしたり、任意ドメインへアクセスしたり、ローカルネットワークをスキャンしたり、つまり「ブラウザの中のミニブラウザ」として振る舞えないようにするためです。
同様に、Apps SDK のウィジェットには任意のネットワークアクセスが存在しない、あるいは強く制限されていることが強調されています。これはバグではなく、意図的なアーキテクチャ上の判断です。
実際にはどうなるか
典型的な ChatGPT 環境では:
- fetch は使えてもドメインが制限されます(通常はあなたの App が動いているドメイン、そして明示的に許可された少数の API など)。
- リクエストはホストの特別なプロキシを経由し、ヘッダーや URL がフィルタされます。
- PUT や DELETE のようなメソッド、あるいは一部のカスタムヘッダーはセキュリティポリシーによりブロックされ得ます。
それでも便利な道はあります。ウィジェットとバックエンドが同じドメインにある場合(Next.js のテンプレートのように MCP サーバーと UI を同一アプリでホストしているケース)、内部リクエスト fetch("/api/...") は許可されることが多いです。
重要なのは、ウィジェットがインターネット上の任意の API にアクセスできると当てにしないことです。外部サービス(Stripe、Notion、CRM など)との「太い」やり取りは MCP/バックエンド側で行い、ChatGPT は信頼済みリソースとしてそこにアクセスするのが基本です。
Insight
ChatGPT のウィジェットでは、相対パスは最初から忘れて絶対 URL で生きるのが安全です。理由は単純で、あなたの HTML はバックエンドと同一ドメインでは動いていないからです。ChatGPT はあなたの HTML を読み込み、自身のホストで保持し、分離された iframe でレンダリングします。すると "/api/..." や "/static/logo.png" は、あなたのアプリではなく ChatGPT のドメインを基準に解決され、すべてが崩れてしまいます。
<base> はほとんど助けになりません。実験上、ウィジェットに widgetCSP を設定していない場合は <base href="https://my-app.dev/"> を指定すればリソースはあなたのドメインから取得できますが、サンドボックスのルールによりスクリプトは依然として動きません。ただし、これは Dev Mode のみ有効です。
一度でも適切な openai/widgetCSP を設定すると(本番のレビューでは必須)、プラットフォームは <base> を無効化し、ゲームは終了です。リソースやスクリプトは CSP で許可されたドメインから、しかも絶対リンクでのみ読み込まれます。
推奨: ChatGPT ウィジェットから外部へ出ていくもの( fetch、画像、CSS、openExternal で開くあなたのページ)は、常にアプリのベースドメインからの完全な URL を構築してください。これは設定や ENV で制御し、相対パスや <base> に頼らないようにします。
4. アーキテクチャ: 薄い UI、厚いバックエンド
fetch の制約とサンドボックスの存在から、コース全体で重要となる一般的な設計原則が導かれます。これまでも何度か触れてきましたが、今一度強調します。ウィジェットは薄い UI 層です。バックエンド(MCP/ツール経由)が用意したものをレンダリングし、ユーザーの操作に応じて反応を見せ、必要なら小さな公開リクエストを数回行う程度に留めます。
認可、個人データへのアクセス、秘密情報、複雑なビジネスロジックはサーバー側に置くべきです。本コースのセキュリティ文書でも、フロントエンド(React ウィジェット)は「public place」、すなわちゼロトラストの領域であり、秘密を置く場所ではないと強調しています。
私の調査の結論は明確です。ChatGPT Apps における「太いクライアント」発想に最後の釘を打ち込むこと。ウィジェットは頭、身体と頭脳は MCP/バックエンドです。
したがって:
- openExternal — ユーザーをあなたの「通常の」サイトに案内するため。そこでは馴染みの SPA やマイページなどを自由に動かせます。
- callTool(次のモジュール)— モデルにあなたのバックエンドが実行するタスクを渡すための主役。
- ウィジェットからの fetch — あなたのアプリ自身への補助的で安全、できれば公開の小さなリクエストに限るのが理想です。
5. 実践: 私たちの GiftGenius での openExternal
openExternal を学習用アプリにもう少し丁寧に組み込み、同時に UX も考えてみましょう。
ミニ UX ルール
ユーザーを外部へ誘導するなら、次の点が有用です。
- ユーザーが「どこへ行くのか」を明示する。
- 説明なしの「突然のジャンプ」を避ける(GPT に「ストアのサイトを開きます…」と言わせるか、ボタンに説明を書く)。
見出しとラベルの例:
<button onClick={() => openExternal(gift.url)}>
ストアのサイトで開く
</button>
ユーザーは、今これから心地よいチャットから現実世界のカートや決済へと移動することを理解できます。
リストコンポーネントの小さなリファクタ
以前、簡単な GiftListWidget を作りました。前の講義で、toolOutput に基づいてギフトの一覧を表示するウィジェットを実装済みだと仮定します。ここでは、もう少し丁寧にして、Gift 型(url フィールド付き)と openExternal ボタンを追加します。
type Gift = {
id: string;
title: string;
priceLabel: string;
url: string;
};
export function GiftListWidget() {
const { toolOutput } = useWidgetProps<{ gifts: Gift[] }>();
const openExternal = useOpenExternal();
if (!toolOutput || toolOutput.gifts.length === 0) {
return <p>該当するものはまだ見つかりません。クエリを変更してみてください。</p>;
}
return (
<div>
{toolOutput.gifts.map((gift) => (
<div key={gift.id} className="flex justify-between gap-2">
<div>
<div>{gift.title}</div>
<div className="text-sm text-muted-foreground">
{gift.priceLabel}
</div>
</div>
<button onClick={() => openExternal(gift.url)}>
見る
</button>
</div>
))}
</div>
);
}
依然として window.openai を直接扱わず、便利なフックを使います。これなら ChatGPT 環境がない場合にも window.open へフォールバックしてくれます。Gift の構造はあくまで一例で、自分のアプリに合わせてバックエンド側の仕様に合わせてください。
6. 実践: バックエンドへの丁寧な fetch
次に fetch を扱います。繰り返しますが、複雑またはセンシティブな操作はツール/MCP 経由で行うのがベターです。ただし、ときにはウィジェットから、あなたのサーバーにある軽量で公開の情報(たとえば人気のギフトカテゴリ一覧)を取得したくなることがあります。
Next.js のシンプルな公開 API ルート
Next.js プロジェクトに、次のハンドラを追加しましょう。
// app/api/public/popular-tags/route.ts
import { NextResponse } from "next/server";
const tags = ["子ども向け", "旅行好き向け", "ゲーマー向け"];
export async function GET() {
return NextResponse.json({ tags });
}
このルートはユーザーを識別せず、トークンも不要で、外部サービスにもアクセスしません。単に静的配列を返すだけです。こうしたコードは、プロダクションでもサンドボックスでも比較的安全に持ち込めます。
ウィジェットから fetch でこのルートを呼び出す
では、ウィジェットのコンポーネント側でこのタグを読み込みます。サンドボックスの制約を踏まえると、絶対 URL にリクエストするのが便利です。すなわち、あなたの App が動作している同じドメイン(トンネル経由で公開し、ChatGPT の Dev Mode に登録したドメイン。これは Dev Mode とトンネルのモジュールで設定しました)に対してです。
重要: ウィジェットのドメインは https://genius.web-sandbox.oaiusercontent.com のようなものになります。したがって、データ取得に相対パスは使わず、必ず絶対 URL を使ってください。例:
import { useEffect, useState } from "react";
export function PopularTags() {
const [tags, setTags] = useState<string[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function loadTags() {
try {
const res = await fetch("https://giftgenius.app/api/public/popular-tags");
if (!res.ok) throw new Error("Bad status");
const data: { tags: string[] } = await res.json();
if (!cancelled) setTags(data.tags);
} catch (e) {
if (!cancelled) setError("人気のカテゴリを読み込めませんでした");
}
}
loadTags();
return () => {
cancelled = true;
};
}, []);
if (error) return <p>{error}</p>;
if (!tags) return <p>人気のカテゴリを読み込み中…</p>;
return (
<div className="flex flex-wrap gap-2 text-sm">
{tags.map((tag) => (
<span key={tag} className="rounded border px-2 py-1">
{tag}
</span>
))}
</div>
);
}
要点は次のとおりです。
- エラーを丁寧に処理し、ユーザーに分かるメッセージを表示すること。
- fetch が「必ず動く」とは仮定しないこと。ドメインを変えたり不審な要求をし始めると、サンドボックスのポリシーがアクセスを遮断することがあります。
- トークンや秘密は一切渡さないこと。認証が必要なら MCP と認証モジュールの仕事です。
7. openExternal vs fetch vs ツール(callTool): 役割の分担
混乱を避けるために、次の「責務マトリクス」を頭に置いておくと便利です。
| シナリオ | 使うもの | 理由 |
|---|---|---|
| ランディング/商品/マイページを開く | openExternal | ホストにより制御される、明示的なユーザー遷移 |
| アプリから公開データを取得 | fetch("my.com/api/...") | 軽量な JSON、同一ドメイン、秘密情報なし |
| ユーザーデータや DB を取得 | callTool/MCP | 認可・ロジック・安全なバックエンドが必要 |
| 外部 API(Stripe など)へアクセス | MCP/サーバー | フロントが秘密情報を見ない。ポリシーを遵守。 |
本モジュールでは、意識的に適切な道具を選べるようになることが重要です。「ウィジェット=フロントエンドだから何でも fetch でできる」という発想から、「ウィジェット=LLM+MCP バックエンドの上にある制御された UI 層」という発想へ切り替えましょう。
Insight
ChatGPT App のサーバー連携は、次の2層に分けると理にかないます。
- ChatGPT ↔ MCP サーバー: モデルが MCP ツールを呼びます。各ツール呼び出しは、ビジネスシナリオ(ギフトの選定、注文作成、料金計算など)の起動または切り替えです。ここに「重い」ロジック、データ操作、外部 API、認可が住みます。
- ウィジェット ↔ サーバー: ウィジェットは自身のバックエンドへ軽い fetch() を行い、または すでにアクティブなシナリオの中で callTool() を通じて同じ MCP ツールを叩きます。これはローカルな一歩です。補助データの読み込み、UI の一部更新、状態の確認など。
つまり、MCP ツール=ビジネスプロセスの起動/制御、fetch()/callTool()(ウィジェット側)=すでに選ばれたシナリオの内部で行う小さな操作、という役割分担です。チャット全体の「ストーリー」を左右するような変更は狙いません。
8. 小さな実践演習
このテーマを実地で固めるために、GiftGenius で小さな機能を作ってみましょう。
提案するシナリオ:
- ギフト一覧に「チェックアウトへ進む」ボタンを追加し、openExternal であなたの開発用サイトのチェックアウトページを開く。
- ギフト一覧の上に、先ほどの PopularTags をレンダリングして人気カテゴリを表示する。読み込みに失敗した場合はフォールバックテキストを表示し、ウィジェット全体は壊さない。
- UX に注意する。GPT の応答文面やウィジェットの UI で「ボタンを押すと新しいタブでストアのページを開きます」のようにユーザーへ説明する。
この機能はミニチュアながら、2つの経路を示します。
- openExternal — 明示的なナビゲーション。
- fetch — あなたの App の近くにある小さな公開 API の呼び出し。
9. window.fetch と openExternal を扱う際のよくある落とし穴
よくある誤り1: ウィジェットを、あなたの全 API へアクセスできるフル機能の SPA クライアントとして扱うこと。
昔の習慣から「React から直接 REST/GraphQL を叩けばいい」となりがちです。ChatGPT Apps の世界ではサンドボックスに正面衝突します。一部のリクエストは通らず、別の一部はポリシーにより遮断され、プロジェクトの安全性が揺らぎます。複雑なロジックやユーザーデータへのアクセスは MCP/ツール経由で行い、ウィジェットから直接は行わないのが原則です。
よくある誤り2: ウィジェットのコードに秘密やトークンを埋め込むこと。
「プロトタイプだから」とフロントエンドのコードに外部サービスの API キーを書きたくなることがあります。一般的な SPA でも良くありませんが、ChatGPT Apps では断固として NG です。ウィジェットは公開環境です。秘密はサーバーの設定や秘密管理システム(Vercel の環境変数、KMS など)に置くべきです。
よくある誤り3: 任意のドメインへの fetch が「そのまま動く」と考えること。
Dev Mode では(たとえばトンネルの都合で)たまたま通ることがあっても、本番環境ではほぼ確実に壊れます。ChatGPT は発信リクエストを制限しており、ウィジェットから任意の外部ドメインへはアクセスできません。ウィジェットが確実にアクセスできるのは、自分のドメインと、明示的に許可されたごく小さなホワイトリストだけだと見積もってください。
よくある誤り4: window.open を openExternal の代わりに使うこと。
技術的には、ブラウザプレビューでは window.open が動くこともあり、「大丈夫そう」な錯覚を生みます。しかし実際の ChatGPT 環境、特にネイティブクライアントでは挙動が予測不能です。ユーザーが遷移を目視できなかったり、不可解なエラーを得たりします。正しい道は openExternal(useOpenExternal フック経由)を使うことです。これが現在の環境に合った正しい開き方を知っています。
よくある誤り5: fetch のエラーを処理せず、読み込み状態を表示しないこと。
サンドボックスではネットワークエラーは例外ではなく日常です。トンネルが落ちたり、ドメインが変わったり、ポリシーが何かを遮断したりします。単に await fetch(...) として、データがある前提で UI をレンダリングすると、「時々動くけど時々壊れる」不思議な UI になります。常に try/catch を置き、res.ok を確認し、「読み込み中…」と丁寧なエラーメッセージを表示しましょう。
よくある誤り6: openExternal を隠れたリダイレクトとして使うこと。
任意のボタンクリックで即座に外部サイト(特にチェックアウト)へ飛ばしたくなることがありますが、文脈なしではユーザーにも Store の審査担当にも不自然です。望ましいのは、「これから何が起こるか」を明示すること。GPT に「ストアのページを開きます…」と言わせるか、ボタンの文言を十分に透明にする(「ストアのサイトで決済へ進む」など)。
よくある誤り7: ウィジェットが対話の「唯一の主人公」だと忘れてしまうこと。
UI が、チャットやフォローアップを無視して独自のリンクやネットワークリクエストだらけの複雑なシナリオを押し付けると、UX もモデル品質も悪化します。アーキテクチャを思い出してください。GPT が App をいつ表示し、どう使うかを決め、ウィジェットはそれを補助し可視化するだけです。ナビゲーションやネットワーク呼び出しは、全体の対話に溶け込むように設計しましょう。
GO TO FULL VERSION