Messages, Not Chat
One stateless endpoint, the whole history resent every turn, and where the system prompt lives.
By the end of this session you will be able to:
- Drive a multi-turn conversation against one stateless endpoint by resending the whole transcript yourself, and say exactly what the server keeps between calls
- Put the system prompt in the right place, including the one case where it belongs inside the
messagesarray - Read
usage.input_tokensacross a conversation and predict what turn N will cost before you send it
The endpoint keeps nothing
There is one endpoint: POST /v1/messages. Every request carries a model, a max_tokens ceiling, and a messages array. The server reads that array, generates a reply, and forgets you existed. There is no conversation id, no session handle, no thread object. "Chat" is a product concept. On the wire there is a stateless function call that takes a transcript and returns one more turn.
Set up first:
pip install "anthropic==0.120.2"
export ANTHROPIC_API_KEY=sk-ant-your-key-hereNow watch the amnesia directly:
import anthropic
client = anthropic.Anthropic()
def text_of(message):
return "".join(b.text for b in message.content if b.type == "text")
first = client.messages.create(
model="claude-opus-5",
max_tokens=4096,
messages=[{"role": "user", "content": "My name is Osama."}],
)
print(text_of(first))
second = client.messages.create(
model="claude-opus-5",
max_tokens=4096,
messages=[{"role": "user", "content": "What is my name?"}],
)
print(text_of(second))The second call has no idea. The client object holds an API key and an HTTP connection pool, nothing else. People lose an afternoon to this because the object is long-lived and the method is called messages.create, which sounds like it is appending to something.
Note text_of. response.content is a list of content blocks, not a string. A plain answer usually arrives as a single text block, but the list can hold other block types, so select by type rather than by position. Code that reads content[0].text works right up until the day it does not.
The loop you write yourself
Because the server keeps nothing, the transcript is your data structure. Two rules make it behave:
- Append the user turn before the call.
- Append the assistant turn after it, using
response.contentverbatim.
Rule 2 is where most implementations break. If you store the extracted string instead of the block list, everything looks fine on a text-only conversation and then silently drops information the moment a turn contains anything other than plain text. Store what the API gave you.
import anthropic
client = anthropic.Anthropic()
SYSTEM = "You are a terse teaching assistant. Answer in at most two sentences."
def text_of(message):
return "".join(b.text for b in message.content if b.type == "text")
history = []
def turn(user_text):
history.append({"role": "user", "content": user_text})
reply = client.messages.create(
model="claude-opus-5",
max_tokens=4096,
system=SYSTEM,
messages=history,
)
history.append({"role": "assistant", "content": reply.content})
print(f"[in={reply.usage.input_tokens} out={reply.usage.output_tokens}]")
return text_of(reply)
print(turn("My name is Osama."))
print(turn("I am teaching a course on the Claude API."))
print(turn("What is my name, and what am I teaching?"))Three calls, one growing list. The third turn answers correctly not because the server remembered, but because you resent turns one and two inside it.
Two shape rules the API enforces on that list. The first message must have role user; a transcript that opens with an assistant turn is a 400. Consecutive same-role messages are legal and get combined into a single turn server-side, so you do not have to interleave perfectly, though you usually want to.
Where the system prompt lives
system is a top-level parameter, a sibling of messages, not an entry inside it. This trips up anyone arriving from an API that models the system prompt as messages[0] with role system. Here that entry is rejected.
It takes two forms. A plain string is fine. A list of text blocks is the form you want once the prompt has parts that change at different rates:
system=[
{"type": "text", "text": "You are a code reviewer for a Python codebase."},
{"type": "text", "text": "Reply with a numbered list of findings, worst first."},
]Now the exception, and it is the reason this session exists rather than being one line in a README. On Claude Opus 5 (and Opus 4.8, Fable 5, and Mythos 5, but not Sonnet 5) you can append a {"role": "system"} message to messages partway through a conversation. No beta header:
history.append({"role": "user", "content": "Draft a release note for v2.1."})
history.append({
"role": "system",
"content": "Legal review is now mandatory: never state a delivery date.",
})
reply = client.messages.create(
model="claude-opus-5",
max_tokens=4096,
system=SYSTEM,
messages=history,
)It has to follow a user turn, it cannot be messages[0], and it must either end the array or be followed by an assistant turn. Content is text only.
Use it for operator instructions that arrive mid-conversation: a mode toggle, a policy change, state your application just learned. Two things break if you instead edit the top-level system string on turn nine. You change the front of the prompt, which throws away everything cached behind it (Phase 3 is where you will measure that). And if you route the instruction into a user message instead, you have put an operator instruction into a channel anything that writes user-visible text can forge.
What resending costs
Resending the transcript is not free, and the bill is not linear. Turn one sends the system prompt plus one message. Turn ten sends the system prompt plus nineteen messages. Total input tokens across a conversation of N turns grows with the square of N.
usage on every response tells you where you are:
{
"usage": {
"input_tokens": 2095,
"output_tokens": 503
}
}input_tokens is the whole prompt you just sent: system, plus every prior turn, plus the new one. max_tokens bounds only what the model generates on this turn, so it never caps the prompt side. If you print those two numbers on every call, as the loop above does, the growth curve is visible from turn three and you stop being surprised by the invoice.
Try it
Run the loop from the second section, then extend it to six turns of your own. Turn one must state a fact the model cannot guess (a project codename, a build number). Turn six must ask for that fact back. Print input_tokens on every call.
Success condition, all three:
- Turn six answers correctly with the fact from turn one.
input_tokensis strictly increasing across all six calls.- Now comment out the line that appends the assistant turn and rerun. Turn six must fail to recall the fact, and
input_tokensat turn six must be roughly half what it was before.
Step three is the point. You just proved the memory is yours.
Common mistakes
- Storing
text_of(reply)in the history instead ofreply.content. The string form reads more naturally and works on text-only conversations, so the bug ships. It discards every non-text block, and later sessions will put plenty of those in the response. - Putting the system prompt in
messagesas the first entry. It belongs in the top-levelsystemparameter. A{"role": "system"}entry is legal only mid-conversation, only on the models listed above, and never at index zero. - Treating the client object as the conversation. One
Anthropic()client can serve a thousand unrelated conversations concurrently, because it carries none of them. The transcript lives in your list, your database row, or your Redis key. - Trimming history by lopping off the front. The moment you drop turn one you may drop the user role that has to open the array, or split a turn from the context that made it make sense. Trim deliberately from a known-safe boundary, and count what you removed.
Where this goes next
The next session, "Streaming, Event by Event", takes the single response object you just built a loop around and pulls it apart on the wire: message_start through message_stop, with the deltas accumulated by hand before you let the SDK helper do it for you.