How to Build a Multi Turn Conversation App
A multi-turn conversation app is any application where the model remembers what was said earlier in the session — a chatbot, a coding assistant, a support agent — rather than treating each request as a one-shot prompt. The core technique is simple: you don't send just the latest user message, you send the entire conversation history (or a trimmed version of it) with every request, and the model uses that array to generate a contextually aware reply.
This article walks through the actual mechanics: how message arrays work, how to manage growing context, how to handle streaming replies in a chat UI, and where things commonly break (duplicate roles, lost system prompts, runaway token counts). The examples use the Claude Messages API format, which is also what SubToAPI exposes, so the same code works whether you're calling Anthropic directly or through a proxy.
The core data structure: a messages array
Every multi-turn app is built around one data structure: an ordered list of {role, content} objects, alternating between user and assistant. You own this array — the API itself is stateless and has no memory between calls. Each request you send must include everything the model needs to know.
[
{ "role": "user", "content": "I'm building a recipe app. What stack would you pick?" },
{ "role": "assistant", "content": "For a recipe app I'd lean toward..." },
{ "role": "user", "content": "What about image storage specifically?" }
]
On every turn, you append the new user message, send the full array, append the assistant's reply to your local copy once it comes back, and repeat. This is the entirety of "memory" in a multi-turn app — there's no hidden server-side state unless your platform explicitly adds one.
Minimal working example
const messages = [];
async function sendTurn(userText) {
messages.push({ role: "user", content: userText });
const res = await fetch("https://api.subtoapi.app/v1/messages", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.SUBTOAPI_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
model: "claude-sonnet-4-5",
max_tokens: 1024,
system: "You are a concise, pragmatic engineering advisor.",
messages
})
});
const data = await res.json();
const reply = data.content[0].text;
messages.push({ role: "assistant", content: reply });
return reply;
}
Call sendTurn() for each new user input and the messages array accumulates the conversation. This is the skeleton every multi-turn app is built on — a web chat, a Slack bot, a CLI tool, a voice assistant transcript — the storage and transport differ, but the array pattern doesn't change. Full request/response shapes are in /docs/messages.
Where state actually lives
In the example above, state lives in a JavaScript variable, which disappears on refresh or restart. For a real app you need to persist it:
- Per-session, server-side: store the array in Redis or a database keyed by session ID, load it at the start of each request.
- Client-held: keep the array in local storage or app state and send it back each time — fine for simple tools, risky for anything where you don't trust the client to not tamper with history.
- Per-user, long-lived: store conversations in a
conversationstable with a foreign key to the user, so they can resume threads across devices.
Whichever you choose, the server-side call pattern is identical — load the array, append the new message, send it, append the reply, save it back.
Managing context growth
Conversations get long, and context windows aren't infinite. Left unmanaged, a multi-turn app will eventually hit token limits or get noticeably slower and more expensive per turn. A few practical strategies:
- Sliding window: keep only the last N turns and drop older ones. Simple, loses long-term context.
- Summarize-and-replace: periodically ask the model to summarize the conversation so far, replace the older messages with that summary as a single system or user message, and continue from there.
- Selective retrieval: store the full history externally (a database) and only inject the turns relevant to the current question, using embeddings or keyword search.
Most production apps combine 1 and 2: keep the last handful of turns verbatim for conversational flow, and compress everything older into a running summary. This keeps token usage predictable and avoids the classic bug where a chat app silently fails after turn 40 because the request exceeds the model's context window.
Streaming replies for a responsive UI
Multi-turn apps feel sluggish if users stare at a blank screen waiting for a full response. Streaming fixes this by returning tokens as they're generated:
const res = await fetch("https://api.subtoapi.app/v1/messages", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.SUBTOAPI_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
model: "claude-sonnet-4-5",
max_tokens: 1024,
messages,
stream: true
})
});
for await (const chunk of res.body) {
// append chunk text to the UI as it arrives
}
Buffer the full streamed text locally and only push it into your messages array once the stream completes — appending partial chunks mid-stream will corrupt the next request's history. See /docs/streaming for event format details.
Common mistakes
- Forgetting to append the assistant's reply before the next turn — the model loses its own previous answer and starts contradicting itself.
- Putting the system prompt inside the messages array instead of the dedicated
systemfield — it works inconsistently and wastes a turn slot. - Never trimming history, leading to slow, expensive requests as conversations grow.
- Not separating tool calls from regular turns when using function calling — tool results need their own role and structure (see /docs/tools) and mixing them into plain text breaks multi-turn coherence.
If you're prototyping this without wanting to manage API key infrastructure, provisioning, and billing yourself, SubToAPI turns your Claude access into a key you can drop straight into the code above — sign up at /signup, check /pricing, or start with /docs/quickstart.
questions
Do I need a special "conversation" or "session" API to build this? No. The Messages API is stateless — you just resend the full message array each turn. Sessions are a concept you build on your own side (database row, session ID), not something the API provides.
How many turns can a conversation have before it breaks? There's no hard turn limit, but there is a token limit per request (the context window). Long conversations need trimming or summarization once their total token count approaches that limit.
Should I store conversation history client-side or server-side? Server-side is safer for anything beyond a toy project — it lets you enforce access control, avoid tampered histories, and recover sessions across devices. Client-side storage is fine for quick demos.