Build a Web Chatbot

Su Suriya Ravichandran Updated 10 Nov 2026
12 min read ·Lesson 66 of 200

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:

ConcernTerminal chatbotWeb chatbot
Who is the user?A command-line optionMust be determined securely for every request
Input and outputinput() and print()HTTP requests and responses
StreamingPrint chunksSend chunks over HTTP and display them with JavaScript
ConcurrencyOne userMany users at the same time
SecurityLocal machineUntrusted clients, cross-site attacks, abuse
ErrorsPrint a messageReturn 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 pathPurposeResponse
GET /The chat pageHTML
GET /api/sessionsList the user's sessions{"sessions": [{"id", "title", "updated_at"}, ...]}
POST /api/sessionsCreate a session201 with {"id": "..."}
GET /api/sessions/{id}/messagesMessages of a session{"messages": [{"role", "text"}, ...]}
POST /api/sessions/{id}/chatSend a message; stream the replytext/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):

EventMeaning
{"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.

ApproachUse
Signed session cookie with a random user IDDemos 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

  1. Services is 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 single with block, and therefore in a single thread.
  2. sse() formats one server-sent event with JSON data. JSON encoding keeps line breaks inside the text from breaking the event format (Lesson 56).
  3. text_of() converts stored content, a string or a list of block dictionaries, into display text for the browser.
  4. check_session() verifies ownership before a stream starts. Once a streamed response has begun, the HTTP status is already 200 and cannot become 404, so the framework layer must call this first.
  5. 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 text events into SSE text events;
    • on failure it sends one error event 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

  1. addMessage() uses textContent, never innerHTML, 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).
  2. api() wraps fetch(), sets the JSON content type, and turns error responses into exceptions carrying the server's friendly error.message.
  3. sendMessage() posts the message, then reads the response body as a stream with getReader(). TextDecoder with {stream: true} correctly handles characters split across chunks.
  4. The event parser collects text in buffer and 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.
  5. After the stream ends, the session list is reloaded, so the auto-generated title appears.
  6. The send button is disabled while an answer streams, preventing overlapping requests that could interleave turns.

Security Checklist for Web Chatbots

RiskMitigation
Users accessing others' chatsServer-side user identity; ownership checks in every operation (Lesson 63)
Cross-site scriptingRender 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 costsMessage length limits, rate limits per user, usage budgets (Lessons 47 and 179)
Leaking API keysKeys live only on the server, never in the page (Lesson 7)
Leaking internal errorsFriendly error messages to users; details only in server logs (Lesson 70)
Prompt injectionSystem-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

  1. Add a "Delete chat" button to the page, and a DELETE /api/sessions/{id} endpoint to the API design.
  2. Add a delete_session(user_id, session_id) function to chat_service.py.
  3. Show a "Thinking…" placeholder in the assistant bubble until the first text event arrives.
  4. Explain why chat_events() closes its database connection before streaming starts.
Show answers for steps 2 and 4

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.

0 Comments

Reviewed before they appear

No comments yet.

Anthropic SDK Using Python
Anthropic SDK Intro Claude API Intro SDK Requirements Install Anthropic SDK Upgrade the SDK Create an API Key Set API Keys Use Environment Variables Import Anthropic Create a Client Send Your First Message Choose a Claude Model Set Max Tokens Read a Response Print Response Text Understand Message Blocks Use System Prompts Send User Messages Set Temperature Handle Stop Reasons Messages API Intro Create Messages Use Message Roles Send Conversation History Continue a Conversation Build a Chat Loop Reset Chat History Use Multiple Turns Inspect Message IDs Read Usage Data Write Clear Prompts Set a System Prompt Add Context to Prompts Use Prompt Templates Add Examples to Prompts Request JSON Output Use XML Tags Control Response Length Improve Prompt Quality Avoid Prompt Injection Read Text Blocks Read Content Blocks Check Stop Reasons Count Input Tokens Count Output Tokens Count Tokens Before Sending Limit Output Tokens Use Stop Sequences Read Request IDs Parse API Responses Streaming Intro Stream Text Output Use Stream Events Use Stream Helpers Get Final Messages Stream Async Responses Handle Stream Errors Build a Live Chat Stream Long Responses Close Streams Safely Build a Chatbot Keep Chat History Manage Chat Sessions Add Chat Memory Build a CLI Chatbot Build a Web Chatbot Use Flask with Claude Use FastAPI with Claude Return JSON from an API Add Chat Error Messages Async SDK Intro Create an Async Client Send Async Messages Use asyncio Run Parallel Requests Stream Async Text Handle Async Errors Set Async Timeouts Close Async Clients Choose Sync or Async Tool Use Intro Define a Tool Create Tool Schemas Send Tools to Claude Read Tool Calls Run Python Tools Return Tool Results Continue After Tool Use Control Tool Choice Handle Tool Errors Use the beta_tool Decorator Create a Tool Runner Add Tool Docstrings Validate Tool Inputs Use Multiple Tools Limit Tool Calls Log Tool Activity Build a Weather Tool Build a Search Tool Build a Calculator Tool Structured Output Intro Request JSON Data Validate JSON Output Use Pydantic Models Handle Invalid JSON Extract Fields from Text Generate Typed Data Use Image URLs Send Base64 Images Read PDFs with Claude Use Output Schemas Build Data Extraction Test Structured Responses Send Image Inputs Upload Files Use File IDs Process Text Files Summarize Documents Extract Table Data Handle File Errors Read Cache Usage Reduce Repeated Input Costs Debug Cache Misses Prompt Caching Intro Enable Prompt Caching Cache System Prompts Cache Long Context Test Cache Performance Use Cache Breakpoints Cache Best Practices Message Batches Intro Create a Message Batch Set Batch IDs Check Batch Status List Message Batches Get Batch Results Process Batch Outputs Handle Failed Batch Items Use Batches for Bulk Tasks Compare Batches and Requests Handle API Errors Handle Rate Limits Retry Failed Requests Set Request Timeouts Handle Network Errors Use Exponential Backoff Build Reliable API Calls Use Extended Thinking Log API Errors Track Request IDs Paginate API Results Set Thinking Budgets Read Thinking Blocks Use Tool Search Use Web Search Tools Use Computer Tools Use Citations Use Token Counting Read Raw Responses Set Custom Headers Use Claude on AWS Bedrock Use Claude on Vertex AI Use Claude on Microsoft Foundry Configure Cloud Credentials Choose a Cloud Provider Compare API Integrations Configure Provider Clients Handle Cloud Errors Use Cloud SDK Extras Deploy Cloud Apps Protect API Keys Use a .env File Ignore Secrets in Git Validate User Input Reduce Prompt Injection Limit Tool Permissions Avoid Unsafe Tool Calls Sanitize Tool Results Set Usage Limits Review Security Logs Test SDK Functions Mock API Responses Test Error Handling Test Streaming Code Measure Token Usage Track API Costs Configure Logging Deploy with Docker Deploy a FastAPI App Monitor Production Apps Build a Claude Chatbot Build a Text Summarizer Build a Code Explainer Build a JSON Extractor Build a PDF Assistant Build a Streaming Chat App Build a Tool-Calling Agent Build a Batch Processor Build a Research Assistant Build a Claude API Dashboard
Ask about this post
AI Ask about this post

Ask questions about Build a Web Chatbot and get answers drawn from it.

Signed-in readers only.