How to Build a Real-Time AI Debate App with Claude

An AI debate app needs four things: a model streamed over Server-Sent Events so responses feel live, a system prompt that forces the model to disagree with the user, a citation pipeline that grounds arguments in real sources, and storage cheap enough to keep every transcript. Here's how we built each piece for DebateAI, with the numbers we run in production.
The hard part is that debate is the opposite of what chat models are trained to do. A debate opponent has to disagree with you, on purpose, indefinitely.
What stack do you need for an AI debate app?
Ours:
- Next.js 15 (App Router) in a Docker container, self-hosted on a Hetzner VPS via Coolify. Not serverless, which matters later.
- Claude Haiku 4.5 via the Anthropic SDK, fast and cheap enough for every debate turn. Calls route through Helicone (a one-line
baseURLchange) for usage logging. - Cloudflare D1 (SQLite over a REST API) for debates, users, and leaderboards.
- Clerk for auth, Stripe for subscriptions.
How do you stream AI responses in real time?
Character streaming was our single most important UX decision. Nobody waits 5 seconds for a wall of text in an argument.
We use Server-Sent Events, not WebSockets: one-directional is all a debate turn needs, and plain HTTP works through proxies. The API route wraps the Anthropic SDK stream in a ReadableStream:
const stream = anthropic.messages.stream({
model: MODEL, // Claude Haiku 4.5
max_tokens: 600,
system: systemPrompt,
messages: conversationHistory,
});
let buffer = "";
stream.on("text", (text) => {
buffer += text;
// flush every 8 characters or 20ms, whichever comes first
if (buffer.length >= BUFFER_SIZE || now - lastFlush >= BUFFER_TIME) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: "chunk", content: buffer })}\n\n`));
buffer = "";
}
});
Two constants do the work: BUFFER_SIZE = 8 characters and BUFFER_TIME = 20 ms. Flush every 1-2 characters and JSON-encoding overhead dominates; flush at 50+ and the response reads like a progress bar. Eight characters at 20ms feels like watching someone type fast.
One hard-won detail: end every stream with an explicit data: [DONE] event, because some mobile browsers never reliably detect stream closure. The frontend treats [DONE] as the completion signal.
How do you make an LLM actually disagree with you?
The hardest problem in the app. Models are trained to be agreeable; left alone, Claude drifts toward validating the user within a few turns. The fix is a blunt block at the top of the system prompt:
<core_rule>
You must ALWAYS argue AGAINST the user's position:
- If they argue FOR something, you argue AGAINST it
- If they argue AGAINST something, you argue FOR it
- Challenge their evidence and reasoning
- Take the opposing stance to create a real debate
</core_rule>
Position matters: the opposition rule comes before the persona instructions because the model weights earlier instructions more heavily.
Users pick an opponent style (The Socratic, The Logician, Devil's Advocate, and more), and the persona block ends with the line that saves the mechanic:
BUT always argue AGAINST the user's position,
even if the real ${persona} might agree
Without it, argue for a position your persona famously holds and the debate collapses into agreement.
The other lever is max_tokens: 600 on every debate response. Longer replies read like term papers and killed the back-and-forth feel. The cap also limits persona drift: over 10+ turns the character voice degrades, and short responses slow the error accumulation.
How do you ground AI arguments in real sources?
Each response may use Claude's built-in web search tool (web_search_20250305) at most once, max_uses: 1, for cost and latency. A search_start event emitted mid-stream lets the UI show a searching state.
Citations come back as structured data: read web_search_result_location citations from the text blocks first, fall back to raw web_search_tool_result blocks, dedupe, cap at three, render as clickable sources. Your opponent doesn't just assert a statistic about remote work versus the office. It links the source.
Why Cloudflare D1 instead of Postgres?
D1 is SQLite behind a REST API, and for this workload it wins three ways. It's close to free at our scale. No connections to pool, no PgBouncer, no cold-start headaches; every query is an HTTP request with a token. And SQLite treats JSON as first-class, so each debate's message history is a JSON text column we can query with json_array_length(messages) without deserializing anything.
The trade-offs are real: an HTTP round trip per query is slower than a local Postgres socket, and there are no change feeds, so live multiplayer would need Durable Objects on top. For saving turns and reading history, neither hurts.
How do you rate limit an expensive AI endpoint?
Every debate turn is a paid model call. We use in-memory limiters, a Map of counters with periodic cleanup, at two layers: 10 requests/minute per IP and 20 per user. The IP check runs before auth because Clerk session validation is itself work worth protecting.
In-memory state is usually the wrong answer, but we run one Docker container, not a serverless fleet, so a single process sees all traffic. A restart wipes the counters; acceptable. No Redis.
What we learned
Error messages are user-facing: we once returned raw Stripe and Cloudflare errors to the client until a security review caught it, and now regression tests scan every API route for leaks. And the model is maybe 20% of the work. The other 80% is streaming, prompts, citations, and limits, the machinery that makes an AI opponent feel fast and adversarial instead of like a chatbot with an attitude. That machinery is most of why we built DebateAI as a product instead of a prompt.
You just read the argument. Can you make one?
The AI takes the other side, every time. Three rounds, one scored verdict.
Argue today's Daily