Index Ai Academy
Phase 1 · Building with LLMs · Prompting

Conversations and memory

APIs are stateless: how chat history and memory really work.

About 30 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

Why it matters

Chat is the most familiar shape for an AI product. It rests on a fact that surprises most people: the model remembers nothing between requests. The memory you feel in a chat app is code that resends the whole conversation on every turn. Once you manage that history yourself, you decide what the model sees, what each turn costs, and what your app remembers about a person. That's the base for any multi-turn feature, from a copy assistant to an agent.

What you'll be able to do

  • Keep a conversation going by appending each turn to a history list and resending it
  • Explain why long conversations get slower and more expensive, and trim history to a budget
  • Tell conversation history apart from long-term memory and from business data
  • Build a small chat helper that manages its history

What it covers

  1. The model has no memory between calls
  2. Worked example: a chat helper
  3. What to store for the assistant's turn
  4. Keeping history in check
  5. Worked example: a sliding window
  6. Memory is a separate thing

The model has no memory between calls

Picture texting a friend who forgets everything the moment they reply. To keep the chat going, you'd have to paste the whole thread into every new message. That's how a model works.

Every request to the API stands alone. The model reads what's in that one request and writes a reply. Then it keeps nothing. A follow-up like "Make it shorter" means nothing on its own. The model can't tell what "it" refers to.

The word for this is stateless: nothing carries over from one request to the next.

Chat apps fake a continuous conversation with a list. Your code keeps every turn in the list, and each request sends all of it:

  1. The user types a message. Append it to the history as a "user" message.
  2. Send the system prompt and the entire history.
  3. Append the model's reply to the history as an "assistant" message.
  4. Repeat for the next message.

The model reads the conversation fresh every time, like a transcript. That's why it can refer back to something said ten turns ago. It's also why it can't refer to anything you left out.

Try it in the request builder below.

Quick check
What does this print?
history = []
for turn in ["Hi", "And?"]:
    history.append(turn)
    sent = len(history)
print(sent)
  1. 1
  2. 3
  3. 2
  4. 0
Show the answer

2

Each pass adds one turn, so history has 1 item and then 2, and sent ends at 2. The model remembers nothing, so each call sends the whole conversation so far.

Try it now

Send it. Click "Add a turn" and paste the reply into the new assistant message. Add a user turn saying "Make it shorter", and send again. Then clear the first two boxes, so only "Make it shorter" is sent. Send once more and compare.

Quick check
A chat has had two full exchanges: user, assistant, user, assistant. The user now types a third message, and your code appends it. How many messages does the next request send in messages?
  1. 1, only the newest message
  2. 5, the whole history including the new message
  3. 6, because the reply is counted too
  4. 3, the newest message plus the last exchange
Show the answer

5, the whole history including the new message

Each request sends the entire history. Two exchanges make four messages, and the new user message makes five. The reply doesn't exist yet: it gets appended after it comes back.

Worked example

Worked example: a chat helper

This function sends one user message as part of an ongoing conversation. The history lives in a list outside the function, so it survives between calls. Read the code, then run it.

import requests

SYSTEM = "You help a marketing team write email copy for Trailhead Outfitters. Keep replies short."
history = []

def chat(user_text):
    history.append({"role": "user", "content": user_text})
    response = requests.post(
        "https://api.anthropic.com/v1/messages",
        headers={"x-api-key": "added-by-the-academy-server", "anthropic-version": "2026-10-21"},
        json={"model": "claude-opus-5-5", "max_tokens": 1000, "system": SYSTEM, "messages": history},
        timeout=60,
    )
    response.raise_for_status()
    reply = ""
    for block in response.json()["content"]:
        if block["type"] == "text":
            reply += block["text"]
    history.append({"role": "assistant", "content": reply})
    return reply

print(chat("Write a subject line for our spring rain jacket sale."))
print()
print(chat("Make it shorter."))
print()
for message in history:
    print(message["role"], "|", message["content"][:50])
It runs right here in your browser, with nothing to install.

After two calls, history holds four messages. They alternate user and assistant. The second request carried three of them. The simulated reply reports the message count, so you can watch the conversation grow.

The system prompt isn't part of history. It goes in its own field on every request.

One gap a real app would close: if a request fails, the user message stays in history with no reply after it. Before the next turn, you'd remove it or retry.

What to store for the assistant's turn

The helper above stores the reply's text. That's all a plain conversation needs, and it's what you'll do in this lesson.

Go deeper Storing the full reply, thinking included

Some replies contain thinking blocks: the model's written reasoning before its answer. You can store the response's whole content list, thinking blocks included, as the assistant message: {"role": "assistant", "content": data["content"]}. That lets the model see its own earlier reasoning. You'll need it once the model starts using tools.

It comes with a rule. Current Claude models check that nothing before a returned thinking block has changed. They can reject a request that sends one back after earlier messages or the system prompt were edited. So histories that keep thinking blocks should only ever be appended to. Text-only histories, like the ones you build here, can be trimmed and rewritten freely.

Long conversations get slower and cost more

Think back to the friend who needs the whole thread pasted in. Every new message makes the paste longer. Requests work the same way. Each one resends the whole conversation as input tokens. (A token is a word or a piece of a word, and you pay for every one you send.)

Here's the size of one request, in words:

In plain words: tokens in this request = tokens per earlier turn × number of earlier turns + tokens in the new message.

Work out turn 3 with small numbers. Say each turn (one user message plus one reply) is about 300 tokens. The new message is about 100 tokens. Turn 3 has two earlier turns:

  • earlier turns: 300 × 2 = 600 tokens
  • new message: 100 tokens
  • this request: 600 + 100 = 700 tokens

