Send Conversation History
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:
| Request | Messages sent | Input tokens (illustrative) |
|---|---|---|
| 1 | 1 | ~15 |
| 2 | 3 | ~120 |
| 3 | 5 | ~450 |
| 4 | 7 | ~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:
| Strategy | Idea | Lesson |
|---|---|---|
| Trimming | Keep only the most recent turns | This lesson |
| Summarizing | Replace old turns with a short summary | 64 |
| Prompt caching | Make the unchanged beginning of the history cheaper to process | 121–130 |
| Server-side context management | API features such as compaction and context editing | Advanced 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:
history[-max_messages:]takes the lastmax_messagesitems. It returns the whole list if it is shorter.- The
whileloop 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 approach | Size | Keeps full context | Works for tool use |
|---|---|---|---|
Full blocks (to_dict()["content"]) | Larger | Yes | Yes |
| Text only | Smaller | Partial | No |
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
pendingis a new list. The user message is added to a copy of the history, not toself.historyitself. If the API call raises an exception,self.historyis unchanged, so no unanswered user message is left behind to create consecutive user turns on the next attempt._trim()limits the request to the most recentmax_messagesand makes sure it starts with a user message.- The system prompt is added only when one was given (Lesson 22).
- 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).
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.save()andload()persist the history as UTF-8 JSON.ensure_ascii=Falsekeeps 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
- Use
Conversationfor five turns and printreply.usage.input_tokensafter each turn. How does it grow? (Hint: return the usage fromsend()or store it on the object.) - Create a
Conversationwithmax_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? - Save a conversation with
save(), create a newConversation,load()the file, and continue the conversation. Does Claude remember earlier turns? - Modify
send()to also refuse committing the turn whenstop_reasonismax_tokens, and return a notice instead.
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.