Hands-On: Build a CLI Chatbot
Introduction
Time to assemble the whole section into something real. In this lesson you'll build a working command-line chatbot — one you can actually talk to in your terminal — and you'll understand every line of it.
It's small (~40 lines), but it exercises everything you just learned: API keys & SDKs (setup lesson), messages & roles (anatomy lesson), the context window / statelessness (you'll implement the "memory" by hand), streaming (responsive output), and token/cost awareness (we'll print usage). This is the moment the theory becomes muscle memory.
You'll build it step by step:
- A skeleton: client, system prompt, and the message loop
- Multi-turn memory — maintaining the conversation yourself
- Streaming so replies appear token-by-token
- Polish: exit/reset commands, error handling, and token usage
- The complete program (plus the OpenAI variant)
What We're Building
A terminal chat loop: you type, the assistant streams a reply, it remembers the conversation, and you keep going until you quit. Like this:
Chat with Claude (type 'exit' to quit, '/reset' to clear)
You: My name is Aryan.
Claude: Nice to meet you, Aryan! How can I help?
[in: 18 tok · out: 11 tok]
You: What's my name?
Claude: Your name is Aryan.
[in: 41 tok · out: 6 tok]
Notice the second answer knows the name — proof our memory works. And notice the input tokens grew (18 → 41) on the second turn: that's the whole history being re-sent, exactly as the context-window and pricing lessons predicted.
Step 1 — Setup & Skeleton
Make sure you've done the setup lesson (pip install anthropic python-dotenv and a .env with ANTHROPIC_API_KEY). Then the bones:
import os
from dotenv import load_dotenv
from anthropic import Anthropic
load_dotenv() # read .env into the environment
client = Anthropic() # picks up ANTHROPIC_API_KEY automatically
SYSTEM = "You are a friendly, concise assistant."
messages = [] # 👈 THIS list is the conversation's memory
print("Chat with Claude (type 'exit' to quit, '/reset' to clear)\n")
The single most important line is messages = []. The model is stateless — it remembers nothing between calls — so we hold the conversation in this list and re-send it every turn. That list is the chatbot's memory.
Step 2 — The Conversation Loop (Multi-Turn Memory)
Now the heart of it: a loop that reads your message, appends it to messages, sends the whole list, and appends the reply. Here's the non-streaming version first (simplest to read):
while True:
user = input("You: ").strip()
if user.lower() in {"exit", "quit"}:
break
messages.append({"role": "user", "content": user}) # remember user turn
resp = client.messages.create(
model="claude-opus-4-8",
max_tokens=1024,
system=SYSTEM,
messages=messages, # 👈 send the ENTIRE history
)
reply = resp.content[0].text
print(f"Claude: {reply}\n")
messages.append({"role": "assistant", "content": reply}) # remember reply
The rhythm to burn in: append user → send all of messages → append assistant. Forget to append the assistant's reply and the bot develops amnesia — it won't recall its own previous answers. (This append-user / append-assistant cycle is the visual below.)
![An infographic titled 'The Chatbot Loop: Your messages[] Is the Memory'. On the left, a five-step cycle with a repeat arrow: step 1, the user types a message; step 2, append a user-role entry to the messages list; step 3, send the ENTIRE messages list to the API (because the model is stateless); step 4, stream the reply token by token; step 5, append an assistant-role entry to the messages list; then loop back to step 1. On the right, a panel shows the messages[] array growing over turns: system, then user, assistant, user, assistant, with a note that it grows every turn and counts toward the context window and cost. A bottom banner states the model is stateless and remembers nothing, so the messages list, re-sent in full each turn, is the conversation.](https://pub-a1b3030acfb94e84ba8a89fb182c53bc.r2.dev/public/aie-content-5be2df73-ecd6-5679-af7c-6c95a54e0c60/chatbot-loop.webp)
Step 3 — Add Streaming
Waiting for the whole reply feels sluggish. Streaming prints tokens as they arrive — the difference between a frozen cursor and a bot that 'types' at you. Swap the API call for the streaming form, and accumulate the text so we can still store it in history:
print("Claude: ", end="", flush=True)
reply = ""
with client.messages.stream(
model="claude-opus-4-8",
max_tokens=1024,
system=SYSTEM,
messages=messages,
) as stream:
for text in stream.text_stream: # tokens arrive here, live
print(text, end="", flush=True)
reply += text # accumulate for history
print()
messages.append({"role": "assistant", "content": reply})
We print each chunk and concatenate it into reply, so the model still 'remembers' what it said. Streaming doesn't make generation faster — it just shows progress immediately, which feels dramatically better.
Step 4 — Polish: Commands, Errors & Token Usage
A few touches make it robust and educational:
- A
/resetcommand to clear history (a fresh context — and cheaper, since history resets). - Error handling so a network hiccup or rate-limit (
429) doesn't crash the program — and we remove the failed user turn so history stays clean. - Token usage printed each turn, so you see your cost growing (straight from the pricing lesson). With streaming, grab it from the final message:
usage = stream.get_final_message().usage
print(f"\n [in: {usage.input_tokens} tok · out: {usage.output_tokens} tok]\n")
Watching input_tokens climb every turn is the multi-turn cost trap made visible — a great habit to build early.
The Complete Program
Everything assembled — a real, streaming, multi-turn chatbot in ~40 lines:
import os
from dotenv import load_dotenv
from anthropic import Anthropic
load_dotenv()
client = Anthropic()
SYSTEM = "You are a friendly, concise assistant."
messages = []
print("Chat with Claude (type 'exit' to quit, '/reset' to clear)\n")
while True:
user = input("You: ").strip()
if user.lower() in {"exit", "quit"}:
break
if user == "/reset":
messages.clear()
print("(conversation cleared)\n")
continue
if not user:
continue
messages.append({"role": "user", "content": user})
print("Claude: ", end="", flush=True)
reply = ""
try:
with client.messages.stream(
model="claude-opus-4-8",
max_tokens=1024,
system=SYSTEM,
messages=messages,
) as stream:
for text in stream.text_stream:
print(text, end="", flush=True)
reply += text
usage = stream.get_final_message().usage
print(f"\n [in: {usage.input_tokens} tok · out: {usage.output_tokens} tok]\n")
except Exception as e:
print(f"\n[error: {e}]\n")
messages.pop() # drop the failed user turn
continue
messages.append({"role": "assistant", "content": reply})
Save it as chat.py, run python chat.py, and you have your own assistant. You built that — and you understand every line.
The OpenAI Variant
Same architecture, the dialect differences from the SDK lesson. The loop and messages memory are identical; only the call, the system placement, and the streaming shape change:
from openai import OpenAI
client = OpenAI()
# system is a MESSAGE at the front of the list (not a separate param):
messages = [{"role": "system", "content": "You are a friendly, concise assistant."}]
# ...inside the loop, after appending the user message:
print("GPT: ", end="", flush=True)
reply = ""
stream = client.chat.completions.create(
model="gpt-5.5",
messages=messages,
stream=True,
)
for chunk in stream:
delta = chunk.choices[0].delta.content or ""
print(delta, end="", flush=True)
reply += delta
print()
messages.append({"role": "assistant", "content": reply})
The key takeaways from the SDK lesson show up live: system is a message (so it lives in messages), and streaming yields chunk.choices[0].delta.content instead of a clean text stream. Same program, two dialects.
Where to Go Next (Extensions)
Your chatbot is a perfect sandbox. Things to try — each previews a later topic:
- Run it on a local model: point the OpenAI client at Ollama (
base_url="http://localhost:11434/v1",model="llama3.1:8b") — free and offline (Ollama lesson). - Add a token budget: when
messagesgets too long, trim or summarize old turns to control cost and stay in the window (context engineering). - Give it tools: let it call a calculator or search function (tool-use container).
- Save the transcript to a file, or add a richer TUI.
- Add a model router: cheap model by default, escalate hard questions (cost optimization).
Don't just read this lesson — run the code and tinker. Hands-on is where AI engineering actually sticks.
Why This Matters for You
- This loop is the skeleton of nearly every chat app. Agents, RAG assistants, customer-support bots — they all start from this exact append-user / send-history / append-assistant cycle.
- You now own the 'memory' abstraction. Knowing that you maintain
messages(not the model) is what lets you later trim it, summarize it, persist it, or inject retrieved context. - You can feel cost and latency. Watching tokens grow and replies stream makes the previous two lessons concrete — and tunes your instincts for designing efficient apps.
- It's provider-portable. The same structure runs on Claude, GPT, or a local model — change a few lines, not your architecture.
- Confidence. You've gone from 'how do LLMs work?' to 'I built a working AI app.' Everything after this is extending this foundation.
🧪 Try It Yourself
Your turn — then break it. Run the chatbot and have a short conversation. Now delete the line messages.append({"role":"assistant", ...}) and re-run. Predict, then watch: → it forgets its own previous replies (it only remembers your turns), proving the model is stateless and messages is the memory. Put the line back, then add a /tokens command that prints usage each turn to watch cost climb.
Key Takeaways
- A chatbot is a loop: read input → append user → send the entire
messageshistory → stream the reply → append assistant → repeat. - Because the model is stateless, your
messageslist is the memory — and re-sending it each turn is why input tokens (and cost) grow. - Streaming (
messages.stream/stream=True) shows tokens live for a far better feel — accumulate the text to keep it in history. - Polish matters: exit/reset commands, error handling (drop failed turns), and printing token usage to stay cost-aware.
- The OpenAI variant is the same loop with the dialect differences (system as a message,
delta.contentstreaming).
That completes 'The AI Engineering Stack & Your Dev Environment' — you can now set up, call, run locally, budget, and build. Next, we turn to choosing which model and understanding its limits — starting with long-context models and where their context really ends.