Back to Tutorials
BeginnerAI/ML

Build Your First AI Chatbot CLI with TypeScript and the Anthropic API

Build a conversational AI chatbot that runs in your terminal. This beginner-friendly guide walks you through TypeScript setup, Anthropic API integration, conversation history, and streaming responses — no prior AI experience required.

by Michael Eakins
22 min read
2/14/2026

Prerequisites

  • Basic JavaScript knowledge (variables, functions, async/await)
  • Node.js 18+ installed on your machine
  • A free Anthropic API key (console.anthropic.com)

What You'll Learn

  • Set up a TypeScript project from scratch
  • Make API calls to Claude using the Anthropic SDK
  • Build an interactive CLI with Node.js readline
  • Implement conversation history for multi-turn chat
  • Add streaming responses for real-time output

Technologies Covered

TypeScriptNode.jsAnthropic SDKreadline

You are about to build something that would have felt like science fiction five years ago: a conversational AI that runs right in your terminal. No web app, no framework, no deployment pipeline. Just you, TypeScript, and a direct line to one of the most capable language models on the planet.

By the end of this tutorial, you will have a chatbot that remembers what you said three messages ago, streams its responses word by word in real time, and feels genuinely fun to talk to. The whole thing fits in a single file under 100 lines of code.

This is a beginner tutorial. If you have written some JavaScript and can open a terminal, you have everything you need. We will explain every TypeScript annotation, every API concept, and every design decision along the way.

What You Will Build

Here is a preview of the finished chatbot running in a terminal:

$ npx ts-node src/index.ts

🤖 AI Chatbot CLI
Type your message and press Enter. Type "exit" to quit.

You: What is TypeScript?

Claude: TypeScript is a programming language built on top of JavaScript. Think
of it as JavaScript with a safety net — it adds a type system that catches
mistakes before your code runs. For example, if you accidentally try to call
a number like a function, TypeScript will warn you immediately instead of
letting your program crash at runtime...

You: Can you give me a simple example?

Claude: Of course! Here's a quick comparison. In plain JavaScript, you might
write:

function greet(name) {
  return "Hello, " + name;
}

Nothing stops you from calling greet(42), which would produce "Hello, 42" —
probably not what you intended. In TypeScript, you add a type annotation...

You: exit
Goodbye! 👋

Notice how the second question ("Can you give me a simple example?") makes sense only because Claude remembers the first question was about TypeScript. That is conversation history at work, and you will implement it yourself.

What You Will Learn

This tutorial covers five core skills:

  1. TypeScript project setup — Creating a project from an empty folder, installing dependencies, and configuring the TypeScript compiler
  2. API calls to Claude — Sending messages to the Anthropic API and understanding the response format
  3. Interactive CLI input — Reading user input from the terminal using Node.js built-in modules
  4. Conversation history — Maintaining a message array so Claude remembers previous turns
  5. Streaming responses — Displaying Claude's response in real time as tokens arrive, instead of waiting for the full answer

Prerequisites

Before you start, make sure you have these three things ready.

Node.js 18 or later. Open your terminal and run:

node --version

You should see v18.x.x or higher. If not, download the latest version from nodejs.org.

A text editor. VS Code is a great free option. Any editor that supports TypeScript will work.

An Anthropic API key. Go to console.anthropic.com, create a free account, and generate an API key. You will get a small amount of free credits to experiment with. Keep this key private — treat it like a password.

Project Setup

Let us build this from scratch. Open your terminal and create a new project folder:

mkdir ai-chatbot-cli
cd ai-chatbot-cli

Initialize the Project

Every Node.js project starts with a package.json file. This file tracks your project's name, version, and dependencies. Create one with default settings:

npm init -y

The -y flag accepts all defaults so you do not have to answer a series of questions. You will see a new package.json file in your folder.

Install Dependencies

You need two packages:

npm install @anthropic-ai/sdk
npm install -D typescript ts-node @types/node

Here is what each one does:

  • @anthropic-ai/sdk — The official Anthropic SDK for making API calls to Claude. This is how your code talks to the AI model.
  • typescript — The TypeScript compiler that turns your .ts files into JavaScript. Installed as a dev dependency (-D) because you only need it during development.
  • ts-node — Lets you run TypeScript files directly without a separate compile step. Think of it as a shortcut: instead of compiling then running, you just run.
  • @types/node — TypeScript type definitions for Node.js built-in modules like readline and process. Without this, TypeScript would not know what functions Node.js provides.

