Send Conversation History

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

Why Conversation History Must Be Sent

The Messages API is stateless (Lesson 21). Each request is processed on its own, with no memory of earlier requests. If a user says "My name is Priya" in one request and asks "What is my name?" in the next, Claude cannot answer unless the second request contains the first exchange.

Conversation history is the list of previous turns that your application stores and sends with each new request:

messages = [
    {"role": "user", "content": "My name is Priya."},           # Turn 1 (history)
    {"role": "assistant", "content": "Nice to meet you, Priya!"},  # Turn 1 reply (history)
    {"role": "user", "content": "What is my name?"},            # New message
]

From Claude's point of view, a "conversation" is simply this list. Your application is responsible for keeping it, growing it, and deciding what to include.


The Basic Pattern

Every turn of a conversation follows the same four steps:

1. Append the new user message to the history
2. Send the whole history to the API
3. Append Claude's reply to the history as an assistant message
4. Repeat for the next user message

In code:

from anthropic import Anthropic

client = Anthropic()
history = []


def send(user_text: str) -> str:
    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})
    return "".join(block.text for block in reply.content if block.type == "text")


print(send("My name is Priya and I am learning Django."))
print(send("What is my name, and what am I learning?"))

Example output:

Nice to meet you, Priya! Django is a great framework to learn. What are you building?
Your name is Priya, and you are learning Django.

Why reply.content Is Appended Unchanged

reply.content is the full list of content blocks Claude returned. It may include thinking blocks as well as text. Appending it unchanged:

  • preserves exactly what Claude produced, so the next request has complete context,
  • works directly, because the SDK accepts response blocks as input, and
  • is required in tool-use conversations, where the API rejects requests in which thinking blocks from the latest assistant turn were edited, reordered, filtered out, or reconstructed (Lesson 16).

What the History Looks Like Over Time

After three exchanges, the history contains six messages:

[0] user:       "My name is Priya and I am learning Django."
[1] assistant:  [ThinkingBlock?, TextBlock("Nice to meet you, Priya! ...")]
[2] user:       "What is my name, and what am I learning?"
[3] assistant:  [TextBlock("Your name is Priya, ...")]
[4] user:       "Suggest a first project."
[5] assistant:  [ThinkingBlock?, TextBlock("Build a simple blog with ...")]

The pattern is always user, assistant, user, assistant, …, and the list sent to the API always ends with the newest user message.


The Cost of History

Because the whole history is sent with every request, input tokens grow with each turn:

for question in ["Hi, I'm Priya.", "What is Django?", "How do I install it?", "And create a project?"]:
    send(question)
    print(f"{len(history):>2} messages in history")

A typical pattern of input tokens per request:

RequestMessages sentInput tokens (illustrative)
11~15
23~120
35~450
47~900

Each request pays for all earlier turns again. Long conversations therefore become slower and more expensive, and they eventually approach the model's context window (1M tokens for current models).

Strategies to control this:

StrategyIdeaLesson
TrimmingKeep only the most recent turnsThis lesson
SummarizingReplace old turns with a short summary64
Prompt cachingMake the unchanged beginning of the history cheaper to process121–130
Server-side context managementAPI features such as compaction and context editingAdvanced docs

Note: The documentation also describes message threads (beta), in which the API stores the conversation so that each request sends only new messages. A thread expires 5 minutes after its last request. Sending history yourself, as in this lesson, works on every model and platform and keeps the history under your control.


Trimming History Safely

A simple way to limit history is to keep only the last N messages. The trimmed list must still follow the role rules (Lesson 23). In particular, it must start with a user message:

def trim_history(history: list[dict], max_messages: int) -> list[dict]:
    """Keep at most max_messages recent messages, starting with a user message."""
    trimmed = history[-max_messages:]
    while trimmed and trimmed[0]["role"] != "user":
        trimmed = trimmed[1:]
    return trimmed

How it works:

  1. history[-max_messages:] takes the last max_messages items. It returns the whole list if it is shorter.
  2. The while loop removes items from the front until the first message is a user message. Cutting at an arbitrary point could otherwise leave an assistant message first.

The trade-off: trimmed information is gone. If the user stated their name in turn 1 and turn 1 is trimmed, Claude no longer knows it. Important facts can be kept in the system prompt, or preserved through summarization (Lesson 64).


Storing History Between Requests

In a web application, each HTTP request is handled separately, so the history must be stored, for example in a database, a cache, or the user's session. It must be stored as plain data (JSON), not as SDK objects.

Convert the response content with to_dict():

reply_dict = reply.to_dict()
history.append({"role": "assistant", "content": reply_dict["content"]})

reply.to_dict()["content"] is a list of plain dictionaries, such as {"type": "text", "text": "..."}, and thinking blocks with their signatures. That list:

  • can be saved with json.dumps,
  • can be loaded with json.loads, and
  • can be sent back to the API exactly as it was received.

Text-Only History

Some applications store only the text of Claude's replies:

history.append({"role": "assistant", "content": text_of_reply})

This is allowed, because assistant turns may be synthetic (Lesson 23). It is smaller and simpler, but it discards thinking blocks and any non-text blocks. It is acceptable for simple chat. It is not acceptable for tool-use conversations, which require the original blocks.

Storage approachSizeKeeps full contextWorks for tool use
Full blocks (to_dict()["content"])LargerYesYes
Text onlySmallerPartialNo

Practical Example: A Conversation Class

This class wraps the pattern in a reusable object with history, error safety, trimming, and JSON persistence.

"""conversation.py: a reusable multi-turn conversation with persistence."""

import json
from pathlib import Path

import anthropic
from anthropic import Anthropic
from anthropic.types import Message


