From a single question in a URL to a real back-and-forth chat window. This is the exact pattern behind every chat app in this series.
In Tutorial 3, your Worker answered one question typed into the URL, then the page was done. A real chat app needs two things that adds: a page with an input box and message list, and a way for the page to send new messages to your Worker without reloading.
That second part uses something called fetch — a small piece of JavaScript that runs in the visitor’s browser and quietly sends data to your Worker in the background, then updates the page with the reply.
This is the habit Tutorial 2 introduced — now it matters more than ever. Your chat page has real structure: a header, a message area, an input box. Store all of it in one variable called HTML near the top of your Worker:
const HTML = "<!DOCTYPE html><html><head><title>My Chat</title></head><body>" + "<div id='messages'></div>" + "<input id='userInput' placeholder='Type a message...'>" + "<button onclick='sendMessage()'>Send</button>" + "<script>" + CHAT_SCRIPT + "</script>" + "</body></html>";
⚠️ The mistake almost everyone makes here: writing the JavaScript inside backtick-style template strings, with HTML, CSS, and JavaScript all mixed together in one giant block. A single stray quote or special character anywhere in that block can break the entire page in ways that are very hard to track down. Keeping HTML and JavaScript as separate, plain string variables — like above — avoids this completely. It's a small habit that saves hours of debugging later.
This is the JavaScript that runs in the visitor’s browser. Store it as its own variable, CHAT_SCRIPT:
const CHAT_SCRIPT = "async function sendMessage() {" + " var input = document.getElementById('userInput');" + " var text = input.value;" + " if (!text) return;" + " document.getElementById('messages').innerHTML += '<p>You: ' + text + '</p>';" + " input.value = '';" + " var res = await fetch('/chat', {" + " method: 'POST'," + " headers: { 'Content-Type': 'application/json' }," + " body: JSON.stringify({ message: text })" + " });" + " var data = await res.json();" + " document.getElementById('messages').innerHTML += '<p>AI: ' + data.reply + '</p>';" + "}";
Walking through what this does: when the Send button is clicked, it grabs whatever was typed, shows it on the page immediately, then sends it to your Worker’s /chat route and waits for a reply. When the reply comes back, it adds that to the page too.
Now the Worker needs to listen for that fetch call and respond with an AI-generated answer. Here’s the complete Worker:
export default { async fetch(request, env) { const url = new URL(request.url); if (request.method === "POST" && url.pathname === "/chat") { const body = await request.json(); const userMessage = body.message || ""; const aiResponse = await env.AI.run("@cf/meta/llama-3.1-8b-instruct-fp8-fast", { messages: [{ role: "user", content: userMessage }] }); return new Response(JSON.stringify({ reply: aiResponse.response }), { headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } }); } return new Response(HTML, { headers: { "Content-Type": "text/html;charset=UTF-8" } }); } };
💡 Notice the pattern: a normal page visit (GET) returns the chat page. A message sent from the chat box (POST to /chat) returns just the AI’s answer as JSON. One Worker, two jobs — this exact branching shows up in every chat app you’ll build from here on.
You may have noticed this line in the response above:
"Access-Control-Allow-Origin": "*"
Browsers have a built-in security rule that blocks a page from fetching data from a different address unless that address explicitly allows it. This line is your Worker saying "yes, anyone is allowed to talk to me." Forgetting this line is one of the most common reasons a chat app looks broken even though the code is otherwise correct — the browser console will show a CORS error, and the fix is always this one line.
Your Worker file needs all three pieces, in this order: CHAT_SCRIPT, then HTML (which uses CHAT_SCRIPT), then the export default block with the fetch function.
Save and deploy. Visit your Worker’s URL, type a message, and click Send. You should see your message appear, followed shortly by a real AI-generated reply — no page reload, no API key, running entirely on Cloudflare.
🎉 This is the real thing. What you just built is the same core pattern behind PrivateAI, Qluv, and DistantGhost — a chat page, a /chat route, and the AI binding. Everything from here is refinement: better design, conversation memory, multiple personas. The hard part is done.
This tutorial kept the design bare on purpose, so the mechanics stay clear. Once it’s working, try asking an AI chatbot to help you: style the chat bubbles with CSS, add a "thinking..." indicator while waiting for a reply, or remember the last few messages so the AI has context. The structure you just built supports all of it.