Skip to content

非公式本サイトは非公式の日本語ドキュメントであり、Cloudflare 公式サイトではありません。最新情報はdevelopers.cloudflare.comをご確認ください。

インタラクティブな ChatGPT App を作る

最終更新 Markdown で表示Agent セットアップ

最初の ChatGPT App をデプロイする

このガイドでは、Cloudflare Workers 上でインタラクティブな ChatGPT App を構築してデプロイします。次のことができます。

  • ChatGPT の会話内に、リッチでインタラクティブな UI ウィジェットを直接描画する
  • Durable Objects で、リアルタイムのマルチユーザー状態を保持する
  • アプリと ChatGPT の双方向通信を有効にする
  • ChatGPT 内だけで動くマルチプレイヤー体験を作る

これらの機能を示す、リアルタイムのマルチプレイヤーチェスゲームを作ります。プレイヤーはゲームを開始または参加し、インタラクティブなチェスボードで手を指し、会話を離れずに ChatGPT に戦略の助言を求められます。

ChatGPT App は Model Context Protocol(MCP) を使い、ChatGPT が代わりに呼び出せるツールと UI リソースを公開します。

この例の完全なコードは こちら で確認できます。

前提条件

始める前に、次が必要です。

1. ChatGPT の開発者モードを有効にする

ChatGPT Apps(コネクタとも呼びます)を使うには、開発者モードを有効にします。

  1. ChatGPT を開きます。
  2. 設定 > Apps & Connectors > 詳細設定 に進みます。
  3. 開発者モード をオンにします。

有効にすると、開発とテストのあいだにカスタムアプリをインストールできます。

2. ChatGPT App プロジェクトを作成する

  1. チェスアプリ用の新しいプロジェクトを作成します。
npm create cloudflare@latest -- my-chess-app
  1. プロジェクトに移動します。
cd my-chess-app
  1. 必要な依存関係をインストールします。
npm install agents @modelcontextprotocol/sdk chess.js react react-dom react-chessboard
  1. 開発用の依存関係をインストールします。
npm install -D @cloudflare/vite-plugin @vitejs/plugin-react vite vite-plugin-singlefile @types/react @types/react-dom

3. プロジェクトを設定する

  1. wrangler.jsonc を更新し、Durable Objects とアセットを設定します。
{
	"name": "my-chess-app",
	"main": "src/index.ts",
	// Set this to today's date
	"compatibility_date": "2026-09-20",
	"compatibility_flags": ["nodejs_compat"],
	"durable_objects": {
		"bindings": [
			{
				"name": "CHESS",
				"class_name": "ChessGame",
			},
		],
	},
	"migrations": [
		{
			"tag": "v1",
			"new_sqlite_classes": ["ChessGame"],
		},
	],
	"assets": {
		"directory": "dist",
		"binding": "ASSETS",
	},
}
name = "my-chess-app"
main = "src/index.ts"
# Set this to today's date
compatibility_date = "2026-09-20"
compatibility_flags = [ "nodejs_compat" ]

[[durable_objects.bindings]]
name = "CHESS"
class_name = "ChessGame"

[[migrations]]
tag = "v1"
new_sqlite_classes = [ "ChessGame" ]

[assets]
directory = "dist"
binding = "ASSETS"
  1. React UI をビルドするための vite.config.ts を作成します。
import { cloudflare } from "@cloudflare/vite-plugin";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";

export default defineConfig({
	plugins: [react(), cloudflare(), viteSingleFile()],
	build: {
		minify: false,
	},
});
  1. package.json のスクリプトを更新します。
{
	"scripts": {
		"dev": "vite",
		"build": "vite build",
		"deploy": "vite build && wrangler deploy"
	}
}

4. チェスのゲームエンジンを作成する

  1. src/chess.tsx に Durable Objects を使ったゲームロジックを作成します。
import { Agent, callable, getCurrentAgent } from "agents";
import { Chess } from "chess.js";

type Color = "w" | "b";

type ConnectionState = {
	playerId: string;
};

export type State = {
	board: string;
	players: { w?: string; b?: string };
	status: "waiting" | "active" | "mate" | "draw" | "resigned";
	winner?: Color;
	lastSan?: string;
};

export class ChessGame extends Agent<Env, State> {
	initialState: State = {
		board: new Chess().fen(),
		players: {},
		status: "waiting",
	};

	game = new Chess();

