Until now every request had one question and one answer. Real chat is different: the user asks, Claude answers, the user asks a follow-up, and Claude must understand the follow-up in light of what was said before. This lesson explains how that works in the Messages API and adds a Chat page with memory to your AI Lab.
The most important fact: Claude has no memory
Many beginners think Claude remembers earlier requests, the way a person remembers a phone call from yesterday. It does not. Every call to the API is a fresh start. Claude only knows what you put into that one request.
Imagine a helper who forgets everything the moment you walk out of the room. To continue a conversation, you must walk in and say, "Here is everything we said so far, and here is my new question." That is exactly what you do in the Messages API. You keep the conversation on your side, and you send the whole conversation again each time. This is the single most important idea in this lesson.
The parts of a request
A request to the Messages API has a few key fields. You already met some of them.
- model: which Claude model answers.
- max_tokens: the longest answer you accept. It is required.
- system: instructions that apply to the whole conversation. This is where you describe who Claude should be and what rules it must follow.
- messages: the list of messages so far, in order.
Here is a complete request with a system prompt:
const reply = await client.messages.create({
model: MODEL,
max_tokens: 600,
system: 'You are a careful career coach. Keep answers short and never invent facts about the user.',
messages: [
{ role: 'user', content: 'I drove a forklift for 4 years. How do I write that on my CV?' },
],
});
The system prompt
The system prompt is like the instructions you give a new employee on the first day: "You work at a resume service. Be polite. Never promise a job. Keep answers under 100 words." The user does not type it and does not usually see it. Claude follows it for the whole conversation. Put stable things here: the role, the tone, the rules, the format of answers. Keep the changing things, like the user's question and their resume text, in the messages.
One detail beginners miss: in this API the system prompt is its own field called system. It is not a message inside the messages list.
User and assistant messages
Inside the messages list, each message has a role. There are two roles you use.
- user: what the person said.
- assistant: what Claude said earlier.
The roles should alternate: user, assistant, user, assistant, and so on. The first message must come from the user, and the last message should be from the user too, because it is the question Claude must answer now. When Claude answers, you take its text and add it to the list as an assistant message. Then the next user message is added after it. That is how the history grows.
// after Claude answered:
history.push({ role: 'assistant', content: reply.text });
// the user asks a follow-up:
history.push({ role: 'user', content: 'Make it shorter.' });
// send the WHOLE history again:
await client.messages.create({ model: MODEL, max_tokens: 600, system, messages: history });
When Claude sees "Make it shorter", it understands what "it" means because the earlier answer is in the history.
History grows, so cost grows
Because you send the whole conversation every time, each new turn is a bit more expensive than the last. After thirty turns, you may be sending a very long history for a one-line question. There is also a limit to how much text Claude can handle in one request, called the context window. Good products manage the history. The common methods are to keep only the last N messages, to summarise old messages into one short note, and to remove messages that are no longer useful. In the AI Lab we use the simplest method: the user chooses how many recent messages to send.
Clean the history on the server
The history comes from the browser, and anything from the browser can be wrong or evil. A user could send a message with a made-up role, a giant text, or a thousand messages. So the server cleans it before use. Our helper cleanMessages() keeps only user and assistant roles, accepts only text, cuts each message to 4,000 characters, keeps the last 20 messages, and removes any assistant messages at the start so the list begins with a user message. It is a small function that prevents many problems.
Reading the answer properly
Besides the text, the reply has two useful fields. usage gives the token counts. stop_reason tells you why Claude stopped writing. The normal value means Claude finished its thought. A different value means it hit your max_tokens limit, so the answer was cut off. You should check this. If the answer was cut off, show a note such as "The reply was cut off. Ask Claude to continue." A cut-off answer that looks complete is a bad surprise for users.
Parameters that change the style
You can also pass settings such as temperature, which controls how random the answer is. Low values give steadier, more repeatable answers, which is good for extraction and classification. Higher values give more variety, which is nice for brainstorming. Not every model supports every setting, so check the documentation for the model you use. A different tool is stop sequences: pieces of text that, when Claude writes them, end the answer. They are useful in special formats.
Your task: build the Chat page
Create a chat page that remembers the conversation, lets the user edit the system prompt, and lets the user choose how many recent messages to send.
What you will build
- server/messages.js with cleanMessages() and lastIsUser().
- A POST /api/chat route that accepts a system prompt and a messages list and returns the answer, usage and stop reason.
- A Chat page with a system prompt box, a "messages to send" selector, the conversation, a message box, and a Clear button.
Steps
- Start from your lesson 3 project, or download the lesson 3 solution.
- Write cleanMessages(): filter roles, check text, cut length, keep the last 20, and make the first message a user message.
- Create server/routes/chat.js. Return 400 if the last message is not from the user. Add the system field only when the prompt is not empty.
- In Angular, keep the history in a signal. When the user sends a message, add it to the history, send the last N messages, and then add Claude's answer to the history.
- Show a warning when stopReason is "max_tokens".
- Test it: tell Claude your name in message one, and ask "What is my name?" in message two. Then set the selector to send fewer messages and see what happens when the name falls outside the window.
Hints
- Use history.update(h => [...h, newMessage]) so the signal changes.
- The last test is a great way to feel how context works. When the message with your name is no longer sent, Claude cannot know it.
- Never put the system prompt inside the messages list.
You know it works when follow-up questions make sense, the Clear button starts a fresh chat, and changing the system prompt visibly changes the tone of Claude's answers.
Solution
The message-cleaning helper:
// Clean the chat history that comes from the browser. Never trust it.
// - only "user" and "assistant" roles
// - text only, limited length
// - keep the last 20 messages
// - the first message must be from the user, and the last one too
export function cleanMessages(input) {
if (!Array.isArray(input)) return [];
let list = input
.filter((m) => m && (m.role === 'user' || m.role === 'assistant') && typeof m.content === 'string' && m.content.trim())
.map((m) => ({ role: m.role, content: m.content.slice(0, 4000) }))
.slice(-20);
while (list.length && list[0].role !== 'user') list.shift();
return list;
}
export const lastIsUser = (list) => list.length > 0 && list[list.length - 1].role === 'user';
The chat route:
import { Router } from 'express';
import { client, MODEL, textOf } from '../claude.js';
import { cleanMessages, lastIsUser } from '../messages.js';
const router = Router();
// POST /api/chat { system?, messages: [{role, content}] }
// Claude has no memory, so the browser sends the whole conversation every time.
router.post('/chat', async (req, res) => {
const system = String(req.body?.system || '').slice(0, 2000);
const messages = cleanMessages(req.body?.messages);
if (!lastIsUser(messages)) {
return res.status(400).json({ error: { message: 'Send at least one message from the user.' } });
}
const reply = await client.messages.create({
model: MODEL,
max_tokens: 600,
...(system ? { system } : {}),
messages,
});
res.json({ text: textOf(reply), usage: reply.usage, stopReason: reply.stop_reason });
});
export default router;
The Angular page:
import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { errorText } from '../util';
type Msg = { role: 'user' | 'assistant'; content: string };
type ChatReply = { text: string; usage: { input_tokens: number; output_tokens: number }; stopReason: string };
@Component({
selector: 'app-chat',
imports: [FormsModule],
template: `
<h1>Chat with memory</h1>
<p class="hint">Claude does not remember earlier calls. This page keeps the history and sends it every time.</p>
<div class="card">
<label for="sys">System prompt (rules and persona)</label>
<textarea id="sys" [(ngModel)]="system" style="min-height:70px"></textarea>
<label for="keep">Messages to send each time</label>
<select id="keep" [(ngModel)]="keep">
<option [ngValue]="4">Last 4</option>
<option [ngValue]="10">Last 10</option>
<option [ngValue]="20">Last 20</option>
</select>
</div>
<div class="card">
@for (m of history(); track $index) {
<p><strong>{{ m.role === 'user' ? 'You' : 'Claude' }}:</strong> <span class="answer">{{ m.content }}</span></p>
} @empty {
<p class="muted">No messages yet. Say hello.</p>
}
@if (cutOff()) { <div class="err">The reply was cut off because it hit the length limit. Ask Claude to continue.</div> }
@if (error()) { <div class="err">{{ error() }}</div> }
<label for="msg">Your message</label>
<textarea id="msg" [(ngModel)]="draft" style="min-height:70px"></textarea>
<button (click)="send()" [disabled]="busy() || !draft.trim()">{{ busy() ? 'Thinking...' : 'Send' }}</button>
<button class="secondary" (click)="clear()">Clear chat</button>
<p class="muted">Sending {{ toSend().length }} of {{ history().length }} messages.</p>
</div>
`,
})
export class ChatPage {
private http = inject(HttpClient);
system = 'You are a careful career coach. Keep answers short and never invent facts about the user.';
keep = 10;
draft = '';
history = signal<Msg[]>([]);
busy = signal(false);
error = signal('');
cutOff = signal(false);
toSend() {
return this.history().slice(-this.keep);
}
async send() {
const text = this.draft.trim();
if (!text) return;
this.history.update((h) => [...h, { role: 'user', content: text }]);
this.draft = '';
this.busy.set(true);
this.error.set('');
this.cutOff.set(false);
try {
const r = await firstValueFrom(
this.http.post<ChatReply>('/api/chat', { system: this.system, messages: this.toSend() }),
);
this.history.update((h) => [...h, { role: 'assistant', content: r.text }]);
this.cutOff.set(r.stopReason === 'max_tokens');
} catch (e) {
this.error.set(errorText(e));
} finally {
this.busy.set(false);
}
}
clear() {
this.history.set([]);
this.error.set('');
this.cutOff.set(false);
}
}
Download the lesson 4 solution (zip)
What you learned
- Claude has no memory. You send the whole conversation each time.
- The system prompt holds the rules; messages hold the conversation. Roles are user and assistant.
- Long histories cost more, so trim or summarise them.
- Always clean the history on the server and check stop_reason.
In lesson 5 we make the chat feel fast by streaming the answer word by word.