What We're Building
What We're Building
Time to put Lessons 1 through 4 into practice with a real, working project: a command-line chatbot that genuinely remembers a conversation across turns, saves that conversation to disk so it survives between runs of the program, and lets you list and resume past conversations by name. This isn't a toy — the patterns here (persisting conversation state, handling multiple named conversations, graceful error handling) are the same patterns a real production chatbot backend uses, just without the web framework wrapped around them.
We'll build this incrementally, in three stages: a minimal single-conversation version, then adding persistence to disk, then adding support for multiple named, resumable conversations. Each stage is fully working code you can run and test before moving to the next.
Project Structure
cli-chatbot/
├── .env
├── requirements.txt
├── chatbot.py # the main program
└── conversations/ # created automatically — stores saved conversation JSON files
Setup
mkdir cli-chatbot && cd cli-chatbot
pip install openai python-dotenv
requirements.txt
openai>=1.50.0
python-dotenv>=1.0.0
.env
OPENAI_API_KEY=your_api_key_here
Remember: never commit .env to version control. Add it to a .gitignore file if you're using Git for this project.
Stage 1: A Minimal Remembering Chatbot
Let's start with the core loop, applying Lesson 2's manual history management directly — no persistence yet, just proving the conversation memory works within a single run.
chatbot.py (Stage 1)
import os
from dotenv import load_dotenv
from openai import OpenAI
load_dotenv()
client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
SYSTEM_PROMPT = "You are a helpful, friendly command-line assistant. Keep responses concise."
def main():
history = [{"role": "developer", "content": SYSTEM_PROMPT}]
print("Chatbot ready. Type 'quit' to exit.\n")
while True:
user_text = input("You: ").strip()
if user_text.lower() in ("quit", "exit"):
print("Goodbye!")
break
if not user_text:
continue
history.append({"role": "user", "content": user_text})
response = client.responses.create(
model="gpt-5.6-luna",
input=history,
)
assistant_text = response.output_text
history.append({"role": "assistant", "content": assistant_text})
print(f"Bot: {assistant_text}\n")
if __name__ == "__main__":
main()
Running It
python chatbot.py
Expected Output
Chatbot ready. Type 'quit' to exit.
You: My name is Diego and I'm learning Python.
Bot: Nice to meet you, Diego! Python is a great language to learn —
happy to help with anything you run into.
You: What's my name?
Bot: Your name is Diego.
You: quit
Goodbye!
This is Lesson 2's technique, applied directly: history grows with every turn, and the entire list is sent as input on each call. If you restart the script, though, history resets to empty — the conversation is only remembered within a single run. That's the gap Stage 2 fixes.
Code Walkthrough
The main() function runs a standard read-eval-print loop: read a line of input, check for the exit command, append it to history, call the API with the full accumulated history, print the response, and append the response back to history before looping again. This is exactly the pattern from Lesson 2's Conversation class, just inlined into a script rather than wrapped in a class — we'll bring the class structure back in Stage 3, once we need to manage more than one conversation at a time.
Stage 2: Persisting Conversations to Disk
A chatbot that forgets everything the moment you close the terminal isn't very useful. Let's add persistence, so a conversation survives between runs of the program.
chatbot.py (Stage 2)
import json
import os
from pathlib import Path
from dotenv import load_dotenv
from openai import OpenAI
load_dotenv()
client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
SYSTEM_PROMPT = "You are a helpful, friendly command-line assistant. Keep responses concise."
CONVERSATIONS_DIR = Path("conversations")
DEFAULT_FILE = CONVERSATIONS_DIR / "default.json"
def load_history() -> list[dict]:
if DEFAULT_FILE.exists():
with open(DEFAULT_FILE, "r") as f:
return json.load(f)
return [{"role": "developer", "content": SYSTEM_PROMPT}]
def save_history(history: list[dict]) -> None:
CONVERSATIONS_DIR.mkdir(exist_ok=True)
with open(DEFAULT_FILE, "w") as f:
json.dump(history, f, indent=2)
def main():
history = load_history()
turn_count = sum(1 for m in history if m["role"] == "user")
print(f"Chatbot ready. Resuming with {turn_count} prior message(s). Type 'quit' to exit.\n")
while True:
user_text = input("You: ").strip()
if user_text.lower() in ("quit", "exit"):
print("Goodbye!")
break
if not user_text:
continue
history.append({"role": "user", "content": user_text})
response = client.responses.create(
model="gpt-5.6-luna",
input=history,
)
assistant_text = response.output_text
history.append({"role": "assistant", "content": assistant_text})
save_history(history) # persist after every turn, not just at exit
print(f"Bot: {assistant_text}\n")
if __name__ == "__main__":
main()
Running It Across Two Sessions
$ python chatbot.py
Chatbot ready. Resuming with 0 prior message(s). Type 'quit' to exit.
You: My favorite color is teal.
Bot: Got it — teal is a great choice, kind of a mix between blue and green.
You: quit
Goodbye!
$ python chatbot.py
Chatbot ready. Resuming with 1 prior message(s). Type 'quit' to exit.
You: What's my favorite color?
Bot: Your favorite color is teal.
You: quit
Goodbye!
The second invocation of the script — a completely separate process, started fresh — correctly recalls information from the first session, because load_history() reads the saved JSON file back in before the loop starts. This is the practical, hands-on proof of everything Lesson 1 taught: nothing is remembered by the API itself, but our own application-level persistence (a JSON file on disk, in this simple case) gives the illusion of memory across separate runs.
Code Walkthrough
load_history() checks whether a saved conversation file already exists. If it does, it loads and returns the saved message list; if not, it returns a fresh list containing just the system prompt — exactly the same fallback pattern used for a brand-new conversation. save_history() writes the current history list to disk as JSON after every single turn, not just when the program exits — this matters because if the program crashes or is killed unexpectedly mid-conversation, you still keep everything up through the last completed turn, rather than losing the whole session.
Notice this is precisely the "database table" tier from Lesson 2's storage-options discussion, just using the filesystem instead of a real database — appropriate for a single-user command-line tool, though a real multi-user web application would use an actual database table instead, exactly as Lesson 2 described.
Stage 3: Multiple Named, Resumable Conversations
A single default conversation is useful, but a real chatbot tool should support multiple separate conversations — one for work questions, one for a personal project, and so on — each independently resumable by name. Let's refactor into a proper class and add conversation naming.
chatbot.py (Stage 3, Final Version)
import json
import sys
from pathlib import Path
import os
from dotenv import load_dotenv
from openai import OpenAI
load_dotenv()
client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
SYSTEM_PROMPT = "You are a helpful, friendly command-line assistant. Keep responses concise."
CONVERSATIONS_DIR = Path("conversations")
class PersistentChatbot:
def __init__(self, conversation_name: str):
self.name = conversation_name
self.file_path = CONVERSATIONS_DIR / f"{conversation_name}.json"
self.history = self._load()
def _load(self) -> list[dict]:
if self.file_path.exists():
with open(self.file_path, "r") as f:
return json.load(f)
return [{"role": "developer", "content": SYSTEM_PROMPT}]
def _save(self) -> None:
CONVERSATIONS_DIR.mkdir(exist_ok=True)
with open(self.file_path, "w") as f:
json.dump(self.history, f, indent=2)
def send(self, user_text: str) -> str:
# Build a candidate list first, so a failed API call doesn't
# leave a dangling, unanswered user message in saved history.
candidate = self.history + [{"role": "user", "content": user_text}]
response = client.responses.create(
model="gpt-5.6-luna",
input=candidate,
)
self.history = candidate + [
{"role": "assistant", "content": response.output_text}
]
self._save()
return response.output_text
def turn_count(self) -> int:
return sum(1 for m in self.history if m["role"] == "user")
def list_conversations() -> list[str]:
if not CONVERSATIONS_DIR.exists():
return []
return sorted(p.stem for p in CONVERSATIONS_DIR.glob("*.json"))
def main():
existing = list_conversations()
if existing:
print("Existing conversations:", ", ".join(existing))
name = input("Conversation name (existing or new): ").strip() or "default"
bot = PersistentChatbot(name)
print(f"\nUsing conversation '{name}' ({bot.turn_count()} prior message(s)). Type 'quit' to exit.\n")
while True:
user_text = input("You: ").strip()
if user_text.lower() in ("quit", "exit"):
print("Goodbye!")
break
if not user_text:
continue
try:
reply = bot.send(user_text)
except Exception as e:
print(f"[Error contacting the model: {e}. Your message wasn't saved — try again.]\n")
continue
print(f"Bot: {reply}\n")
if __name__ == "__main__":
main()
Running It
$ python chatbot.py
Conversation name (existing or new): work
Using conversation 'work' (0 prior message(s)). Type 'quit' to exit.
You: Let's brainstorm names for my new API client library.
Bot: Sure! What language is it for, and what's the core thing it does?
You: quit
Goodbye!
$ python chatbot.py
Existing conversations: work
Conversation name (existing or new): personal
Using conversation 'personal' (0 prior message(s)). Type 'quit' to exit.
You: quit
Goodbye!
$ python chatbot.py
Existing conversations: personal, work
Conversation name (existing or new): work
Using conversation 'work' (1 prior message(s)). Type 'quit' to exit.
You: It's a Python client for a weather API.
Bot: Got it. A few ideas: WeatherKit, SkyClient, or Forecastly...
Notice the third session correctly resumes the "work" conversation exactly where it left off, while "personal" remains a completely separate, independent conversation file — this is the multi-conversation equivalent of the session-keyed dictionary pattern from Lesson 2's multi-user widget example, just applied to named conversations instead of user sessions.
Code Walkthrough
PersistentChatbot bundles the loading, saving, and sending logic into a reusable class, keyed by a conversation name that maps directly to a JSON file on disk. This mirrors the Conversation class from Lesson 2, with persistence layered on top — each instance owns exactly one conversation's history and knows how to load and save it.
The send() method uses the candidate-history pattern from Lesson 2's error-handling section: it builds a candidate list including the new user message, only commits it to self.history (and saves it to disk) once the API call succeeds. Combined with the try/except around bot.send() in main(), this means a network failure or API error doesn't corrupt the saved conversation — the user's unanswered message is simply not persisted, and they can retry cleanly.
list_conversations() scans the conversations/ directory for existing .json files and reports their names, giving the user visibility into what conversations already exist before they choose to resume one or start fresh — a small but genuinely useful piece of UX that a real chatbot interface would also need to provide.
An Optional Extension: Switching to the Conversations API
Everything above uses Lesson 2's manual, file-based persistence. As a worthwhile exercise, try rewriting PersistentChatbot using Lesson 4's Conversations API instead — storing just the OpenAI conversation_id in a small local mapping file (conversation name → conversation_id) rather than the full message history yourself:
# Sketch of the alternative approach — left as an exercise to complete
class ConversationsAPIChatbot:
def __init__(self, conversation_name: str):
self.name = conversation_name
self.mapping_file = CONVERSATIONS_DIR / "name_to_id.json"
self.conversation_id = self._load_or_create_id()
def send(self, user_text: str) -> str:
response = client.responses.create(
model="gpt-5.6-luna",
conversation=self.conversation_id,
input=user_text,
)
return response.output_text
This version trades local visibility (you can no longer just open a JSON file and read the full conversation) for reduced local storage responsibility (OpenAI holds the actual message content; your local file only maps a name to an ID). Building both versions and comparing them directly is one of the best ways to internalize the tradeoffs Lesson 4 described in the abstract — you'll feel the difference in how much code each version needs, and how much visibility each one gives you into what's actually being stored.
Adding Useful Commands: History, Reset, and Delete
A real command-line tool benefits from a few more commands beyond just chatting and quitting. Let's extend Stage 3 with /history (print the full conversation), /reset (clear the current conversation but keep its name), and /delete (remove a conversation entirely).
def print_history(bot: "PersistentChatbot") -> None:
for message in bot.history:
role = message["role"]
content = message["content"]
if role == "developer":
continue # skip printing the system prompt in the transcript view
label = "You" if role == "user" else "Bot"
print(f"{label}: {content}")
def main():
existing = list_conversations()
if existing:
print("Existing conversations:", ", ".join(existing))
name = input("Conversation name (existing or new): ").strip() or "default"
bot = PersistentChatbot(name)
print(f"\nUsing conversation '{name}' ({bot.turn_count()} prior message(s)).")
print("Commands: /history, /reset, /delete, quit\n")
while True:
user_text = input("You: ").strip()
if user_text.lower() in ("quit", "exit"):
print("Goodbye!")
break
if user_text == "/history":
print_history(bot)
continue
if user_text == "/reset":
bot.history = [{"role": "developer", "content": SYSTEM_PROMPT}]
bot._save()
print("Conversation reset.\n")
continue
if user_text == "/delete":
if bot.file_path.exists():
bot.file_path.unlink()
print(f"Conversation '{bot.name}' deleted. Exiting.")
break
if not user_text:
continue
try:
reply = bot.send(user_text)
except Exception as e:
print(f"[Error contacting the model: {e}. Your message wasn't saved — try again.]\n")
continue
print(f"Bot: {reply}\n")
Expected Output
Using conversation 'work' (3 prior message(s)).
Commands: /history, /reset, /delete, quit
You: /history
You: Let's brainstorm names for my new API client library.
Bot: Sure! What language is it for, and what's the core thing it does?
You: It's a Python client for a weather API.
Bot: Got it. A few ideas: WeatherKit, SkyClient, or Forecastly...
You: /reset
Conversation reset.
You: quit
Goodbye!
/history gives a human-readable transcript without needing to open the raw JSON file directly — genuinely useful once a conversation has grown long enough that scrolling back through the terminal itself isn't practical. /reset demonstrates something worth noticing: it deliberately keeps the conversation's name (and its file) while clearing its content back to just the system prompt, which is a different, more surgical operation than /delete, which removes the conversation entirely. Distinguishing "clear this conversation's content" from "remove this conversation as a concept" is a small design decision, but it's exactly the kind of distinction a real chatbot product needs to get right — users expect "clear this chat" and "delete this chat" to be different actions with different consequences.
Exporting a Conversation
Another genuinely useful extension: exporting a saved conversation to a readable Markdown file, useful for sharing a conversation's content outside the tool entirely, or archiving it before deleting it from the active conversations/ directory.
def export_to_markdown(bot: "PersistentChatbot") -> Path:
export_path = CONVERSATIONS_DIR / f"{bot.name}_export.md"
lines = [f"# Conversation: {bot.name}\n"]
for message in bot.history:
if message["role"] == "developer":
continue
label = "**You**" if message["role"] == "user" else "**Bot**"
lines.append(f"{label}: {message['content']}\n")
export_path.write_text("\n".join(lines))
return export_path
Wiring this in as an /export command (following the same pattern as /history, /reset, and /delete above) is a natural next exercise — and it's a good illustration of a broader point worth internalizing from this whole unit: once you own the conversation data yourself (as Lesson 2's manual approach does, and as this project does by extension), you can do essentially anything with it — export it, search it, analyze it, feed a summary of it into an entirely different feature — because it's just data sitting in a file or a database you control. This flexibility is part of the real, ongoing tradeoff against the Conversations API's convenience, and it's worth having actually felt both sides of that tradeoff by building this project both ways.
Testing the Persistence Logic
Following Lesson 2's guidance on testing conversation logic without burning API calls, the parts of this project most worth unit testing are the pure, non-network pieces — loading, saving, and the reset/delete commands — since these are exactly the pieces most likely to have a genuine bug that has nothing to do with the model's actual responses.
import json
import tempfile
from pathlib import Path
def test_load_creates_fresh_history_when_no_file_exists(tmp_path, monkeypatch):
monkeypatch.setattr("chatbot.CONVERSATIONS_DIR", tmp_path)
bot = PersistentChatbot("test_conversation")
assert len(bot.history) == 1
assert bot.history[0]["role"] == "developer"
def test_save_and_reload_roundtrips_correctly(tmp_path, monkeypatch):
monkeypatch.setattr("chatbot.CONVERSATIONS_DIR", tmp_path)
bot = PersistentChatbot("test_conversation")
bot.history.append({"role": "user", "content": "hello"})
bot._save()
# Load it again as a fresh instance, simulating a new process
reloaded = PersistentChatbot("test_conversation")
assert reloaded.history[-1] == {"role": "user", "content": "hello"}
These tests use pytest's built-in tmp_path fixture to create a temporary directory for each test run, so tests never touch your real conversations/ directory and never depend on or interfere with each other. Combined with the mocked-API testing pattern from Lesson 2, this gives you solid coverage over the entire project without a single real network call — reserve actual API-hitting tests for a small number of manual or integration checks, run deliberately rather than on every test run.
Frequently Asked Questions
Why store conversations as JSON files instead of a lightweight database like SQLite, even for this simple project? JSON files keep the project dependency-free and easy to inspect directly (you can cat a conversation file and read it), which is ideal for a learning project. For anything beyond a single-user command-line tool, though, a real database — even a simple SQLite file — becomes the better choice quickly, since it handles concurrent access safely and makes querying across conversations (search, filtering, analytics) far more practical than scanning a directory of JSON files.
Could this same structure work with streaming responses instead of waiting for the full reply? Yes, and it's a natural extension — the Unit 5: Streaming and Responsiveness material in this course covers exactly how to convert a client.responses.create() call into a streamed one, printing tokens as they arrive rather than waiting for the complete response. The conversation-memory logic in this lesson is unaffected either way; streaming changes how you display the response, not how you manage conversation state.
What would need to change to turn this into a real web application instead of a command-line tool? The core PersistentChatbot class barely changes — you'd swap file-based persistence for a proper database (as discussed throughout this lesson and Lesson 2), and wrap send() in an API endpoint (using a framework like FastAPI, covered in this course's capstone unit) instead of a terminal input loop. The conversation-management concepts transfer directly; only the storage layer and the input/output mechanism change.
Is it safe to commit the conversations/ directory to version control for a personal project? Generally no, unless you're deliberately fine with your conversation history being stored in Git history — treat it the same way you'd treat any file containing personal data or potentially sensitive content, and add conversations/ to your .gitignore alongside .env.
Handling Interruption Gracefully
One more real-world detail worth building in: what happens if the user presses Ctrl+C mid-conversation, or closes the terminal unexpectedly? Python raises a KeyboardInterrupt in the first case, and without handling it, the program exits with a somewhat alarming traceback rather than a clean goodbye.
def main():
existing = list_conversations()
if existing:
print("Existing conversations:", ", ".join(existing))
name = input("Conversation name (existing or new): ").strip() or "default"
bot = PersistentChatbot(name)
print(f"\nUsing conversation '{name}' ({bot.turn_count()} prior message(s)).")
print("Commands: /history, /reset, /delete, quit\n")
try:
while True:
user_text = input("You: ").strip()
# ... rest of the loop body as before ...
except KeyboardInterrupt:
print("\n\nInterrupted — your conversation has already been saved through the last completed turn. Goodbye!")
Because _save() is called after every successfully completed turn (not just at the end of the session), the KeyboardInterrupt handler doesn't even need to do any special saving work — the conversation is already safely persisted up through the last full exchange, and the handler's only job is to exit cleanly instead of printing a raw traceback. This is a direct payoff of the "commit only after success" design from Stage 3: by the time an interruption happens, there's never a question of whether unsaved state might be lost, because there never was any unsaved state sitting around waiting to be lost.
Project Checklist: What You've Actually Built
Before moving on, it's worth explicitly naming everything this project demonstrates, since it's easy to have followed along step by step without stepping back to see the full picture: manual conversation history management applied to a real, runnable program (Lesson 2's core technique); persistence across process restarts, proving conversation "memory" is an application-level responsibility rather than something the API provides (the central lesson of Lesson 1); a multi-conversation design keyed by name, mirroring the session-keyed pattern needed for any multi-user or multi-thread production system; defensive error handling that keeps persisted state consistent even when an API call fails; and a set of genuinely useful auxiliary commands (/history, /reset, /delete) that mirror the kind of conversation-management features a real chat product needs to offer its users.
If any part of this project felt unclear while building it, it's worth going back to the specific earlier lesson that introduced that concept — Lesson 1 for why persistence is needed at all, Lesson 2 for the mechanics of building and growing a history list, and this lesson for how those mechanics translate into an actual, runnable tool. Everything here is deliberately built from first principles you've already learned, rather than introducing anything new — the goal of this project is consolidation, not new material.
Common Mistakes
Saving history before confirming the API call succeeded. As covered in the Stage 3 walkthrough, saving too early can leave a corrupted, dangling conversation on disk if a call fails partway through. Always confirm success before committing state to persistent storage — this applies whether that storage is a JSON file, as here, or a real database in a production system.
Not handling the case where the saved file exists but contains invalid JSON. A conversation file that gets corrupted (a crash mid-write, manual editing gone wrong) will cause json.load() to raise an exception on the next run. A more robust _load() would catch json.JSONDecodeError and fall back to a fresh conversation rather than crashing the entire program on startup.
Forgetting that this simple file-based approach doesn't scale to concurrent access. This project is built for a single user running the script interactively — it doesn't handle two processes trying to write to the same conversation file simultaneously. A real multi-user application needs a proper database with appropriate transaction handling, exactly as Lesson 2 discussed.
Troubleshooting
The script can't find the conversations/ directory. Both save_history() in Stage 2 and _save() in Stage 3 call .mkdir(exist_ok=True) before writing, which should create the directory automatically on first save — if you're still seeing a missing-directory error, confirm the script has write permissions in its current working directory.
A resumed conversation doesn't seem to include everything from the previous session. Check the saved JSON file directly (cat conversations/<name>.json) to confirm what's actually stored — this is the same debugging technique Lesson 2 recommended: when in doubt, look directly at the exact data being sent, rather than guessing.
The OPENAI_API_KEY isn't being found when running the script. Confirm .env is in the same directory you're running python chatbot.py from, and that load_dotenv() is called before OpenAI(api_key=...) — a .env file in the wrong directory is a very common cause of this specific error.
Best Practices
Always separate "build the candidate next state" from "commit it," as Stage 3's send() method does — this one pattern eliminates an entire class of bugs where a failed network call leaves your persisted state inconsistent. Persist after every turn, not just at the end of a session, so a crash or forced exit never loses more than the single in-flight message. And treat this project as a genuine template: the same three-stage progression — get it working in memory, add persistence, add support for multiple independent conversations — applies directly to building a real production chatbot backend, just swapping a JSON file for a proper database once you move beyond a single-user command-line tool.
What's Next
You've now built a complete, working example that applies every technique from Lessons 1 through 4. The one thing this project hasn't had to deal with yet is a conversation growing long enough to hit real context-window or cost problems — something that will eventually happen to any of these conversations if you keep chatting in the same named thread for long enough. Lesson 6 tackles that problem directly: counting tokens before you hit a limit, and applying trimming and compaction strategies so a long-running conversation degrades gracefully instead of failing outright.