	constructor(
		ctx: DurableObjectState,
		public env: Env,
	) {
		super(ctx, env);
		this.game.load(this.state.board);
	}

	private colorOf(playerId: string): Color | undefined {
		const { players } = this.state;
		if (players.w === playerId) return "w";
		if (players.b === playerId) return "b";
		return undefined;
	}

	@callable()
	join(params: { playerId: string; preferred?: Color | "any" }) {
		const { playerId, preferred = "any" } = params;
		const { connection } = getCurrentAgent();
		if (!connection) throw new Error("Not connected");

		connection.setState({ playerId });
		const s = this.state;

		// Already seated? Return seat
		const already = this.colorOf(playerId);
		if (already) {
			return { ok: true, role: already as Color, state: s };
		}

		// Choose a seat
		const free: Color[] = (["w", "b"] as const).filter((c) => !s.players[c]);
		if (free.length === 0) {
			return { ok: true, role: "spectator" as const, state: s };
		}

		let seat: Color = free[0];
		if (preferred === "w" && free.includes("w")) seat = "w";
		if (preferred === "b" && free.includes("b")) seat = "b";

		s.players[seat] = playerId;
		s.status = s.players.w && s.players.b ? "active" : "waiting";
		this.setState(s);
		return { ok: true, role: seat, state: s };
	}

	@callable()
	move(
		move: { from: string; to: string; promotion?: string },
		expectedFen?: string,
	) {
		if (this.state.status === "waiting") {
			return {
				ok: false,
				reason: "not-in-game",
				fen: this.game.fen(),
				status: this.state.status,
			};
		}

		const { connection } = getCurrentAgent();
		if (!connection) throw new Error("Not connected");
		const { playerId } = connection.state as ConnectionState;

		const seat = this.colorOf(playerId);
		if (!seat) {
			return {
				ok: false,
				reason: "not-in-game",
				fen: this.game.fen(),
				status: this.state.status,
			};
		}

		if (seat !== this.game.turn()) {
			return {
				ok: false,
				reason: "not-your-turn",
				fen: this.game.fen(),
				status: this.state.status,
			};
		}

		// Optimistic sync guard
		if (expectedFen && expectedFen !== this.game.fen()) {
			return {
				ok: false,
				reason: "stale",
				fen: this.game.fen(),
				status: this.state.status,
			};
		}

		const res = this.game.move(move);
		if (!res) {
			return {
				ok: false,
				reason: "illegal",
				fen: this.game.fen(),
				status: this.state.status,
			};
		}

		const fen = this.game.fen();
		let status: State["status"] = "active";
		if (this.game.isCheckmate()) status = "mate";
		else if (this.game.isDraw()) status = "draw";

		this.setState({
			...this.state,
			board: fen,
			lastSan: res.san,
			status,
			winner:
				status === "mate" ? (this.game.turn() === "w" ? "b" : "w") : undefined,
		});

		return { ok: true, fen, san: res.san, status };
	}

	@callable()
	resign() {
		const { connection } = getCurrentAgent();
		if (!connection) throw new Error("Not connected");
		const { playerId } = connection.state as ConnectionState;

		const seat = this.colorOf(playerId);
		if (!seat) return { ok: false, reason: "not-in-game", state: this.state };

		const winner = seat === "w" ? "b" : "w";
		this.setState({ ...this.state, status: "resigned", winner });
		return { ok: true, state: this.state };
	}
}

5. MCP サーバーと UI リソースを作成する

  1. src/index.ts にメインの Worker を作成します。
import { createMcpHandler } from "agents/mcp";
import { routeAgentRequest } from "agents";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { env } from "cloudflare:workers";

const getWidgetHtml = async (host: string) => {
	let html = await (await env.ASSETS.fetch("http://localhost/")).text();
	html = html.replace(
		"<!--RUNTIME_CONFIG-->",
		`<script>window.HOST = \`${host}\`;</script>`,
	);
	return html;
};

