What you will build
By the end of this tutorial you will have a small Express API with one route: POST /chat. Send it a message, and it calls the OpenAI API with your full conversation history and returns the model's reply. Send another message, and it picks up where it left off. The conversation history lives in a plain in-memory array, so it resets when the server restarts and never touches a database.
That simplicity is intentional. The goal is to understand the API call, the message format, and the history pattern before adding persistence, streaming, or a frontend. Once you have this working, every common extension is a small, contained change.
Every file you need is in this page. Paste each one into a fresh project, follow the setup steps, and it runs. No repo to clone.
One thing to know before you start: this costs money
The OpenAI API charges per token, not per month. New accounts get a small trial credit, but after that each request costs a fraction of a cent. For a tutorial like this the total cost is likely under a dollar, but there is no free tier once the credit runs out.
If you want a version of this tutorial that costs nothing per request, our local LLM tutorial builds the same conversational pattern with Ollama running on your machine instead of a paid API. The code structure is similar; the trade-off is that a local model is less capable than GPT-4o for most tasks.
If the OpenAI cost is fine for you, read on.
What you need
- Node.js 18 or newer. Run
node -vto check. Update at nodejs.org if you are behind. - An OpenAI API key. Create one at platform.openai.com. You need a funded account or active trial credit to make API calls.
- A terminal and a text editor. Everything else installs with npm.
Setup
Create a fresh project and install the two runtime dependencies. Express handles the HTTP routing; the official openai package wraps the OpenAI REST API with TypeScript types built in.
mkdir ai-chatbot
cd ai-chatbot
npm init -y
npm install express openai
npm install -D typescript tsx @types/node @types/expressAdd a tsconfig.json at the project root. These settings work with tsx in development and compile with tsc for a production build:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
}Then open package.json, add "type": "module", and set these scripts:
{
"type": "module",
"scripts": {
"dev": "tsx src/server.ts",
"build": "tsc",
"start": "node dist/server.js"
}
}Your API key goes in an environment variable so it never ends up in your code. The simplest approach for a local tutorial: set it in the shell before running the server. On macOS and Linux:
export OPENAI_API_KEY=sk-your-key-hereOr put it in a .env file and load it with dotenv. The tutorial reads process.env.OPENAI_API_KEY, so either approach works. Create a src/ directory in the project root; the source file below goes there.
How the OpenAI chat API works
The OpenAI chat completion API takes a messages array. Each item has a role (system, user, or assistant) and a content string. The model reads the whole array and replies as the assistant.
Three roles:
- system sets the personality and instructions. It is typically the first message and stays constant.
- user is what you, or your user, sent.
- assistant is what the model replied last time.
To keep a conversation going across multiple requests, you add each new user message to the array and each reply from the model before sending the next request. The model reads the history and responds in context. This tutorial holds that array in memory on the server.
The server
The whole project is one file. Create src/server.ts:
import express, { type Request, type Response } from "express";
import OpenAI from "openai";
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
const app = express();
app.use(express.json());
const client = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
// Conversation history shared across all requests to this server.
// It resets when the server restarts -- add a database or session store to persist it.
const messages: ChatCompletionMessageParam[] = [
{
role: "system",
content:
"You are a helpful assistant. Keep your answers concise and direct.",
},
];
app.post("/chat", async (req: Request, res: Response) => {
const { message } = req.body ?? {};
if (typeof message !== "string" || message.trim() === "") {
res.status(400).json({ error: 'Send a non-empty "message" string.' });
return;
}
messages.push({ role: "user", content: message });
try {
const completion = await client.chat.completions.create({
model: "gpt-4o-mini",
messages,
});
const reply = completion.choices[0]?.message?.content ?? "";
messages.push({ role: "assistant", content: reply });
res.json({ reply });
} catch (err) {
// Remove the user message so a retry does not double-count it.
messages.pop();
const message =
err instanceof Error ? err.message : "Unknown error from OpenAI API";
res.status(502).json({ error: message });
}
});
const port = Number(process.env.PORT) || 3000;
app.listen(port, () => {
console.log(`AI chatbot API listening on http://localhost:${port}`);
console.log(`POST /chat with { "message": "your text" } to start.`);
});A few things worth noting.
`gpt-4o-mini` is the model. It is OpenAI's fastest and cheapest chat model, which makes it the right default for a tutorial. Swap the string for gpt-4o if you want the full model; the code is otherwise identical.
The `messages` array is the conversation. Every call appends the user's message and the model's reply. On the next call, the model sees the full history and responds in context. That is all that is happening here; the OpenAI API is stateless on its side.
On error, remove the last user message. If the API call fails, the user message is already in the array. Rolling it back before returning the 502 keeps the history clean so a retry does not send a duplicated message.
Run it and test with curl
Make sure OPENAI_API_KEY is set in your shell, then start the server:
npm run dev
# -> AI chatbot API listening on http://localhost:3000In a second terminal, send a message:
curl -X POST http://localhost:3000/chat \
-H "Content-Type: application/json" \
-d '{"message": "What is a closure in JavaScript?"}'
# -> {"reply":"A closure is a function that retains access to variables..."}Send a follow-up that only makes sense in context, to verify the history is working:
curl -X POST http://localhost:3000/chat \
-H "Content-Type: application/json" \
-d '{"message": "Can you give me a short code example of that?"}'
# -> {"reply":"Sure! Here's a simple closure example:..."}The second reply references the first question without you repeating it. That is the history at work.
Go deeper on AI engineering
Want to understand what is happening inside these API calls? ChatGPT Prompt Engineering for Developers (DeepLearning.AI + OpenAI) is a free two-hour course that covers system prompts, few-shot examples, and how to shape model output. Our AI engineer roadmap maps the free courses in order if you want the full learning path.
What to build next
You have a working chatbot API in about 50 lines. A few natural next steps.
Add streaming. Right now the response arrives all at once after the model finishes generating. OpenAI supports server-sent events (SSE) for streaming tokens as they arrive. Change stream: true in the completion call and use for await (const chunk of completion) to write chunks to the response as they come in.
Add a system prompt configuration. Accept an optional systemPrompt field in the request body and let callers set the assistant's personality per session. Useful when you want one backend to power multiple chatbot experiences.
Persist the history. Move the messages array into a session store or a database, keyed by a session id the client sends. Then each user gets their own conversation that survives a server restart.
Build a frontend. A simple HTML form with a <textarea> and a fetch call to POST /chat is enough to make this browser-usable. Add a small Express static handler, or wire it into a Next.js app.
For prompt design and the broader context of building AI products, our guide on becoming an AI engineer covers the free resources in order. And if you want to skip the per-token cost entirely, the local LLM tutorial builds the same pattern with Ollama running on your machine.
Frequently asked questions
Do I have to pay for the OpenAI API?
Yes, after your trial credit runs out. OpenAI charges per token, not per month. For a tutorial like this you are looking at a few cents total, but there is no permanently free tier. If you want a chatbot that costs nothing per request after setup, see our local LLM tutorial at /tutorials/run-a-local-llm-with-nodejs, which uses Ollama on your machine instead.
Can I use a different model provider, like Anthropic or Google?
Yes. The message format (role + content) is the same across most providers. For Anthropic's Claude, install the @anthropic-ai/sdk package and use client.messages.create instead of client.chat.completions.create. The conversation history shape is slightly different but the concept is identical. This tutorial uses the openai package because it is the most widely used and the pattern generalizes well.
Why does the history reset when I restart the server?
The messages array lives in memory on the server process. When the process stops, the array is gone. To persist conversations, store the messages in a database (Postgres, SQLite, Redis) keyed by a session id. The db.ts pattern from our URL shortener tutorial at /tutorials/build-url-shortener-express-typescript-sqlite shows one clean way to add SQLite persistence.
How do I keep the conversation from growing forever?
Each message you send costs tokens, and the model has a context window limit (a maximum number of tokens it can read at once). For a short tutorial session this is fine, but in a real app you would trim the history: keep the last N messages, or summarize older messages into a single context block. A simple approach is to slice the array before the API call: messages.slice(-20) keeps the 20 most recent turns.