def get_text(message: Message) -> str:
    return "".join(block.text for block in message.content if block.type == "text")


class Conversation:
    def __init__(self, client: Anthropic, system: str = "", model: str = "claude-opus-5-5",
                 max_messages: int = 40):
        self.client = client
        self.system = system
        self.model = model
        self.max_messages = max_messages
        self.history: list[dict] = []

    def send(self, user_text: str) -> str:
        pending = self.history + [{"role": "user", "content": user_text}]
        pending = self._trim(pending)

        request = {"model": self.model, "max_tokens": 2048, "messages": pending}
        if self.system:
            request["system"] = self.system

        reply = self.client.messages.create(**request)   # May raise; history is unchanged then

        if reply.stop_reason == "refusal":
            return "[Claude declined this request. Please rephrase.]"

        self.history = pending + [{"role": "assistant", "content": reply.to_dict()["content"]}]
        return get_text(reply)

    def _trim(self, messages: list[dict]) -> list[dict]:
        trimmed = messages[-self.max_messages:]
        while trimmed and trimmed[0]["role"] != "user":
            trimmed = trimmed[1:]
        return trimmed

    def save(self, path: str) -> None:
        Path(path).write_text(json.dumps(self.history, ensure_ascii=False, indent=2), encoding="utf-8")

    def load(self, path: str) -> None:
        self.history = json.loads(Path(path).read_text(encoding="utf-8"))


if __name__ == "__main__":
    chat = Conversation(Anthropic(), system="You are a friendly Django tutor. Keep answers short.")

    for question in [
        "Hi, I'm Priya. I'm new to Django.",
        "What command creates a new project?",
        "What was my name again?",
    ]:
        try:
            print(f"You:    {question}")
            print(f"Claude: {chat.send(question)}\n")
        except anthropic.APIError as error:
            print(f"[Request failed: {error}]\n")

    chat.save("priya_chat.json")
    print(f"Saved {len(chat.history)} messages.")

Example output:

You:    Hi, I'm Priya. I'm new to Django.
Claude: Welcome, Priya! Django is a great place to start. What would you like to build?

You:    What command creates a new project?
Claude: Run: django-admin startproject mysite — it creates a "mysite" folder with the project files.

You:    What was my name again?
Claude: Your name is Priya.

Saved 6 messages.

How It Works

  1. pending is a new list. The user message is added to a copy of the history, not to self.history itself. If the API call raises an exception, self.history is unchanged, so no unanswered user message is left behind to create consecutive user turns on the next attempt.
  2. _trim() limits the request to the most recent max_messages and makes sure it starts with a user message.
  3. The system prompt is added only when one was given (Lesson 22).
  4. Refusals are not committed. The history is updated only for usable replies, so a refused exchange does not stay in the conversation (Lesson 25 discusses this further).
  5. reply.to_dict()["content"] stores the assistant turn as plain JSON-compatible data, including any thinking blocks, so the history can be saved and sent back unchanged.
  6. save() and load() persist the history as UTF-8 JSON. ensure_ascii=False keeps non-English text readable in the file.

Common Mistakes

Forgetting to Append Claude's Reply

What happens: the history contains only user messages. Claude cannot see its own earlier answers, and consecutive user turns are merged into one confusing turn.

Fix: after every successful request, append the reply as an assistant message.

Leaving a User Message Behind After a Failed Request

What happens: the user message is appended, the request fails, and the next user message creates two user turns in a row.

Fix: add the user message to a copy, and commit both turns only after a successful response, as Conversation.send() does.

Trimming So the History Starts with an Assistant Message

What happens: the first message is not a user message, which breaks the role rules.

Fix: after trimming, drop leading non-user messages.

Storing SDK Objects Instead of Data

What happens: json.dumps(history) fails with TypeError: Object of type TextBlock is not JSON serializable.

Fix: store reply.to_dict()["content"], or the text, as plain data.

Letting History Grow Without Limits

What happens: each request becomes slower and more expensive, and eventually reaches the context window.

Fix: trim, summarize, or use caching, and monitor usage.input_tokens per request.


Best Practices

  • Treat history as application state that you own: store it, limit it, and protect it.
  • Append full content blocks for maximum fidelity, and always for tool-use conversations.
  • Commit turns only after a successful response.
  • Trim safely, keeping the first message a user message.
  • Persist history as JSON-compatible data, with UTF-8 encoding.
  • Keep key facts in the system prompt if they must survive trimming.
  • Monitor input tokens to see how history affects cost.

Exercise

  1. Use Conversation for five turns and print reply.usage.input_tokens after each turn. How does it grow? (Hint: return the usage from send() or store it on the object.)
  2. Create a Conversation with max_messages=4. Tell Claude your name in the first message, then ask three unrelated questions, and finally ask for your name. What happens, and why?
  3. Save a conversation with save(), create a new Conversation, load() the file, and continue the conversation. Does Claude remember earlier turns?
  4. Modify send() to also refuse committing the turn when stop_reason is max_tokens, and return a notice instead.
Show answers for steps 2 and 4

Step 2: with max_messages=4, only the last two exchanges are sent. By the time you ask for your name, the first message has been trimmed, so Claude does not know your name. This shows the trade-off of trimming: older information is lost unless it is kept elsewhere, for example in the system prompt or a summary.

Step 4:

if reply.stop_reason == "max_tokens":
    return "[The answer was too long and was cut off. Please ask for a shorter answer.]"

Place this check after the refusal check and before updating self.history. The truncated exchange is then never stored, and the conversation stays clean.

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 Send Conversation History and get answers drawn from it.

Signed-in readers only.