function createServer() {
	const server = new McpServer({ name: "Chess", version: "v1.0.0" });

	// Register a UI resource that ChatGPT can render
	server.registerResource(
		"chess",
		"ui://widget/index.html",
		{},
		async (_uri, extra) => {
			return {
				contents: [
					{
						uri: "ui://widget/index.html",
						mimeType: "text/html+skybridge",
						text: await getWidgetHtml(
							extra.requestInfo?.headers.host as string,
						),
					},
				],
			};
		},
	);

	// Register a tool that ChatGPT can call to render the UI
	server.registerTool(
		"playChess",
		{
			title: "Renders a chess game menu, ready to start or join a game.",
			annotations: { readOnlyHint: true },
			_meta: {
				"openai/outputTemplate": "ui://widget/index.html",
				"openai/toolInvocation/invoking": "Opening chess widget",
				"openai/toolInvocation/invoked": "Chess widget opened",
			},
		},
		async (_, _extra) => {
			return {
				content: [
					{ type: "text", text: "Successfully rendered chess game menu" },
				],
			};
		},
	);

	return server;
}

export default {
	async fetch(req: Request, env: Env, ctx: ExecutionContext) {
		const url = new URL(req.url);
		if (url.pathname.startsWith("/mcp")) {
			// Create a new server instance per request
			const server = createServer();
			return createMcpHandler(server)(req, env, ctx);
		}

		return (
			(await routeAgentRequest(req, env)) ??
			new Response("Not found", { status: 404 })
		);
	},
} satisfies ExportedHandler<Env>;

export { ChessGame } from "./chess";

6. React UI を構築する

  1. index.html に HTML のエントリポイントを作成します。
<!doctype html>
<html>
	<head>
		<!--RUNTIME_CONFIG-->
	</head>
	<body>
		<div id="root" style="font-family: verdana"></div>
		<script type="module" src="/src/app.tsx"></script>
	</body>
</html>
  1. src/app.tsx に React アプリを作成します。
import { useEffect, useRef, useState } from "react";
import { useAgent } from "agents/react";
import { createRoot } from "react-dom/client";
import { Chess, type Square } from "chess.js";
import { Chessboard, type PieceDropHandlerArgs } from "react-chessboard";
import type { State as ServerState } from "./chess";

function usePlayerId() {
	const [pid] = useState(() => {
		const existing = localStorage.getItem("playerId");
		if (existing) return existing;
		const id = crypto.randomUUID();
		localStorage.setItem("playerId", id);
		return id;
	});
	return pid;
}