Configure TypeScript

Create a tsconfig.json file in your project root. This tells the TypeScript compiler how to process your code:

// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "strict": true,
    "esModuleInterop": true,
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src/**/*"]
}

Let us break down the important settings:

  • target: "ES2022" — Generate modern JavaScript. Since you have Node.js 18+, you can use recent language features.
  • module: "commonjs" — Use the require() style of imports that Node.js understands natively.
  • strict: true — Enable all of TypeScript's safety checks. This is the whole point of using TypeScript — let it catch your mistakes early.
  • esModuleInterop: true — Lets you write import Anthropic from '@anthropic-ai/sdk' instead of a more awkward syntax. A quality-of-life setting.
  • outDir / rootDir — Your source code lives in src/, compiled output goes to dist/.

Create the Source Directory

mkdir src

Your project structure should now look like this:

ai-chatbot-cli/
├── node_modules/
├── src/
├── package.json
├── package-lock.json
└── tsconfig.json

That is the complete setup. Every TypeScript project you build in the future will start with these same steps.

Your First API Call

Before building the full chatbot, let us make sure the API works by sending a single message to Claude. Create a file called src/index.ts:

// src/index.ts
import Anthropic from '@anthropic-ai/sdk'

const client = new Anthropic({
  apiKey: 'your-api-key-here',
})

async function main(): Promise<void> {
  const response = await client.messages.create({
    model: 'claude-sonnet-4-20250514',
    max_tokens: 1024,
    messages: [
      {
        role: 'user',
        content: 'Hello! What are you?',
      },
    ],
  })

  console.log(response.content[0])
}

main()

Let us unpack this piece by piece.

The import. import Anthropic from '@anthropic-ai/sdk' brings in the Anthropic SDK class. If you are used to JavaScript's require(), this is the TypeScript equivalent.

The client. new Anthropic({ apiKey: '...' }) creates an API client configured with your key. Every request to Claude goes through this client.

The function signature. async function main(): Promise<void> is TypeScript syntax. The : Promise<void> part is a type annotation — it tells TypeScript (and other developers) that this function is async and does not return a meaningful value. If you have never seen type annotations before, think of them as labels that describe what type of data something holds.

The API call. client.messages.create() sends a message to Claude and waits for a response. The parameters are:

  • model — Which Claude model to use. claude-sonnet-4-20250514 is a great balance of speed and intelligence.
  • max_tokens — The maximum length of Claude's response, measured in tokens (roughly words). 1024 tokens is about 750 words.
  • messages — An array of messages in the conversation. Each message has a role (either 'user' or 'assistant') and content (the text).

Run It

Save the file and run it from your project root:

npx ts-node src/index.ts

You should see something like:

{
  type: 'text',
  text: "Hello! I'm Claude, an AI assistant made by Anthropic. I'm designed to be helpful, harmless, and honest. I can help with a wide range of tasks including writing, analysis, coding, math, and general conversation. How can I help you today?"
}

The response is an object with a type field and a text field. For text conversations, the type will always be 'text' and the actual answer is in text.

If you see an authentication error, double-check that your API key is correct. If you see a network error, make sure you have an internet connection.

Use Environment Variables (Important)

Hardcoding your API key in source code is a bad habit. If you ever push this code to GitHub, your key would be exposed to the world. Let us fix that now.

The Anthropic SDK automatically reads the ANTHROPIC_API_KEY environment variable. Update your code to remove the hardcoded key:

// src/index.ts
import Anthropic from '@anthropic-ai/sdk'

const client = new Anthropic()

async function main(): Promise<void> {
  const response = await client.messages.create({
    model: 'claude-sonnet-4-20250514',
    max_tokens: 1024,
    messages: [
      {
        role: 'user',
        content: 'Hello! What are you?',
      },
    ],
  })

  console.log(response.content[0])
}

main()

Now run it with the environment variable set:

ANTHROPIC_API_KEY=your-key-here npx ts-node src/index.ts

On macOS and Linux, you can also export it for your entire terminal session:

