本指南将向你展示如何在 Cloudflare Workers 上构建并部署交互式 ChatGPT 应用,该应用可以:
- 在 ChatGPT 对话中直接渲染丰富的交互式 UI 组件
- 使用 Durable Objects 维护实时、多用户状态
- 实现应用与 ChatGPT 之间的双向通信
- 构建完全在 ChatGPT 内运行的多人体验
你将构建一个实时多人国际象棋游戏来演示这些能力。玩家可以开始或加入游戏、在交互式棋盘上走棋,甚至向 ChatGPT 寻求战略建议——全程无需离开对话。
你的 ChatGPT 应用将使用 Model Context Protocol (MCP) 公开 ChatGPT 可以代表你调用的工具和 UI 资源。
你可以在此处 ↗查看此示例的完整代码。
开始之前,你需要:
- 一个 Cloudflare 账户 ↗
- 已安装 Node.js ↗(v18 或更高版本)
- 一个已启用开发者模式的 ChatGPT Plus 或 Team 账户 ↗
- 具备 React 和 TypeScript 的基础知识
要使用 ChatGPT 应用(也称为连接器),你需要启用开发者模式:
- 打开 ChatGPT ↗。
- 前往 Settings(设置) > Apps & Connectors(应用和连接器) > Advanced Settings(高级设置)
- 将 Developer mode 切换为 ON
启用后,你将在开发和测试期间能够安装自定义应用。
- 为你的国际象棋应用创建新项目:
npm create cloudflare@latest -- my-chess-appyarn create cloudflare my-chess-apppnpm create cloudflare@latest my-chess-app- 进入项目目录:
cd my-chess-app- 安装所需依赖:
npm install agents @modelcontextprotocol/sdk chess.js react react-dom react-chessboard- 安装开发依赖:
npm install -D @cloudflare/vite-plugin @vitejs/plugin-react vite vite-plugin-singlefile @types/react @types/react-dom- 更新
wrangler.jsonc,配置 Durable Objects 和静态资源:
{
"name": "my-chess-app",
"main": "src/index.ts",
// Set this to today's date
"compatibility_date": "2026-08-17",
"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-08-17"
compatibility_flags = [ "nodejs_compat" ]
[[durable_objects.bindings]]
name = "CHESS"
class_name = "ChessGame"
[[migrations]]
tag = "v1"
new_sqlite_classes = [ "ChessGame" ]
[assets]
directory = "dist"
binding = "ASSETS"- 创建
vite.config.ts用于构建 React UI:
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,
},
});- 更新
package.json中的脚本:
{
"scripts": {
"dev": "vite",
"build": "vite build",
"deploy": "vite build && wrangler deploy"
}
}- 在
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 };
}
}- 在
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";- 在
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>- 在
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 />);- 构建 React UI:
npm run build这会将 React 应用编译为 dist 目录中的单个 HTML 文件。
- 部署到 Cloudflare:
npx wrangler deploy部署完成后,你将看到应用 URL:
https://my-chess-app.YOUR_SUBDOMAIN.workers.dev现在将已部署的应用连接到 ChatGPT:
- 打开 ChatGPT ↗。
- 前往 Settings(设置) > Apps & Connectors(应用和连接器) > Create(创建)
- 为应用填写 name,并可选择填写 description 和 icon。
- 输入 MCP 端点:
https://my-chess-app.YOUR_SUBDOMAIN.workers.dev/mcp。 - 选择 "No authentication"。
- 选择 "Create"。
试试看:
- 在 ChatGPT 对话中输入:"Let's play chess"。
- ChatGPT 将调用
playChess工具并渲染你的交互式国际象棋组件。 - 选择 "Start a new game" 创建游戏。
- 将游戏 ID 分享给朋友,他们可以通过自己的 ChatGPT 对话加入。
- 在棋盘上拖动棋子来走棋。
- 选择 "Ask for help" 向 ChatGPT 获取战略建议
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;
}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 };
}useAgent hook 将你的 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);
},
});在 Agent 上调用方法:
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 应用,你可以:
- 添加更多工具:通过 MCP 工具和资源公开更多功能和 UI。
- 增强 UI:使用 React 构建更复杂的界面。
Agents API
Agents SDK 完整 API 参考。
Durable Objects
了解底层有状态基础设施。
Model Context Protocol
MCP 规范与文档。
OpenAI Apps SDK
OpenAI Apps SDK 官方参考。