How to Build an AI Chatbot with the Claude API and Node.js
Build a production-ready AI chatbot using Anthropic's Claude API and Node.js. Learn prompt engineering, conversation memory, streaming responses, and deployment.
Why Claude Is a Strong Choice for AI Chatbots
When building a conversational AI application, the underlying language model determines the quality of every interaction. Anthropic's Claude stands out for several reasons that matter in production chatbot scenarios.
First, Claude offers an industry-leading context window of up to 200K tokens. This means your chatbot can maintain long, multi-turn conversations without losing track of earlier messages. For customer support bots, educational assistants, or any use case where context matters, this is a decisive advantage.
Second, Claude is designed with safety and helpfulness as core principles. It follows instructions precisely, refuses harmful requests gracefully, and produces responses that are well-structured and natural. For businesses deploying chatbots to real users, this reliability is non-negotiable.
Third, the Claude API is straightforward to integrate. The official SDK for Node.js handles authentication, retries, and streaming out of the box, letting you focus on building features instead of wrestling with HTTP plumbing.
In this tutorial, you will build a fully functional AI chatbot from scratch using the Claude API and Node.js. By the end, you will have a working application with conversation memory, streaming responses, and a web interface ready for deployment.
Getting Your Anthropic API Key
Before writing any code, you need access to the Claude API.
- Visit console.anthropic.com and create an account.
- Navigate to API Keys in the dashboard sidebar.
- Click Create Key, give it a descriptive name like "chatbot-dev", and copy the key immediately. You will not be able to see it again.
- Store the key securely. Never commit it to version control.
Anthropic offers a free tier with limited usage, which is more than enough to follow this tutorial. For production workloads, you will want to add billing information and select a plan that fits your expected volume.
Setting Up the Node.js Project
Create a new project directory and initialize it:
// Terminal commands (run these in your shell)
// mkdir claude-chatbot && cd claude-chatbot
// npm init -y
// npm install @anthropic-ai/sdk express dotenv
Create a .env file in the project root to store your API key:
ANTHROPIC_API_KEY=sk-ant-your-key-here
PORT=3000
Add .env to your .gitignore file immediately:
node_modules/
.env
Now create the entry point file, index.js, and initialize the Anthropic client:
require("dotenv").config();
const Anthropic = require("@anthropic-ai/sdk").default;
const client = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
});
The SDK reads ANTHROPIC_API_KEY from the environment automatically, but passing it explicitly makes your code clearer and easier to debug.
Making Your First API Call
Let's start with a simple, single-turn request to confirm everything works:
async function chat(userMessage) {
const response = await client.messages.create({
model: "claude-sonnet-4-20250514",
max_tokens: 1024,
messages: [
{ role: "user", content: userMessage },
],
});
return response.content[0].text;
}
chat("What is Node.js?").then(console.log);
Run this with node index.js. If you see a coherent explanation of Node.js printed to the console, your setup is working. A few things to note about the request parameters:
- model: Specifies which Claude model to use.
claude-sonnet-4-20250514offers a strong balance of speed and capability. Useclaude-opus-4-20250514when you need maximum reasoning quality. - max_tokens: The upper limit on the response length. Set this based on your use case to control costs.
- messages: An array of message objects, each with a
role("user" or "assistant") andcontent.
Building Conversation Memory
A single-turn bot is not much of a chatbot. Real conversations require memory. The Claude API is stateless, meaning it does not remember previous requests. You must send the full conversation history with every API call.
Here is a Conversation class that manages this:
class Conversation {
constructor(systemPrompt = "") {
this.systemPrompt = systemPrompt;
this.history = [];
}
async sendMessage(userMessage) {
this.history.push({ role: "user", content: userMessage });
const response = await client.messages.create({
model: "claude-sonnet-4-20250514",
max_tokens: 2048,
system: this.systemPrompt,
messages: this.history,
});
const assistantMessage = response.content[0].text;
this.history.push({ role: "assistant", content: assistantMessage });
return assistantMessage;
}
clearHistory() {
this.history = [];
}
}
Each time the user sends a message, it is appended to the history array. The assistant's reply is also appended, so the next request includes the full thread. Claude sees the entire conversation and can refer back to earlier messages naturally.
For long-running conversations, monitor the token count. If the history grows too large, you can truncate older messages or summarize them to stay within model limits.
Streaming Responses for Real-Time UX
Waiting for a complete response before showing anything to the user creates an awkward pause, especially for longer answers. Streaming sends tokens to the client as they are generated, producing a natural "typing" effect.
async function streamMessage(conversation, userMessage) {
conversation.history.push({ role: "user", content: userMessage });
const stream = await client.messages.stream({
model: "claude-sonnet-4-20250514",
max_tokens: 2048,
system: conversation.systemPrompt,
messages: conversation.history,
});
let fullResponse = "";
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;
}
}
conversation.history.push({ role: "assistant", content: fullResponse });
console.log(); // newline after streaming completes
return fullResponse;
}
The SDK's .stream() method returns an async iterable. You process each delta event as it arrives, writing partial text to the output immediately. The accumulated response is then saved to conversation history for continuity.
System Prompts and Prompt Engineering Tips
The system prompt shapes your chatbot's personality, knowledge boundaries, and behavior. It is the single most impactful piece of configuration in your application.
const systemPrompt = `You are a helpful customer support assistant for TechStore,
an online electronics retailer.
Guidelines:
- Be friendly, concise, and professional.
- If asked about order status, ask for the order number.
- Never make up product information. If unsure, say so.
- For billing disputes, escalate to a human agent.
- Keep responses under 150 words unless the user asks for detail.`;
const conversation = new Conversation(systemPrompt);
Here are practical prompt engineering tips that make a measurable difference:
- Be specific about format: If you want bullet points, numbered steps, or short paragraphs, say so explicitly.
- Define boundaries: Tell the model what it should refuse or redirect. This prevents hallucination in domains where accuracy matters.
- Use role framing: "You are a..." is more effective than "Please act like...". It sets a consistent identity.
- Include examples: For nuanced behavior, show the model an example input and ideal output within the system prompt.
- Iterate and test: Prompt engineering is empirical. Change one thing at a time, test with real queries, and measure the results.
Adding a Web Interface with Express
Let's wrap the chatbot in a simple HTTP API so it can power a web frontend:
const express = require("express");
const app = express();
app.use(express.json());
app.use(express.static("public"));
// Store conversations by session ID
const conversations = new Map();
app.post("/api/chat", async (req, res) => {
const { message, sessionId } = req.body;
if (!message || !sessionId) {
return res.status(400).json({ error: "message and sessionId are required" });
}
if (!conversations.has(sessionId)) {
conversations.set(
sessionId,
new Conversation("You are a helpful AI assistant. Be concise and clear.")
);
}
const conversation = conversations.get(sessionId);
try {
const reply = await conversation.sendMessage(message);
res.json({ reply });
} catch (error) {
console.error("Claude API error:", error.message);
res.status(500).json({ error: "Failed to generate a response." });
}
});
// Streaming endpoint using Server-Sent Events
app.post("/api/chat/stream", async (req, res) => {
const { message, sessionId } = req.body;
if (!message || !sessionId) {
return res.status(400).json({ error: "message and sessionId are required" });
}
if (!conversations.has(sessionId)) {
conversations.set(
sessionId,
new Conversation("You are a helpful AI assistant. Be concise and clear.")
);
}
const conversation = conversations.get(sessionId);
conversation.history.push({ role: "user", content: message });
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
try {
const stream = await client.messages.stream({
model: "claude-sonnet-4-20250514",
max_tokens: 2048,
system: conversation.systemPrompt,
messages: conversation.history,
});
let fullResponse = "";
for await (const event of stream) {
if (
event.type === "content_block_delta" &&
event.delta.type === "text_delta"
) {
fullResponse += event.delta.text;
res.write(`data: ${JSON.stringify({ text: event.delta.text })}\n\n`);
}
}
conversation.history.push({ role: "assistant", content: fullResponse });
res.write("data: [DONE]\n\n");
res.end();
} catch (error) {
console.error("Streaming error:", error.message);
res.write(`data: ${JSON.stringify({ error: "Stream failed" })}\n\n`);
res.end();
}
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Chatbot server running on http://localhost:${PORT}`);
});
This gives you two endpoints: /api/chat for simple request-response interactions, and /api/chat/stream for real-time streaming via Server-Sent Events. A frontend can consume the streaming endpoint with the EventSource API or a fetch call reading the response body as a stream.
Error Handling and Rate Limiting
Production applications must handle failures gracefully. The Claude API can return errors for several reasons: rate limits, invalid requests, network issues, or server-side problems.
async function sendWithRetry(conversation, message, maxRetries = 3) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await conversation.sendMessage(message);
} catch (error) {
if (error.status === 429) {
// Rate limited - wait with exponential backoff
const delay = Math.pow(2, attempt) * 1000;
console.warn(`Rate limited. Retrying in ${delay}ms...`);
await new Promise((resolve) => setTimeout(resolve, delay));
} else if (error.status >= 500) {
// Server error - retry
const delay = attempt * 1000;
console.warn(`Server error. Retrying in ${delay}ms...`);
await new Promise((resolve) => setTimeout(resolve, delay));
} else {
// Client error - do not retry
throw error;
}
}
}
throw new Error("Max retries exceeded");
}
Beyond retry logic, implement these safeguards for production:
- Input validation: Limit message length to prevent abuse and control token costs. A reasonable cap is 4,000 characters per message.
- Rate limiting per user: Use a library like
express-rate-limitto cap requests per session or IP address. A starting point is 20 requests per minute per user. - Conversation length limits: Cap the history array at a reasonable size (e.g., 50 messages) and trim from the beginning when exceeded.
- Timeout handling: Set a request timeout so a single slow response does not block your server indefinitely.
- Cost monitoring: Log token usage from API responses (
response.usage.input_tokensandresponse.usage.output_tokens) to track spending.
Deploying to Production
Once your chatbot is working locally, deploy it to a cloud platform. Here is a straightforward path using a Node.js hosting provider:
Environment variables: Set ANTHROPIC_API_KEY and PORT in your hosting provider's environment configuration. Never include secrets in your codebase or Docker image.
Process management: Use a process manager like PM2 or rely on your platform's built-in process management. Ensure the app restarts on crashes.
HTTPS: Always serve your chatbot over HTTPS in production. Most platforms handle TLS termination automatically.
Persistent sessions: The in-memory Map used for conversations is fine for development, but it will not survive server restarts. For production, store conversation history in a database like PostgreSQL or Redis. This also enables horizontal scaling across multiple server instances.
Monitoring: Add structured logging and connect to a monitoring service. Track response times, error rates, and token usage. Set up alerts for anomalies.
A minimal deployment checklist:
- Set environment variables on the hosting platform.
- Ensure
node_modulesis not deployed (usenpm ciin the build step). - Configure health check endpoints.
- Set up logging and error tracking.
- Test the deployed endpoint with curl or Postman before pointing your frontend to it.
Where to Go from Here
You now have a working AI chatbot with conversation memory, streaming responses, prompt engineering, a web API layer, and a path to production deployment. From here, you can extend it with tool use (letting Claude call external APIs), retrieval-augmented generation (feeding in your own documents), or multi-modal inputs like images.
Building AI-powered applications is one of the most in-demand skills in software engineering today. If you want to go deeper into production AI systems, prompt engineering patterns, and building real-world applications with large language models, check out the AI Engineering course at Mctaba Academy. The course covers everything from foundational concepts to deploying production-grade AI applications, with hands-on projects that go well beyond tutorials.
Bonaventure Ogeto
Founder, Mctaba Labs
Software engineer building products for the African market. Teaching 10,000+ students across multiple platforms. BSc Mathematics & Computer Science from JKUAT.