So I had this idea that kept nagging at me.
Every AI chat app works the same way. You type something, the model returns text or markdown, the UI renders it as a nice formatted paragraph. That is fine if you want an answer. It is genuinely boring if you want to actually build something.
What if the AI could respond with a working game board you could click? What if saying "make it Barbie themed" actually transformed the whole interface while you watched? What if "add a starfield in the background" dropped an animated canvas behind your chat in real time?
I spent a few weeks building exactly that. I call it FlowChat.
Here is the live version: https://flowchat-public.varshithvh.workers.dev
And yes, someone immediately asked it to play Tic Tac Toe and then asked it to switch to an Oppenheimer theme mid-game. I could not be prouder.
The Idea
Normal AI chat: model returns markdown, client renders it as text. Simple, predictable, boring.
FlowChat: model returns raw HTML with CSS and JavaScript, client injects it directly into the DOM using a streaming protocol built on the browser's native template system.
That one change makes the entire experience different. You are not reading about a game. You are playing one. You are not reading about a Barbie color palette. You are sitting inside one.
The AI does not just answer questions. It rebuilds the UI from its responses.
What You Can Actually Do With It
I want to give you a feel for what this means in practice before getting into the technical bits, because the demos are more interesting than any architecture diagram.
Games: Ask it to build Tic Tac Toe. You get a playable board, click-to-move, an AI opponent, win detection. Ask for Connect 4. Ask for Snake. The game renders in the chat as an agent bubble with a form inside it. Each move submits to the LLM which processes it and updates only the cells that changed.
Themes: Say "change to a Barbie theme". The model injects CSS overrides and the whole interface turns pink. Messages, borders, buttons, the prompt box. Say "Oppenheimer themed". You get dark sepia tones and heavy typography. The sidebar and topbar stay locked so the shell never breaks, but everything inside the chat transforms.
Backgrounds: Say "add a starfield". An animated canvas renders behind your messages. Say "DVD bounce animation". The logo bounces around the chat viewport. Say "use a space image". An image fills the background. All of this lives in a contained layer so it never covers the actual UI.
Full interface takeover: At one point I asked it to make the page look like Wikipedia. It replaced the prompt box with links. Clicking any link submitted a form back to the LLM which generated a new article replacing the chat content. I was reading about the Roman Empire in a chat app I built on a Sunday afternoon.
The Tech Stack
Everything runs on Cloudflare's edge infrastructure. No traditional server. No Node.js process to keep alive. No managed database to worry about.
Cloudflare Workers runs the TypeScript on every request. Cold starts are under 50ms globally. The whole worker is one file that handles routing, auth, rate limiting, WebSocket upgrades, and LLM streaming.
Cloudflare Durable Objects is the part that makes this work. Each chat room is a single Durable Object: a stateful actor with its own SQLite database, its own in-memory queue, and its own WebSocket connections. When you and a friend open the same chat URL, you both connect to the same DO. Sync is not something you have to build. It is just how the architecture works.
Each DO stores:
- The full LLM message history in SQLite
- Client session records
- A queue of pending prompts (max 5)
- Rate limit state per browser/IP
- A fork index for read-only snapshots
Hibernatable WebSockets keep connections alive without keeping the DO alive. Cloudflare auto-handles ping/pong. The DO wakes up when a message arrives and goes back to sleep between them.
better-auth handles optional authentication. If you do not configure it, the app is open to everyone. If you do, you get Google, GitHub, and email/password with role-based access (admin, dev, chat, view, blocked).
Inception Labs Mercury-2 is the model powering responses. It is a diffusion-based language model rather than autoregressive, which means it generates differently to GPT or Claude. In practice it feels fast and it seems to genuinely understand the HTML output format I need from it.
The Protocol
This was the most enjoyable part to design and the part I am most proud of.
The AI cannot just dump raw HTML into a response stream. A single response might need to update three different parts of the page independently. A Tic Tac Toe move should update one cell, not redraw the entire board. A background animation should not affect the sidebar. A private message to one player should not appear in the other player's chat.
So I built a delimiter-based streaming protocol. The model wraps every DOM update in a structured envelope:
PpqUtcLGQdYN4oqc:BODY_START
<template for="/chat/append-message">
<div class="message message-user" data-client-id="1">Lets play Tic Tac Toe</div>
<div class="message message-agent message-full-width" id="msg-1">
<!-- entire game board HTML -->
</div>
<?marker name="/chat/append-message">
</template>
PpqUtcLGQdYN4oqc:BODY_END
The for attribute on the template targets a named marker in the DOM. The client runtime walks the document tree looking for processing instructions with matching names and replaces them with the template content. Surgically. Without touching anything else on the page.
A single AI response can contain multiple messages separated by a split delimiter:
PpqUtcLGQdYN4oqc:SPLIT_MESSAGE
So the model can send a public chat confirmation to all users AND simultaneously route a private message to only one player by including SERVER_PROPS routing instructions that the server strips before forwarding over WebSockets.
The whole thing is built on two browser polyfills that implement the Dynamic Partial Update spec that is landing in Chrome.
Writing the System Prompt
Getting the AI to consistently produce valid HTML inside this protocol format took a lot of iteration. The final system prompt is about 300 lines and honestly reads more like an API contract than a prompt.
It covers the exact hex values of every CSS variable in the design system so the model writes var(--accent) correctly instead of guessing colors. Rules for border-radius, shadow values, animation timing. The async CDN loading pattern for Chart.js and d3, because the model kept calling new Chart() before the library loaded.
The biggest bug I chased was this one: the model kept placing the append-message marker inside the app container div instead of after it. Every subsequent chat message would inject into the game board. I fixed it with a wrong-vs-correct example in the prompt:
<!-- WRONG: marker inside app div, next message injects here forever -->
<div id="ttt-app-1">
...board...
<?marker name="/chat/append-message">
</div>
<!-- CORRECT: marker after ALL divs close -->
<div id="ttt-app-1">
...board...
</div>
<?marker name="/chat/append-message">
Wrong examples with explicit comments are more useful than correct-only documentation. The model needs to know what the failure mode looks like, not just the happy path.
I also learned that diffusion models like Mercury-2 need slightly different prompting than autoregressive models. The responses feel less like a typewriter and more like content materializing. It pairs naturally with this use case.
Multi-User by Default
Every chat URL is shared. Open the same link in two browser tabs and both receive every AI response over WebSockets in real time. Each client gets a unique ID. User bubbles are color-coded per client.
The LLM knows each client's ID:
[1]: I want to guess a secret word
[2]: I want to give the hint
The model can respond with one message visible to both players and a second message containing the secret visible only to client 1. Routed server-side, stripped from WebSocket payloads before they reach the wrong browser.
I did not add any special multi-user logic. The Durable Object architecture just makes it work naturally. Every client connects to the same DO instance. The DO has the WebSocket connections. When the LLM responds, the DO broadcasts to all of them.
The UI
Pure CSS. No framework, no Tailwind, no component library. Inter font loaded non-blocking, a deep navy palette (#06091a to #101630), periwinkle indigo accent (#5b6ef5).
The app shell is a sidebar plus a main area with topbar. The sidebar and topbar are always physically opaque. The chat viewport is the only zone where themes and backgrounds can render. This prevents the AI from accidentally covering the navigation with a space photo, which it absolutely would do otherwise. I know because it did, many times, before I fixed the containment.
.chat-viewport {
position: relative;
isolation: isolate;
}
#fc-bg-layer {
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none !important;
}
.chat {
position: relative;
z-index: 2;
}
The background layer sits at z-index 0. Chat messages sit at z-index 2. The sidebar and topbar are separate elements outside the viewport entirely. Structural containment beats trying to enforce it with !important and MutationObservers, which I tried first and which caused an infinite loop that froze the whole page. Lesson learned.
Typing indicator, optimistic user bubbles, spring entrance animations on messages. The send button has a glow. It is small things but they add up.
Some Honest Pain Points
The model marker placement bug took two days to properly fix because the issue was invisible until the second message arrived. The first message always looked correct.
The background containment wars took about a week of back-and-forth. I tried CSS !important, then a MutationObserver enforcer, then a JS-level background lock. All of them broke something else. The right answer was structural: move the background layer inside the chat viewport so it is physically impossible for it to escape.
CDN script loading trips up every AI-generated app. The model writes code that calls Chart.js APIs before the library loads. The fix is teaching it to poll:
function init() {
if (typeof Chart === 'undefined') { setTimeout(init, 50); return; }
// safe to use Chart here
}
init();
That pattern is now baked into the system prompt and it works reliably.
The form action URL bug was embarrassing. The form action in app.html was c/CHAT_ID/prompt (relative) instead of /c/CHAT_ID/prompt (absolute). On a fresh load the path resolved correctly. After a redirect it did not. Every prompt submitted to /c/c/CHAT_ID/prompt and got a 404. I caught it from the server logs and added a global form submit interceptor that normalizes any relative action URL before submission, as a safety net for AI-generated forms too.
Deploying
The whole thing runs on Cloudflare's free tier. One command:
npx wrangler deploy --env public
No Docker. No server to provision. No database UI to configure. Cloudflare handles scaling, WebSocket hibernation, global distribution, and the SQLite storage inside each Durable Object automatically.
Secrets like the API key are stored via Wrangler:
npx wrangler secret put INCEPTION_API_KEY --env public
They never touch the codebase or version control.
Try It
Live: https://flowchat-public.varshithvh.workers.dev
Source: https://github.com/Varshithvhegde/flowchat
Open a new chat. Type anything. Ask it to build a game, change the theme, add a background, or make the page look like something completely different. It will.
Varshithvhegde
/
flowchat
Multi-user AI chat that generates live HTML UI — games, dashboards, apps — powered by Cloudflare Workers + Durable Objects
FlowChat
A multi-user AI chat where the model responds with live HTML instead of markdown. Every reply can be a game, dashboard, animated background, interactive form, or a full UI redesign — running directly in the browser.
Live demo: https://flowchat-public.varshithvh.workers.dev
What it does
- The AI responds with raw HTML, CSS, and JS — not markdown
- Updates are injected into the page using a streaming partial-update protocol
- Multiple users on the same URL see updates in real time over WebSockets
- The AI can target specific parts of the page independently (update one game cell, not the whole board)
- Style themes, background animations, and full interface redesigns all work via CSS marker injection
- Each chat room is a Cloudflare Durable Object with its own SQLite storage and WebSocket connections
Stack
Layer
Technology
Runtime
Cloudflare Workers
State + WebSockets
Cloudflare Durable Objects
Storage
SQLite (via Durable Object storage)
Auth (optional)
better-auth
AI Model
The thing I keep coming back to is how much of this was just moving one assumption. Instead of "the AI returns text and the UI renders it", it became "the AI returns HTML and the browser runs it". That one change opened up everything else.
If you have questions about the protocol, the Durable Objects architecture, or the system prompt engineering, ask in the comments. I spent a lot of time on all three and I am happy to go deeper on any of it.
And if you build something interesting with it, or fork it and take it somewhere I did not think of, I genuinely want to see it.
You can also find me on LinkedIn and Dev.to. I write about things I am actually building, not things I think I should be building. There is a difference.









