Build a Web Chatbot
From Terminal to Browser
Lesson 65 built a complete chatbot for the terminal. A web chatbot offers the same conversation features in a browser, which is what most users expect. Moving to the web adds new concerns:
| Concern | Terminal chatbot | Web chatbot |
|---|---|---|
| Who is the user? | A command-line option | Must be determined securely for every request |
| Input and output | input() and print() | HTTP requests and responses |
| Streaming | Print chunks | Send chunks over HTTP and display them with JavaScript |
| Concurrency | One user | Many users at the same time |
| Security | Local machine | Untrusted clients, cross-site attacks, abuse |
| Errors | Print a message | Return status codes and messages the browser can display |
This lesson designs the web chatbot independently of any framework. It defines the API, a shared chat service module, and the browser page. Lessons 67 and 68 then implement the same API with Flask and FastAPI.
Architecture
Browser (chat.html + JavaScript)
│ JSON requests, streamed server-sent events
▼
Web framework (Flask, Lesson 67 / FastAPI, Lesson 68) ← routing, user identity, HTTP details
│
▼
chat_service.py (this lesson) ← framework-independent chat operations
│
▼
chatbot_core · history_store · sessions · memory ← Lessons 61–64
│
▼
Anthropic SDK → Claude API
Each layer has one job. The framework layer only translates HTTP into function calls, so switching from Flask to FastAPI changes one file.
The HTTP API
The browser and the server communicate through a small API:
| Method and path | Purpose | Response |
|---|---|---|
GET / | The chat page | HTML |
GET /api/sessions | List the user's sessions | {"sessions": [{"id", "title", "updated_at"}, ...]} |
POST /api/sessions | Create a session | 201 with {"id": "..."} |
GET /api/sessions/{id}/messages | Messages of a session | {"messages": [{"role", "text"}, ...]} |
POST /api/sessions/{id}/chat | Send a message; stream the reply | text/event-stream |
Errors use one consistent JSON shape, designed in detail in Lesson 69:
{"error": {"code": "session_not_found", "message": "That conversation was not found."}}
The Streamed Chat Response
POST /api/sessions/{id}/chat accepts {"message": "..."} and returns server-sent events, each a JSON object (Lesson 56):
| Event | Meaning |
|---|---|
{"type": "text", "text": "..."} | A chunk of the answer |
{"type": "done", "truncated": false} | The answer is complete and saved |
{"type": "error", "message": "..."} | The answer failed; show the message |
Why POST with fetch() instead of EventSource: the browser's EventSource only supports GET requests, which would put the message in the URL. Messages belong in a request body: URLs end up in logs and browser history, and they have length limits. fetch() with POST can read a streamed response body, as the JavaScript below shows.
Identifying the User
Every request must be tied to a user by the server, never by data the client can freely choose, such as a user_id field in the request body. Otherwise, anyone could act as anyone else.
| Approach | Use |
|---|---|
| Signed session cookie with a random user ID | Demos and anonymous chats: each browser gets its own identity |
| Your application's login system (Django auth, OAuth, …) | Real applications: the logged-in user's ID |
Lessons 67 and 68 use a signed session cookie. The framework signs the cookie with a secret key, so users cannot change its content without detection. In a production application, replace this with your real authentication.
The Chat Service Module
chat_service.py contains every operation the web API needs, without any framework code. It reuses the modules from Lessons 61–64.
Threads and SQLite
Web servers handle requests in several threads, and the streamed chat response may even be produced by different threads over time. Python's sqlite3 connections must stay in the thread that created them. The service therefore opens a short-lived database connection wherever it needs one, through a small Services helper, instead of sharing one connection.
"""chat_service.py: framework-independent chat operations for the web chatbot."""
import json
from collections.abc import Iterator
from dataclasses import replace
from anthropic import Anthropic
from chatbot_core import BotConfig
from history_store import HistoryStore
from memory import MemoryStore, extract_facts
from sessions import SessionManager
DB_PATH = "chat.db"
BASE_SYSTEM = (
"You are a friendly programming tutor for GOCOURSE students. "
"Keep answers under 150 words unless the student asks for more detail."
)
BASE_CONFIG = BotConfig(system=BASE_SYSTEM)
client = Anthropic()
ERROR_MESSAGES = {
"empty_message": "Please type a message.",
"message_too_long": "That message is too long. Please shorten it.",
"refusal": "I can't help with that request.",
}
GENERIC_ERROR = "Something went wrong. Please try again."
class Services:
"""Database-backed services with a connection that lives only inside a `with` block."""
def __enter__(self) -> "Services":
self.store = HistoryStore(DB_PATH)
self.sessions = SessionManager(self.store, client, BASE_CONFIG)
self.memory = MemoryStore(self.store)
return self
def __exit__(self, *exc_info) -> None:
self.store.db.close()
def sse(data: dict) -> str:
return f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
def text_of(content) -> str:
if isinstance(content, str):
return content
return "".join(block.get("text", "") for block in content if block.get("type") == "text")
def list_sessions(user_id: str) -> list[dict]:
with Services() as s:
return [{"id": sid, "title": title, "updated_at": updated}
for sid, title, updated in s.sessions.list_sessions(user_id)]
def create_session(user_id: str) -> str:
with Services() as s:
return s.sessions.create(user_id)
def session_messages(user_id: str, session_id: str) -> list[dict]:
with Services() as s:
bot = s.sessions.open(session_id, user_id) # Raises SessionNotFound
return [{"role": m["role"], "text": text_of(m["content"])} for m in bot.history]
def check_session(user_id: str, session_id: str) -> None:
with Services() as s:
s.sessions.open(session_id, user_id) # Raises SessionNotFound
def chat_events(user_id: str, session_id: str, text: str) -> Iterator[str]:
"""Stream one chat turn as server-sent events, then save it."""
with Services() as s:
bot = s.sessions.open(session_id, user_id)
section = s.memory.system_section(user_id)
bot.config = replace(BASE_CONFIG, system=f"{BASE_SYSTEM}\n\n{section}" if section else BASE_SYSTEM)
is_first_turn = not bot.history
reply = None
for event in bot.stream_events(text):
if event["type"] == "text":
yield sse({"type": "text", "text": event["text"]})
else:
reply = event["reply"]
if reply is None or not reply.ok:
error = reply.error if reply else None
yield sse({"type": "error", "message": ERROR_MESSAGES.get(error, GENERIC_ERROR)})
return
with Services() as s:
s.sessions.save_turn(session_id, user_id, reply)
if is_first_turn:
s.sessions.auto_title(session_id, user_id, text)
yield sse({"type": "done", "truncated": reply.stop_reason == "max_tokens"})
with Services() as s: # Best effort, after the user has the answer
known = [fact for _, fact in s.memory.facts(user_id)]
s.memory.add(user_id, extract_facts(client, text, known))
How the Chat Service Works
Servicesis a context manager: entering it opens a database connection and builds the session and memory managers; leaving it closes the connection. Every use of the database happens inside a singlewithblock, and therefore in a single thread.sse()formats one server-sent event with JSON data. JSON encoding keeps line breaks inside the text from breaking the event format (Lesson 56).text_of()converts stored content, a string or a list of block dictionaries, into display text for the browser.check_session()verifies ownership before a stream starts. Once a streamed response has begun, the HTTP status is already200and cannot become404, so the framework layer must call this first.chat_events()is a generator that the web framework streams to the browser:- it loads the session and the user's memory, then closes the connection before streaming, which can take many seconds;
- it converts the core's
textevents into SSEtextevents; - on failure it sends one
errorevent with a friendly message and stops, without saving; - on success it saves the turn and generates the title before sending
done, so the browser's next session refresh shows the new title; - memory extraction runs after
done, so the user does not wait for it. If the browser disconnects early, the generator may be closed before this step, which is acceptable because memory is best-effort.
The Browser Page
The page has a sidebar of sessions, a message area, and an input form. Save it as static/chat.html. It works unchanged with both the Flask and FastAPI versions.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>GOCOURSE Tutor</title>
<style>
body { margin: 0; display: flex; height: 100vh; font-family: system-ui, sans-serif; }
aside { width: 240px; border-right: 1px solid #ddd; padding: 12px; overflow-y: auto; }
aside button.session { display: block; width: 100%; text-align: left; margin: 4px 0; }
aside button.active { font-weight: bold; }
main { flex: 1; display: flex; flex-direction: column; }
#messages { flex: 1; overflow-y: auto; padding: 16px; }
.msg { white-space: pre-wrap; margin: 8px 0; padding: 8px 12px; border-radius: 8px; max-width: 75%; }
.user { background: #e7f0ff; margin-left: auto; }
.assistant { background: #f3f3f3; }
form { display: flex; gap: 8px; padding: 12px; border-top: 1px solid #ddd; }
textarea { flex: 1; height: 48px; }
</style>
</head>
<body>
<aside>
<button id="new-chat">+ New chat</button>
<div id="sessions"></div>
</aside>
<main>
<div id="messages"></div>
<form id="chat-form">
<textarea id="input" maxlength="10000" placeholder="Ask a programming question..."></textarea>
<button id="send" type="submit">Send</button>
</form>
</main>
<script>
let currentId = null;
const messagesEl = document.getElementById("messages");
function addMessage(role, text) {
const div = document.createElement("div");
div.className = "msg " + role;
div.textContent = text; // textContent never interprets HTML
messagesEl.appendChild(div);
messagesEl.scrollTop = messagesEl.scrollHeight;
return div;
}
async function api(path, options = {}) {
const response = await fetch(path, {headers: {"Content-Type": "application/json"}, ...options});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.error?.message ?? "Request failed.");
}
return response;
}
async function loadSessions() {
const {sessions} = await (await api("/api/sessions")).json();
const list = document.getElementById("sessions");
list.replaceChildren();
for (const s of sessions) {
const button = document.createElement("button");
button.className = "session" + (s.id === currentId ? " active" : "");
button.textContent = s.title;
button.onclick = () => openSession(s.id);
list.appendChild(button);
}
return sessions;
}
async function openSession(id) {
currentId = id;
const {messages} = await (await api(`/api/sessions/${id}/messages`)).json();
messagesEl.replaceChildren();
for (const m of messages) addMessage(m.role, m.text);
await loadSessions();
}
async function newSession() {
const {id} = await (await api("/api/sessions", {method: "POST"})).json();
await openSession(id);
}
async function sendMessage(text) {
addMessage("user", text);
const bubble = addMessage("assistant", "");
let response;
try {
response = await api(`/api/sessions/${currentId}/chat`,
{method: "POST", body: JSON.stringify({message: text})});
} catch (error) {
bubble.textContent = "[" + error.message + "]";
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const {value, done} = await reader.read();
if (done) break;
buffer += decoder.decode(value, {stream: true});
let end;
while ((end = buffer.indexOf("\n\n")) !== -1) {
const raw = buffer.slice(0, end);
buffer = buffer.slice(end + 2);
if (!raw.startsWith("data: ")) continue;
const event = JSON.parse(raw.slice(6));
if (event.type === "text") bubble.textContent += event.text;
else if (event.type === "error") bubble.textContent += "\n[" + event.message + "]";
else if (event.type === "done" && event.truncated) bubble.textContent += "\n[Answer cut off.]";
messagesEl.scrollTop = messagesEl.scrollHeight;
}
}
await loadSessions();
}
document.getElementById("new-chat").onclick = newSession;
document.getElementById("chat-form").onsubmit = async (event) => {
event.preventDefault();
const input = document.getElementById("input");
const text = input.value.trim();
if (!text) return;
input.value = "";
document.getElementById("send").disabled = true;
try { await sendMessage(text); } finally { document.getElementById("send").disabled = false; }
};
(async () => {
const sessions = await loadSessions();
if (sessions.length) await openSession(sessions[0].id); else await newSession();
})();
</script>
</body>
</html>
How the Page Works
addMessage()usestextContent, neverinnerHTML, so text from users or from Claude is displayed as text and cannot run as HTML or JavaScript. That prevents cross-site scripting (XSS) (Lesson 15).api()wrapsfetch(), sets the JSON content type, and turns error responses into exceptions carrying the server's friendlyerror.message.sendMessage()posts the message, then reads the response body as a stream withgetReader().TextDecoderwith{stream: true}correctly handles characters split across chunks.- The event parser collects text in
bufferand processes each complete event, which ends with a blank line (\n\n). Incomplete events stay in the buffer until the rest arrives. This is necessary because network chunks do not line up with event boundaries. - After the stream ends, the session list is reloaded, so the auto-generated title appears.
- The send button is disabled while an answer streams, preventing overlapping requests that could interleave turns.
Security Checklist for Web Chatbots
| Risk | Mitigation |
|---|---|
| Users accessing others' chats | Server-side user identity; ownership checks in every operation (Lesson 63) |
| Cross-site scripting | Render messages with textContent; sanitize if you render Markdown as HTML (Lesson 15) |
| Cross-site request forgery (CSRF) | SameSite=Lax session cookies and JSON-only endpoints; add CSRF tokens with cookie-based login systems |
| Abuse and runaway costs | Message length limits, rate limits per user, usage budgets (Lessons 47 and 179) |
| Leaking API keys | Keys live only on the server, never in the page (Lesson 7) |
| Leaking internal errors | Friendly error messages to users; details only in server logs (Lesson 70) |
| Prompt injection | System-prompt rules, input screening, and no dangerous tools (Lesson 40) |
Common Mistakes
Calling the Claude API from Browser JavaScript
What happens: the API key is exposed to every visitor.
Fix: the browser talks only to your server; only the server talks to Claude.
Taking the User ID from the Request Body
What happens: anyone can read or write any user's conversations.
Fix: determine the user on the server, from a signed session or your login system.
Rendering Messages with innerHTML
What happens: text containing HTML or scripts is executed in the page.
Fix: use textContent, or sanitize rendered HTML.
Checking Ownership Inside the Stream
What happens: a "not found" error can only be reported as an in-stream event with status 200.
Fix: check the session before starting the streamed response.
Sharing One SQLite Connection Across Threads
What happens: ProgrammingError about objects created in a different thread.
Fix: open short-lived connections where they are used, or use a database designed for concurrent access.
Best Practices
- Separate the web framework, chat service, and chatbot core.
- Design the HTTP API first, including the error format and stream events.
- Identify users on the server.
- Stream with
POST+fetch(), and parse events with a buffer. - Render text safely in the browser.
- Save before
done; run optional work after it. - Apply limits and logging from the start.
Exercise
- Add a "Delete chat" button to the page, and a
DELETE /api/sessions/{id}endpoint to the API design. - Add a
delete_session(user_id, session_id)function tochat_service.py. - Show a "Thinking…" placeholder in the assistant bubble until the first
textevent arrives. - Explain why
chat_events()closes its database connection before streaming starts.
Step 2:
def delete_session(user_id: str, session_id: str) -> None:
with Services() as s:
s.sessions.delete(session_id, user_id) # Raises SessionNotFound if not owned
Step 4: a streamed answer can take many seconds, and the generator may resume in different server threads between chunks. Keeping a SQLite connection open that long would tie up a database resource and, because sqlite3 connections must stay in their creating thread, could fail when the generator resumes elsewhere. Opening short-lived connections only for the moments when the database is needed avoids both problems.