export ANTHROPIC_API_KEY=your-key-here
npx ts-node src/index.ts

From this point forward, the code assumes the ANTHROPIC_API_KEY environment variable is set.

Building the Interactive CLI

A single API call is nice, but a chatbot needs a conversation loop. You type something, Claude responds, you type again, and so on. Node.js has a built-in module called readline that handles exactly this.

Replace the contents of src/index.ts with:

// src/index.ts
import Anthropic from '@anthropic-ai/sdk'
import * as readline from 'readline'

const client = new Anthropic()

const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout,
})

function prompt(question: string): Promise<string> {
  return new Promise(resolve => {
    rl.question(question, (answer: string) => {
      resolve(answer)
    })
  })
}

async function main(): Promise<void> {
  console.log('\n🤖 AI Chatbot CLI')
  console.log('Type your message and press Enter. Type "exit" to quit.\n')

  while (true) {
    const userInput = await prompt('You: ')

    if (userInput.toLowerCase() === 'exit') {
      console.log('Goodbye! 👋')
      rl.close()
      break
    }

    if (!userInput.trim()) {
      continue
    }

    const response = await client.messages.create({
      model: 'claude-sonnet-4-20250514',
      max_tokens: 1024,
      messages: [
        {
          role: 'user',
          content: userInput,
        },
      ],
    })

    const assistantMessage =
      response.content[0].type === 'text' ? response.content[0].text : ''

    console.log(`\nClaude: ${assistantMessage}\n`)
  }
}

main()

Let us walk through the new pieces.

readline.createInterface creates a connection between your program and the terminal. process.stdin reads keyboard input, process.stdout writes text to the screen.

The prompt function wraps rl.question() in a Promise. The question parameter has a type annotation : string, and the function returns Promise<string>. This means: "give me a string question, and I'll eventually give you back a string answer." The Promise wrapper lets us use await so our code reads top-to-bottom instead of using nested callbacks.

The while loop runs forever until the user types "exit". Each iteration reads input, sends it to Claude, and prints the response. The !userInput.trim() check skips empty inputs — if the user just presses Enter without typing anything, we loop back and ask again.

The type check response.content[0].type === 'text' is TypeScript being careful. The API response could contain different content types, so TypeScript wants us to check before accessing .text. This is TypeScript's safety net in action.

Run it:

ANTHROPIC_API_KEY=your-key-here npx ts-node src/index.ts

Try having a conversation. Ask a question, get a response, ask another. It works, but there is a problem — try this:

You: My name is Alex.

Claude: Nice to meet you, Alex! How can I help you today?

You: What is my name?

Claude: I don't have access to personal information about you. Could you
tell me your name?

Claude forgot your name between messages. Each API call is completely independent — Claude has no memory of previous messages unless we explicitly provide them.

Adding Conversation History

The fix is simple but powerful. Instead of sending only the latest message, we send the entire conversation so far. Think of it like giving Claude a notebook with all previous messages written in it. Each time you send a new message, Claude reads the whole notebook to understand the context.

The Anthropic API expects a messages array where each entry has a role ('user' or 'assistant') and content. To maintain context, we build up this array over time.

Here is the type for a message:

interface Message {
  role: 'user' | 'assistant'
  content: string
}

This is a TypeScript interface — a way of defining the shape of an object. It says: "A Message has a role that must be either the string 'user' or the string 'assistant', and a content that must be a string." If you try to create a Message with role: 'bot', TypeScript will flag it as an error before you even run your code.

Update src/index.ts:

// src/index.ts
import Anthropic from '@anthropic-ai/sdk'
import * as readline from 'readline'

const client = new Anthropic()

interface Message {
  role: 'user' | 'assistant'
  content: string
}

const conversationHistory: Message[] = []

const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout,
})

function prompt(question: string): Promise<string> {
  return new Promise(resolve => {
    rl.question(question, (answer: string) => {
      resolve(answer)
    })
  })
}

async function chat(userMessage: string): Promise<string> {
  conversationHistory.push({
    role: 'user',
    content: userMessage,
  })

  const response = await client.messages.create({
    model: 'claude-sonnet-4-20250514',
    max_tokens: 1024,
    messages: conversationHistory,
  })

  const assistantMessage =
    response.content[0].type === 'text' ? response.content[0].text : ''

  conversationHistory.push({
    role: 'assistant',
    content: assistantMessage,
  })

  return assistantMessage
}

