Build an AI Writing Assistant in JS
Build a Node.js app that streams Claude AI responses in real-time.
Introduction
30 Second Summary
Every day, millions of people use AI writing tools to draft emails, brainstorm ideas, and polish their prose. Behind each of those tools is a simple pattern: a server that talks to an AI model and streams the response back to the browser.
In this project, you will build your own AI writing assistant from scratch using JavaScript and the Claude API. You'll have a working web app where you type a prompt and watch the AI's response appear word by word in real time.
What You'll Build
A local web application with a Node.js/Express backend that securely calls the Claude API and an HTML/CSS/JS frontend that displays streamed AI responses in a chat-style interface.
By the end of this project, you'll have:
- A Node.js/Express server that securely proxies requests to the Claude API.
- A browser-based chat interface styled with HTML and CSS.
- Real-time streaming of AI responses using Server-Sent Events.
- Secret Mission: Add conversation memory so Claude remembers previous messages in the session.
Are there any prerequisites?
None. This guide includes full setup instructions for every tool you need, including Node.js, your code editor, and the Claude API key.
Set Up Your Development Environment
Before you can build your AI writing assistant, you need a few tools in place. Node.js runs JavaScript outside the browser, and the Claude API gives your app access to AI-generated writing.
In this step, you will install Node.js, grab your Claude API key, and create the project folder that holds your app.
In this step, get ready to:
- Install Node.js and verify it works.
- Get your Claude API key and store it as an environment variable.
- Create and initialize the project directory.
Install Node.js
Node.js lets you run JavaScript on your computer instead of only in a browser. The Anthropic SDK requires version 18 or higher.
- Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), typing Terminal (macOS) or PowerShell (Windows), and pressing Enter.
- Check whether Node.js is already installed by running this command:
node --version
✔️ I see version 18 or higher
You are all set. Any version number starting with v18, v20, v22, v24, or higher means Node.js is ready.
- Also confirm npm is available by running:
npm --version
You should see a version number printed. npm ships with Node.js, so if Node is installed, npm will be too.
ⓧ I see a version lower than 18
Your installed version is too old for the Anthropic SDK. You need to upgrade to version 18 or higher.
macOS
- Download the latest LTS installer from https://nodejs.org/en/download.
- Run the .pkg installer and follow the prompts.
- Alternatively, if you use Homebrew, run:
brew install node
- After installing, close and reopen your terminal, then run node --version again to confirm the new version.
Windows
- Download the latest LTS installer from https://nodejs.org/en/download.
- Run the .msi installer and accept the defaults.
- Close and reopen PowerShell, then run node --version again to confirm the new version.
ⓧ Command not found
Node.js is not installed yet. Follow the instructions below for your operating system.
macOS
- Download the latest LTS installer from https://nodejs.org/en/download.
- Run the .pkg installer and follow the prompts.
- Alternatively, if you use Homebrew, run:
brew install node
- After installing, close and reopen your terminal, then run node --version to confirm.
Windows
- Download the latest LTS installer from https://nodejs.org/en/download.
- Run the .msi installer and accept the defaults.
- Close and reopen PowerShell, then run node --version to confirm.
Get your Claude API key
Your app needs an API key to authenticate with Claude. The key lives on your server, never in the browser, so it stays secret.
- Open https://platform.claude.com in your browser.
- Sign up for a free account if you do not already have one.
- Once signed in, navigate to Settings, then API Keys.
- Click Create Key and give it a name (for example, writing-assistant).
- Copy the key immediately. You will not be able to see it again.
What is an API key?
An API key is a long string of characters that proves your app has permission to use a service. It works like a password between your server and the Claude API.
Anthropic charges per token processed, so keep your key private. Anyone with your key could make requests on your account.
Now export the key as an environment variable so your code can read it without hardcoding it into a file.
macOS
- In your terminal, run the following command, replacing your-api-key-here with the key you just copied:
export ANTHROPIC_API_KEY="your-api-key-here"
- Verify it is set by running:
echo $ANTHROPIC_API_KEY
You should see your key printed back in the terminal.
Windows
- In PowerShell, run the following command, replacing your-api-key-here with the key you just copied:
$env:ANTHROPIC_API_KEY = "your-api-key-here"
- Verify it is set by running:
echo $env:ANTHROPIC_API_KEY
You should see your key printed back in the terminal.
Why use an environment variable?
Environment variables keep secrets out of your source code. If you ever push your project to GitHub, the key stays on your machine instead of leaking into a public repository.
The Anthropic SDK automatically reads ANTHROPIC_API_KEY from your environment, so you do not need to pass it manually in your code.
Key not showing up?
- Make sure you included the quotes around your key in the export command.
- On macOS, the variable only lasts for the current terminal session. If you close the terminal and reopen it, you will need to run the export command again.
- On Windows, use $env:ANTHROPIC_API_KEY (not $ANTHROPIC_API_KEY). PowerShell uses a different syntax than macOS.
Help me troubleshoot my ANTHROPIC_API_KEY environment variable
Create and initialize the project
With Node.js and your API key ready, you can now create the folder that will hold your entire writing assistant app.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir ai-writing-assistant
cd ai-writing-assistant
- Confirm the folder exists by running:
ls
You should see the ai-writing-assistant folder listed if you are still in the Desktop directory. If you already moved into it, the folder will be empty for now.
- Initialize a new Node.js project and enable ES module syntax by running:
npm init -y
npm pkg set type=module
What do these commands do?
- npm init -y creates a package.json file with default settings. This file tracks your project's name, version, and dependencies.
- npm pkg set type=module adds "type": "module" to your package.json. This lets you use modern import statements instead of the older require() syntax.
- Verify the file was created by running:
cat package.json
You should see a JSON file with "name": "ai-writing-assistant" and "type": "module" among its fields.
Seeing an error from npm init?
- Make sure you are inside the ai-writing-assistant folder. Run pwd to check your current directory.
- If you see a permissions error, try running the command without sudo. npm should not require elevated permissions for project-level commands.
Help me fix an error when running npm init in my project folder
✔️ Awesome, I've got everything!
Great. Make sure you have saved your work and your terminal is still inside the ai-writing-assistant folder.
ⓧ I'd like to double check the full code
Your package.json should contain at least these fields. Your file may have additional fields like description, main, keywords, author, and license generated by npm init. That is fine.
{
"name": "ai-writing-assistant",
"version": "1.0.0",
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
}
}
Your development environment is ready. Next up, you will build the Express backend that talks to the Claude API and returns AI-generated responses.
Build the Express Backend
Your project directory is ready. Now you need a server that sits between your browser and the Claude API.
The server's job is to receive messages from the frontend, forward them to Claude securely (keeping your API key hidden), and return Claude's response. You'll build this with Express, a lightweight web framework for Node.js.
In this step, get ready to:
- Install the Anthropic SDK and Express as project dependencies.
- Create a server that accepts chat messages and calls the Claude API.
- Test the server with a real API request using curl.
Install dependencies and set up the start script
Your server needs two packages: the official Anthropic SDK to communicate with Claude, and Express to handle HTTP requests.
- Install both packages by running this command:
npm install @anthropic-ai/sdk express
- You should see output showing both packages being added to your project. A node_modules folder and a package-lock.json file now appear in your directory.
Seeing permission errors?
On macOS, try prefixing the command with sudo. On Windows, run your terminal as Administrator. If you see network errors, check your internet connection and try again.
Help me troubleshoot this npm install error
- Add a convenient start script and remove the placeholder test script by running these commands:
npm pkg set scripts.start="node server.js"
npm pkg delete scripts.test
What do these commands do?
The first command adds a start script to your package.json so you can later run your server with npm start. The second removes the default placeholder test script that npm init created.
Create the Express server with a chat endpoint
This is the core of your backend. You'll create a single file that initializes the Anthropic client, sets up Express, and defines an endpoint that the frontend will call to get AI responses.
- Create a new file called server.js in the root of your ai-writing-assistant folder.
- Copy the following setup code into server.js:
import Anthropic from "@anthropic-ai/sdk";
import express from "express";
// Initialize Express app and Anthropic client
const app = express();
const client = new Anthropic();
// Parse incoming JSON request bodies
app.use(express.json());
// Serve frontend files from the public/ directory
app.use(express.static("public"));
What does this code do?
The first two lines import the Anthropic SDK and Express. new Anthropic() automatically reads your ANTHROPIC_API_KEY environment variable, so you never hardcode your key.
The express.json() middleware lets the server parse JSON from incoming requests. The express.static("public") middleware serves your frontend HTML files from a public/ folder (you'll create this in the next step).
- Below the setup code, add the chat endpoint that receives messages and calls Claude:
// Handle chat requests from the frontend
app.post("/api/chat", async (req, res) => {
try {
// Extract the user's message and model preference
const { message, model } = req.body;
const selectedModel = model || "claude-haiku-4-5-20241022";
// Send the message to Claude and wait for a response
const response = await client.messages.create({
model: selectedModel,
max_tokens: 1024,
messages: [{ role: "user", content: message }],
});
// Return Claude's text response as JSON
res.json({ response: response.content[0].text });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
What does this endpoint do?
This defines a POST route at /api/chat. When the frontend sends a message, the server extracts it from the request body and forwards it to Claude using client.messages.create().
The model defaults to claude-haiku-4-5-20241022 (fast and cheap). The try/catch block handles errors gracefully so the server doesn't crash if something goes wrong with the API call.
Why does the AI call happen on the server?
API keys must never be exposed in browser code. Anyone could open DevTools, find your key, and use it to rack up charges on your account.
The server acts as a secure proxy. Your browser talks to your server, your server talks to Claude. The key stays safely in your server's environment.
- At the bottom of server.js, add the server startup code:
// Start the server on port 3000
const PORT = 3000;
app.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}`);
});
What does this do?
This tells Express to start listening for requests on port 3000. Once running, you'll access your app at http://localhost:3000.
- Save server.js.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
Here is the complete server.js file:
import Anthropic from "@anthropic-ai/sdk";
import express from "express";
const app = express();
const client = new Anthropic();
app.use(express.json());
app.use(express.static("public"));
app.post("/api/chat", async (req, res) => {
try {
const { message, model } = req.body;
const selectedModel = model || "claude-haiku-4-5-20241022";
const response = await client.messages.create({
model: selectedModel,
max_tokens: 1024,
messages: [{ role: "user", content: message }],
});
res.json({ response: response.content[0].text });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
const PORT = 3000;
app.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}`);
});
Test the server with curl
Your server is complete. Time to start it up and send a real request to Claude through it.
- Start the server by running:
node server.js
- You should see this message in your terminal:
Server running at http://localhost:3000
- Open a second terminal window. Press Cmd+T (macOS) or Ctrl+Shift+T (Windows) to open a new tab in your terminal.
- Send a test request to your server by running this curl command:
curl -X POST http://localhost:3000/api/chat -H "Content-Type: application/json" -d '{"message": "Say hello in one sentence"}'
- You should see a JSON response containing Claude's generated text, something like:
{"response": "Hello! It's wonderful to meet you."}
What just happened?
Your curl command sent a POST request to your Express server. The server received the message, forwarded it to Claude's API using your ANTHROPIC_API_KEY, and returned Claude's response as JSON.
This is the exact same flow your frontend will use. The only difference is that your browser will make the request instead of curl.
Not getting a response?
If you see Connection refused, make sure your server is still running in the first terminal window. Look for the "Server running" message.
If you see an error about an invalid API key, verify your environment variable is set. Run echo $ANTHROPIC_API_KEY in the terminal where you started the server. If it's empty, re-export your key and restart the server.
Help me debug my Express server
Your backend is up and running. Next up, you'll build the frontend chat interface that sends messages to this endpoint and displays Claude's responses.
Build the Frontend Chat Interface
Your backend is ready to receive messages and return AI-generated responses. Now you need a way for users to actually interact with it.
In this step, you'll build a browser-based chat interface that sends messages to your Express server and displays Claude's responses. The frontend is a single HTML file served directly by Express using the static middleware you already configured.
In this step, get ready to:
- Create the HTML chat interface with a clean, centered layout.
- Add JavaScript that sends user messages to your backend and displays responses.
- Implement a loading state that disables input while Claude is thinking.
Create the public folder and HTML structure
Express is already configured to serve static files from a public/ directory. Any file you place in that folder becomes accessible in the browser.
- Create the public folder inside your ai-writing-assistant directory by running this command:
mkdir public
- Create a new file called index.html inside the public folder. Paste in the following HTML and CSS:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Writing Assistant</title>
<style>
/* Reset default browser spacing */
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f5f5f5;
height: 100vh;
display: flex;
flex-direction: column;
}
/* Scrollable area where messages appear */
#chat-container {
flex: 1;
overflow-y: auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
/* Shared message bubble styles */
.message {
max-width: 720px;
width: 100%;
margin: 0 auto;
padding: 12px 16px;
border-radius: 12px;
line-height: 1.6;
white-space: pre-wrap;
}
/* User messages get a dark background */
.message.user { background: #1a1a2e; color: white; }
/* Assistant messages get a light card look */
.message.assistant { background: white; border: 1px solid #e0e0e0; }
/* Fixed input area at the bottom */
#input-area {
border-top: 1px solid #e0e0e0;
padding: 16px 24px;
background: white;
}
#input-form {
max-width: 720px;
margin: 0 auto;
display: flex;
gap: 12px;
}
#message-input {
flex: 1;
padding: 12px 16px;
border-radius: 8px;
border: 1px solid #ddd;
font-size: 15px;
}
#send-btn {
padding: 12px 24px;
border-radius: 8px;
border: none;
background: #1a1a2e;
color: white;
font-size: 15px;
cursor: pointer;
}
#send-btn:disabled { background: #999; cursor: not-allowed; }
</style>
</head>
<body>
<div id="chat-container"></div>
<div id="input-area">
<form id="input-form">
<input type="text" id="message-input" placeholder="Ask me to write something..." autocomplete="off">
<button type="submit" id="send-btn">Send</button>
</form>
</div>
<script>
</script>
</body>
</html>
What does this HTML do?
- chat-container is an empty scrollable area where message bubbles will appear.
- input-form holds the text input and send button, pinned to the bottom of the page.
- The CSS uses flexbox to make the chat area fill all available height and push the input bar to the bottom.
- User messages get a dark background and assistant messages get a white card style, making it easy to tell who said what.
- Save public/index.html.
- Start your server by running:
node server.js
- Open http://localhost:3000 in your browser. You should see a light gray page with a text input and a Send button pinned to the bottom.
Seeing a blank page or "Cannot GET /"?
- Make sure you saved the file as public/index.html (inside the public folder, not in the project root).
- Confirm your server is running. If you see Server running at http://localhost:3000 in the terminal, Express is serving from the public directory automatically.
- help me debug why my page isn't loading at localhost:3000
Add JavaScript for message handling and loading state
The page has structure, but no behavior yet. You need JavaScript to capture what the user types, send it to your backend, and display the response.
- In public/index.html, find the empty <script> tag. Replace it with the following:
<script>
// Grab references to DOM elements
const chatContainer = document.getElementById("chat-container");
const inputForm = document.getElementById("input-form");
const messageInput = document.getElementById("message-input");
const sendBtn = document.getElementById("send-btn");
// Create a message bubble and append it to the chat
function addMessage(role, text) {
const div = document.createElement("div");
div.className = `message ${role}`;
div.textContent = text;
chatContainer.appendChild(div);
chatContainer.scrollTop = chatContainer.scrollHeight;
return div;
}
// Disable or enable the input while waiting for a response
function setLoading(loading) {
sendBtn.disabled = loading;
messageInput.disabled = loading;
if (loading) sendBtn.textContent = "Thinking...";
else sendBtn.textContent = "Send";
}
// Listen for form submission
inputForm.addEventListener("submit", async (e) => {
e.preventDefault();
const text = messageInput.value.trim();
if (!text) return;
addMessage("user", text);
messageInput.value = "";
setLoading(true);
try {
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: text }),
});
const data = await response.json();
addMessage("assistant", data.response);
} catch (error) {
addMessage("assistant", `Error: ${error.message}`);
}
setLoading(false);
});
</script>
What does this JavaScript do?
- addMessage() creates a new <div> with either the user or assistant class and appends it to the chat area. The CSS you wrote earlier styles each class differently.
- setLoading() disables the input field and button while the request is in progress. It also swaps the button text to "Thinking..." so the user knows something is happening.
- The event listener intercepts the form submission, sends the user's text to /api/chat via a POST request, and displays Claude's response when it arrives.
- If the request fails (network error, server down), the catch block shows the error as a message bubble instead of crashing silently.
Why does the loading state matter?
API calls take a few seconds to complete. Without a loading state, the user could click Send repeatedly and flood your server with duplicate requests. Disabling the input prevents this and gives clear visual feedback that the app is working.
- Save public/index.html.
- If your server is still running, refresh the browser at http://localhost:3000. If you stopped it, run node server.js again first.
- Type a prompt like Write me a one-sentence thank you note and click Send. You should see your message appear in a dark bubble, the button change to "Thinking...", and then Claude's response appear in a white bubble.
Not getting a response from Claude?
- Check your terminal for error messages. If you see 401, your ANTHROPIC_API_KEY environment variable is missing or incorrect. Re-export it in the same terminal where you run the server.
- If the button stays on "Thinking..." indefinitely, your server may not be running. Check the terminal for the Server running at http://localhost:3000 message.
- Open your browser's developer console (Cmd+Option+J on macOS or Ctrl+Shift+J on Windows) to see any network errors logged there.
- help me debug why my chat app isn't getting a response from Claude
✔️ Awesome, I've got everything!
Great. Double check you've saved public/index.html and your chat is working.
ⓧ I'd like to double check the full code
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Writing Assistant</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f5f5f5;
height: 100vh;
display: flex;
flex-direction: column;
}
#chat-container {
flex: 1;
overflow-y: auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.message {
max-width: 720px;
width: 100%;
margin: 0 auto;
padding: 12px 16px;
border-radius: 12px;
line-height: 1.6;
white-space: pre-wrap;
}
.message.user { background: #1a1a2e; color: white; }
.message.assistant { background: white; border: 1px solid #e0e0e0; }
#input-area {
border-top: 1px solid #e0e0e0;
padding: 16px 24px;
background: white;
}
#input-form {
max-width: 720px;
margin: 0 auto;
display: flex;
gap: 12px;
}
#message-input {
flex: 1;
padding: 12px 16px;
border-radius: 8px;
border: 1px solid #ddd;
font-size: 15px;
}
#send-btn {
padding: 12px 24px;
border-radius: 8px;
border: none;
background: #1a1a2e;
color: white;
font-size: 15px;
cursor: pointer;
}
#send-btn:disabled { background: #999; cursor: not-allowed; }
</style>
</head>
<body>
<div id="chat-container"></div>
<div id="input-area">
<form id="input-form">
<input type="text" id="message-input" placeholder="Ask me to write something..." autocomplete="off">
<button type="submit" id="send-btn">Send</button>
</form>
</div>
<script>
const chatContainer = document.getElementById("chat-container");
const inputForm = document.getElementById("input-form");
const messageInput = document.getElementById("message-input");
const sendBtn = document.getElementById("send-btn");
function addMessage(role, text) {
const div = document.createElement("div");
div.className = `message ${role}`;
div.textContent = text;
chatContainer.appendChild(div);
chatContainer.scrollTop = chatContainer.scrollHeight;
return div;
}
function setLoading(loading) {
sendBtn.disabled = loading;
messageInput.disabled = loading;
if (loading) sendBtn.textContent = "Thinking...";
else sendBtn.textContent = "Send";
}
inputForm.addEventListener("submit", async (e) => {
e.preventDefault();
const text = messageInput.value.trim();
if (!text) return;
addMessage("user", text);
messageInput.value = "";
setLoading(true);
try {
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: text }),
});
const data = await response.json();
addMessage("assistant", data.response);
} catch (error) {
addMessage("assistant", `Error: ${error.message}`);
}
setLoading(false);
});
</script>
</body>
</html>
Your writing assistant now has a working conversation loop. You type a message, the frontend sends it to Express, Express calls Claude, and the response appears in the chat. Next up, you'll make responses stream in word-by-word instead of waiting for the full response to arrive.
Add Real-Time Streaming
Right now, your assistant waits for Claude to finish generating the entire response before showing anything to the user. That creates an awkward silence that makes the app feel slow.
Real AI applications stream text as it generates, showing words appearing in real-time. In this step, you'll add a streaming endpoint using Server-Sent Events and update the frontend to display text chunk-by-chunk as it arrives from Claude.
In this step, get ready to:
- Add a streaming API endpoint to your Express server.
- Update the frontend to consume streamed responses in real-time.
- Add a blinking cursor animation while Claude is generating.
Add the streaming endpoint to your server
The Anthropic SDK provides a client.messages.stream() method that emits text chunks as they generate, instead of waiting for the full response. You'll wrap this in a new endpoint that forwards each chunk to the browser using the SSE protocol.
- In server.js, find the closing }); of your existing /api/chat endpoint.
- Add the following new endpoint below it, before the const PORT = 3000; line:
app.post("/api/chat/stream", async (req, res) => {
try {
const { messages, model } = req.body;
const selectedModel = model || "claude-haiku-4-5-20241022";
// Set headers for Server-Sent Events
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
// Use stream() instead of create() to get text as it generates
const stream = client.messages.stream({
model: selectedModel,
max_tokens: 1024,
messages: messages,
});
// Send each text chunk to the browser as an SSE data event
stream.on("text", (text) => {
res.write(`data: ${JSON.stringify({ text })}\n\n`);
});
// Signal completion so the frontend knows to stop listening
stream.on("end", () => {
res.write(`data: ${JSON.stringify({ done: true })}\n\n`);
res.end();
});
// Forward any errors to the frontend
stream.on("error", (error) => {
res.write(`data: ${JSON.stringify({ error: error.message })}\n\n`);
res.end();
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
What does this code do?
This endpoint works differently from your original /api/chat endpoint. Instead of waiting for the full response and sending it as JSON, it keeps the connection open and sends data in real-time.
- The three setHeader calls tell the browser to expect a stream of events, not a single response.
- client.messages.stream() returns a stream object that fires events as Claude generates text.
- Each "text" event sends one chunk to the browser. The "end" event signals that generation is complete.
- Save server.js.
You won't see a change yet. The next chunks update the frontend to use this new streaming endpoint.
Why Server-Sent Events instead of WebSockets?
SSE is a simpler protocol that works over regular HTTP. It's one-directional (server to client), which is exactly what you need here. The server sends text chunks and the browser listens.
WebSockets would add complexity for no benefit since the user only sends one message, then listens for the streamed response.
Add the cursor animation styles
Before updating the JavaScript, you'll add CSS for a blinking cursor that shows while Claude is generating text. This gives the user a visual indicator that more content is on the way.
- In public/index.html, find this CSS rule inside the <style> tag:
.message.assistant { background: white; border: 1px solid #e0e0e0; }
- Add the following CSS directly below it:
.cursor {
display: inline-block;
width: 2px;
height: 16px;
background: #333;
animation: blink 1s infinite;
vertical-align: text-bottom;
margin-left: 2px;
}
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
What does this code do?
The .cursor class creates a thin vertical bar that blinks on and off every second. The JavaScript you'll add next creates a <span> element with this class and appends it to the assistant's message while streaming is in progress.
- Save public/index.html.
You won't see the cursor yet. The next chunk adds the JavaScript that creates and removes it during streaming.
Update the frontend JavaScript to stream responses
Now you'll replace the existing submit handler so it uses the new streaming endpoint. Instead of waiting for one big JSON response, the frontend reads the stream chunk by chunk and appends text to the page as it arrives.
- In public/index.html, find the setLoading function inside the <script> tag:
function setLoading(loading) {
sendBtn.disabled = loading;
messageInput.disabled = loading;
if (loading) sendBtn.textContent = "Thinking...";
else sendBtn.textContent = "Send";
}
- Replace the entire setLoading function with this simplified version:
function setLoading(loading) {
sendBtn.disabled = loading;
messageInput.disabled = loading;
}
Why simplify setLoading?
With streaming, the blinking cursor provides the visual feedback that the assistant is working. You no longer need the button text to change to "Thinking..." because the cursor in the chat area communicates that more content is on the way.
- Now find the entire inputForm.addEventListener("submit", ...) block and replace it with the following streaming version:
inputForm.addEventListener("submit", async (e) => {
e.preventDefault();
const text = messageInput.value.trim();
if (!text) return;
addMessage("user", text);
messageInput.value = "";
setLoading(true);
// Create an empty assistant message and add a blinking cursor
const assistantDiv = addMessage("assistant", "");
const cursor = document.createElement("span");
cursor.className = "cursor";
assistantDiv.appendChild(cursor);
try {
// Fetch the streaming endpoint
const response = await fetch("/api/chat/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: [{ role: "user", content: text }] }),
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
let fullResponse = "";
let buffer = "";
// Read chunks from the stream as they arrive
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop();
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = JSON.parse(line.slice(6));
if (data.done) break;
if (data.error) throw new Error(data.error);
if (data.text) {
fullResponse += data.text;
assistantDiv.textContent = fullResponse;
assistantDiv.appendChild(cursor);
chatContainer.scrollTop = chatContainer.scrollHeight;
}
}
}
}
// Remove the cursor when streaming is complete
cursor.remove();
} catch (error) {
cursor.remove();
assistantDiv.textContent = `Error: ${error.message}`;
}
setLoading(false);
});
What does this code do?
- It creates an empty assistant message div and appends a blinking cursor span immediately, so the user sees something happening right away.
- response.body.getReader() gives you a ReadableStream reader that yields chunks as the server sends them.
- Each chunk is decoded and split by newlines. Lines starting with data: are SSE events containing JSON with the text delta.
- assistantDiv.textContent = fullResponse replaces the entire content each time so the text builds up visually. The cursor is re-appended after each update.
- When the stream ends (data.done is true or the reader is done), the cursor is removed.
- Save public/index.html.
- Switch to your terminal, stop the server with Ctrl+C, and restart it by running:
node server.js
- Open http://localhost:3000 in your browser (or refresh if already open).
- Type a prompt like Write me a short poem about coding and click Send.
You should see text appearing word-by-word with a blinking cursor, instead of the entire response popping in at once.
Response still appearing all at once?
- Make sure you restarted the server after saving server.js. The new endpoint only loads on restart.
- Check that your frontend is posting to /api/chat/stream (not the old /api/chat path).
- Open the browser's Developer Console (Cmd+Option+J on macOS or Ctrl+Shift+J on Windows) and look for error messages.
Help me debug my streaming endpoint
✔️ Awesome, I've got everything!
Great. Double check you've saved both files before moving on.
ⓧ I'd like to double check the full code
Here is the full server.js at this point:
import Anthropic from "@anthropic-ai/sdk";
import express from "express";
const app = express();
const client = new Anthropic();
app.use(express.json());
app.use(express.static("public"));
app.post("/api/chat", async (req, res) => {
try {
const { message, model } = req.body;
const selectedModel = model || "claude-haiku-4-5-20241022";
const response = await client.messages.create({
model: selectedModel,
max_tokens: 1024,
messages: [{ role: "user", content: message }],
});
res.json({ response: response.content[0].text });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.post("/api/chat/stream", async (req, res) => {
try {
const { messages, model } = req.body;
const selectedModel = model || "claude-haiku-4-5-20241022";
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
const stream = client.messages.stream({
model: selectedModel,
max_tokens: 1024,
messages: messages,
});
stream.on("text", (text) => {
res.write(`data: ${JSON.stringify({ text })}\n\n`);
});
stream.on("end", () => {
res.write(`data: ${JSON.stringify({ done: true })}\n\n`);
res.end();
});
stream.on("error", (error) => {
res.write(`data: ${JSON.stringify({ error: error.message })}\n\n`);
res.end();
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
const PORT = 3000;
app.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}`);
});
Here is the full public/index.html at this point:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Writing Assistant</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f5f5f5;
height: 100vh;
display: flex;
flex-direction: column;
}
#chat-container {
flex: 1;
overflow-y: auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.message {
max-width: 720px;
width: 100%;
margin: 0 auto;
padding: 12px 16px;
border-radius: 12px;
line-height: 1.6;
white-space: pre-wrap;
}
.message.user { background: #1a1a2e; color: white; }
.message.assistant { background: white; border: 1px solid #e0e0e0; }
.cursor {
display: inline-block;
width: 2px;
height: 16px;
background: #333;
animation: blink 1s infinite;
vertical-align: text-bottom;
margin-left: 2px;
}
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
#input-area {
border-top: 1px solid #e0e0e0;
padding: 16px 24px;
background: white;
}
#input-form {
max-width: 720px;
margin: 0 auto;
display: flex;
gap: 12px;
}
#message-input {
flex: 1;
padding: 12px 16px;
border-radius: 8px;
border: 1px solid #ddd;
font-size: 15px;
}
#send-btn {
padding: 12px 24px;
border-radius: 8px;
border: none;
background: #1a1a2e;
color: white;
font-size: 15px;
cursor: pointer;
}
#send-btn:disabled { background: #999; cursor: not-allowed; }
</style>
</head>
<body>
<div id="chat-container"></div>
<div id="input-area">
<form id="input-form">
<input type="text" id="message-input" placeholder="Ask me to write something..." autocomplete="off">
<button type="submit" id="send-btn">Send</button>
</form>
</div>
<script>
const chatContainer = document.getElementById("chat-container");
const inputForm = document.getElementById("input-form");
const messageInput = document.getElementById("message-input");
const sendBtn = document.getElementById("send-btn");
function addMessage(role, text) {
const div = document.createElement("div");
div.className = `message ${role}`;
div.textContent = text;
chatContainer.appendChild(div);
chatContainer.scrollTop = chatContainer.scrollHeight;
return div;
}
function setLoading(loading) {
sendBtn.disabled = loading;
messageInput.disabled = loading;
}
inputForm.addEventListener("submit", async (e) => {
e.preventDefault();
const text = messageInput.value.trim();
if (!text) return;
addMessage("user", text);
messageInput.value = "";
setLoading(true);
const assistantDiv = addMessage("assistant", "");
const cursor = document.createElement("span");
cursor.className = "cursor";
assistantDiv.appendChild(cursor);
try {
const response = await fetch("/api/chat/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: [{ role: "user", content: text }] }),
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
let fullResponse = "";
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop();
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = JSON.parse(line.slice(6));
if (data.done) break;
if (data.error) throw new Error(data.error);
if (data.text) {
fullResponse += data.text;
assistantDiv.textContent = fullResponse;
assistantDiv.appendChild(cursor);
chatContainer.scrollTop = chatContainer.scrollHeight;
}
}
}
}
cursor.remove();
} catch (error) {
cursor.remove();
assistantDiv.textContent = `Error: ${error.message}`;
}
setLoading(false);
});
</script>
</body>
</html>
Your assistant now feels responsive and modern. Next up, you'll add a system prompt to shape Claude's personality and give users the ability to switch models.
Add a System Prompt and Polish
Your assistant can write, but it has no personality or focus yet. Every AI-powered app uses a system prompt to define how the model should behave and what expertise it brings to the conversation.
In this step, you will add a system prompt that specializes Claude as a writing assistant, give users the ability to switch between models, and add error handling so the app fails gracefully instead of crashing.
In this step, get ready to:
- Add a system prompt that shapes Claude into a writing specialist.
- Add a model selector so users can choose between fast and capable models.
- Add error handling and polish the interface with a proper header.
Add a system prompt to shape Claude's responses
A system prompt is a set of instructions that tells the AI model how to behave before the user ever sends a message. It defines the assistant's personality, expertise, and response style.
- In server.js, replace the entire file content with the following code:
import Anthropic from "@anthropic-ai/sdk";
import express from "express";
const app = express();
const client = new Anthropic();
app.use(express.json());
app.use(express.static("public"));
// System prompt that defines Claude's personality and expertise
const SYSTEM_PROMPT = `You are a helpful writing assistant specializing in professional communication, creative writing, and editing. You help users draft emails, letters, blog posts, stories, and other written content. Keep responses concise and well-structured. When editing, explain your changes briefly.`;
app.post("/api/chat", async (req, res) => {
try {
const { message, model } = req.body;
const selectedModel = model || "claude-haiku-4-5-20241022";
const response = await client.messages.create({
model: selectedModel,
max_tokens: 1024,
system: SYSTEM_PROMPT,
messages: [{ role: "user", content: message }],
});
res.json({ response: response.content[0].text });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.post("/api/chat/stream", async (req, res) => {
try {
const { messages, model } = req.body;
const selectedModel = model || "claude-haiku-4-5-20241022";
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
const stream = client.messages.stream({
model: selectedModel,
max_tokens: 1024,
system: SYSTEM_PROMPT,
messages: messages,
});
stream.on("text", (text) => {
res.write(`data: ${JSON.stringify({ text })}\n\n`);
});
stream.on("end", () => {
res.write(`data: ${JSON.stringify({ done: true })}\n\n`);
res.end();
});
stream.on("error", (error) => {
res.write(`data: ${JSON.stringify({ error: error.message })}\n\n`);
res.end();
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
const PORT = 3000;
app.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}`);
});
What changed in server.js?
- SYSTEM_PROMPT is a constant that tells Claude to focus on writing tasks like emails, blog posts, and editing.
- The system parameter is passed to both messages.create() and messages.stream() so every response reflects the writing assistant persona.
- The system prompt lives outside the messages array. It is a top-level parameter in the Claude API, not a message with a "system" role.
- Save server.js.
Seeing a syntax error on save?
The SYSTEM_PROMPT uses backticks (template literal) for the multi-line string. Make sure you copied the backtick characters, not regular quotes.
Help me debug my system prompt in server.js
Add a model selector, header, and error handling
Right now, the app always uses the same model. Different models offer different trade-offs. claude-haiku-4-5-20241022 is fast and cheap, while claude-sonnet-4-6-20250514 produces higher-quality output.
You will also add proper error handling so that if the API call fails, the user sees a friendly message instead of the app silently breaking.
- In public/index.html, replace the entire file content with the following code:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Writing Assistant</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f5f5f5;
height: 100vh;
display: flex;
flex-direction: column;
}
header {
background: #1a1a2e;
color: white;
padding: 16px 24px;
display: flex;
align-items: center;
justify-content: space-between;
}
header h1 { font-size: 18px; font-weight: 600; }
.controls {
display: flex;
align-items: center;
gap: 12px;
}
select {
padding: 6px 12px;
border-radius: 6px;
border: 1px solid #444;
background: #2d2d44;
color: white;
font-size: 13px;
}
#new-chat-btn {
padding: 6px 14px;
border-radius: 6px;
border: none;
background: #4a4a6a;
color: white;
cursor: pointer;
font-size: 13px;
}
#new-chat-btn:hover { background: #5a5a7a; }
#chat-container {
flex: 1;
overflow-y: auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.message {
max-width: 720px;
width: 100%;
margin: 0 auto;
padding: 12px 16px;
border-radius: 12px;
line-height: 1.6;
white-space: pre-wrap;
}
.message.user {
background: #1a1a2e;
color: white;
align-self: flex-end;
margin-left: auto;
margin-right: 0;
max-width: 600px;
}
.message.assistant {
background: white;
border: 1px solid #e0e0e0;
}
.message.error {
background: #fee;
border: 1px solid #fcc;
color: #c00;
}
.cursor {
display: inline-block;
width: 2px;
height: 16px;
background: #333;
animation: blink 1s infinite;
vertical-align: text-bottom;
margin-left: 2px;
}
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
#input-area {
border-top: 1px solid #e0e0e0;
padding: 16px 24px;
background: white;
}
#input-form {
max-width: 720px;
margin: 0 auto;
display: flex;
gap: 12px;
}
#message-input {
flex: 1;
padding: 12px 16px;
border-radius: 8px;
border: 1px solid #ddd;
font-size: 15px;
outline: none;
}
#message-input:focus { border-color: #1a1a2e; }
#send-btn {
padding: 12px 24px;
border-radius: 8px;
border: none;
background: #1a1a2e;
color: white;
font-size: 15px;
cursor: pointer;
}
#send-btn:disabled { background: #999; cursor: not-allowed; }
#send-btn:hover:not(:disabled) { background: #2d2d44; }
</style>
</head>
<body>
<header>
<h1>AI Writing Assistant</h1>
<div class="controls">
<select id="model-select">
<option value="claude-haiku-4-5-20241022">Haiku (Fast)</option>
<option value="claude-sonnet-4-6-20250514">Sonnet (Capable)</option>
</select>
<button id="new-chat-btn">New Chat</button>
</div>
</header>
<div id="chat-container"></div>
<div id="input-area">
<form id="input-form">
<input type="text" id="message-input" placeholder="Ask me to write something..." autocomplete="off">
<button type="submit" id="send-btn">Send</button>
</form>
</div>
<script>
const chatContainer = document.getElementById("chat-container");
const inputForm = document.getElementById("input-form");
const messageInput = document.getElementById("message-input");
const sendBtn = document.getElementById("send-btn");
const modelSelect = document.getElementById("model-select");
const newChatBtn = document.getElementById("new-chat-btn");
// Track the full conversation so Claude has context
let conversationHistory = [];
function addMessage(role, text) {
const div = document.createElement("div");
div.className = `message ${role}`;
div.textContent = text;
chatContainer.appendChild(div);
chatContainer.scrollTop = chatContainer.scrollHeight;
return div;
}
function setLoading(loading) {
sendBtn.disabled = loading;
messageInput.disabled = loading;
}
// Clear the chat and reset conversation history
newChatBtn.addEventListener("click", () => {
conversationHistory = [];
chatContainer.innerHTML = "";
});
inputForm.addEventListener("submit", async (e) => {
e.preventDefault();
const text = messageInput.value.trim();
if (!text) return;
addMessage("user", text);
messageInput.value = "";
setLoading(true);
// Add the user message to conversation history
conversationHistory.push({ role: "user", content: text });
const assistantDiv = addMessage("assistant", "");
const cursor = document.createElement("span");
cursor.className = "cursor";
assistantDiv.appendChild(cursor);
try {
const response = await fetch("/api/chat/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
messages: conversationHistory,
model: modelSelect.value,
}),
});
// Check for server errors before reading the stream
if (!response.ok) {
throw new Error(`Server error: ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let fullResponse = "";
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop();
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = JSON.parse(line.slice(6));
if (data.done) break;
if (data.error) throw new Error(data.error);
if (data.text) {
fullResponse += data.text;
assistantDiv.textContent = fullResponse;
assistantDiv.appendChild(cursor);
chatContainer.scrollTop = chatContainer.scrollHeight;
}
}
}
}
cursor.remove();
// Save Claude's response to conversation history
conversationHistory.push({ role: "assistant", content: fullResponse });
} catch (error) {
cursor.remove();
// Show a styled error message instead of crashing
assistantDiv.className = "message error";
assistantDiv.textContent = `Error: ${error.message}`;
}
setLoading(false);
});
</script>
</body>
</html>
What's new in this update?
- Header with model selector: The #model-select dropdown lets users pick between Haiku (fast, cheap) and Sonnet (more capable). The selected value is sent to the backend in the request body.
- Conversation history: The conversationHistory array stores every message. Each request sends the full history so Claude can reference earlier context.
- Error handling: If the server returns a non-200 status or the stream errors, the catch block applies the .message.error class to display a red error message instead of silently failing.
- New Chat button: The #new-chat-btn clears both the visible chat and the conversation history array, giving a fresh start.
- Save public/index.html.
- In your terminal, stop the running server with Ctrl+C and restart it by running:
node server.js
- Refresh http://localhost:3000 in your browser. You should see a dark header with the title "AI Writing Assistant", a model dropdown, and a "New Chat" button.
Don't see the header or dropdown?
Make sure you saved public/index.html and did a hard refresh in the browser (Cmd+Shift+R on macOS or Ctrl+Shift+R on Windows). Cached CSS and HTML can hide your changes.
Help me debug my updated index.html
Test the system prompt, model switching, and error handling
Now that everything is in place, you will verify that each new feature works correctly.
- In the chat input, type Write me a professional email declining a meeting and press Send.
- Confirm that Claude responds with writing-focused content that reads like a professional email draft.
- Change the dropdown from Haiku (Fast) to Sonnet (Capable) and send another message to verify the model switches.
- To test error handling: stop the server with Ctrl+C, then try sending a message in the browser.
- You should see a red error message in the chat area instead of the page crashing or freezing.
- Restart the server by running node server.js again so the app is ready for the next step.
Why is error handling important here?
API calls can fail for many reasons: an expired API key, a network timeout, or hitting a rate limit. Without error handling, the chat would freeze with no feedback. The .message.error style makes failures visible and recoverable.
Seeing an error even when the server is running?
Check that your ANTHROPIC_API_KEY environment variable is still set in the terminal where you started the server. If you opened a new terminal window, you need to export the key again before running node server.js.
Help me fix API errors in my writing assistant
✔️ Awesome, I've got everything!
Great. Double check you have saved both server.js and public/index.html.
ⓧ I'd like to double check the full code
Here is the complete server.js:
import Anthropic from "@anthropic-ai/sdk";
import express from "express";
const app = express();
const client = new Anthropic();
app.use(express.json());
app.use(express.static("public"));
const SYSTEM_PROMPT = `You are a helpful writing assistant specializing in professional communication, creative writing, and editing. You help users draft emails, letters, blog posts, stories, and other written content. Keep responses concise and well-structured. When editing, explain your changes briefly.`;
app.post("/api/chat", async (req, res) => {
try {
const { message, model } = req.body;
const selectedModel = model || "claude-haiku-4-5-20241022";
const response = await client.messages.create({
model: selectedModel,
max_tokens: 1024,
system: SYSTEM_PROMPT,
messages: [{ role: "user", content: message }],
});
res.json({ response: response.content[0].text });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.post("/api/chat/stream", async (req, res) => {
try {
const { messages, model } = req.body;
const selectedModel = model || "claude-haiku-4-5-20241022";
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
const stream = client.messages.stream({
model: selectedModel,
max_tokens: 1024,
system: SYSTEM_PROMPT,
messages: messages,
});
stream.on("text", (text) => {
res.write(`data: ${JSON.stringify({ text })}\n\n`);
});
stream.on("end", () => {
res.write(`data: ${JSON.stringify({ done: true })}\n\n`);
res.end();
});
stream.on("error", (error) => {
res.write(`data: ${JSON.stringify({ error: error.message })}\n\n`);
res.end();
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
const PORT = 3000;
app.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}`);
});
Here is the complete public/index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Writing Assistant</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f5f5f5;
height: 100vh;
display: flex;
flex-direction: column;
}
header {
background: #1a1a2e;
color: white;
padding: 16px 24px;
display: flex;
align-items: center;
justify-content: space-between;
}
header h1 { font-size: 18px; font-weight: 600; }
.controls {
display: flex;
align-items: center;
gap: 12px;
}
select {
padding: 6px 12px;
border-radius: 6px;
border: 1px solid #444;
background: #2d2d44;
color: white;
font-size: 13px;
}
#new-chat-btn {
padding: 6px 14px;
border-radius: 6px;
border: none;
background: #4a4a6a;
color: white;
cursor: pointer;
font-size: 13px;
}
#new-chat-btn:hover { background: #5a5a7a; }
#chat-container {
flex: 1;
overflow-y: auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.message {
max-width: 720px;
width: 100%;
margin: 0 auto;
padding: 12px 16px;
border-radius: 12px;
line-height: 1.6;
white-space: pre-wrap;
}
.message.user {
background: #1a1a2e;
color: white;
align-self: flex-end;
margin-left: auto;
margin-right: 0;
max-width: 600px;
}
.message.assistant {
background: white;
border: 1px solid #e0e0e0;
}
.message.error {
background: #fee;
border: 1px solid #fcc;
color: #c00;
}
.cursor {
display: inline-block;
width: 2px;
height: 16px;
background: #333;
animation: blink 1s infinite;
vertical-align: text-bottom;
margin-left: 2px;
}
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
#input-area {
border-top: 1px solid #e0e0e0;
padding: 16px 24px;
background: white;
}
#input-form {
max-width: 720px;
margin: 0 auto;
display: flex;
gap: 12px;
}
#message-input {
flex: 1;
padding: 12px 16px;
border-radius: 8px;
border: 1px solid #ddd;
font-size: 15px;
outline: none;
}
#message-input:focus { border-color: #1a1a2e; }
#send-btn {
padding: 12px 24px;
border-radius: 8px;
border: none;
background: #1a1a2e;
color: white;
font-size: 15px;
cursor: pointer;
}
#send-btn:disabled { background: #999; cursor: not-allowed; }
#send-btn:hover:not(:disabled) { background: #2d2d44; }
</style>
</head>
<body>
<header>
<h1>AI Writing Assistant</h1>
<div class="controls">
<select id="model-select">
<option value="claude-haiku-4-5-20241022">Haiku (Fast)</option>
<option value="claude-sonnet-4-6-20250514">Sonnet (Capable)</option>
</select>
<button id="new-chat-btn">New Chat</button>
</div>
</header>
<div id="chat-container"></div>
<div id="input-area">
<form id="input-form">
<input type="text" id="message-input" placeholder="Ask me to write something..." autocomplete="off">
<button type="submit" id="send-btn">Send</button>
</form>
</div>
<script>
const chatContainer = document.getElementById("chat-container");
const inputForm = document.getElementById("input-form");
const messageInput = document.getElementById("message-input");
const sendBtn = document.getElementById("send-btn");
const modelSelect = document.getElementById("model-select");
const newChatBtn = document.getElementById("new-chat-btn");
let conversationHistory = [];
function addMessage(role, text) {
const div = document.createElement("div");
div.className = `message ${role}`;
div.textContent = text;
chatContainer.appendChild(div);
chatContainer.scrollTop = chatContainer.scrollHeight;
return div;
}
function setLoading(loading) {
sendBtn.disabled = loading;
messageInput.disabled = loading;
}
newChatBtn.addEventListener("click", () => {
conversationHistory = [];
chatContainer.innerHTML = "";
});
inputForm.addEventListener("submit", async (e) => {
e.preventDefault();
const text = messageInput.value.trim();
if (!text) return;
addMessage("user", text);
messageInput.value = "";
setLoading(true);
conversationHistory.push({ role: "user", content: text });
const assistantDiv = addMessage("assistant", "");
const cursor = document.createElement("span");
cursor.className = "cursor";
assistantDiv.appendChild(cursor);
try {
const response = await fetch("/api/chat/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
messages: conversationHistory,
model: modelSelect.value,
}),
});
if (!response.ok) {
throw new Error(`Server error: ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let fullResponse = "";
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop();
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = JSON.parse(line.slice(6));
if (data.done) break;
if (data.error) throw new Error(data.error);
if (data.text) {
fullResponse += data.text;
assistantDiv.textContent = fullResponse;
assistantDiv.appendChild(cursor);
chatContainer.scrollTop = chatContainer.scrollHeight;
}
}
}
}
cursor.remove();
conversationHistory.push({ role: "assistant", content: fullResponse });
} catch (error) {
cursor.remove();
assistantDiv.className = "message error";
assistantDiv.textContent = `Error: ${error.message}`;
}
setLoading(false);
});
</script>
</body>
</html>
Your writing assistant now has a focused personality, model flexibility, and graceful error handling. These are the patterns that production AI apps use. Next up, you will tackle the Secret Mission and give Claude memory across an entire conversation.
Secret mission
Add Conversation Memory
Right now your assistant treats every message as a fresh conversation. Can you make Claude remember what you talked about earlier in the session?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing cloud costs. API charges only occur when you actively send messages through the app.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- The ai-writing-assistant project folder on your Desktop (contains server.js, public/index.html, package.json, and node_modules).
- Your Claude API key (stored as the ANTHROPIC_API_KEY environment variable in your terminal session).
Keep everything running
No action needed. Choose this if you're still actively building or experimenting with your AI writing assistant.
- Your project folder stays on your Desktop for future development.
- Your API key remains available in your current terminal session. API charges only occur when you run the server and send messages.
- To start the app again later, open a terminal, navigate to the ai-writing-assistant folder, and run node server.js.
Pause - I'll come back to this later
Stop the server to free up your terminal. Your files stay on disk and no charges accumulate while the server is stopped.
- In the terminal where your server is running, press Ctrl+C to stop the Node.js process.
- Your project files and node_modules remain intact. When you're ready to resume, navigate to the folder and run node server.js again.
- If you open a new terminal session later, you will need to re-export your API key with export ANTHROPIC_API_KEY="your-api-key-here" before starting the server.
Delete - I don't want to use this again
Remove all project files and optionally revoke your API key so nothing remains on your machine or account.
- Stop the server by pressing Ctrl+C in the terminal where it is running.
- Delete the project folder by running this command (macOS/Linux):
rm -rf ~/Desktop/ai-writing-assistant
On Windows, run:
rmdir /s /q %USERPROFILE%\Desktop\ai-writing-assistant
- Unset the environment variable in your current terminal session by running:
unset ANTHROPIC_API_KEY
- To revoke your API key entirely, visit platform.claude.com, navigate to the API Keys page, and delete the key you created for this project.
Nice Work!
Nice Work!
Nice work! You just built a fully functional AI writing assistant from scratch using JavaScript, Node.js, and the Claude API.
You've learned how to:
- Built a Node.js/Express backend that securely communicates with the Claude API using environment variables to protect your API key.
- Created a browser-based chat interface with real-time streaming using Server-Sent Events for word-by-word response display.
- Added model selection and a system prompt to customize the AI's personality and expertise.
- Secret Mission: Added multi-turn conversation memory so Claude maintains context across messages in a session.
Ready to quiz yourself?