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.
Search across all documentation pages
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.
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.
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:
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:
Conversation class owns self.messages so state doesn't leak into module-level globals.send() appends the user turn before the API call and the assistant turn after, keeping strict role alternation intact.response.content list is stored, not a plain string, so tool-use blocks or other structured content survive into later turns.system_prompt is set once at construction and reused unchanged across every call in the conversation.client.messages.create() is fully self-contained: the API has no session concept, no conversation ID, and no memory between requests.messages list you send is walked in order; the API validates that it starts with user and strictly alternates roles from there.Message object whose .content is a list of content blocks - typically one text block for a plain reply, but potentially more for tool use or mixed content.response.content (the object, not the derived string) as the next assistant turn is what lets Claude "see" its own prior reasoning and tool calls on the next request.| 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.
# 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.
response.content[0].text instead of the full content list. This discards tool_use blocks and any structured content, breaking tool-use flows on the next turn. Fix: always append response.content as-is for the assistant turn; extract text separately for display.messages.create() must already include the latest question. Fix: append the user turn first, then call the API, then append the reply.system string 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.messages list across concurrent users. A single global list used by multiple simultaneous conversations will interleave unrelated turns and break alternation. Fix: scope one messages list per conversation/session, never a module-level shared list.user turns) 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.| 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 |
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
Reviewed by Chris St. John·Last updated Jul 18, 2026