Build a CLI Chatbot

Su Suriya Ravichandran Updated 10 Nov 2026
8 min read ·Lesson 65 of 200

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:

FeatureProvided by
Conversation logic, validation, streaming, stop reasonschatbot_core.py (Lesson 61)
Persistent history in SQLitehistory_store.py (Lesson 62)
Multiple sessions with automatic titlessessions.py (Lesson 63)
Long-term memory across sessionsmemory.py (Lesson 64)
Command-line interface, commands, and displaycli_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

CommandAction
(any text)Send a message; the answer streams live
/newStart a new session
/sessionsList your sessions, newest first; * marks the current one
/switch <n>Switch to session number n from the list
/rename <title>Rename the current session
/deleteDelete the current session
/memoryShow the facts the bot remembers about you
/forget <id|all>Forget one fact, or all of them
/helpShow the commands
/quitExit
Ctrl+C during an answerStop the answer; the chat continues

Command-line options:

OptionDefaultPurpose
--userlocal-userWhich user's sessions and memory to use
--dbchat.dbThe SQLite database file
--modelclaude-opus-5-5The 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

  1. 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.
  2. switch_to() loads a session's stored history into a fresh ChatBot through the session manager, which checks ownership.
  3. handle_command() splits a command line with str.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.
  4. chat() rebuilds the system prompt with the user's current memory, using dataclasses.replace() on the base configuration. It then streams the answer through stream_events(), printing text events and keeping the final Reply from the done event.
  5. Ctrl+C during an answer raises KeyboardInterrupt inside the stream. The stream is closed (Lesson 60), nothing is saved, and the chat continues.
  6. 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.
  7. 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.
  8. SessionNotFound is 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

  1. Add a /history command that prints the current session's messages, showing only the text of assistant turns.
  2. Add a --no-memory option that disables memory extraction and injection.
  3. Add a /model <id> command that switches the model for the rest of the session.
  4. Package the project with a pyproject.toml that defines a claude-chat console command.
Show answers for steps 1 and 4

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.

0 Comments

Reviewed before they appear

No comments yet.

Anthropic SDK Using Python
Anthropic SDK Intro Claude API Intro SDK Requirements Install Anthropic SDK Upgrade the SDK Create an API Key Set API Keys Use Environment Variables Import Anthropic Create a Client Send Your First Message Choose a Claude Model Set Max Tokens Read a Response Print Response Text Understand Message Blocks Use System Prompts Send User Messages Set Temperature Handle Stop Reasons Messages API Intro Create Messages Use Message Roles Send Conversation History Continue a Conversation Build a Chat Loop Reset Chat History Use Multiple Turns Inspect Message IDs Read Usage Data Write Clear Prompts Set a System Prompt Add Context to Prompts Use Prompt Templates Add Examples to Prompts Request JSON Output Use XML Tags Control Response Length Improve Prompt Quality Avoid Prompt Injection Read Text Blocks Read Content Blocks Check Stop Reasons Count Input Tokens Count Output Tokens Count Tokens Before Sending Limit Output Tokens Use Stop Sequences Read Request IDs Parse API Responses Streaming Intro Stream Text Output Use Stream Events Use Stream Helpers Get Final Messages Stream Async Responses Handle Stream Errors Build a Live Chat Stream Long Responses Close Streams Safely Build a Chatbot Keep Chat History Manage Chat Sessions Add Chat Memory Build a CLI Chatbot Build a Web Chatbot Use Flask with Claude Use FastAPI with Claude Return JSON from an API Add Chat Error Messages Async SDK Intro Create an Async Client Send Async Messages Use asyncio Run Parallel Requests Stream Async Text Handle Async Errors Set Async Timeouts Close Async Clients Choose Sync or Async Tool Use Intro Define a Tool Create Tool Schemas Send Tools to Claude Read Tool Calls Run Python Tools Return Tool Results Continue After Tool Use Control Tool Choice Handle Tool Errors Use the beta_tool Decorator Create a Tool Runner Add Tool Docstrings Validate Tool Inputs Use Multiple Tools Limit Tool Calls Log Tool Activity Build a Weather Tool Build a Search Tool Build a Calculator Tool Structured Output Intro Request JSON Data Validate JSON Output Use Pydantic Models Handle Invalid JSON Extract Fields from Text Generate Typed Data Use Image URLs Send Base64 Images Read PDFs with Claude Use Output Schemas Build Data Extraction Test Structured Responses Send Image Inputs Upload Files Use File IDs Process Text Files Summarize Documents Extract Table Data Handle File Errors Read Cache Usage Reduce Repeated Input Costs Debug Cache Misses Prompt Caching Intro Enable Prompt Caching Cache System Prompts Cache Long Context Test Cache Performance Use Cache Breakpoints Cache Best Practices Message Batches Intro Create a Message Batch Set Batch IDs Check Batch Status List Message Batches Get Batch Results Process Batch Outputs Handle Failed Batch Items Use Batches for Bulk Tasks Compare Batches and Requests Handle API Errors Handle Rate Limits Retry Failed Requests Set Request Timeouts Handle Network Errors Use Exponential Backoff Build Reliable API Calls Use Extended Thinking Log API Errors Track Request IDs Paginate API Results Set Thinking Budgets Read Thinking Blocks Use Tool Search Use Web Search Tools Use Computer Tools Use Citations Use Token Counting Read Raw Responses Set Custom Headers Use Claude on AWS Bedrock Use Claude on Vertex AI Use Claude on Microsoft Foundry Configure Cloud Credentials Choose a Cloud Provider Compare API Integrations Configure Provider Clients Handle Cloud Errors Use Cloud SDK Extras Deploy Cloud Apps Protect API Keys Use a .env File Ignore Secrets in Git Validate User Input Reduce Prompt Injection Limit Tool Permissions Avoid Unsafe Tool Calls Sanitize Tool Results Set Usage Limits Review Security Logs Test SDK Functions Mock API Responses Test Error Handling Test Streaming Code Measure Token Usage Track API Costs Configure Logging Deploy with Docker Deploy a FastAPI App Monitor Production Apps Build a Claude Chatbot Build a Text Summarizer Build a Code Explainer Build a JSON Extractor Build a PDF Assistant Build a Streaming Chat App Build a Tool-Calling Agent Build a Batch Processor Build a Research Assistant Build a Claude API Dashboard
Ask about this post
AI Ask about this post

Ask questions about Build a CLI Chatbot and get answers drawn from it.

Signed-in readers only.