async function main(): Promise<void> {
  console.log('\n🤖 AI Chatbot CLI')
  console.log('Type your message and press Enter. Type "exit" to quit.\n')

  while (true) {
    const userInput = await prompt('You: ')

    if (userInput.toLowerCase() === 'exit') {
      console.log('Goodbye! 👋')
      rl.close()
      break
    }

    if (!userInput.trim()) {
      continue
    }

    const response = await chat(userInput)
    console.log(`\nClaude: ${response}\n`)
  }
}

main()

The key changes:

conversationHistory array. const conversationHistory: Message[] = [] creates an empty array that holds Message objects. The : Message[] annotation tells TypeScript this array can only contain objects matching our Message interface.

The chat function. This is the core logic. It:

  1. Pushes the user's message onto the history array
  2. Sends the entire history to the API (not just the latest message)
  3. Pushes Claude's response onto the history array
  4. Returns the response text

Every API call now includes all previous messages, so Claude can reference anything from earlier in the conversation.

Run it again and test:

You: My name is Alex.

Claude: Nice to meet you, Alex! How can I help you today?

You: What is my name?

Claude: Your name is Alex! You just told me. Is there something
specific I can help you with, Alex?

Claude remembers now. The conversation history is what makes this feel like a real chat instead of isolated question-and-answer pairs.

A Note on Token Costs

Every message in the conversation history counts toward your API usage. A 20-message conversation sends all 20 messages on every new turn, which means costs grow quadratically. For a beginner project, this is completely fine. For a production chatbot, you would eventually implement strategies like summarizing older messages or capping history length. That is a topic for an intermediate tutorial.

Streaming Responses

Right now, your chatbot waits for Claude's entire response before displaying anything. For short answers this is fine, but for longer responses you might wait several seconds staring at a blank screen. Streaming fixes this by displaying each word (technically, each token) as it arrives.

The experience difference is dramatic. Without streaming, you wait three seconds and then see a wall of text appear all at once. With streaming, text flows onto the screen in real time, just like watching someone type. It makes the chatbot feel alive and responsive.

Update the chat function to use streaming:

// src/index.ts
import Anthropic from '@anthropic-ai/sdk'
import * as readline from 'readline'

const client = new Anthropic()

interface Message {
  role: 'user' | 'assistant'
  content: string
}

const conversationHistory: Message[] = []

const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout,
})

function prompt(question: string): Promise<string> {
  return new Promise(resolve => {
    rl.question(question, (answer: string) => {
      resolve(answer)
    })
  })
}

async function chat(userMessage: string): Promise<string> {
  conversationHistory.push({
    role: 'user',
    content: userMessage,
  })

  process.stdout.write('\nClaude: ')

  let fullResponse = ''

  const stream = client.messages.stream({
    model: 'claude-sonnet-4-20250514',
    max_tokens: 1024,
    messages: conversationHistory,
  })

  for await (const event of stream) {
    if (
      event.type === 'content_block_delta' &&
      event.delta.type === 'text_delta'
    ) {
      process.stdout.write(event.delta.text)
      fullResponse += event.delta.text
    }
  }

  console.log('\n')

  conversationHistory.push({
    role: 'assistant',
    content: fullResponse,
  })

  return fullResponse
}

async function main(): Promise<void> {
  console.log('\n🤖 AI Chatbot CLI')
  console.log('Type your message and press Enter. Type "exit" to quit.\n')

  while (true) {
    const userInput = await prompt('You: ')

    if (userInput.toLowerCase() === 'exit') {
      console.log('Goodbye! 👋')
      rl.close()
      break
    }

    if (!userInput.trim()) {
      continue
    }

    await chat(userInput)
  }
}

main()

Here is what changed:

client.messages.stream instead of client.messages.create. The stream method returns an async iterable — an object you can loop over with for await...of. Each iteration gives you one "event" from the API.

for await...of loop. This is how you consume a stream in JavaScript. Each time the API sends a new chunk of text, the loop body runs. The for await syntax works just like a regular for...of loop, but it waits for each async value before continuing. If you understand regular for loops, you understand this one.

