// 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 pts

Implement chat_turn(history, user_input, client):

  • Append {"role": "user", "content": user_input} to history (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>} to history.
  • Return the reply string.

Log in to submit a solution and earn points.