Conversations and Memory 💬
One call is a party trick. A conversation that remembers what you said is an assistant. The secret is that the model remembers nothing, and you do the remembering.
The model forgets everything, instantly
Every call is independent. The model you talked to a second ago has no idea you exist. So how does a chatbot remember your name? You resend the whole conversation every time. The "memory" lives entirely in a list you maintain in your program.
# WITHOUT memory: two separate calls, the second has no idea about the first
# call 1: messages=[{"role": "user", "content": "My name is Guybrush."}]
# call 2: messages=[{"role": "user", "content": "What is my name?"}] -> it cannot know
# WITH memory: the second call carries the whole history
messages = [
{"role": "user", "content": "My name is Guybrush."},
{"role": "assistant", "content": "Nice to meet you, Guybrush!"},
{"role": "user", "content": "What is my name?"},
]
# now the model can see the earlier turns and answer "Guybrush"
print("Message count sent on turn 3:", len(messages))
Message count sent on turn 3: 3
This is the whole trick, and it is worth pausing on because it is so unlike how it feels from the outside. The illusion of a continuous mind is manufactured, fresh, on every single turn, by you resending the transcript.
The model is a pure function: same input, same distribution of outputs, no memory, no state. All the apparent continuity is your list of messages growing. Once you see it this way, nothing about building a chatbot is mysterious any more.
A real chat loop
Here is a complete, genuine conversational assistant. It is thirty lines, and it is the beating heart of your Jarvis.
import anthropic
client = anthropic.Anthropic()
SYSTEM = ("You are Jarvis, a concise and helpful assistant. "
"Keep answers short unless asked to elaborate.")
def chat():
"""A stateful conversation. The history list is the memory."""
messages = []
print("Jarvis is ready. Type 'quit' to leave.\n")
while True:
user_input = input("You: ")
if user_input.strip().lower() in {"quit", "exit", "bye"}:
print("Jarvis: Goodbye.")
break
# 1. Add the user's turn to the history
messages.append({"role": "user", "content": user_input})
# 2. Send the WHOLE history and get a reply
response = client.messages.create(
model="claude-sonnet-5",
max_tokens=1024,
system=SYSTEM,
messages=messages,
)
reply = response.content[0].text
# 3. Add the assistant's turn to the history, so the next call remembers it
messages.append({"role": "assistant", "content": reply})
print(f"Jarvis: {reply}\n")
if __name__ == "__main__":
chat()
Three steps, forever: append the user turn, send everything, append the reply. That append-the-reply step is the one beginners forget, and forgetting it gives you an assistant with amnesia that cannot follow up on its own answers.
The roles, precisely
| Role | Who / what | Rules |
|---|---|---|
system | Standing instructions and persona | A separate parameter, not a message. Applies to every turn |
user | The human | The conversation must start with a user turn |
assistant | The model's replies | You append these yourself from each response |
The list must alternate user, assistant, user, assistant. If you send two user turns in a row without an assistant reply between them, most models will combine them, but keeping the alternation clean keeps your code predictable.
The context window will eventually fill
Every turn makes messages longer, and you pay for the whole thing on every
call. A long chat gets slow and expensive, and eventually overflows the context window.
The simplest fix is a sliding window: keep the last N turns.
def trim_history(messages, keep_turns=10):
"""Keep only the most recent turns to bound cost and stay in the window.
Keeps whole user+assistant pairs so the alternation stays valid.
"""
if len(messages) <= keep_turns * 2:
return messages
return messages[-keep_turns * 2:]
# a pretend history of 30 messages (15 exchanges)
history = []
for i in range(15):
history.append({"role": "user", "content": f"question {i}"})
history.append({"role": "assistant", "content": f"answer {i}"})
trimmed = trim_history(history, keep_turns=5)
print(f"kept {len(trimmed)} of {len(history)} messages")
print("oldest kept:", trimmed[0]["content"])
kept 10 of 30 messages
oldest kept: question 10
Trimming loses the start of the conversation, which is sometimes fine and sometimes not. The grown-up answer is summarisation: when the history gets long, ask the model to summarise the old part into a paragraph, and keep the summary plus the recent turns. Some providers offer this automatically (it is sometimes called compaction), and you can always do it by hand.
Saving and loading conversations
import json
from pathlib import Path
def save_conversation(messages, path="chat_history.json"):
"""Persist a conversation so Jarvis remembers across restarts."""
Path(path).write_text(json.dumps(messages, indent=2), encoding="utf-8")
def load_conversation(path="chat_history.json"):
"""Load a saved conversation, or start fresh if there is none."""
file = Path(path)
if file.exists():
return json.loads(file.read_text(encoding="utf-8"))
return []
# messages are just dicts, so JSON handles them perfectly (Lesson 23)
messages = [
{"role": "user", "content": "Remember I like tea, not coffee."},
{"role": "assistant", "content": "Noted: tea, not coffee."},
]
save_conversation(messages, "demo_chat.json")
loaded = load_conversation("demo_chat.json")
print("saved and reloaded", len(loaded), "messages")
print(loaded[0]["content"])
saved and reloaded 2 messages
Remember I like tea, not coffee.
Because a conversation is just a list of dictionaries, it is plain JSON (Lesson 23). Save it on exit, load it on start, and your Jarvis now remembers across sessions. This is genuine persistent memory, built from tools you already own.
This lesson is conversational memory: the running transcript. Lesson 58 covers knowledge memory: giving Jarvis access to your own documents and notes, so it can answer from information that was never in the chat and never in its training. Real assistants use both.
Add a persona and a persistence layer
Take the chat loop and give it two upgrades: a system prompt that gives Jarvis a distinct personality, and save/load so the conversation survives a restart. Print a friendly message noting how many past messages were loaded.
Reveal solution
import json
from pathlib import Path
import anthropic
client = anthropic.Anthropic()
HISTORY = Path("jarvis_memory.json")
SYSTEM = ("You are Jarvis, dry-witted but genuinely helpful. "
"You remember the user's preferences across sessions.")
def load():
return json.loads(HISTORY.read_text(encoding="utf-8")) if HISTORY.exists() else []
def save(messages):
HISTORY.write_text(json.dumps(messages, indent=2), encoding="utf-8")
def chat():
messages = load()
print(f"Jarvis: Welcome back. I recall {len(messages)} earlier messages.\n"
if messages else "Jarvis: Hello for the first time.\n")
while True:
text = input("You: ")
if text.strip().lower() in {"quit", "exit"}:
save(messages)
print("Jarvis: Saved. Until next time.")
break
messages.append({"role": "user", "content": text})
reply = client.messages.create(
model="claude-sonnet-5", max_tokens=1024,
system=SYSTEM, messages=messages,
).content[0].text
messages.append({"role": "assistant", "content": reply})
print(f"Jarvis: {reply}\n")
if __name__ == "__main__":
chat()Run it, tell it a preference, quit, and start it again. It greets you and remembers. That is a real assistant with a real memory, in about forty lines.
Reason about the bill
A user has a 100-turn conversation with no trimming. On turn 100, roughly how much conversation is being sent, and why does this get expensive fast? What are two fixes?
Reveal solution
On turn 100 you resend all 99 previous turns plus the new one, so the input grows every single turn. A 100-turn chat sends the first message 100 times, the second 99 times, and so on: the total tokens billed grow with the square of the conversation length. That is why a long unmanaged chat can quietly cost far more than you expect.
Two fixes: trim to a sliding window of recent turns (cheap, loses old context), or summarise the old turns into a short note and keep that plus recent turns (a little more work, keeps the gist). Most production assistants do the second. A third lever is prompt caching, where the provider charges much less to re-read an unchanged prefix, which is worth reading about once your bills matter.