process.stdout.write instead of console.log. The difference is subtle but important: console.log adds a newline after each call, but process.stdout.write does not. Since we are printing one chunk at a time, we do not want newlines between every few words. We add the newlines manually at the end.

Event filtering. The stream sends multiple event types. We only care about content_block_delta events with a text_delta — those contain the actual text tokens. Other events carry metadata we do not need right now.

Building fullResponse. Even though we print tokens immediately, we still accumulate the complete response in a string. We need this to add to the conversation history.

Run it and you will see Claude's responses appear word by word in real time. It is a small change in code but a huge improvement in how the chatbot feels to use.

Adding Polish

The chatbot works, but a few finishing touches will make it feel more professional and handle edge cases gracefully.

System Prompts

A system prompt tells Claude how to behave throughout the conversation. It is like giving a new employee a job description on their first day. Add a system prompt to the API call:

const stream = client.messages.stream({
  model: 'claude-sonnet-4-20250514',
  max_tokens: 1024,
  system:
    'You are a friendly coding assistant. Keep your answers concise and include code examples when relevant. If the user asks about something dangerous or unethical, politely decline.',
  messages: conversationHistory,
})

The system parameter accepts a plain string. Claude will follow these instructions throughout the entire conversation, no matter what the user asks. This is the primary way to control Claude's personality and behavior.

Error Handling

Network requests can fail. API keys expire. Rate limits get hit. Without error handling, any of these would crash your chatbot with an ugly stack trace. Let us wrap the API call in a try/catch:

async function chat(userMessage: string): Promise<string> {
  conversationHistory.push({
    role: 'user',
    content: userMessage,
  })

  try {
    process.stdout.write('\nClaude: ')

    let fullResponse = ''

    const stream = client.messages.stream({
      model: 'claude-sonnet-4-20250514',
      max_tokens: 1024,
      system:
        'You are a friendly coding assistant. Keep your answers concise and include code examples when relevant.',
      messages: conversationHistory,
    })

    for await (const event of stream) {
      if (
        event.type === 'content_block_delta' &&
        event.delta.type === 'text_delta'
      ) {
        process.stdout.write(event.delta.text)
        fullResponse += event.delta.text
      }
    }

    console.log('\n')

    conversationHistory.push({
      role: 'assistant',
      content: fullResponse,
    })

    return fullResponse
  } catch (error: unknown) {
    console.log('\n')

    // Remove the failed user message from history
    conversationHistory.pop()

    if (error instanceof Anthropic.APIError) {
      if (error.status === 401) {
        console.error(
          'Error: Invalid API key. Check your ANTHROPIC_API_KEY environment variable.\n'
        )
      } else if (error.status === 429) {
        console.error(
          'Error: Rate limit exceeded. Wait a moment and try again.\n'
        )
      } else {
        console.error(`API Error (${error.status}): ${error.message}\n`)
      }
    } else if (error instanceof Error) {
      console.error(`Error: ${error.message}\n`)
    } else {
      console.error('An unexpected error occurred.\n')
    }

    return ''
  }
}

The catch (error: unknown) annotation is TypeScript's strict mode at work. In JavaScript, a catch block can receive literally anything — a string, a number, an Error object. TypeScript's unknown type forces us to check what we got before using it. The instanceof checks narrow the type down so we can safely access specific properties like error.status.

Notice that we also pop the failed user message from conversation history. If we did not, the broken message would be included in every future API call, potentially causing repeated errors.

Graceful Exit on Ctrl+C

Users commonly press Ctrl+C to exit CLI programs. By default, this kills the process abruptly. Let us handle it gracefully:

process.on('SIGINT', () => {
  console.log('\nGoodbye! 👋')
  rl.close()
  process.exit(0)
})

Add this right after creating the readline interface. Now pressing Ctrl+C prints a friendly goodbye instead of an ugly ^C and a stack trace.

Colored Output

A splash of color makes the CLI easier to scan. Node.js supports ANSI color codes natively — no dependencies needed:

const COLORS = {
  reset: '\x1b[0m',
  bright: '\x1b[1m',
  cyan: '\x1b[36m',
  green: '\x1b[32m',
  yellow: '\x1b[33m',
  dim: '\x1b[2m',
}

