Build an Interactive ChatGPT App

Last updated Oct 12, 2026

Deploy your first ChatGPT App

This guide will show you how to build and deploy an interactive ChatGPT App on Cloudflare Workers that can:

  • Render rich, interactive UI widgets directly in ChatGPT conversations
  • Maintain real-time, multi-user state using Durable Objects
  • Enable bidirectional communication between your app and ChatGPT
  • Build multiplayer experiences that run entirely within ChatGPT

You will build a real-time multiplayer chess game that demonstrates these capabilities. Players can start or join games, make moves on an interactive chessboard, and even ask ChatGPT for strategic advice—all without leaving the conversation.

Your ChatGPT App will use the Model Context Protocol (MCP) to expose tools and UI resources that ChatGPT can invoke on your behalf.

You can view the full code for this example here ↗︎.

Prerequisites

Before you begin, you will need:

1. Enable ChatGPT Developer Mode

To use ChatGPT Apps (also called connectors), you need to enable developer mode:

  1. Open ChatGPT ↗︎.
  2. Go to Settings > Apps & Connectors > Advanced Settings
  3. Toggle Developer mode ON

Once enabled, you will be able to install custom apps during development and testing.

2. Create your ChatGPT App project

  1. Create a new project for your Chess App:

npmyarnpnpm

npm create cloudflare@latest -- my-chess-app
yarn create cloudflare my-chess-app
pnpm create cloudflare@latest my-chess-app
  1. Navigate into your project:
cd my-chess-app
  1. Install the required dependencies:
npm install agents @modelcontextprotocol/sdk chess.js react react-dom react-chessboard
  1. Install development dependencies:
npm install -D @cloudflare/vite-plugin @vitejs/plugin-react vite vite-plugin-singlefile @types/react @types/react-dom

3. Configure your project

  1. Update your wrangler.jsonc to configure Durable Objects and assets:
{
    "name": "my-chess-app",
    "main": "src/index.ts",
    "upload_source_maps": true,
    // Set this to today's date
    "compatibility_date": "2026-10-27",
    "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"
upload_source_maps = true
# Set this to today's date
compatibility_date = "2026-10-27"
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. Create a vite.config.ts for building your 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,
    },
});
  1. Update your package.json scripts:
{
    "scripts": {
        "dev": "vite",
        "build": "vite build",
        "deploy": "vite build && wrangler deploy"
    }
}

4. Create the Chess game engine

  1. Create the game logic using Durable Objects at src/chess.tsx:
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. Create the MCP server and UI resource

  1. Create your main worker at src/index.ts:
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. Build the React UI

  1. Create the HTML entry point at index.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. Create the React app at src/app.tsx:

```tsx 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); }