function App() {
	const playerId = usePlayerId();
	const [gameId, setGameId] = useState<string | null>(null);
	const [gameIdInput, setGameIdInput] = useState("");
	const [menuError, setMenuError] = useState<string | null>(null);

	const gameRef = useRef(new Chess());
	const [fen, setFen] = useState(gameRef.current.fen());
	const [myColor, setMyColor] = useState<"w" | "b" | "spectator">("spectator");
	const [pending, setPending] = useState(false);
	const [serverState, setServerState] = useState<ServerState | null>(null);
	const [joined, setJoined] = useState(false);

	const host = window.HOST ?? "http://localhost:5173/";

	const { stub } = useAgent<ServerState>({
		host,
		name: gameId ?? "__lobby__",
		agent: "chess",
		onStateUpdate: (s) => {
			if (!gameId) return;
			gameRef.current.load(s.board);
			setFen(s.board);
			setServerState(s);
		},
	});

	useEffect(() => {
		if (!gameId || joined) return;

		(async () => {
			try {
				const res = await stub.join({ playerId, preferred: "any" });
				if (!res?.ok) return;

				setMyColor(res.role);
				gameRef.current.load(res.state.board);
				setFen(res.state.board);
				setServerState(res.state);
				setJoined(true);
			} catch (error) {
				console.error("Failed to join game", error);
			}
		})();
	}, [playerId, gameId, stub, joined]);

	async function handleStartNewGame() {
		const newId = crypto.randomUUID();
		setGameId(newId);
		setGameIdInput(newId);
		setMenuError(null);
		setJoined(false);
	}

	async function handleJoinGame() {
		const trimmed = gameIdInput.trim();
		if (!trimmed) {
			setMenuError("Enter a game ID to join.");
			return;
		}
		setGameId(trimmed);
		setMenuError(null);
		setJoined(false);
	}

	const handleHelpClick = () => {
		window.openai?.sendFollowUpMessage?.({
			prompt: `Help me with my chess game. I am playing as ${myColor} and the board is: ${fen}. Please only offer written advice.`,
		});
	};

	function onPieceDrop({ sourceSquare, targetSquare }: PieceDropHandlerArgs) {
		if (!gameId || !sourceSquare || !targetSquare || pending) return false;

		const game = gameRef.current;
		if (myColor === "spectator" || game.turn() !== myColor) return false;

		const piece = game.get(sourceSquare as Square);
		if (!piece || piece.color !== myColor) return false;

		const prevFen = game.fen();

		try {
			const local = game.move({
				from: sourceSquare,
				to: targetSquare,
				promotion: "q",
			});
			if (!local) return false;
		} catch {
			return false;
		}

		const nextFen = game.fen();
		setFen(nextFen);
		setPending(true);

		stub
			.move({ from: sourceSquare, to: targetSquare, promotion: "q" }, prevFen)
			.then((r) => {
				if (!r.ok) {
					game.load(r.fen);
					setFen(r.fen);
				}
			})
			.finally(() => setPending(false));

		return true;
	}

	return (
		<div style={{ padding: "20px", background: "#f8fafc", minHeight: "100vh" }}>
			{!gameId ? (
				<div
					style={{
						maxWidth: "420px",
						margin: "0 auto",
						background: "#fff",
						borderRadius: "16px",
						padding: "24px",
					}}
				>
					<h1>Ready to play?</h1>
					<p>Start a new match or join an existing game.</p>
					<button
						onClick={handleStartNewGame}
						style={{
							padding: "12px",
							background: "#2563eb",
							color: "#fff",
							border: "none",
							borderRadius: "8px",
							cursor: "pointer",
							width: "100%",
						}}
					>
						Start a new game
					</button>
					<div style={{ marginTop: "16px" }}>
						<input
							placeholder="Paste a game ID"
							value={gameIdInput}
							onChange={(e) => setGameIdInput(e.target.value)}
							style={{
								width: "100%",
								padding: "10px",
								borderRadius: "8px",
								border: "1px solid #ccc",
							}}
						/>
						<button
							onClick={handleJoinGame}
							style={{
								marginTop: "8px",
								padding: "10px",
								background: "#0f172a",
								color: "#fff",
								border: "none",
								borderRadius: "8px",
								cursor: "pointer",
								width: "100%",
							}}
						>
							Join
						</button>
						{menuError && (
							<p style={{ color: "red", fontSize: "0.85rem" }}>{menuError}</p>
						)}
					</div>
				</div>
			) : (
				<div style={{ maxWidth: "600px", margin: "0 auto" }}>
					<div
						style={{
							background: "#fff",
							padding: "16px",
							borderRadius: "16px",
							marginBottom: "16px",
						}}
					>
						<h2>Game {gameId}</h2>
						<p>Status: {serverState?.status}</p>
						<button
							onClick={handleHelpClick}
							style={{
								padding: "10px",
								background: "#2563eb",
								color: "#fff",
								border: "none",
								borderRadius: "8px",
								cursor: "pointer",
							}}
						>
							Ask for help
						</button>
					</div>
					<div
						style={{
							background: "#fff",
							padding: "16px",
							borderRadius: "16px",
						}}
					>
						<Chessboard
							position={fen}
							onPieceDrop={onPieceDrop}
							boardOrientation={myColor === "b" ? "black" : "white"}
						/>
					</div>
				</div>
			)}
		</div>
	);
}

const root = createRoot(document.getElementById("root")!);
root.render(<App />);

7. ビルドしてデプロイする

  1. React UI をビルドします。
npm run build

これで React アプリが、dist ディレクトリ内の 1 つの HTML ファイルにコンパイルされます。

  1. Cloudflare にデプロイします。
npx wrangler deploy

デプロイ後、アプリの URL が表示されます。

https://my-chess-app.YOUR_SUBDOMAIN.workers.dev

8. ChatGPT に接続する

デプロイしたアプリを ChatGPT に接続します。

  1. ChatGPT を開きます。
  2. 設定 > Apps & Connectors > 作成 に進みます。
  3. アプリに 名前 を付け、必要なら 説明アイコン も設定します。
  4. MCP エンドポイントを入力します: https://my-chess-app.YOUR_SUBDOMAIN.workers.dev/mcp
  5. 認証なし を選択します。
  6. 作成 を選択します。

9. ChatGPT でチェスを指す

試してみます。

  1. ChatGPT の会話で、「チェスをしよう」と入力します。
  2. ChatGPT が playChess ツールを呼び出し、インタラクティブなチェスウィジェットを描画します。
  3. Start a new game を選択してゲームを作成します。
  4. ゲーム ID を友人と共有します。友人は自分の ChatGPT 会話から参加できます。
  5. ボード上で駒をドラッグして手を指します。
  6. Ask for help を選択すると、ChatGPT から戦略の助言を得られます。

