Build a Chat Loop

Su Suriya Ravichandran Updated 10 Nov 2026
9 min read ·Lesson 26 of 200

What a Chat Loop Is

A chat loop is a program that repeatedly:

  1. reads a message from the user,
  2. sends it to Claude together with the conversation history,
  3. shows Claude's reply, and
  4. 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:

ProblemWhat happens
No way to quitThe user must force-stop the program with Ctrl+C, which prints a traceback
Empty input is sentThe API rejects empty content with a 400 error, and the program crashes
Any API error crashes the programA brief network problem ends the whole conversation
A failed request leaves a dangling user messageThe next message creates two user turns in a row
Unlimited historyEach 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: continue skips empty input instead of sending an invalid request.
  • quit / exit end the loop with break. .lower() makes the check case-insensitive.
  • KeyboardInterrupt (Ctrl+C) and EOFError (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:

  • pending is a new list containing the history plus the new message. history is updated only after a successful, usable reply. If the request fails, history is unchanged, so no dangling user message is left behind (Lesson 24).
  • continue after 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 APIStatusError catches 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

  1. 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 in main() handles only input and commands. This separation makes send() easy to reuse, for example in a web view, and easy to test.
  2. History safety: on errors and refusals, send() returns the original history, so failed turns are never stored.
  3. trim() keeps at most MAX_HISTORY_MESSAGES messages, starting with a user message, which caps cost per turn (Lesson 24).
  4. 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.
  5. Commands (/reset, /tokens, /help) are handled locally and are never sent to Claude. /reset simply empties the list (Lesson 27).
  6. Token totals are accumulated from reply.usage for 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

  1. Run chat_loop.py, have a short conversation, and use /tokens after each answer. How do input tokens change from turn to turn?
  2. Add a /history command that prints how many messages are currently stored.
  3. Add a /save command that writes the history to chat.json. Remember that reply.content contains SDK objects, so convert it with reply.to_dict()["content"] before storing.
  4. Change MAX_HISTORY_MESSAGES to 4. Tell Claude your name, ask two other questions, and then ask for your name. Explain the result.
Show answers for steps 2–4

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.

0 Comments

Reviewed before they appear

No comments yet.

Anthropic SDK Using Python
Anthropic SDK Intro Claude API Intro SDK Requirements Install Anthropic SDK Upgrade the SDK Create an API Key Set API Keys Use Environment Variables Import Anthropic Create a Client Send Your First Message Choose a Claude Model Set Max Tokens Read a Response Print Response Text Understand Message Blocks Use System Prompts Send User Messages Set Temperature Handle Stop Reasons Messages API Intro Create Messages Use Message Roles Send Conversation History Continue a Conversation Build a Chat Loop Reset Chat History Use Multiple Turns Inspect Message IDs Read Usage Data Write Clear Prompts Set a System Prompt Add Context to Prompts Use Prompt Templates Add Examples to Prompts Request JSON Output Use XML Tags Control Response Length Improve Prompt Quality Avoid Prompt Injection Read Text Blocks Read Content Blocks Check Stop Reasons Count Input Tokens Count Output Tokens Count Tokens Before Sending Limit Output Tokens Use Stop Sequences Read Request IDs Parse API Responses Streaming Intro Stream Text Output Use Stream Events Use Stream Helpers Get Final Messages Stream Async Responses Handle Stream Errors Build a Live Chat Stream Long Responses Close Streams Safely Build a Chatbot Keep Chat History Manage Chat Sessions Add Chat Memory Build a CLI Chatbot Build a Web Chatbot Use Flask with Claude Use FastAPI with Claude Return JSON from an API Add Chat Error Messages Async SDK Intro Create an Async Client Send Async Messages Use asyncio Run Parallel Requests Stream Async Text Handle Async Errors Set Async Timeouts Close Async Clients Choose Sync or Async Tool Use Intro Define a Tool Create Tool Schemas Send Tools to Claude Read Tool Calls Run Python Tools Return Tool Results Continue After Tool Use Control Tool Choice Handle Tool Errors Use the beta_tool Decorator Create a Tool Runner Add Tool Docstrings Validate Tool Inputs Use Multiple Tools Limit Tool Calls Log Tool Activity Build a Weather Tool Build a Search Tool Build a Calculator Tool Structured Output Intro Request JSON Data Validate JSON Output Use Pydantic Models Handle Invalid JSON Extract Fields from Text Generate Typed Data Use Image URLs Send Base64 Images Read PDFs with Claude Use Output Schemas Build Data Extraction Test Structured Responses Send Image Inputs Upload Files Use File IDs Process Text Files Summarize Documents Extract Table Data Handle File Errors Read Cache Usage Reduce Repeated Input Costs Debug Cache Misses Prompt Caching Intro Enable Prompt Caching Cache System Prompts Cache Long Context Test Cache Performance Use Cache Breakpoints Cache Best Practices Message Batches Intro Create a Message Batch Set Batch IDs Check Batch Status List Message Batches Get Batch Results Process Batch Outputs Handle Failed Batch Items Use Batches for Bulk Tasks Compare Batches and Requests Handle API Errors Handle Rate Limits Retry Failed Requests Set Request Timeouts Handle Network Errors Use Exponential Backoff Build Reliable API Calls Use Extended Thinking Log API Errors Track Request IDs Paginate API Results Set Thinking Budgets Read Thinking Blocks Use Tool Search Use Web Search Tools Use Computer Tools Use Citations Use Token Counting Read Raw Responses Set Custom Headers Use Claude on AWS Bedrock Use Claude on Vertex AI Use Claude on Microsoft Foundry Configure Cloud Credentials Choose a Cloud Provider Compare API Integrations Configure Provider Clients Handle Cloud Errors Use Cloud SDK Extras Deploy Cloud Apps Protect API Keys Use a .env File Ignore Secrets in Git Validate User Input Reduce Prompt Injection Limit Tool Permissions Avoid Unsafe Tool Calls Sanitize Tool Results Set Usage Limits Review Security Logs Test SDK Functions Mock API Responses Test Error Handling Test Streaming Code Measure Token Usage Track API Costs Configure Logging Deploy with Docker Deploy a FastAPI App Monitor Production Apps Build a Claude Chatbot Build a Text Summarizer Build a Code Explainer Build a JSON Extractor Build a PDF Assistant Build a Streaming Chat App Build a Tool-Calling Agent Build a Batch Processor Build a Research Assistant Build a Claude API Dashboard
Ask about this post
AI Ask about this post

Ask questions about Build a Chat Loop and get answers drawn from it.

Signed-in readers only.