Use these in your output:

console.log(`${COLORS.bright}${COLORS.cyan}\n🤖 AI Chatbot CLI${COLORS.reset}`)
console.log(
  `${COLORS.dim}Type your message and press Enter. Type "exit" to quit.${COLORS.reset}\n`
)

// In the chat function:
process.stdout.write(`\n${COLORS.green}Claude: ${COLORS.reset}`)

ANSI codes are special character sequences that terminals interpret as formatting instructions. \x1b[36m means "start coloring text cyan" and \x1b[0m means "reset all formatting." Every modern terminal supports them.

The Complete Code

Here is the full, polished chatbot with every feature we have built — conversation history, streaming, system prompt, error handling, colored output, and graceful exit. This is the final version.

// src/index.ts
import Anthropic from '@anthropic-ai/sdk'
import * as readline from 'readline'

// ANSI color codes for terminal output
const COLORS = {
  reset: '\x1b[0m',
  bright: '\x1b[1m',
  cyan: '\x1b[36m',
  green: '\x1b[32m',
  yellow: '\x1b[33m',
  dim: '\x1b[2m',
}

// TypeScript interface defining the shape of a conversation message
interface Message {
  role: 'user' | 'assistant'
  content: string
}

// Initialize the Anthropic client (reads ANTHROPIC_API_KEY from environment)
const client = new Anthropic()

// Conversation history array — this is Claude's "memory"
const conversationHistory: Message[] = []

// System prompt — defines Claude's personality and behavior
const SYSTEM_PROMPT =
  'You are a friendly coding assistant. Keep your answers concise and include code examples when relevant. If you are unsure about something, say so honestly.'

// Create readline interface for terminal input/output
const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout,
})

// Handle Ctrl+C gracefully
process.on('SIGINT', () => {
  console.log(`\n${COLORS.yellow}Goodbye! 👋${COLORS.reset}`)
  rl.close()
  process.exit(0)
})

/**
 * Wraps readline.question in a Promise so we can use async/await
 */
function prompt(question: string): Promise<string> {
  return new Promise(resolve => {
    rl.question(question, (answer: string) => {
      resolve(answer)
    })
  })
}

/**
 * Sends a message to Claude and streams the response to the terminal.
 * Maintains conversation history for multi-turn context.
 */
async function chat(userMessage: string): Promise<string> {
  // Add the user's message to conversation history
  conversationHistory.push({
    role: 'user',
    content: userMessage,
  })

  try {
    process.stdout.write(`\n${COLORS.green}Claude: ${COLORS.reset}`)

    let fullResponse = ''

    // Create a streaming request to Claude
    const stream = client.messages.stream({
      model: 'claude-sonnet-4-20250514',
      max_tokens: 1024,
      system: SYSTEM_PROMPT,
      messages: conversationHistory,
    })

    // Process each streamed event as it arrives
    for await (const event of stream) {
      if (
        event.type === 'content_block_delta' &&
        event.delta.type === 'text_delta'
      ) {
        process.stdout.write(event.delta.text)
        fullResponse += event.delta.text
      }
    }

    console.log('\n')

    // Add Claude's response to conversation history
    conversationHistory.push({
      role: 'assistant',
      content: fullResponse,
    })

    return fullResponse
  } catch (error: unknown) {
    console.log('\n')

    // Remove the failed user message so it doesn't poison future requests
    conversationHistory.pop()

    if (error instanceof Anthropic.APIError) {
      if (error.status === 401) {
        console.error(
          `${COLORS.yellow}Error: Invalid API key. Check your ANTHROPIC_API_KEY environment variable.${COLORS.reset}\n`
        )
      } else if (error.status === 429) {
        console.error(
          `${COLORS.yellow}Error: Rate limit exceeded. Wait a moment and try again.${COLORS.reset}\n`
        )
      } else {
        console.error(
          `${COLORS.yellow}API Error (${error.status}): ${error.message}${COLORS.reset}\n`
        )
      }
    } else if (error instanceof Error) {
      console.error(`${COLORS.yellow}Error: ${error.message}${COLORS.reset}\n`)
    } else {
      console.error(
        `${COLORS.yellow}An unexpected error occurred.${COLORS.reset}\n`
      )
    }

    return ''
  }
}