Top comments (14)
You're not the only one who's done this; Microsoft has already beaten you to it with VibeOS, an operating system where AI decides how to draw the interface. It's very close to your concept.
So, we're waiting for you to create a VibeOS clone! 🤗
Ohh , yeah it is just small project I thought is cool 😅. Thanks 🙌
Self-rewriting UIs are a powerful demo but I'd be cautious about production use. The key challenge isn't technical — it's predictability. When the UI can change based on LLM output, you lose deterministic testing, accessibility compliance becomes a moving target, and debugability drops significantly. That said, for internal tools and admin dashboards where the user is technical, this pattern could be a game-changer. I've seen similar approaches work well in data exploration tools where the AI generates filter components on the fly. The sweet spot seems to be "AI-suggested, human-approved" UI mutations rather than fully autonomous rewrites.
Trueee agreed . The problem is having context and accuracy . I think that can be improved with giving guidance and strict prompts or something.
thanks for insight
DPU is really interesting, and I’m excited to see where it goes from here!
This is a great chat app and a nice example of what frontend development could look like in the future. Great work! 😸⭐
Thank You 🙌
This is a really impressive project! The idea of transforming AI from a simple text generator into a real-time UI builder is a very exciting direction for web development.
The ability to create interactive games, dynamic themes, and live interface changes through AI responses shows how the future of applications may look. The combination of WebSockets, Cloudflare Durable Objects, and AI agents is a great example of modern development techniques.
Projects like this are inspiring for developers and platforms like CodeCan.net, where sharing innovative code ideas and developer resources can help the community learn and build better applications.
Great work, and I would love to see how this concept evolves in the future!
Thank you🙌🙌
This is a really cool concept! Streaming raw HTML directly into the DOM looks super interactive, but how do you handle CSS scoping and prevent generated styles from leaking or breaking the existing UI layout?
Great question! A few layers working together:
Design tokens: The AI is given the full CSS variable system in its system prompt (
--accent,--surface-1, etc.) so it builds on the existing tokens rather than inventing random values.Scoped styles: For scoped styles, it wraps them inside the message ID —
#msg-agent-42 button { ... }— so nothing leaks out.Full themes: For full themes, it targets style markers like
/style/chat-overrides, which replace a specific<?start>block rather than appending to the<head>freely.Structural isolation: The shell elements (sidebar, topbar, prompt bar) are structurally separate from the chat viewport, so even if the AI goes rogue with a
background: url(...), it can only affect the chat area, not the navigation.It still breaks occasionally when the model ignores the rules, and I fix it by adding a better example to the system prompt. The prompt engineering is honestly half the project at this point 😄
The marker placement bug is the one that got me, a bug that's invisible on the first message and only shows up on the second is exactly the kind of thing that eats two days, because your instinct is to assume the first working case proves the logic is sound. It doesn't; it just proves the happy path works once.
The structural containment fix (moving the background layer inside the chat viewport instead of fighting it with !important and a MutationObserver) is a good lesson on its own, separate from the AI angle — "make the wrong state physically impossible" beats "detect and correct the wrong state" almost every time, and it's easy to reach for the detection approach first because it feels like less upfront work.
Genuinely curious about the routing mechanism for the multi-user secret-word example, if the model itself decides what's public vs. private per client, what stops it from ever leaking the secret to the wrong client in the content of a "public" message, even if the SERVER_PROPS routing correctly restricts the message envelope? That seems like the one place where trusting the model's judgment (not just its formatting) becomes load-bearing.
the part that's gnarly to get right at scale: injecting model returned HTML into the DOM is fun until the model starts writing script tags that reference each other across turns. you end up with execution order nondeterminism and event listener leaks that accumulate across the conversation.
we explored a similar pattern for a dynamic report builder — ended up sandboxing each bubble in an iframe with a message API so the host document stayed clean. adds a little overhead but the isolation is worth it.
are you scoping each bubble's JS execution to its own context, or is there global state the model can accidentally write to across turns?
Thank you for sharing. Interesting stuff.
🙌🙌