Build a Chat Loop
What a Chat Loop Is
A chat loop is a program that repeatedly:
- reads a message from the user,
- sends it to Claude together with the conversation history,
- shows Claude's reply, and
- waits for the next message,
until the user decides to stop. It is the core of every chatbot, from a simple terminal tool to a web chat interface.
┌──────────────────────────────────────────────┐
│ read user input │
│ ↓ │
│ append to history → send history to Claude │
│ ↓ │
│ print reply → append reply to history │
│ ↓ │
│ repeat (until the user quits) │
└──────────────────────────────────────────────┘
Lessons 24 and 25 explained conversation history and continuation. This lesson combines them into a working, interactive loop, built step by step from a minimal version to a robust one.
Version 1: The Minimal Chat Loop
from anthropic import Anthropic
client = Anthropic()
history = []
while True:
user_text = input("You: ")
history.append({"role": "user", "content": user_text})
reply = client.messages.create(
model="claude-opus-5-5",
max_tokens=2048,
messages=history,
)
history.append({"role": "assistant", "content": reply.content})
print("Claude:", "".join(b.text for b in reply.content if b.type == "text"))
How it works:
while True:repeats forever. Each iteration is one conversation turn.input("You: ")shows a prompt and waits for the user to type a line.- The user message is appended to
history, and the whole history is sent, so Claude remembers earlier turns (Lesson 24). - Claude's reply is appended as an assistant turn, then its text is printed.
This works, but it has real problems:
| Problem | What happens |
|---|---|
| No way to quit | The user must force-stop the program with Ctrl+C, which prints a traceback |
| Empty input is sent | The API rejects empty content with a 400 error, and the program crashes |
| Any API error crashes the program | A brief network problem ends the whole conversation |
| A failed request leaves a dangling user message | The next message creates two user turns in a row |
| Unlimited history | Each turn becomes slower and more expensive |
The next versions fix each of these.
Version 2: Quitting and Input Handling
from anthropic import Anthropic
client = Anthropic()
history = []
print("Chat with Claude. Type 'quit' to exit.\n")
while True:
try:
user_text = input("You: ").strip()
except (KeyboardInterrupt, EOFError):
print("\nGoodbye!")
break
if not user_text:
continue
if user_text.lower() in {"quit", "exit"}:
print("Goodbye!")
break
history.append({"role": "user", "content": user_text})
reply = client.messages.create(model="claude-opus-5-5", max_tokens=2048, messages=history)
history.append({"role": "assistant", "content": reply.content})
print("Claude:", "".join(b.text for b in reply.content if b.type == "text"), "\n")
What changed and why:
.strip()removes surrounding whitespace, so" "becomes"".if not user_text: continueskips empty input instead of sending an invalid request.quit/exitend the loop withbreak..lower()makes the check case-insensitive.KeyboardInterrupt(Ctrl+C) andEOFError(Ctrl+D on macOS/Linux, or Ctrl+Z then Enter on Windows) are caught, so the program exits politely instead of printing a traceback.
Version 3: Error Handling and Safe History Updates
A chat loop must survive errors. One failed request should produce a message, not end the conversation.
import anthropic
from anthropic import Anthropic
client = Anthropic()
history = []
while True:
try:
user_text = input("You: ").strip()
except (KeyboardInterrupt, EOFError):
print("\nGoodbye!")
break
if not user_text:
continue
if user_text.lower() in {"quit", "exit"}:
break
pending = history + [{"role": "user", "content": user_text}]
try:
reply = client.messages.create(model="claude-opus-5-5", max_tokens=2048, messages=pending)
except anthropic.APIConnectionError:
print("[Network problem. Check your connection and try again.]\n")
continue
except anthropic.RateLimitError:
print("[Too many requests. Wait a moment and try again.]\n")
continue
except anthropic.APIStatusError as error:
print(f"[The API returned an error ({error.status_code}). Please try again.]\n")
continue
if reply.stop_reason == "refusal":
print("[Claude declined this request. Please rephrase.]\n")
continue
history = pending + [{"role": "assistant", "content": reply.content}]
print("Claude:", "".join(b.text for b in reply.content if b.type == "text"), "\n")
Key ideas:
pendingis a new list containing the history plus the new message.historyis updated only after a successful, usable reply. If the request fails,historyis unchanged, so no dangling user message is left behind (Lesson 24).continueafter an error returns to the top of the loop, so the user can try again.- Specific exceptions first: connection and rate-limit errors get tailored messages, and
APIStatusErrorcatches everything else. The SDK has already retried temporary errors automatically before raising them (Lesson 10). - Refusals are reported and not stored, which follows the guidance to reset context after a refusal (Lesson 25).
Version 4: A Complete, Reusable Chat Loop
The final version adds a system prompt, history limits, commands, token information, and truncation handling, and organizes the code into functions.
"""chat_loop.py: a robust terminal chat loop with Claude.
Commands: /help /reset /tokens /quit
"""
import anthropic
from anthropic import Anthropic
from anthropic.types import Message
MODEL = "claude-opus-5-5"
SYSTEM = "You are a friendly programming tutor. Keep answers under 150 words unless asked for more."
MAX_HISTORY_MESSAGES = 30
client = Anthropic()
def get_text(message: Message) -> str:
return "".join(block.text for block in message.content if block.type == "text").strip()
def trim(messages: list[dict]) -> list[dict]:
trimmed = messages[-MAX_HISTORY_MESSAGES:]
while trimmed and trimmed[0]["role"] != "user":
trimmed = trimmed[1:]
return trimmed
def send(history: list[dict], user_text: str) -> tuple[list[dict], str, Message | None]:
"""Return (new_history, text_to_show, reply_or_None)."""
pending = trim(history + [{"role": "user", "content": user_text}])
try:
reply = client.messages.create(model=MODEL, max_tokens=2048, system=SYSTEM, messages=pending)
except anthropic.APIConnectionError:
return history, "[Network problem. Please try again.]", None
except anthropic.RateLimitError:
return history, "[Rate limited. Wait a moment and try again.]", None
except anthropic.APIStatusError as error:
return history, f"[API error {error.status_code}. Please try again.]", None
if reply.stop_reason == "refusal":
return history, "[Claude declined this request. Please rephrase.]", reply
text = get_text(reply) or "[No text in the reply.]"
if reply.stop_reason == "max_tokens":
text += "\n[Answer cut off. Ask for a shorter answer or say 'continue'.]"
new_history = pending + [{"role": "assistant", "content": reply.content}]
return new_history, text, reply
def main() -> None:
history: list[dict] = []
total_in = total_out = 0
print("Chat with Claude. Type /help for commands.\n")
while True:
try:
user_text = input("You: ").strip()
except (KeyboardInterrupt, EOFError):
break
if not user_text:
continue
if user_text in {"/quit", "quit", "exit"}:
break
if user_text == "/help":
print("Commands: /reset (new conversation), /tokens (usage so far), /quit\n")
continue
if user_text == "/reset":
history = []
print("[Conversation reset.]\n")
continue
if user_text == "/tokens":
print(f"[Tokens so far: input={total_in}, output={total_out}]\n")
continue
history, text, reply = send(history, user_text)
print(f"Claude: {text}\n")
if reply is not None:
total_in += reply.usage.input_tokens
total_out += reply.usage.output_tokens
print(f"\nGoodbye! Session used {total_in} input and {total_out} output tokens.")
if __name__ == "__main__":
main()
Example session:
Chat with Claude. Type /help for commands.
You: What is a Python generator?
Claude: A generator is a function that produces values one at a time using yield...
You: Show a tiny example.
Claude: def count_up(n):
for i in range(n):
yield i
...
You: /tokens
[Tokens so far: input=412, output=356]
You: /quit
Goodbye! Session used 412 input and 356 output tokens.
How It Works
send()is a pure step function. It takes the current history and a message, and returns the new history, the text to display, and the reply. The loop inmain()handles only input and commands. This separation makessend()easy to reuse, for example in a web view, and easy to test.- History safety: on errors and refusals,
send()returns the original history, so failed turns are never stored. trim()keeps at mostMAX_HISTORY_MESSAGESmessages, starting with a user message, which caps cost per turn (Lesson 24).- Truncation notice: if the reply hit
max_tokens, the user is told and can ask Claude to continue. The partial reply is kept in history, so "continue" makes sense to Claude. - Commands (
/reset,/tokens,/help) are handled locally and are never sent to Claude./resetsimply empties the list (Lesson 27). - Token totals are accumulated from
reply.usagefor every successful reply (Lesson 30).
Note: For long answers, users see nothing until the full reply arrives. Lesson 58 (Build a Live Chat) converts this loop to streaming so text appears as it is generated.
Common Mistakes
No Exit Path
What happens: users must kill the program, and see a traceback.
Fix: support quit/exit commands, and catch KeyboardInterrupt and EOFError.
Sending Empty Messages
What happens: a 400 error, because the API rejects empty content.
Fix: strip the input and skip empty lines.
Crashing on the First API Error
What happens: a temporary network problem ends the session and loses the conversation.
Fix: wrap each request in try/except, report the problem, and continue the loop.
Appending the User Message Before the Request Succeeds
What happens: after a failure, the history contains an unanswered user message, followed by the next one, giving two consecutive user turns.
Fix: build a pending list and update the history only after success.
Sending Commands to Claude
What happens: /reset is sent as a normal message, so Claude tries to answer it and the conversation is not reset.
Fix: check for commands before building the request, and continue after handling them.
Unbounded History
What happens: long sessions become slow and expensive.
Fix: trim or summarize the history, and show token usage so users and developers notice growth.
Best Practices
- Separate the loop from the request logic. Put request handling in a function that returns new state.
- Update history only after successful, usable replies.
- Handle errors per turn, with clear, friendly messages.
- Provide local commands for reset, help, and diagnostics.
- Limit history length, and track token usage.
- Use a system prompt to define the assistant's role and response length.
- Stream responses in interactive tools for a better experience (Lessons 51–60).
Exercise
- Run
chat_loop.py, have a short conversation, and use/tokensafter each answer. How do input tokens change from turn to turn? - Add a
/historycommand that prints how many messages are currently stored. - Add a
/savecommand that writes the history tochat.json. Remember thatreply.contentcontains SDK objects, so convert it withreply.to_dict()["content"]before storing. - Change
MAX_HISTORY_MESSAGESto4. Tell Claude your name, ask two other questions, and then ask for your name. Explain the result.
Step 2:
if user_text == "/history":
print(f"[{len(history)} messages stored]\n")
continue
Step 3: in send(), store the assistant content as plain data:
new_history = pending + [{"role": "assistant", "content": reply.to_dict()["content"]}]
Then add the command:
import json
from pathlib import Path
if user_text == "/save":
Path("chat.json").write_text(json.dumps(history, ensure_ascii=False, indent=2), encoding="utf-8")
print("[Saved to chat.json]\n")
continue
Step 4: with only four messages kept, the turn where you said your name is trimmed away before you ask for it, so Claude no longer knows it. Trimming saves tokens but loses older information. Lesson 27 shows how to carry important context forward with a summary.