主要な概念

MCP サーバー

Model Context Protocol(MCP)サーバーは、ChatGPT がアクセスできるツールとリソースを定義します。クライアント間でレスポンスが漏れないよう、リクエストごとに新しいサーバーインスタンスを作ります。

function createServer() {
	const server = new McpServer({ name: "Chess", version: "v1.0.0" });

	// Register a UI resource that ChatGPT can render
	server.registerResource(
		"chess",
		"ui://widget/index.html",
		{},
		async (_uri, extra) => {
			return {
				contents: [
					{
						uri: "ui://widget/index.html",
						mimeType: "text/html+skybridge",
						text: await getWidgetHtml(
							extra.requestInfo?.headers.host as string,
						),
					},
				],
			};
		},
	);

	// Register a tool that ChatGPT can call to render the UI
	server.registerTool(
		"playChess",
		{
			title: "Renders a chess game menu, ready to start or join a game.",
			annotations: { readOnlyHint: true },
			_meta: {
				"openai/outputTemplate": "ui://widget/index.html",
				"openai/toolInvocation/invoking": "Opening chess widget",
				"openai/toolInvocation/invoked": "Chess widget opened",
			},
		},
		async (_, _extra) => {
			return {
				content: [
					{ type: "text", text: "Successfully rendered chess game menu" },
				],
			};
		},
	);

	return server;
}

Agents を使ったゲームエンジン

ChessGame クラスは Agent を拡張し、状態を持つゲームエンジンを作ります。

export class ChessGame extends Agent<Env, State> {
  initialState: State = {
    board: new Chess().fen(),
    players: {},
    status: "waiting"
  };

  game = new Chess();

  constructor(
    ctx: DurableObjectState,
    public env: Env
  ) {
    super(ctx, env);
    this.game.load(this.state.board);
  }

ゲームごとに独自の Agent インスタンスが割り当てられ、次が可能になります。

  • ゲームごとの 分離された状態
  • プレイヤー間の リアルタイム同期
  • Worker の再起動後も残る 永続ストレージ

呼び出し可能なメソッド

クライアントが呼べるメソッドを公開するには、@callable() デコレーターを使います。

@callable()
join(params: { playerId: string; preferred?: Color | "any" }) {
  const { playerId, preferred = "any" } = params;
  const { connection } = getCurrentAgent();
  if (!connection) throw new Error("Not connected");

  connection.setState({ playerId });
  const s = this.state;

  // Already seated? Return seat
  const already = this.colorOf(playerId);
  if (already) {
    return { ok: true, role: already as Color, state: s };
  }

  // Choose a seat
  const free: Color[] = (["w", "b"] as const).filter((c) => !s.players[c]);
  if (free.length === 0) {
    return { ok: true, role: "spectator" as const, state: s };
  }

  let seat: Color = free[0];
  if (preferred === "w" && free.includes("w")) seat = "w";
  if (preferred === "b" && free.includes("b")) seat = "b";

  s.players[seat] = playerId;
  s.status = s.players.w && s.players.b ? "active" : "waiting";
  this.setState(s);
  return { ok: true, role: seat, state: s };
}

React との統合

useAgent フックは、React アプリを Durable Object に接続します。

const { stub } = useAgent<ServerState>({
	host,
	name: gameId ?? "__lobby__",
	agent: "chess",
	onStateUpdate: (s) => {
		gameRef.current.load(s.board);
		setFen(s.board);
		setServerState(s);
	},
});

エージェントのメソッドを呼び出します。

const res = await stub.join({ playerId, preferred: "any" });
await stub.move({ from: "e2", to: "e4" });

双方向通信

アプリから ChatGPT にメッセージを送れます。

const handleHelpClick = () => {
	window.openai?.sendFollowUpMessage?.({
		prompt: `Help me with my chess game. I am playing as ${myColor} and the board is: ${fen}. Please only offer written advice as there are no tools for you to use.`,
	});
};

これで、現在のゲーム状態を含む新しいメッセージが ChatGPT の会話に作られます。

次のステップ

動作する ChatGPT App ができたら、次に進めます。

  • ツールを追加する: MCP のツールとリソースで、追加の機能と UI を公開します。
  • UI を強化する: React で、より本格的な画面を作ります。

関連リソース

Agents API

Agents SDK の API リファレンスです。

Durable Objects

状態を持つ基盤インフラについて説明します。

役に立ちましたか?