Build a CLI Chatbot
What This Lesson Builds
A CLI chatbot is a chatbot used from the command line (CLI: command-line interface). Lessons 26 and 58 built simple chat loops. This lesson assembles the modules from Lessons 61–64 into a complete, reusable command-line application:
| Feature | Provided by |
|---|---|
| Conversation logic, validation, streaming, stop reasons | chatbot_core.py (Lesson 61) |
| Persistent history in SQLite | history_store.py (Lesson 62) |
| Multiple sessions with automatic titles | sessions.py (Lesson 63) |
| Long-term memory across sessions | memory.py (Lesson 64) |
| Command-line interface, commands, and display | cli_chat.py (this lesson) |
The CLI itself contains no conversation logic. It reads input, calls the modules, and displays results. That is the separation designed in Lesson 61.
Project Structure
claude-cli-chat/
├── chatbot_core.py # Lesson 61
├── history_store.py # Lesson 62
├── sessions.py # Lesson 63
├── memory.py # Lesson 64
├── cli_chat.py # This lesson
└── chat.db # Created automatically on first run
Install the SDK (Lesson 4) and set ANTHROPIC_API_KEY (Lesson 7). No other packages are needed: Pydantic, used by memory.py, is installed with the SDK.
Features of the CLI
| Command | Action |
|---|---|
| (any text) | Send a message; the answer streams live |
/new | Start a new session |
/sessions | List your sessions, newest first; * marks the current one |
/switch <n> | Switch to session number n from the list |
/rename <title> | Rename the current session |
/delete | Delete the current session |
/memory | Show the facts the bot remembers about you |
/forget <id|all> | Forget one fact, or all of them |
/help | Show the commands |
/quit | Exit |
| Ctrl+C during an answer | Stop the answer; the chat continues |
Command-line options:
| Option | Default | Purpose |
|---|---|---|
--user | local-user | Which user's sessions and memory to use |
--db | chat.db | The SQLite database file |
--model | claude-opus-5-5 | The Claude model for answers |
Practical Example: cli_chat.py
"""cli_chat.py: a command-line chatbot with sessions, history, memory, and streaming.
Usage:
python cli_chat.py --user meena
"""
import argparse
from dataclasses import replace
from anthropic import Anthropic
from chatbot_core import BotConfig
from history_store import HistoryStore
from memory import MemoryStore, extract_facts
from sessions import SessionManager, SessionNotFound
BASE_SYSTEM = (
"You are a friendly programming tutor for GOCOURSE students. "
"Keep answers under 150 words unless the student asks for more detail."
)
ERROR_TEXT = {
"empty_message": "Please type a message.",
"message_too_long": "That message is too long. Please shorten it.",
"refusal": "I can't help with that request.",
}
HELP = """Commands:
/new start a new session
/sessions list your sessions
/switch <n> switch to session number n
/rename <title> rename the current session
/delete delete the current session
/memory show what the bot remembers about you
/forget <id|all> forget one fact, or everything
/help show this help
/quit exit"""
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Chat with Claude from the terminal.")
parser.add_argument("--user", default="local-user", help="your user ID")
parser.add_argument("--db", default="chat.db", help="SQLite database file")
parser.add_argument("--model", default="claude-opus-5-5", help="Claude model ID")
return parser.parse_args()
class ChatApp:
def __init__(self, args: argparse.Namespace):
self.client = Anthropic()
store = HistoryStore(args.db)
self.memory = MemoryStore(store)
self.config = BotConfig(model=args.model, system=BASE_SYSTEM)
self.sessions = SessionManager(store, self.client, self.config)
self.user = args.user
existing = self.sessions.list_sessions(self.user)
self.switch_to(existing[0][0] if existing else self.sessions.create(self.user))
# ---------- sessions ----------
def switch_to(self, session_id: str) -> None:
self.session_id = session_id
self.bot = self.sessions.open(session_id, self.user)
def current_title(self) -> str:
for session_id, title, _ in self.sessions.list_sessions(self.user):
if session_id == self.session_id:
return title
return "New chat"
# ---------- commands ----------
def handle_command(self, line: str) -> bool:
"""Run a slash command. Return False when the app should exit."""
command, _, argument = line.partition(" ")
argument = argument.strip()
if command == "/quit":
return False
if command == "/help":
print(HELP)
elif command == "/new":
self.switch_to(self.sessions.create(self.user))
print("[New session started.]")
elif command == "/sessions":
for number, (session_id, title, _) in enumerate(self.sessions.list_sessions(self.user), start=1):
marker = "*" if session_id == self.session_id else " "
print(f" {marker} {number}. {title}")
elif command == "/switch":
available = self.sessions.list_sessions(self.user)
if argument.isdigit() and 1 <= int(argument) <= len(available):
self.switch_to(available[int(argument) - 1][0])
print(f"[Switched to: {self.current_title()} ({len(self.bot.history)} messages)]")
else:
print("[Usage: /switch <number from /sessions>]")
elif command == "/rename":
if argument:
self.sessions.rename(self.session_id, self.user, argument)
print("[Session renamed.]")
else:
print("[Usage: /rename <title>]")
elif command == "/delete":
self.sessions.delete(self.session_id, self.user)
remaining = self.sessions.list_sessions(self.user)
self.switch_to(remaining[0][0] if remaining else self.sessions.create(self.user))
print(f"[Session deleted. Now in: {self.current_title()}]")
elif command == "/memory":
facts = self.memory.facts(self.user)
if not facts:
print("[No memory stored.]")
for fact_id, fact in facts:
print(f" [{fact_id}] {fact}")
elif command == "/forget":
if argument == "all":
print(f"[Forgot {self.memory.forget_all(self.user)} facts.]")
elif argument.isdigit():
found = self.memory.forget(self.user, int(argument))
print("[Fact forgotten.]" if found else "[No fact with that ID.]")
else:
print("[Usage: /forget <id|all>]")
else:
print("[Unknown command. Type /help.]")
return True
# ---------- chatting ----------
def chat(self, text: str) -> None:
section = self.memory.system_section(self.user)
system = f"{BASE_SYSTEM}\n\n{section}" if section else BASE_SYSTEM
self.bot.config = replace(self.config, system=system)
is_first_turn = not self.bot.history
reply = None
print("Claude: ", end="", flush=True)
try:
for event in self.bot.stream_events(text):
if event["type"] == "text":
print(event["text"], end="", flush=True)
else:
reply = event["reply"]
except KeyboardInterrupt:
print("\n[Answer stopped. It was not saved.]")
return
print()
if reply is None or not reply.ok:
error = reply.error if reply else None
print(f"[{ERROR_TEXT.get(error, 'Something went wrong. Please try again.')}]")
return
if reply.stop_reason == "max_tokens":
print("[The answer was cut off. Ask me to continue.]")
self.sessions.save_turn(self.session_id, self.user, reply)
if is_first_turn:
self.sessions.auto_title(self.session_id, self.user, text)
known = [fact for _, fact in self.memory.facts(self.user)]
self.memory.add(self.user, extract_facts(self.client, text, known))
def run(self) -> None:
print(f"Claude CLI chat. Current session: {self.current_title()}. Type /help for commands.\n")
while True:
try:
line = input("You: ").strip()
except (KeyboardInterrupt, EOFError):
print()
break
if not line:
continue
if line.startswith("/"):
try:
if not self.handle_command(line):
break
except SessionNotFound:
print("[That session is no longer available.]")
continue
self.chat(line)
print("Goodbye!")
if __name__ == "__main__":
ChatApp(parse_args()).run()
Example session:
$ python cli_chat.py --user meena
Claude CLI chat. Current session: New chat. Type /help for commands.
You: Hi, I'm Meena. I'm building a FastAPI app for my internship.
Claude: Hi Meena! That's a great internship project. What does your app need to do?
You: /new
[New session started.]
You: How should I structure my project folders?
Claude: For your FastAPI internship app, a clean layout is: app/ (main.py, routers/, models/, schemas/), tests/, and a requirements.txt...
You: /sessions
* 1. FastAPI Project Structure
2. FastAPI Internship App
You: /memory
[1] The user's name is Meena.
[2] The user is building a FastAPI app for an internship.
You: /quit
Goodbye!
How It Works
ChatApp.__init__wires the modules together: one Anthropic client, one database (HistoryStore) shared by sessions and memory, and one base configuration. On start, it opens the user's most recent session, or creates one.switch_to()loads a session's stored history into a freshChatBotthrough the session manager, which checks ownership.handle_command()splits a command line withstr.partition(" ")into the command and its argument. Each command calls one module method and prints a short confirmation. Invalid arguments print a usage hint instead of raising exceptions.chat()rebuilds the system prompt with the user's current memory, usingdataclasses.replace()on the base configuration. It then streams the answer throughstream_events(), printingtextevents and keeping the finalReplyfrom thedoneevent.- Ctrl+C during an answer raises
KeyboardInterruptinside the stream. The stream is closed (Lesson 60), nothing is saved, and the chat continues. - Unsuccessful replies are translated into friendly text with
ERROR_TEXT. Unknown error codes, such as API errors, fall back to a generic message. Lesson 70 builds a complete error-message system. - After a successful turn, the CLI saves the turn, generates a title on the session's first turn, and extracts memory facts, in that order. Saving comes first, so the conversation is never lost because a later, optional step failed.
SessionNotFoundis caught around commands, so a session deleted elsewhere, for example by another process using the same database, produces a message instead of a crash.
Common Mistakes
Putting Logic in the CLI
What happens: the web versions in Lessons 66–68 must duplicate the logic.
Fix: keep the CLI to input, output, and commands. Everything else lives in the modules.
Sending Commands to Claude
What happens: /sessions is answered by Claude instead of being executed.
Fix: check for the / prefix before treating input as a message.
Saving Before Checking the Reply
What happens: failed or refused turns are stored.
Fix: check reply.ok first, as chat() does.
Optional Steps Before Saving
What happens: if title generation or memory extraction fails, the turn may never be saved.
Fix: save the turn first, then run optional steps.
Crashing on Bad Command Arguments
What happens: /switch abc raises ValueError.
Fix: validate arguments (isdigit(), range checks) and print usage hints.
Best Practices
- Assemble the CLI from reusable modules.
- Stream answers, and let users stop them with Ctrl+C.
- Provide clear, short commands with usage hints.
- Save turns first, then run optional steps such as titles and memory.
- Translate error codes into friendly messages.
- Make the user, database, and model configurable with command-line options.
Exercise
- Add a
/historycommand that prints the current session's messages, showing only the text of assistant turns. - Add a
--no-memoryoption that disables memory extraction and injection. - Add a
/model <id>command that switches the model for the rest of the session. - Package the project with a
pyproject.tomlthat defines aclaude-chatconsole command.
Step 1: add a branch to handle_command():
elif command == "/history":
for message in self.bot.history:
content = message["content"]
if isinstance(content, str):
text = content
else:
text = "".join(block.get("text", "") for block in content if block.get("type") == "text")
print(f"{message['role']:>9}: {text[:200]}")
User content is usually a string, and assistant content is a list of block dictionaries (Lesson 62), so both forms are handled.
Step 4:
[project]
name = "claude-cli-chat"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = ["anthropic>=2.0,<3"]
[project.scripts]
claude-chat = "cli_chat:main_entry"
[tool.setuptools]
py-modules = ["chatbot_core", "history_store", "sessions", "memory", "cli_chat"]
Add def main_entry(): ChatApp(parse_args()).run() to cli_chat.py. After pip install -e ., the command claude-chat --user meena starts the chatbot.