Building Multi-Turn Conversations with the Messages Array
A real conversation with Claude isn't one API call - it's a sequence of calls, each carrying the entire history so far.
This page covers the practical mechanics of building and maintaining that history in application code.
Summary
The Messages API has no server-side memory of past requests.
Every call is evaluated independently, using only the messages array included in that specific request.
To build a conversation that feels continuous, your application owns a growing list of turns and resends the whole thing every time.
This is a deliberate design: it keeps the API simple and stateless, and it puts you in full control of exactly what context Claude sees on each turn.
The tradeoff is that state management - appending turns correctly, in the right order, with the right content - becomes your application's job.
Recipe
Quick-reference recipe card - copy-paste ready.
import anthropic
client = anthropic.Anthropic()
messages = []
def send(user_text: str) -> str:
messages.append({"role": "user", "content": user_text})
response = client.messages.create(
model="claude-opus-4-8",
max_tokens=1024,
system="You are a helpful assistant.",
messages=messages,
)
messages.append({"role": "assistant", "content": response.content})
return response.content[0].textWhen to reach for this:
- Any chat-style interface where the user sends more than one message per session.
- Agentic loops where Claude's replies (including tool calls) need to inform its next turn.
- Backend services that maintain a conversation per user, session, or ticket.
- Anywhere you need Claude to refer back to something said earlier in the same interaction.
Working Example
import anthropic
client = anthropic.Anthropic()
class Conversation:
"""Owns the messages array for one conversation and appends to it on every turn."""
def __init__(self, system_prompt: str, model: str = "claude-opus-4-8"):
self.system_prompt = system_prompt
self.model = model
self.messages: list[dict] = []
def send(self, user_text: str, max_tokens: int = 1024) -> str:
self.messages.append({"role": "user", "content": user_text})
response = client.messages.create(
model=self.model,
max_tokens=max_tokens,
system=self.system_prompt,
messages=self.messages,
)
# Append the full content list, not just the extracted text, so any
# non-text blocks (tool_use, etc.) are preserved for future turns.
self.messages.append({"role": "assistant", "content": response.content})
# Concatenate only the text blocks for the caller-facing return value.
return "".join(block.text for block in response.content if block.type == "text")
if __name__ == "__main__":
convo = Conversation(system_prompt="You are a helpful trip-planning assistant.")
print(convo.send("I'm planning a trip to Japan in April."))
print(convo.send("What should I pack, given what I just told you?"))
print(convo.send("How many messages have we exchanged so far?"))What this demonstrates:
- A small
Conversationclass ownsself.messagesso state doesn't leak into module-level globals. send()appends theuserturn before the API call and theassistantturn after, keeping strict role alternation intact.- The full
response.contentlist is stored, not a plain string, so tool-use blocks or other structured content survive into later turns. system_promptis set once at construction and reused unchanged across every call in the conversation.
Deep Dive
How It Works
- Each call to
client.messages.create()is fully self-contained: the API has no session concept, no conversation ID, and no memory between requests. - The
messageslist you send is walked in order; the API validates that it starts withuserand strictly alternates roles from there. - Claude's reply arrives as a
Messageobject whose.contentis a list of content blocks - typically onetextblock for a plain reply, but potentially more for tool use or mixed content. - Appending
response.content(the object, not the derived string) as the nextassistantturn is what lets Claude "see" its own prior reasoning and tool calls on the next request. - Because the whole array is resent, both the request payload size and the number of billed input tokens grow as the conversation lengthens - covered further in the context-window management page.
Turn Growth Per Exchange
| After | messages length | Roles present |
|---|---|---|
| First user question | 1 | user |
| First Claude reply appended | 2 | user, assistant |
| Second user question | 3 | user, assistant, user |
| Second Claude reply appended | 4 | user, assistant, user, assistant |
Every exchange adds exactly two entries: one user, one assistant.
Python Notes
# Prefer a typed structure over loose dicts if you're managing many conversations.
from dataclasses import dataclass, field
@dataclass
class Turn:
role: str
content: str | list
@dataclass
class Session:
messages: list[Turn] = field(default_factory=list)
def as_api_payload(self) -> list[dict]:
return [{"role": t.role, "content": t.content} for t in self.messages]Using a small wrapper type (or a dataclass, as above) instead of raw dicts scattered through your codebase makes it much easier to serialize a conversation to storage and reload it later without guessing at the shape.
Gotchas
- Appending only
response.content[0].textinstead of the full content list. This discards tool_use blocks and any structured content, breaking tool-use flows on the next turn. Fix: always appendresponse.contentas-is for the assistant turn; extract text separately for display. - Forgetting to append the user turn before calling the API. The array sent to
messages.create()must already include the latest question. Fix: append the user turn first, then call the API, then append the reply. - Mutating the same
systemstring mid-conversation. Editing the system prompt between calls works, but it silently invalidates any prompt cache built on the prior prefix and can shift Claude's behavior mid-conversation in confusing ways. Fix: keep the system prompt frozen for the life of a conversation unless you have an explicit reason to change it. - Letting the messages array grow unbounded. Every request resends the full history, so a very long conversation eventually risks exceeding the model's context window and increases cost on every call. Fix: plan for trimming or summarizing older turns before that happens.
- Sharing one mutable
messageslist across concurrent users. A single global list used by multiple simultaneous conversations will interleave unrelated turns and break alternation. Fix: scope onemessageslist per conversation/session, never a module-level shared list. - Assuming the API deduplicates or ignores repeated context. Sending the same information twice in the array (e.g., duplicating a large document in two different
userturns) is billed as separate input tokens both times. Fix: include shared context once, and rely on it staying in the conversation's history rather than re-sending it.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Server-side session storage (database/cache) keyed by conversation ID | Multiple users, multiple devices, or conversations that must survive a process restart | A short-lived script or single-session CLI tool where in-memory state is enough |
| Compaction or summarization of older turns | The conversation is long-running and approaching the context window | The conversation is short and unlikely to grow large |
A single one-shot request with all context in one user turn | The task is a single question with no back-and-forth needed | You actually need Claude to reference its own prior replies across multiple turns |
FAQs
Does the Messages API store my conversation history for me?
No. The API is stateless - it has no concept of a conversation ID or session. Your application is responsible for keeping the messages array and resending it on every call.
What exactly should I append after Claude replies?
Append response.content - the full list of content blocks - as the content of a new {"role": "assistant", ...} entry. Don't just store the extracted text string.
Do I need to resend the system prompt on every request?
Yes. Unlike messages, system isn't cumulative - it's a fresh top-level parameter on each call, so you pass the same value every time you want consistent behavior.
Can I edit an earlier turn in the array before resending it?
Yes, nothing stops you from modifying history client-side - the API only validates role alternation and structure, not that the content matches a prior response verbatim. Editing history is occasionally useful for correcting Claude's own past output, but do it deliberately since it changes what Claude "believes" happened.
What happens if I skip appending the assistant's reply and just add my next question?
You'll violate the alternation rule - two user turns in a row - and the next request will fail with a 400 error. Every assistant reply must be appended before the next user turn is added.
Is there a limit to how many turns I can have in a conversation?
There's no fixed limit on turn count, but the combined token count of every turn plus the system prompt must fit within the model's context window. Long conversations eventually need trimming or summarization.
Should I store the messages array as JSON in a database?
That's a common and reasonable approach for anything beyond a single in-memory script - store the array (or an equivalent structured representation) keyed by conversation or session ID, and reload it before the next call.
Does resending the full array cost more as the conversation grows?
Yes. Every request bills for all input tokens in that request, including the resent history, so token usage and request size both increase as a conversation gets longer.
Can two different conversations share the same messages list safely?
No. A shared, mutable list used by concurrent conversations will interleave turns from different users and break the required role alternation. Scope one list per conversation.
What's the minimum valid messages array?
A single entry with role user and non-empty content. The array must start with user, and a bare single-turn request is a completely valid call.
If Claude calls a tool, does that change how I build the array?
The same append pattern applies - the assistant turn requesting the tool goes in as usual, and the tool's result comes back as a tool_result content block inside the next user turn, keeping alternation intact.
Is it safe to truncate or drop early turns from the array myself?
Yes, and it's a normal technique for long conversations - just make sure the array you send still starts with a user turn and still alternates correctly after truncation.
Related
- Understanding Roles and Turns in the Messages API - the conceptual model this page builds on.
- Messages API Basics - shorter, example-driven intro to the same mechanics.
- Managing Conversation History and Trimming for the Context Window - what to do once the array gets too large.
- Messages API Roles and Content Block Types Reference - reference for every content block type you might append.
Stack versions: Written against the Claude model lineup current as of ~June 2026 - Claude Fable 5, Claude Opus 4.8, Claude Sonnet 5 (the default), and Claude Haiku 4.5 - and the official
anthropicPython SDK (latest 0.x release). Model names, SDK versions, and pricing move quickly - verify current specifics at platform.claude.com/docs before relying on them.