Tic-tac-toe against the model
A complete app that runs inside ChatGPT (and Claude): an MCP server in Python plus a React widget that the chat app draws in a sandboxed iframe. You tap the board, the model answers with its move, and the board updates.
About 800 lines in three files:
| File | What it does |
|---|---|
main.py |
The MCP server: game rules, four tools, and the one HTML file it hands out via resources/read. |
web/src/bridge.js |
The only code that knows which chat app it's in: window.openai (ChatGPT) or JSON-RPC over postMessage (MCP Apps / Claude). |
web/src/App.jsx |
The game screen in plain React: state, a 3×3 grid, and a loop that hands the turn to the model. |
How it works
ChatGPT app main.py
+-------------------------------+ +---------------------------+
| model ------------------------+-- tools/call -->| tictactoe_start / _move |
| iframe: React widget |<-resources/read-| widget.html (Vite build) |
| tap -- callTool ------------+-- tools/call -->| tictactoe_play / _state |
| "your move" -- sendFollowUpMessage --> model | GAMES{} = the real board |
+-------------------------------+ +---------------------------+
- Tools with
_meta.tictactoe_startcarriesopenai/outputTemplate: ui://widget/tictactoe.html, so ChatGPT draws the widget.tictactoe_playandtictactoe_stateareopenai/visibility: private: only the widget can call them, so only a tap can place an X. - The widget ships as one string. Vite +
vite-plugin-singlefilebuilds React and the CSS into one ~160 KB HTML file, returned byresources/readastext/html+skybridge. - One tool result, three readers.
contentis text for the model (ending in "Your move: call tictactoe_move…"),structuredContentis JSON for the widget,_metais for the host. - The loop. A tap calls
tictactoe_play, thensendFollowUpMessage("I played X in cell 5 … your move")starts a model turn. The model callstictactoe_move, and the widget pollstictactoe_stateevery 1.2 s until the O appears (servers can't push to widgets). - The server owns the board. It checks turns, rejects taken cells and decides wins. The widget reloads the board from the server on every mount, because ChatGPT can re-create a widget and hand it a stale result.
TTT_HOST=chatgpt advertises the Apps SDK metadata; TTT_HOST=claude advertises MCP Apps metadata
(_meta.ui.resourceUri, text/html;profile=mcp-app). Same image, same widget.
Run it
cp .env.example .env # set PUBLIC_HOST
docker compose up -d --build # ChatGPT server on 127.0.0.1:8094, Claude on :8093
ChatGPT and Claude need a public HTTPS URL: put nginx (see nginx/ttt.conf.example), a tunnel such
as ngrok, or any reverse proxy in front.
Widget development without Docker:
cd web && npm install && npm run build # writes web/dist/index.html
cp dist/index.html ../widget.html
cd .. && pip install -r requirements.txt && uvicorn main:app --port 8000
Add it to ChatGPT
- chatgpt.com → Settings → Apps & Connectors → Advanced → turn on Developer mode (paid plans). On a phone, first choose Request Desktop Website in Safari ("aA") or Chrome (⋯).
- Create: any name, URL
https://<your-host>/ttt/mcp, No authentication, tick "I trust" → Create. - In a chat:
@Tic-tac-toe let's play, then tap a square.
ChatGPT caches the tool list and widget when the connector is created: after any change, delete the connector and add it again.
Try the live one: https://api.palomaressoftware.com/ttt/mcp (ChatGPT) or
https://api.palomaressoftware.com/ttt-claude/mcp (Claude).
Known differences between hosts
- ChatGPT plays hands-free:
sendFollowUpMessagestarts a model turn after each tap. - Claude (MCP Apps, tested on iOS):
ui/messagedoesn't start a model turn, so you type "your move" after each tap. The widget asks you to after 6 s. - In voice mode the model may announce a move without calling the tool; the tool descriptions forbid it, and only the server's board counts.
Tracing (optional)
tracing.py prints one JSON line per request (method, tool, timing, trace id) and a game event per
move. Set SPLUNK_HEC_URL and SPLUNK_HEC_TOKEN to also send them to Splunk. It logs ids and
timings only.
License
MIT