Concept
A chat application is not one API feature, it's an architecture built from pieces covered individually elsewhere in this domain (the SDK call, streaming, tool use) composed around one central fact: the Messages API is entirely stateless. There is no server-side conversation object living at Anthropic that your app can just "add a message to." Every single request must carry the full conversation history your application wants Claude to see, from scratch.
Modeling conversation state
The messages array is your conversation state, in the exact shape the API expects:
interface ChatSession {
id: string;
messages: Anthropic.MessageParam[]; // the durable, growing conversation history
}
function appendUserTurn(session: ChatSession, text: string): ChatSession {
return {
...session,
messages: [...session.messages, { role: "user", content: text }],
};
}
function appendAssistantTurn(session: ChatSession, content: Anthropic.ContentBlock[]): ChatSession {
return {
...session,
messages: [...session.messages, { role: "assistant", content }],
};
}Your database (or in-memory store, for a simple demo) persists this array, keyed by a session/conversation id. Every turn, your backend loads the array, appends the new user message, sends the entire array to client.messages.create(), appends the assistant's response, and persists the updated array again.
The full request shape a real chat UI sends
export async function POST(req: Request) {
const { sessionId, userMessage } = await req.json();
const session = await loadSession(sessionId); // your own persistence layer
const messages: Anthropic.MessageParam[] = [
...session.messages,
{ role: "user", content: userMessage },
];
const stream = client.messages.stream({
model: "claude-opus-4-8",
max_tokens: 2048,
system: STABLE_ASSISTANT_PERSONA
const stream = client.messages.stream({model: 'claude-opus-4-8',max_tokens: 1024,messages: [{ role: 'user', content: 'Explain TCP handshakes' }],});
…
Calling .stream() opens a long-lived connection instead of waiting for one full response. Nothing has arrived yet, the UI should show a pending state, not a blank one.
Notice that messages sent to the API is reconstructed fresh every single request from persisted state plus the new turn, there is no sessionId parameter on client.messages.create() that would let Anthropic "remember" a conversation for you.
Multi-turn history growth and trimming
A conversation that runs for dozens of turns eventually approaches the model's context window. Two common strategies, both operating on the persisted messages array before it's sent:
function trimToRecentTurns(messages: Anthropic.MessageParam[], keepLast = 20): Anthropic.MessageParam[] {
if (messages.length <= keepLast) return messages;
// Keep the most recent N turns. A production app typically also keeps
// an early summarization step instead of simply discarding older turns outright.
return messages.slice(-keepLast);
}A naive fixed-window trim (as above) is simple but silently discards earlier context the user might still expect the assistant to remember. Production systems more often summarize the discarded portion into a short system-level note before dropping the raw turns, or rely on the API's own compaction feature (beta, on supporting models) which server-side summarizes older context automatically rather than requiring you to hand-roll the trimming logic.
Regenerate and edit-message flows
Two UI actions that are common in real chat products but easy to get wrong: "regenerate this response" and "edit my earlier message and continue from there."
function regenerateLastResponse(messages: Anthropic.MessageParam[]): Anthropic.MessageParam[] {
// Drop the last assistant turn; the last message is now the user's turn
// that we want a NEW assistant response for.
const last = messages[messages.length - 1];
if (last.role === "assistant") return messages.slice(0, -1);
return messages;
}
function editMessageAndTruncate(
messages: Anthropic.MessageParam[],
editIndex: number,
newText:
Both operations are really just "construct a different messages array and call the API again", there's no special "regenerate" or "edit" endpoint. The important behavioral detail is that editing an earlier message must discard everything that came after it in the array, since those later turns were responses to content that no longer exists in the edited conversation.
Multiple concurrent conversations per user
A chat product almost always supports more than one conversation per user (a sidebar of past chats). Each conversation is an independent messages array under its own id, there's no cross-conversation state to manage, since statelessness means each conversation is, from the API's perspective, a completely separate sequence of requests that happen to share no history with any other.
Try It
Predict what goes wrong with this "regenerate" implementation before checking the solution.
function regenerateLastResponse(messages: Anthropic.MessageParam[]): Anthropic.MessageParam[] {
return messages; // "just call the API again with the same messages"
}If a developer implements "regenerate" by simply re-sending the exact same messages array unchanged, what actually happens?
Solution
The array as given already ends with the assistant's PREVIOUS response as its last element (since it was appended after that response was generated), sending it unchanged to client.messages.create() would either fail validation (consecutive same-role turns / an assistant turn as the last element with no new user input to respond to, depending on exact API validation behavior) or, if it somehow succeeded, wouldn't actually ask Claude to generate a NEW response to anything, since there's no unanswered turn at the end. The correct implementation must first drop the trailing assistant turn (leaving the last user message as the "unanswered" tail of the array) before calling the API again, that's what actually prompts a genuinely new response to the same user turn. This is exactly why the working version in the Concept section slices off the last element when it's an assistant turn.
Implement It Yourself
Build a minimal, framework-free chat session manager that handles the core lifecycle: append, persist, and reconstruct the request, the actual state machine underlying every production chat UI.
interface StoredSession {
id: string;
messages: Anthropic.MessageParam[];
}
class ChatSessionStore {
private sessions = new Map<string, StoredSession>();
create(id: string): StoredSession {
const session = { id, messages: [] };
this.sessions.set(id, session);
return session;
}
get(id: string): StoredSession {
This is the actual mental model production chat apps run on: a stored array of turns, mutated by well-defined operations (append, truncate-and-replace), always resent in full on the next call, there is no hidden server-side conversation object anywhere in Anthropic's infrastructure to lean on instead.
Under the Hood
The requirement to resend the full conversation history on every request is a direct consequence of HTTP itself being stateless by design, the same foundational fact underlying Building an HTTP Server in Node.js, a server has no inherent memory of a prior request unless the application explicitly reconstructs that context (here, via a persisted messages array) and includes it again. Streaming an assistant's response into a chat bubble while the rest of the UI stays interactive relies on the same non-blocking execution model as The JavaScript Event Loop, the stream's events arrive as a sequence of asynchronous callbacks, never freezing the rest of the page while tokens accumulate.
Common Mistakes
1. Assuming the API remembers a conversation by session id
// ❌ there is no such parameter, Anthropic has no memory of past requests
const response = await client.messages.create({
model: "claude-opus-4-8",
sessionId: "abc123", // not a real parameter
messages: [{ role: "user", content: "What did I just ask?" }],
});The Messages API has no concept of a persistent session. Your own application must persist and resend the full messages array on every turn, there is no shortcut parameter for this.
2. Regenerating by resending the array unchanged
// ❌ the array already ends in an assistant turn, nothing new is being asked
async function regenerate(messages: Anthropic.MessageParam[]) {
return client.messages.create({ model: "claude-opus-4-8", max_tokens: 1024, messages });
}Regeneration requires dropping the trailing assistant turn first, so the array ends on the unanswered user turn, otherwise there's nothing new for Claude to respond to.
3. Editing an earlier message without discarding what came after it
// ❌ leaves stale assistant turns that responded to content that no longer exists
messages[3] = { role: "user", content: newText };
// messages[4], messages[5], ... (responses to the OLD message[3]) are left in placeEditing message N must truncate the array to just before N and insert the new content as the new tail, every turn that came after the edited message was a response to content that's now been replaced, so it no longer makes sense to keep it in the history sent to the model.
Best Practices
- Persist the
messagesarray per conversation in your own database, this array is the conversation state; there is no Anthropic-side equivalent. - Always resend the full relevant history on every request, the API is stateless by design, with no session/conversation-id shortcut.
- Implement "regenerate" as drop-last-assistant-turn-then-recall, not as an unchanged resend.
- Implement "edit message N" as truncate-to-N-then-append-new-content, discard every turn that followed the edited message.
- Plan for context growth explicitly (fixed-window trimming, summarization, or the API's compaction feature) before a long-running conversation silently approaches the context window limit.
- Keep each conversation's history in its own independent array/id, there is no cross-conversation state to manage since the API itself has none.
Performance Tips
- Stream every chat turn (see the streaming topic) rather than waiting for the full response, this is the single biggest perceived-latency improvement available in a chat UI.
- Cache the stable portion of the system prompt (persona, instructions) with
cache_control, since it's identical across every turn of every conversation, this is pure savings with no downside once a conversation has more than one or two turns. - Trim or summarize history proactively rather than reactively, waiting until a request actually fails on context-length lets a user's conversation silently degrade mid-session instead of gracefully summarizing ahead of the limit.
