Manage Chat Sessions
What a Chat Session Is
A chat session is one separate conversation. Users of chat applications expect to have several: one about a Django bug, another about interview preparation, a third about a database design. Each session has its own history, so topics do not mix, and each can be resumed, renamed, or deleted independently.
Session management covers:
| Operation | Example |
|---|---|
| Create | "New chat" button |
| List | The sidebar of previous chats, newest first |
| Open / switch | Clicking a chat in the sidebar |
| Rename | Giving a chat a meaningful title, manually or automatically |
| Delete | Removing a chat the user no longer needs |
| Clean up | Removing old, inactive sessions according to a retention policy |
| Protect | Ensuring users can only access their own sessions |
Lesson 62's conversations table already has a user_id and a title. This lesson builds a SessionManager on top of it.
Session IDs
Each session needs a unique identifier. Use random, unguessable IDs, such as UUIDs:
import uuid
session_id = uuid.uuid4().hex # e.g. '3f9c2a7e41d84b6f9c1e0a5b7d2f8c61'
| ID style | Example | Problem or benefit |
|---|---|---|
| Sequential integers | 1, 2, 3 | Easy to guess; users could try other numbers |
| Random UUIDs | 3f9c2a7e… | Practically impossible to guess |
Unguessable IDs are a useful extra layer, but they are not a replacement for ownership checks. Every operation must still verify that the session belongs to the current user.
Ownership Checks
Every session operation must confirm that the session belongs to the user making the request. Without this, a user who obtains or guesses another session's ID could read or modify someone else's conversation. This is an insecure direct object reference (IDOR) vulnerability (Lesson 25).
The check is simple: load the session's owner and compare it with the current user. If the session does not exist or belongs to someone else, respond with the same "not found" error. Using the same error for both cases avoids revealing which session IDs exist.
Automatic Titles
A sidebar full of "New chat" entries is not helpful. A common pattern is to generate a short title from the first message, using a small, fast model such as Claude Haiku 5.5:
First message: "My Django view returns a 500 error when the form is empty"
Generated title: "Django Empty Form 500 Error"
Title generation is a convenience, so it must never break the chat: if it fails, fall back to the first words of the message.
Retention and Cleanup
Stored conversations contain user data, so decide how long to keep them. A simple policy deletes sessions with no activity for a set number of days. Run cleanup periodically, for example once a day from a scheduled job. Tell users about your retention policy.
Practical Example: A Session Manager
sessions.py
"""sessions.py: manage multiple chat sessions per user on top of HistoryStore."""
import time
import uuid
import anthropic
from anthropic import Anthropic
from chatbot_core import BotConfig, ChatBot, Reply
from history_store import HistoryStore
TITLE_MODEL = "claude-haiku-5-5"
class SessionNotFound(Exception):
"""The session does not exist, or does not belong to this user."""
class SessionManager:
def __init__(self, store: HistoryStore, client: Anthropic, config: BotConfig):
self.store = store
self.db = store.db
self.client = client
self.config = config
def create(self, user_id: str) -> str:
session_id = uuid.uuid4().hex
self.store.ensure_conversation(session_id, user_id)
return session_id
def list_sessions(self, user_id: str) -> list[tuple[str, str, float]]:
return self.db.execute(
"SELECT id, title, updated_at FROM conversations WHERE user_id = ? ORDER BY updated_at DESC",
(user_id,),
).fetchall()
def open(self, session_id: str, user_id: str) -> ChatBot:
self._check_owner(session_id, user_id)
return ChatBot(self.client, self.config, history=self.store.load(session_id))
def save_turn(self, session_id: str, user_id: str, reply: Reply) -> None:
self._check_owner(session_id, user_id)
if reply.ok:
self.store.append(session_id, reply.new_messages)
def rename(self, session_id: str, user_id: str, title: str) -> None:
self._check_owner(session_id, user_id)
title = title.strip()[:80] or "New chat"
with self.db:
self.db.execute("UPDATE conversations SET title = ? WHERE id = ?", (title, session_id))
def delete(self, session_id: str, user_id: str) -> None:
self._check_owner(session_id, user_id)
with self.db:
self.db.execute("DELETE FROM conversations WHERE id = ?", (session_id,))
def auto_title(self, session_id: str, user_id: str, first_message: str) -> str:
prompt = (
f"<message>\n{first_message[:2000]}\n</message>\n\n"
"Write a short title (at most 6 words) for a chat that starts with the message above. "
"Reply with only the title."
)
try:
response = self.client.messages.create(
model=TITLE_MODEL, max_tokens=1024,
messages=[{"role": "user", "content": prompt}],
)
title = "".join(b.text for b in response.content if b.type == "text").strip().strip('"')
except anthropic.APIError:
title = ""
title = title or first_message[:40]
self.rename(session_id, user_id, title)
return title
def cleanup(self, older_than_days: float) -> int:
cutoff = time.time() - older_than_days * 86_400
with self.db:
cursor = self.db.execute("DELETE FROM conversations WHERE updated_at < ?", (cutoff,))
return cursor.rowcount
def _check_owner(self, session_id: str, user_id: str) -> None:
row = self.db.execute("SELECT user_id FROM conversations WHERE id = ?", (session_id,)).fetchone()
if row is None or row[0] != user_id:
raise SessionNotFound(session_id)
Using the Session Manager
"""demo_sessions.py: create, title, list, resume, and protect chat sessions."""
from anthropic import Anthropic
from chatbot_core import BotConfig
from history_store import HistoryStore
from sessions import SessionManager, SessionNotFound
manager = SessionManager(HistoryStore("chat.db"), Anthropic(),
BotConfig(system="You are a concise programming tutor."))
USER = "student-1"
for first_message in ["My Django view crashes when the form is empty.",
"How should I prepare for a Python interview?"]:
session_id = manager.create(USER)
bot = manager.open(session_id, USER)
reply = bot.send(first_message)
manager.save_turn(session_id, USER, reply)
manager.auto_title(session_id, USER, first_message)
print("Sessions (newest first):")
for number, (session_id, title, _) in enumerate(manager.list_sessions(USER), start=1):
print(f" {number}. {title}")
oldest_id = manager.list_sessions(USER)[-1][0]
bot = manager.open(oldest_id, USER)
reply = bot.send("What did I say was crashing?")
manager.save_turn(oldest_id, USER, reply)
print(f"\nResumed oldest session: {reply.text}")
try:
manager.open(oldest_id, "someone-else")
except SessionNotFound:
print("Another user cannot open this session.")
Example output:
Sessions (newest first):
1. Preparing for Python Interviews
2. Django View Crash on Empty Form
Resumed oldest session: You said your Django view crashes when the form is empty.
Another user cannot open this session.
How It Works
create()generates a random UUID and creates the conversation row with its owner, usingensure_conversation()from Lesson 62.list_sessions()returns(id, title, updated_at)for the user's sessions, newest activity first. That is exactly what a sidebar needs. The method is namedlist_sessionsrather thanlistto avoid hiding Python's built-inlist.open()loads the session's stored history into a newChatBot(Lesson 61), after checking ownership.save_turn()appends a successful turn'snew_messages, which also updatesupdated_at, so active sessions move to the top of the list._check_owner()runs before every operation that touches an existing session. Missing sessions and other users' sessions raise the sameSessionNotFounderror.auto_title()asks a fast model for a title of at most six words, removes surrounding quotes, and falls back to the first 40 characters of the message if the request fails or returns nothing. A failure never breaks the chat.rename()trims and limits titles to 80 characters, with "New chat" as the fallback.cleanup()deletes inactive sessions. Thanks toON DELETE CASCADE, their messages are deleted too.cursor.rowcountreports how many sessions were removed.
Common Mistakes
Missing Ownership Checks
What happens: users can read or change other users' conversations by changing an ID.
Fix: check ownership in every operation, centrally, as _check_owner() does.
Guessable Session IDs
What happens: sequential IDs make it easy to probe for other sessions.
Fix: use random UUIDs, and check ownership.
Letting Title Generation Break the Chat
What happens: an API error while generating a title produces an error for the user's message.
Fix: treat titles as optional, with a fallback.
Keeping Conversations Forever
What happens: storage grows without limit, and old personal data accumulates.
Fix: define a retention policy, and run cleanup regularly.
Different Errors for "Missing" and "Not Yours"
What happens: attackers can tell which session IDs exist.
Fix: return the same "not found" error in both cases.
Best Practices
- Use random, unguessable session IDs.
- Check ownership on every session operation.
- List sessions by recent activity.
- Generate titles with a fast model, and fall back gracefully.
- Delete sessions completely, including their messages.
- Apply a retention policy, and communicate it to users.
Exercise
- Add a
search(user_id, text)method that returns sessions whose title contains the given text. - Add a
message_countcolumn to the output oflist_sessions()using aLEFT JOINwithmessagesandGROUP BY. - Write a small scheduled cleanup script that deletes sessions inactive for more than 90 days and prints how many were removed.
- Explain why
save_turn()checks ownership even thoughopen()already did.
Step 2:
def list_sessions(self, user_id: str) -> list[tuple[str, str, float, int]]:
return self.db.execute(
"""
SELECT c.id, c.title, c.updated_at, COUNT(m.id) AS message_count
FROM conversations AS c
LEFT JOIN messages AS m ON m.conversation_id = c.id
WHERE c.user_id = ?
GROUP BY c.id
ORDER BY c.updated_at DESC
""",
(user_id,),
).fetchall()
LEFT JOIN keeps sessions that have no messages yet. Their count is 0, because COUNT(m.id) ignores the missing rows.
Step 4: in a web application, open() and save_turn() usually run in different requests. The session ID for saving comes from the client again, so it could have been changed. Each operation must verify ownership on its own, rather than relying on a check made earlier.