/**
 * Main function — entry point for the chatbot CLI
 */
async function main(): Promise<void> {
  console.log(
    `${COLORS.bright}${COLORS.cyan}\n🤖 AI Chatbot CLI${COLORS.reset}`
  )
  console.log(
    `${COLORS.dim}Type your message and press Enter. Type "exit" to quit.${COLORS.reset}\n`
  )

  // Main conversation loop
  while (true) {
    const userInput = await prompt(`${COLORS.bright}You: ${COLORS.reset}`)

    // Exit command
    if (userInput.toLowerCase() === 'exit') {
      console.log(`${COLORS.yellow}Goodbye! 👋${COLORS.reset}`)
      rl.close()
      break
    }

    // Skip empty input
    if (!userInput.trim()) {
      continue
    }

    await chat(userInput)
  }
}

// Start the chatbot
main()

Running the Final Version

ANTHROPIC_API_KEY=your-key-here npx ts-node src/index.ts

You now have a fully functional AI chatbot running in your terminal. It remembers your conversation, streams responses in real time, handles errors gracefully, and looks great doing it.

Troubleshooting Common Issues

"Cannot find module '@anthropic-ai/sdk'" — Run npm install in your project directory. Make sure you are in the ai-chatbot-cli folder, not a parent directory.

"ANTHROPIC_API_KEY is not set" — The SDK needs your API key. Either pass it as an environment variable (ANTHROPIC_API_KEY=sk-... npx ts-node src/index.ts) or export it (export ANTHROPIC_API_KEY=sk-...).

"401 Unauthorized" — Your API key is invalid or expired. Generate a new one at console.anthropic.com.

"429 Rate Limited" — You are sending too many requests too fast. Wait 30 seconds and try again. Free tier accounts have lower rate limits.

TypeScript errors about types — Make sure your tsconfig.json has "strict": true and "esModuleInterop": true. Also verify @types/node is installed with npm install -D @types/node.

Streaming output looks garbled — Some terminals do not handle rapid process.stdout.write calls well. Try a different terminal emulator, or remove the streaming and go back to the non-streaming client.messages.create version.

Understanding What You Built

Let us step back and appreciate what just happened. In under 100 lines of TypeScript, you built a program that:

  1. Reads input from the terminal using Node.js readline
  2. Sends messages to one of the world's most advanced AI models via HTTP API
  3. Maintains context by accumulating a message history array
  4. Streams output token by token for a responsive experience
  5. Handles failures with specific error messages for common issues
  6. Exits cleanly whether the user types "exit" or presses Ctrl+C

The concepts here — API clients, message arrays, streaming, error handling — are the same ones used in production AI applications at major companies. The scale is different, but the patterns are identical.

Next Steps

You have a working chatbot. Here are some ideas to take it further:

Add a conversation save/load feature. Write the conversationHistory array to a JSON file when the user exits, and load it when the chatbot starts. Now your chatbot remembers conversations across sessions.

Experiment with system prompts. Change the SYSTEM_PROMPT to make Claude act like a pirate, a Socratic tutor, or a code reviewer who only responds in haiku. System prompts are incredibly powerful for controlling behavior.

Add slash commands. Check if the user's input starts with / and handle special commands: /clear to reset conversation history, /history to display all messages, /model to switch between Claude models mid-conversation.

Limit conversation history. Once the history gets very long, keep only the most recent 20 messages to control token costs. Or implement a summarization step that compresses older messages into a brief summary.

Add tool use. Claude can call functions that you define — for example, reading files, checking the weather, or querying a database. This is called "tool use" and it turns your chatbot from a text generator into an agent that can take actions. The Anthropic documentation has excellent guides on implementing tool use.

Build a multi-model CLI. Add support for switching between Claude Haiku (fast and cheap), Sonnet (balanced), and Opus (most capable) mid-conversation. Compare how different models respond to the same questions.

The complete source code for this tutorial is available at github.com/CrashBytes/ByteSizedExamples/tree/main/ai-chatbot-cli. Clone it, experiment with it, break it, fix it. That is how you learn.

Welcome to the world of building with AI. You just wrote your first chatbot — the first of many.

Last updated: 2/14/2026