AI vs AI debate arena. Watch Claude, Gemini, and ChatGPT debate each other in real-time.
Sometimes two AIs debating can spark insights that neither would produce alone. Or at least it's entertaining to watch.
# Install dependencies
npm install
# Run with default topic (Claude vs Gemini)
npm start
# Run with custom topic
npm start "静态类型 vs 动态类型哪个更适合大型项目"
# Choose any two services to debate
npm start "AI会取代程序员吗" claude chatgpt
npm start "AI会取代程序员吗" gemini chatgptAvailable services: claude, gemini, chatgpt
- Spawns two Chrome windows (any two of Claude / Gemini / ChatGPT), connects via CDP after login
- You log in manually (first time only - sessions are saved in
./profiles/) - Press Enter to start
- Both AIs receive the topic and state their opening positions (B's opening streams to A in real-time)
- They debate back and forth with real-time streaming sync — each AI sees the other's response as it's being generated
- Runs indefinitely (Ctrl+C to stop), transcript auto-saved to
./logs/after each round
┌──────────────────┐ ┌──────────────────┐
│ Claude │ │ Gemini │
│ Browser │ │ Browser │
│ │ │ │
│ ┌──────────┐ │ │ ┌──────────┐ │
│ │ │ │ ───► │ │ │ │
│ │ Chat │ │ │ │ Chat │ │
│ │ │ │ ◄─── │ │ │ │
│ └──────────┘ │ │ └──────────┘ │
└──────────────────┘ └──────────────────┘
│ │
└─────────┬───────────────┘
│
▼
┌─────────────┐
│ Agora │
│ Orchestrator│
└─────────────┘
agora/
├── src/
│ ├── index.js # Entry point, Chrome launcher, service registry
│ ├── arena.js # Debate orchestrator
│ ├── bridge.js # Generic chat bridge (no service-specific selectors)
│ ├── claude.js # Claude bridge
│ ├── gemini.js # Gemini bridge
│ ├── chatgpt.js # ChatGPT bridge
│ └── templates.js # i18n moderator/turn prompt templates
├── profiles/ # Browser sessions (gitignored)
├── logs/ # Debate transcripts
└── package.json
The bridge uses generic DOM discovery — no service-specific CSS selectors needed. Just extend ChatBridge:
import { ChatBridge } from './bridge.js'
export class NewServiceBridge extends ChatBridge {
constructor(page) {
super(page, {
name: 'NewService',
url: 'https://newservice.example.com',
})
this.useEnterToSubmit = true
}
}Then register it in src/index.js:
const SERVICES = {
// ...existing services
newservice: { BridgeClass: NewServiceBridge, profileDir: './profiles/newservice', url: 'https://newservice.example.com' },
}The first attempt used Playwright to launch and control browsers. Problems:
- Bot detection. Playwright injects automation markers (
navigator.webdriver = true, modifiedRuntime.enabledomains, etc.) that Cloudflare and other bot-detection systems pick up immediately. Claude.ai and ChatGPT both blocked automated sessions on sight. - Session persistence. Playwright's browser contexts don't map cleanly to real Chrome user-data directories. Cookies and login state were lost between runs, forcing re-login every time.
Switched to puppeteer.launch() with puppeteer-extra-plugin-stealth. Better, but still not enough:
puppeteer.launch()still sets--enable-automationand other flags that leak automation intent. Stealth plugin patches many signals but not all — Cloudflare's challenge page still detected it intermittently.- Service-specific CSS selectors. Each AI service (Claude, Gemini, ChatGPT) has a different DOM structure for chat messages. Early versions hardcoded selectors like
.agent-turn .markdownor[data-message-author-role="assistant"]. These broke every time a service updated their frontend. - Response detection by selector. Used
isStillStreaming()with service-specific selectors (e.g..result-streamingfor ChatGPT). Fragile — a single class name change breaks the entire flow.
The current architecture splits browser control into two phases:
Phase 1 — Spawn a bare Chrome process via child_process.spawn() with --remote-debugging-port and --user-data-dir. No Puppeteer, no automation flags. Chrome opens the AI service URL as a completely normal browser. The user logs in manually and passes any Cloudflare challenges.
Phase 2 — Connect Puppeteer after login via puppeteer.connect({ browserURL }). At this point the session is already authenticated. Puppeteer only provides the CDP bridge for DOM interaction — it never launched the browser, so no automation traces exist.
Generic DOM discovery replaces all service-specific selectors:
| Problem | Old approach | Current approach |
|---|---|---|
| Find chat input | Hardcoded #prompt-textarea, div.ProseMirror |
Find the largest visible [contenteditable="true"] on page |
| Find response container | Hardcoded .agent-turn, message-content |
Send a probe message, walk up from the deepest match to find the scrollable ancestor, then watch for new sibling nodes |
| Detect new responses | Service-specific getResponseCount() |
Mark all existing children with data-agora-seen, any unmarked child is new |
| Detect streaming | Service-specific isStillStreaming() + polling selectors |
Compare extractResponse() across two polls + detect visible stop/cancel buttons |
| Extract response text | Service-specific selectors | Multi-level extraction: Level 1 (container children), Level 2 (scroll container → wrapper → block), Level 3 (raw innerText fallback) |
Result: zero service-specific CSS selectors. Adding a new AI service is ~10 lines of code (see Adding More AI Services).
- Gemini's Angular DOM replacement. Gemini renders a
<pending-request>placeholder that Angular replaces with the actual response node. A cachedElementHandlepointing to the placeholder becomes stale. Fixed by multi-level extraction that walks the live DOM tree instead of relying on a single cached node. - ElementHandle memory leak.
page.evaluateHandle()returns handles that must bedispose()d. ThefindInput()call runs every 300ms duringupdateInput()— without caching, handles accumulated until Node.js OOM-crashed at ~4GB after 15 minutes. Fixed by caching handles and disposing on replacement via_setHandle(). - Frame detachment. Long-running debates (7+ rounds) occasionally trigger page re-renders that detach the main frame, crashing Puppeteer calls. Fixed with
resetDOM()cleanup +framenavigatedlistener for proactive recovery. - macOS sleep. Screen sleep suspends the Chrome process and breaks the CDP WebSocket connection. Fixed by spawning
caffeinate -dimsto prevent system sleep during debates.
- ToS: This automates web interfaces, which may violate terms of service. Use for personal experiments only.
- Rate limits: Don't run too many rounds or too frequently.
MIT