// lesson: chat-loop
The Chat Loop
A chatbot is a loop: read input, append a user message, send the whole history, append the reply, print it, repeat. The design question is where to put the seam โ a point in the code where you can swap in different behavior (a fake client instead of a real one) without touching the surrounding logic โ so the loop logic is testable without the network.
The answer: define the turn as a function that takes the API as an argument.
A client in this course is just a callable โ client(messages) -> str โ
that takes the full message list and returns the assistant's reply text. In
production that callable wraps a real SDK; in tests it's a fake defined in
three lines. Add the turn function to chatbot.py:
def chat_turn(history, user_input, client):
history.append({"role": "user", "content": user_input})
reply = client(history)
history.append({"role": "assistant", "content": reply})
return reply
Note the ordering: the user message goes into history before the client
is called, so the model sees the question it's answering. The reply goes in
after, so the next turn has the full exchange. chat_turn mutates history
in place and returns the reply for printing.
Now the real client. This is the adapter from the last lesson โ it pulls the system message out of our portable history shape and calls Anthropic's API:
import anthropic
_anthropic = anthropic.Anthropic()
def anthropic_client(messages):
system = ""
if messages and messages[0]["role"] == "system":
system = messages[0]["content"]
messages = messages[1:]
response = _anthropic.messages.create(
model="claude-opus-4-8",
max_tokens=1024,
system=system,
messages=messages,
)
return response.content[0].text
Prefer OpenAI? Same shape, no splitting needed because OpenAI accepts the system message in the list (check their models page for a current model name):
from openai import OpenAI
_openai = OpenAI() # reads OPENAI_API_KEY
def openai_client(messages):
response = _openai.chat.completions.create(
model="gpt-4o-mini", # or any current chat model
messages=messages,
)
return response.choices[0].message.content
Either callable plugs into the same chat_turn. Finish the app with the
loop:
def main():
history = make_conversation("You are a concise assistant.")
print("chatbot ready โ type 'quit' to exit")
while True:
user_input = input("> ").strip()
if user_input in ("quit", "exit"):
break
if not user_input:
continue
print(chat_turn(history, user_input, anthropic_client))
if __name__ == "__main__":
main()
Run python chatbot.py and have a conversation. Ask a question, then ask a
follow-up that only makes sense with memory ("shorter, please") โ it works,
because the whole history rides along on every call.
The challenge below tests chat_turn exactly as your app uses it โ but the
client the tests inject is a fake that records what it was called with.
That's the payoff of the seam: the same function that talks to a real model
in your terminal is verified in a sandbox with no network at all.
โบ One Turn of Chat
15 ptsImplement chat_turn(history, user_input, client):
- Append
{"role": "user", "content": user_input}tohistory(mutate it in place). - Call
client(history)exactly once โ the client must see the full history including the new user message. - Append
{"role": "assistant", "content": <the client's return value>}tohistory. - Return the reply string.
Log in to submit a solution and earn points.