Build a Claude Chatbot
The Goal
This project builds the core of a chatbot: a component that remembers the conversation, sends the history with each new message, keeps the history within a limit, and records the transcript. It is the heart of every chat product, whatever its interface. The chat window, a web page, or a messaging app are all presentation layers around this component.
You will build the component as a class with one method that accepts a user message and returns the reply. The class receives its client from outside, so the same code runs against the live API or against a stand-in that the tests and the demonstration use. Nothing here requires a key to run the demonstration.
What You Will Build
A ChatSession object that:
- keeps the conversation as a list of messages, in the order they were exchanged,
- sends the system prompt and the recent history with every request,
- keeps only the most recent turns, so the request stays within a size you choose,
- saves the full transcript, so the conversation can be stored and resumed.
The history is the only memory the model has. Each request must carry the context it needs, which is why the session sends the history every time (Lesson 24), and why the trimming must be done carefully.
Project Structure
chatbot/
├── chat_session.py # the ChatSession class
└── demo.py # a stand-in client, and a short conversation
Step 1: The Session
The session holds the history, and its send method performs the exchange. The history alternates between user and assistant messages, which the API requires, so the code must keep that alternation intact even when it trims:
"""chat_session.py: a conversation that keeps its history and sends it with each request."""
import json
class ChatSession:
def __init__(self, client, system: str, model: str = "claude-sonnet-5-5",
max_turns: int = 10, max_tokens: int = 512):
self.client = client
self.system = system
self.model = model
self.max_turns = max_turns
self.max_tokens = max_tokens
self.history: list[dict] = []
def send(self, text: str) -> str:
self.history.append({"role": "user", "content": text})
response = self.client.messages.create(
model=self.model,
max_tokens=self.max_tokens,
system=self.system,
messages=self._recent(),
)
reply = "".join(block.text for block in response.content if block.type == "text")
self.history.append({"role": "assistant", "content": reply})
return reply
def _recent(self) -> list[dict]:
start = max(0, len(self.history) - 2 * self.max_turns)
if start % 2:
start += 1 # Begin on a user message, never an assistant message
return self.history[start:]
def transcript(self) -> str:
return json.dumps(self.history, ensure_ascii=False, indent=2)
How It Works
- The history grows by two messages per exchange, one from the user and one from the assistant. Storing the full reply, not only its text, keeps the history in the form the API expects.
_recent()selects the messages to send. It keeps the last2 × max_turnsmessages, which is the number of messages in the most recent turns.- The start index is forced to be even. The history begins with a user message at index zero, and every user message sits at an even index. A start at an odd index would begin on an assistant message, which the API rejects, so the code moves the start forward by one when needed.
- The system prompt is sent separately, on every request, so trimming the history never removes the instructions.
- The transcript is the full history, not the trimmed view, so the record of the conversation is complete even though the model saw only the recent part.
Step 2: A Stand-In Client
To run the project without a key, the demonstration uses a client that records what it was asked and returns a predictable reply. It has the same shape as the real client's messages method, so the session cannot tell the difference:
"""demo.py: run a short conversation against a stand-in client."""
from types import SimpleNamespace
from chat_session import ChatSession
class EchoClient:
def __init__(self):
self.calls: list[list[dict]] = []
self.messages = self
def create(self, **request):
self.calls.append(request["messages"])
last = request["messages"][-1]["content"]
text = f"reply to '{last}'"
return SimpleNamespace(content=[SimpleNamespace(type="text", text=text)])
if __name__ == "__main__":
client = EchoClient()
session = ChatSession(client, system="You are a helpful tutor.", max_turns=2)
for number, message in enumerate(["Hi, I am Priya", "What is a loop?", "What is my name?"], start=1):
reply = session.send(message)
sent = client.calls[-1]
print(f"turn {number} sent {len(sent)} messages: {reply}")
print("saved messages:", len(session.history))
Output:
turn 1 sent 1 messages: reply to 'Hi, I am Priya'
turn 2 sent 3 messages: reply to 'What is a loop?'
turn 3 sent 3 messages: reply to 'What is my name?'
saved messages: 6
How It Works
EchoClientmirrors the real client's shape. Itsmessagesattribute has acreatemethod that accepts the same keyword arguments, so the session calls it exactly as it would call the live API.- The first turn sends one message, because there is no history yet.
- The second turn sends three: the first exchange and the new question, so the model can see what was said before.
- The third turn still sends three, because the limit of two turns is reached. The first exchange has been dropped from the request, though it remains in the transcript, which the last line confirms with six saved messages.
- The stand-in's reply shows what the model received. The reply quotes the last user message, and the count shows how much history went with it.
Going Live
To use the real API, replace the stand-in with the SDK client, and keep everything else the same:
from anthropic import Anthropic
session = ChatSession(Anthropic(), system="You are a helpful tutor.")
print(session.send("What is a loop?"))
The Anthropic() client reads the key from the environment (Lesson 172), so the code itself contains no secret. The session does not change, because the call signature is the same.
Where the Design Can Grow
The class deliberately does less than a production chatbot. A production version adds streaming (Lessons 51 to 60), persistence of the transcript to a database (Lesson 62), error handling around the call (Lessons 141 to 150), and a token-based limit in place of a turn count (Lesson 158). Each addition is a change inside send, so the structure built here remains the base.