Getting Started with the OpenAI Assistants API in JavaScript/Node.js
The OpenAI Assistants API lets you create purpose‑built AI agents that can reason over conversation history, call tools, and pull in external knowledge. When paired with the official Node.js SDK, you get a type‑safe, fully featured client that works in plain Node, Deno, Bun, and even edge runtimes. This guide walks through the whole lifecycle—from installing the SDK to running a streaming assistant that uses the code interpreter and custom functions—so you can embed a smart assistant into any Node‑backed application.
1. Setting Up Your Project
First, add the OpenAI Node library to your project. The SDK is published as @openai/openai on npm and works with TypeScript out of the box.
npm install @openai/openai dotenv
Create a .env file at the root to keep your secret key out of source control:
OPENAI_API_KEY=sk-XXXXXXXXXXXXXXXXXXXXXXXX
If you plan to use Azure OpenAI or Amazon Bedrock, you’ll swap the client later, but for the standard OpenAI endpoint the above is enough.
Initialize the client in a utility file (openai.js):
require('dotenv').config();
const { OpenAI } = require('@openai/openai');
const client = new OpenAI({
// apiKey is read from OPENAI_API_KEY automatically
// optional: tweak retries, timeout, logging
maxRetries: 2,
timeout: 20 * 1000, // 20 seconds
});
module.exports = { client };
All examples below import { client } from this file.
2. Creating an Assistant
An assistant groups a model, a set of instructions, and the tools it may call. Tools extend the model’s abilities—code interpreter for running Python, retrieval for file‑based knowledge, and function calling for arbitrary APIs.
// assistants.js
const { client } = require('./openai');
async function createMathTutor() {
return await client.beta.assistants.create({
name: 'Math Tutor',
instructions:
'You are a personal math tutor. Write and run code to answer math questions.',
tools: [{ type: 'code_interpreter' }],
model: 'gpt-4o', // pick a model that supports the chosen tools
});
}
module.exports = { createMathTutor };
When you run createMathTutor(), the API returns an assistant object whose id you’ll store (e.g., in a database) for later use. You can also create assistants via the OpenAI playground and copy the ID into your code.
3. Managing Conversations with Threads
A thread isolates a conversation so the assistant can keep context without leaking data between users. Think of a thread as a chat session.
// threads.js
const { client } = require('./openai');
async function createThread() {
return await client.beta.threads.create();
}
async function addUserMessage(threadId, content) {
return await client.beta.threads.messages.create(threadId, {
role: 'user',
content,
});
}
module.exports = { createThread, addUserMessage };
Best practice: one thread per user
If your app serves many users, keep a mapping (e.g., in Redis or a database) from user ID → thread ID. When a user sends their first message, create a thread and store the ID; for subsequent messages, reuse the existing thread.
4. Running the Assistant and Waiting for Completion
Running an assistant on a thread produces a run. The run progresses through states: queued → in_progress → requires_action → completed → failed/expired. While the run is in_progress, the thread is locked—no new messages can be added until the run finishes or requires external tool output.
The simplest approach is to poll the run status every few seconds.
// run.js
const { client } = require('./openai');
async function runAssistant(threadId, assistantId, extraInstructions = '') {
const run = await client.beta.threads.runs.create(threadId, {
assistant_id: assistantId,
instructions: extraInstructions, // overrides assistant defaults if set
});
return run;
}
async function waitForRunCompletion(threadId, runId) {
while (true) {
const run = await client.beta.threads.runs.retrieve(threadId, runId);
if (run.status === 'completed' || run.status === 'failed' || run.status === 'expired') {
return run;
}
// optional: exponential backoff
await new Promise((resolve) => setTimeout(resolve, 2000));
}
}
module.exports = { runAssistant, waitForRunCompletion };
Putting it together:
// demo.js
const { client } = require('./openai');
const { createMathTutor } = require('./assistants');
const { createThread, addUserMessage } = require('./threads');
const { runAssistant, waitForRunCompletion } = require('./run');
(async () => {
const assistant = await createMathTutor();
const thread = await createThread();
await addUserMessage(thread.id, "I need to solve the equation 3x + 11 = 14. Can you help me?");
const run = await runAssistant(thread.id, assistant.id, 'Please address the user as Alex.');
const finishedRun = await waitForRunCompletion(thread.id, run.id);
if (finishedRun.status === 'completed') {
const messages = await client.beta.threads.messages.list(thread.id);
// The latest assistant message is at index 0
console.log('Assistant:', messages.data[0].content[0].text.value);
} else {
console.error('Run ended with status:', finishedRun.status);
}
})();
Running node demo.js prints the tutor’s step‑by‑step solution.
5. Streaming Responses with Server‑Sent Events
Polling works, but for a responsive chat UI you’ll want to stream incremental tokens as the assistant generates them. The SDK exposes a createAndStream method that returns an async iterable of events.
Backend (Node/Express)
// server.js
require('dotenv').config();
const express = require('express');
const { OpenAI } = require('@openai/openai');
const app = express();
app.use(express.json());
const openai = new OpenAI();
// Helper to broadcast SSE to all connected clients
function sseBroadcast(res, data) {
res.write(`data: ${JSON.stringify(data)}\n\n`);
}
app.post('/api/chat', async (req, res) => {
const { query, threadId } = req.body;
// Ensure we have a thread
let currentThreadId = threadId;
if (!currentThreadId) {
const thread = await openai.beta.threads.create();
currentThreadId = thread.id;
}
// Add user message
await openai.beta.threads.messages.create(currentThreadId, {
role: 'user',
content: query,
});
// Set up SSE response
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
res.write('\n'); // flush headers
let accumulated = '';
const stream = await openai.beta.threads.runs.createAndStream(currentThreadId, {
assistant_id: process.env.ASSISTANT_ID,
});
stream.on('textCreated', () => {
sseBroadcast(res, { type: 'textCreated', threadId: currentThreadId });
});
stream.on('textDelta', ({ delta }) => {
accumulated += delta.value;
sseBroadcast(res, {
type: 'textDelta',
threadId: currentThreadId,
content: accumulated,
});
});
stream.on('end', () => {
// Clean up for next request (optional)
sseBroadcast(res, { type: 'end', threadId: currentThreadId });
res.end();
});
stream.on('error', (err) => {
console.error('Stream error:', err);
res.status(500).end();
});
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Server listening on ${PORT}`));
Frontend (React with EventSource)
// ChatWindow.jsx (simplified)
const [message, setMessage] = useState('');
const [history, setHistory] = useState([]);
const loadingRef = useRef(false);
const eventSourceRef = useRef(null);
const handleSend = async () => {
if (!message.trim() || loadingRef.current) return;
loadingRef.current = true;
// Close previous EventSource
if (eventSourceRef.current) {
eventSourceRef.current.close();
}
// Optimistically add user message
setHistory((h) => [...h, { role: 'user', content: message }]);
setMessage('');
const es = new EventSource(`/api/chat?threadId=${localStorage.getItem(
'currentThreadId'
)}&query=${encodeURIComponent(message)}`);
eventSourceRef.current = es;
es.onmessage = (ev) => {
const data = JSON.parse(ev.data);
if (data.type === 'textCreated') {
setHistory((h) => [
...h,
{ role: 'assistant', content: '', threadId: data.threadId },
]);
localStorage.setItem('currentThreadId', data.threadId);
}
if (data.type === 'textDelta') {
setHistory((h) => {
const copy = [...h];
const last = copy.length - 1;
copy[last] = { ...copy[last], content: data.content };
return copy;
});
}
if (data.type === 'end') {
loadingRef.current = false;
es.close();
}
};
es.onerror = () => {
loadingRef.current = false;
es.close();
};
};
// Auto‑scroll to bottom
useEffect(() => {
const wrapper = document.querySelector('.chat-window');
if (wrapper) wrapper.scrollTop = wrapper.scrollHeight;
}, [history]);
return (
<div className="chat-container">
<div className="chat-window">{history.map(renderMessage)}</div>
<form onSubmit={(e) => { e.preventDefault(); handleSend(); }}>
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Ask something…"
/>
<button type="submit" disabled={loadingRef.current}>
Send
</button>
</form>
</div>
);
}
This pattern gives the user a real‑time typing indicator without any polling overhead.
6. Enriching Assistants with Tools
Code Interpreter
The code interpreter lets the assistant execute Python in a sandbox, perfect for math, data analysis, or file generation. When you enable it, any file the assistant creates appears as an annotation in the message content, which you can download via client.files.retrieveContent(file_id).
// Example: ask assistant to generate a plot
await addUserMessage(thread.id, 'Create a sine wave plot and give me the image.');
const run = await runAssistant(thread.id, assistant.id);
await waitForRunCompletion(thread.id, run.id);
const msgs = await client.beta.threads.messages.list(thread.id);
const assistantMsg = msgs.data.find((m) => m.role === 'assistant');
if (assistantMsg) {
// Look for image_file annotations
for (const block of assistantMsg.content) {
if (block.type === 'image_file') {
const fileId = block.image_file.file_id;
const buffer = await client.files.retrieveContent(fileId);
// Save or stream the image to the user
}
}
}
Retrieval (File Search / Knowledge Retrieval
If you have proprietary documents, upload them with purpose "assistants" and attach the resulting file IDs to an assistant that has the retrieval tool.
const fs = require('fs');
const { client } = require('./openai');
async function uploadKnowledge(filePath) {
const data = await fs.promises.readFile(filePath);
const file = await client.files.create({
file: data,
purpose: 'assistants',
});
return file.id;
}
// Later
const fileId = await uploadKnowledge('./policies.pdf');
const assistant = await client.beta.assistants.create({
name: 'Policy Helper',
instructions: 'Answer questions using the provided policy document.',
tools: [{ type: 'retrieval' }],
file_ids: [fileId],
model: 'gpt-4o',
});
When the assistant runs, it will automatically retrieve relevant snippets from the file and cite them.
Custom Function Calling
Function calling lets the assistant invoke any HTTP endpoint or local logic you define. You describe the function in JSON Schema; the assistant returns a function_call delta that your code must execute and then submit the output back via submitToolOutputs.
// Define a function that gets current weather
const weatherFunction = {
type: 'function',
function: {
name: 'getCurrentWeather',
description: 'Get the current weather for a latitude and longitude.',
parameters: {
type: 'object',
properties: {
latitude: { type: 'number' },
longitude: { type: 'number' },
units: { type: 'string', enum: ['c', 'f'] },
},
required: ['latitude', 'longitude'],
},
},
};
// Create assistant with the function
const assistant = await client.beta.assistants.create({
name: 'Weather Bot',
instructions: 'You are a weather helper. Use the getCurrentWeather function when needed.',
tools: [weatherFunction],
model: 'gpt-4o',
});
// In your run event handler:
stream.on('tool_call_created', async (toolCall) => {
if (toolCall.function.name === 'getCurrentWeather') {
const args = JSON.parse(toolCall.function.arguments);
// Call your weather API (e.g., Open-Meteo)
const result = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${args.latitude}&longitude=${args.longitude}¤t_weather=true&temperature_unit=${args.units === 'c' ? 'celsius' : 'fahrenheit'}`
).then((r) => r.json());
// Submit the output
await client.beta.threads.runs.submitToolOutputs(threadId, runId, {
tool_outputs: [
{
tool_call_id: toolCall.id,
output: JSON.stringify(result),
},
],
});
}
});
The assistant will then incorporate the returned data into its next token generation.
7. Error Handling, Retries, and Timeouts
The OpenAI Node SDK automatically retries certain transient errors (network hiccups, 429 rate limits, 5xx server errors) with exponential backoff. You can adjust the global retry count or override it per request:
const client = new OpenAI({ maxRetries: 3 }); // global
// Per‑request override
await client.beta.threads.runs.create(threadId, {
assistant_id: assistantId,
}, { maxRetries: 5 });
If a request exceeds the timeout (default 10 minutes), an APIConnectionTimeoutError is thrown. You can catch it and decide whether to retry or surface a friendly message:
try {
const run = await client.beta.threads.runs.create(threadId, { assistant_id: assistantId });
} catch (err) {
if (err instanceof OpenAI.APIError) {
console.error(`OpenAI error ${err.status}: ${err.message}`);
// Optionally inspect err.type, err.request_id, etc.
} else {
console.error('Unexpected error:', err);
}
}
Always log the request_id (available on the error or on successful responses) when contacting OpenAI support—it speeds up troubleshooting.
8. Deploying to Alternate Endpoints
Azure OpenAI
Swap the client for AzureOpenAI and provide an Azure AD token provider.
const credential = new DefaultAzureCredential();
const tokenProvider = getBearerTokenProvider(
credential,
'https://cognitiveservices.azure.com/.default'
);
const client = new AzureOpenAI({
azureADTokenProvider: tokenProvider,
apiVersion: '2024-08-01-preview', // check latest
});
The rest of the assistant/thread/run calls stay identical.
Amazon Bedrock (OpenAI‑compatible)
const client = new OpenAI({
provider: bedrock({ region: 'us-west-2' }),
});
Again, the same Assistants API methods work; just ensure the model you select is available via Bedrock.
9. Practical Tips for Production
| Area | Recommendation |
|---|---|
| Thread storage | Persist thread IDs in a durable store (Postgres, Redis, DynamoDB). Never rely solely in‑memory if you need horizontal scaling. |
| Assistant versioning | When you update instructions or tools, create a new assistant ID. Keep the old one running for existing conversations until they naturally end. |
| Rate limits | Monitor 429 responses. Implement a queue or leaky bucket to smooth bursts. |
| Security | Never expose your secret key to the browser. All assistant creation, thread management, and run submission should happen on a trusted backend. |
| Streaming reliability | If the client drops the SSE connection, the server will eventually abort the run. Consider storing the run ID so you can resume or retrieve the final message later. |
| Observability | Enable SDK logging (logLevel: 'http' or via OPENAI_LOG=http) to capture request/response bodies for debugging (redact sensitive headers). |
| Cost control | Set a reasonable max_tokens on runs if you want to bound usage, and periodically audit token consumption via the usage endpoint. |
10. Full Example: A Minimal Chat Service
Below is a condensed version that ties together thread management, streaming, and function calling. You can copy‑paste this into a file called index.js and run it with node index.js.
require('dotenv').config();
const express = require('express');
const { OpenAI } = require('@openai/openai');
const app = express();
app.use(express.json());
const openai = new OpenAI();
const ASSISTANT_ID = process.env.ASSISTANT_ID; // pre‑created with function tool
app.post('/chat', async (req, res) => {
const { userId, message } = req.body;
// 1️⃣ Get or create thread
let threadId = await getThreadForUser(userId); // implement your own store
if (!threadId) {
const thread = await openai.beta.threads.create();
threadId = thread.id;
await saveThreadForUser(userId, threadId);
}
// 2️⃣ Add user message
await openai.beta.threads.messages.create(threadId, {
role: 'user',
content: message,
});
// 3️⃣ Set up SSE
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
res.write('\n');
let acc = '';
const stream = await openai.beta.threads.runs.createAndStream(threadId, {
assistant_id: ASSISTANT_ID,
});
stream.on('textCreated', () => {
res.write(`data: ${JSON.stringify({ type: 'textStart', threadId })}\n\n`);
});
stream.on('textDelta', ({ delta }) => {
acc += delta.value;
res.write(
`data: ${JSON.stringify({ type: 'textDelta', threadId, content: acc })}\n\n`
);
});
stream.on('tool_call_created', async (toolCall) => {
if (toolCall.function.name === 'getCurrentWeather') {
const args = JSON.parse(toolCall.function.arguments);
const result = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${args.latitude}&longitude=${args.longitude}¤t_weather=true&temperature_unit=${args.units === 'c' ? 'celsius' : 'fahrenheit'}`
).then((r) => r.json());
await openai.beta.threads.runs.submitToolOutputs(threadId, stream.runId, {
tool_outputs: [{ tool_call_id: toolCall.id, output: JSON.stringify(result) }],
});
}
});
stream.on('end', () => {
res.write(`data: ${JSON.stringify({ type: 'end', threadId })}\n\n`);
res.end();
});
stream.on('error', (err) => {
console.error(err);
res.status(500).end();
});
});
function getThreadForUser(userId) {
// Stub – replace with DB lookup
return null;
}
function saveThreadForUser(userId, threadId) {
// Stub – replace with DB save
}
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Listening on ${PORT}`));
Run the server, point a simple frontend to /chat, and you’ll have a streaming assistant that can answer math questions, fetch the weather, and even generate charts—all powered by the OpenAI Assistants API.
11. Wrapping Up
The Assistants API transforms the raw power of GPT models into a programmable agent that can keep state, call tools, and stream results—all with a familiar REST‑like interface. By using the official Node.js SDK, you gain automatic retries, timeout handling, and TypeScript definitions that make integration painless.
Whether you’re building an internal copilot, a customer‑support bot, or a creative writing companion, the pattern is the same:
- Create (or reuse) an assistant with the right model, instructions, and tools.
- Maintain a thread per user or conversation.
- Add user messages, launch a run, and either poll or stream the outcome.
- Handle tool calls (code interpreter, retrieval, or custom functions) and submit their outputs.
- Present the final assistant message to your user, optionally with attached files or images.
Take the snippets above, adapt them to your data store and UI framework, and you’ll have a production‑ready AI assistant in JavaScript/Node.js in no time. Happy building!