Turn 1 sent only 100 tokens. Turn 3 sends 700. The code below runs the same sum for 20 turns and keeps a running total:

tokens_per_turn = 300   # one user message plus one reply, roughly
total = 0
for turn in range(1, 21):
    this_request = tokens_per_turn * (turn - 1) + 100   # all earlier turns, plus the new message
    total += this_request
    if turn in (1, 5, 10, 20):
        print(f"Turn {turn}: {this_request} input tokens in this request, {total} sent so far")

By turn 20, one request is 5,800 tokens. Twenty short turns send 59,000 input tokens in total. Yet the conversation itself is only about 6,000 tokens long. Each turn costs more than the last, so the total climbs much faster than the number of turns.

Long histories also take longer to process. A very long one can eventually pass the model's context window, the most text it can handle in one request.

Quick check
Use the same numbers: 300 tokens for each earlier turn, 100 for the new message. How many input tokens does turn 4 send?
  1. 1,000
  2. 400
  3. 1,200
  4. 900
Show the answer

1,000

Turn 4 has three earlier turns: 300 × 3 = 900. Add the 100-token new message and you get 1,000.

Keeping history in check

You have four common options. Each trades cost against what the model gets to see.

  • Keep the last N messages. N is a number you pick, such as 10. This is called a sliding window, like a group chat that only shows the latest messages. It's simple and cheap, but early details vanish. Examples: the product the user named in their first message, or a decision made an hour ago.
  • Summarize older turns. Ask the model for a short summary of the early conversation, like meeting notes. Then send that summary plus the recent turns. Key facts survive. The cost is an extra call and some lost detail.
  • Pin the facts that matter. Keep a short list of decisions and details in code: the campaign, the audience, the agreed tone. Put it in the system prompt on every request.
  • Let the API do it. Anthropic's API offers server-side compaction. It summarizes older context automatically once a conversation gets long. It was a beta feature at the time of writing.
Worked example

Worked example: a sliding window

Keep only the most recent messages. There's one catch: the first message you send must come from the user.

def trim_history(history, max_messages):
    trimmed = history[-max_messages:]
    if trimmed and trimmed[0]["role"] == "assistant":
        trimmed = trimmed[1:]
    return trimmed

history = [
    {"role": "user", "content": "Write a subject line for the rain jacket sale."},
    {"role": "assistant", "content": "Rain jackets 30% off, through Sunday"},
    {"role": "user", "content": "Make it shorter."},
    {"role": "assistant", "content": "30% off rain jackets"},
    {"role": "user", "content": "Now one for the fleece launch."},
    {"role": "assistant", "content": "Meet the Ridgeline Fleece"},
    {"role": "user", "content": "Add the price."},
]

recent = trim_history(history, 4)
print(len(history), "messages in the full history,", len(recent), "sent")
for message in recent:
    print(message["role"], "|", message["content"])
In the lesson you can change this and run it.

history[-4:] takes the last four messages. The minus sign counts from the end of the list. Here those four start with an assistant reply. The first message sent must have the "user" role, so the function drops that reply and sends three.

Slicing makes a new list, so the full history is untouched. Your app can still show the whole conversation on screen while it sends only the recent part.

Notice what the model loses. "Add the price" is clear in context. But the rain jacket conversation is gone.

Quick check
After trimming, your history starts with an assistant message. Why drop it before sending?
  1. The model can't read its own earlier replies
  2. Trimmed histories must have an even number of messages
  3. The first message in messages must use the user role, or the API rejects the request
  4. Assistant messages cost more tokens than user messages
Show the answer

The first message in messages must use the user role, or the API rejects the request

The API requires messages to start with a user message. A sliding window can cut a conversation in the middle of an exchange. So check the first role after trimming.

Memory is a separate thing

Think of your phone. The thread with a friend is one thing. The note you saved on their contact card ("allergic to nuts") is another. The note is still there when you start a new thread.

Apps work the same way:

  • Conversation history is the transcript of one chat.
  • Long-term memory is facts saved outside any single chat, in a database or a file. The app adds them to future requests.

When an app "remembers" that you like short drafts, it stored a note. Later, it put that note in the system prompt of a new conversation. The model itself learned nothing.

You help a marketing team write email copy for Trailhead Outfitters.

<user_notes>
- Prefers short drafts, no emoji.
- Works on the email newsletter, not paid social.
</user_notes>

Memory is data you keep about a person. Design it like any other customer data:

  • Store what's useful, for a clear purpose.
  • Tell users what's remembered.
  • Let them edit or delete it.
  • Never store secrets such as passwords or card numbers.

Business data isn't memory either. Budgets, order statuses, stock levels and campaign results live in your real systems. They can change at any time. Say a user mentioned "a $5,000 budget" thirty turns ago, and finance has since cut it. The conversation is now out of date. When you need the figure, fetch it from the system that owns it. Put that current figure in the request.

Quick check
Early in a chat, a user said the campaign budget was $5,000. Finance has since changed it to $4,000 in the budgeting system. Your assistant is about to plan spend. What should the request contain?
  1. The $5,000 figure, because it's in the conversation history
  2. Neither; the model will remember the right number
  3. Both figures, and let the model decide
  4. The current $4,000 figure, fetched from the budgeting system for this request
Show the answer

The current $4,000 figure, fetched from the budgeting system for this request

Conversation history is a snapshot of what was said. For facts that change, ask the system that owns them each time.

Practice what you just read

  • 3 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 5 review cards for spaced repetition

Create a free account

Try a quiz question

A user types "Make it shorter." Your app sends only that message to the API, with no history. What happens?

  1. The model remembers the earlier draft from the previous request
  2. The API looks up the earlier conversation by the user's account
  3. The model has no idea what "it" is, so it asks or guesses
  4. The API returns an error because the history is missing

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson