AI Chat Gateway with Groq
Add a streaming AI research assistant to your lab notebook API.
Introduction
30 Second Summary
Scientists keep detailed lab notebooks, but finding patterns across dozens of experiments is like searching for a needle in a haystack. What if your notebook could answer questions about your own data?
In this project, you will build an AI-powered chat layer for your bioengineering lab notebook API. Users send natural language questions and receive streamed AI responses grounded in their actual experiment entries.
What You'll Build
A Groq-powered streaming chat endpoint that integrates with your existing Express 5 API, using Server-Sent Events to deliver real-time AI responses built from the researcher's own experiment history.
By the end of this project, you'll have:
- A Groq AI service module with context-building and streaming chat functions.
- Conversations and messages database tables persisted in PostgreSQL via Drizzle ORM.
- A streaming POST /api/chat endpoint that delivers AI responses in real-time via Server-Sent Events.
- GET endpoints to list conversations and retrieve message history.
- Secret Mission: Add smart context retrieval that filters experiments by keyword relevance instead of just recency.
Do I need to complete Parts 1 and 2 first?
Yes. This project builds directly on your existing Express 5 API with experiment CRUD and JWT authentication from Parts 1 and 2. You need a working server, a PostgreSQL database with an experiments table, and user authentication before starting.
Install Groq and Configure Your API Key
Your BioLab Notebook API already handles experiment CRUD and authentication. Now it needs an AI brain that can reason about that experiment data and answer questions in natural language.
In this step, you will install the Groq SDK and wire up an API key so your server can talk to a large language model. You will also confirm your existing project from Parts 1 and 2 is running cleanly.
In this step, get ready to:
- Install the Groq SDK and add your API key to the project environment.
- Verify your Express server and database are still working from Parts 1 and 2.
- Test a quick Groq completion call to confirm everything connects.
Install the Groq SDK
The Groq SDK is the official TypeScript client for calling Groq's inference API. It has zero external dependencies and follows the same interface pattern as OpenAI's SDK.
- Install the Groq SDK by running this command in your project directory:
npm install groq-sdk
What is Groq?
Groq is an inference platform that runs large language models at extremely high speed. Its free tier never charges you. If you exceed the rate limit, you get a 429 error instead of a bill.
Your project will use the llama-3.3-70b-versatile model, which has a 131,072-token context window. That gives plenty of room for a system prompt, experiment context, and conversation history.
- Confirm the install succeeded by checking your terminal output for a line showing groq-sdk was added.
Install failed?
Make sure you are in the same directory as your package.json from Parts 1 and 2. If npm cannot find the file, run ls to check your current directory.
Help me debug this npm install error in my BioLab project
Create your Groq API key
Your server needs an API key to authenticate with Groq's inference endpoint. You will generate one from the Groq Console and store it in your .env file where your server already reads environment variables.
- Open your browser and navigate to console.groq.com/keys.
- Sign in with the Groq account you created earlier.
- Click Create API Key.
- Give your key a descriptive name (such as biolab-dev) and confirm.
- Copy the key immediately. Groq only shows it once.
Why store the key in .env?
Environment variables keep secrets out of your source code. Your .env file should already be listed in .gitignore from Parts 1 and 2, so it will never be committed to version control.
- Open your .env file in VS Code.
- Add the following line at the end of the file, replacing the placeholder with the key you just copied:
GROQ_API_KEY=your-key-here
- Save .env.
What should my .env look like now?
Your .env file should contain your existing variables from Parts 1 and 2 plus the new Groq key:
PORT=3000
DATABASE_URL=postgresql://...
JWT_SECRET=...
GROQ_API_KEY=your-key-here
Can't create your API key?
Some accounts need the developer role assigned before key creation works. If the button is disabled, try signing in with a Gmail address directly rather than an email alias. If the issue persists, contact Groq support at support@groq.com.
Help me troubleshoot my Groq API key creation issue
Verify your existing project and test the Groq connection
Before building new features, confirm your server and database from Parts 1 and 2 still work. Then run a quick test to prove your Groq key is valid.
- Start your server by running:
npm run dev
- Confirm you see a message like Server running on port 3000 in your terminal.
- Using your API client (such as curl or Postman), send a GET request to /api/experiments with a valid JWT in the Authorization header.
- Confirm you receive your seeded test experiments in the response. If your experiments table is empty, seed a few test entries first.
Server won't start?
Make sure your PostgreSQL database is running. Check that DATABASE_URL in your .env still points to the correct database from Parts 1 and 2.
Help me fix my Express server startup error
Now test that your Groq API key is working. Create a temporary test file to make a quick completion call.
- Create a file called test-groq.ts in your project root by running:
touch test-groq.ts
- Add the following code to test-groq.ts:
import Groq from 'groq-sdk';
import dotenv from 'dotenv';
dotenv.config();
const groq = new Groq({
apiKey: process.env.GROQ_API_KEY,
});
async function main() {
// Make a simple completion call to verify the key works
const response = await groq.chat.completions.create({
messages: [{ role: 'user', content: 'Say hello in one sentence.' }],
model: 'llama-3.3-70b-versatile',
});
console.log('Groq response:', response.choices[0]?.message?.content);
}
main().catch(console.error);
What does this test do?
This script imports the Groq SDK, loads your GROQ_API_KEY from .env, and sends a single chat completion request to the llama-3.3-70b-versatile model. If it prints a response, your key is valid and the connection works.
- Stop your running server with Ctrl+C, then run the test script:
npx tsx test-groq.ts
- You should see a line like Groq response: Hello! ... printed in your terminal.
- Delete the test file now that you have confirmed the connection:
rm test-groq.ts
Seeing an authentication error?
Double-check that GROQ_API_KEY in your .env has no extra spaces or quotes around the key value. The line should be GROQ_API_KEY=gsk_... with no wrapping quotes.
Help me fix this Groq API authentication error
- Restart your server by running npm run dev again so it is ready for the next step.
Your environment is fully set up. The Groq SDK is installed, your API key is configured, and you have confirmed the connection works. Next up, you will create the database tables that store conversation history.
Create the Conversations and Messages Schema
Your Groq connection is live and ready to generate responses. Before the AI can hold a real conversation, your API needs somewhere to store those conversations.
In this step, you will create two new database tables. A conversations table tracks each chat session per user, and a messages table stores every message exchanged within a conversation.
In this step, get ready to:
- Define conversations and messages tables in your Drizzle schema.
- Run a database migration to create the tables in PostgreSQL.
- Add Drizzle relations so your query builder understands the table relationships.
Define the database tables for chat persistence
Every chat system needs two core pieces of data: the conversation itself and the individual messages within it. You will add both of these as Drizzle ORM table definitions to your existing schema file.
- In VS Code, open src/db/schema.ts.
- Find the closing line of your experiments table definition.
- Below it, add the conversations table by pasting this code:
// New in Part 3
export const conversations = pgTable('conversations', {
id: serial('id').primaryKey(),
userId: integer('user_id').notNull().references(() => users.id),
title: varchar('title', { length: 255 }),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
What does this table store?
Each row in conversations represents one chat session. The userId foreign key ties the conversation to a specific authenticated user.
The title column stores a label for the conversation (you will auto-generate this from the first message later). The updatedAt timestamp lets you sort conversations by most recent activity.
You won't see a change yet. The next block adds the messages table that makes conversations useful.
- Directly below the conversations table, add the messages table by pasting this code:
export const messages = pgTable('messages', {
id: serial('id').primaryKey(),
conversationId: integer('conversation_id').notNull().references(() => conversations.id),
role: varchar('role', { length: 20 }).notNull(),
content: text('content').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
What does the messages table do?
Each row is one message in a conversation. The role column stores either user or assistant so you can rebuild the full chat thread in order.
The conversationId foreign key links each message to its parent conversation. This is the classic one-to-many pattern: one conversation holds many messages.
- Save src/db/schema.ts.
Seeing a TypeScript error?
If you see a red squiggly under conversations in the messages table's .references() call, make sure the conversations table is defined above the messages table. The reference must point to an already-declared table.
help me fix a TypeScript error in my Drizzle schema references
Now generate and apply the migration so PostgreSQL actually creates these tables.
- Generate the migration file by running this command in your terminal:
npx drizzle-kit generate
- Apply the migration to your database by running:
npx drizzle-kit migrate
- You should see output confirming that the conversations and messages tables were created.
Migration failed?
Make sure your PostgreSQL database is running and your DATABASE_URL in .env is correct. Also confirm that your drizzle.config.ts points to the right schema file path.
help me debug a failed Drizzle migration
Add Drizzle relations for the new tables
Your tables exist in the database, but Drizzle's query builder does not know how they connect to each other yet. Relations tell Drizzle how to join tables automatically when you use the relational query API.
- In src/db/schema.ts, find your existing usersRelations definition. It currently looks like this:
export const usersRelations = relations(users, ({ many }) => ({
experiments: many(experiments),
}));
- Add conversations: many(conversations), inside the return object, so it now looks like this:
export const usersRelations = relations(users, ({ many }) => ({
experiments: many(experiments),
conversations: many(conversations),
}));
Why update usersRelations?
A single user can start many conversations. Adding this line teaches Drizzle's relational query builder that users has a one-to-many relationship with conversations. Without it, you cannot query a user's conversations using Drizzle's with clause.
- Below experimentsRelations, add the conversationsRelations definition by pasting this code:
export const conversationsRelations = relations(conversations, ({ one, many }) => ({
user: one(users, {
fields: [conversations.userId],
references: [users.id],
}),
messages: many(messages),
}));
What does conversationsRelations define?
This defines both sides of the conversation's relationships. The user field maps back to the owning user via the userId foreign key. The messages field declares that one conversation contains many messages.
- Directly below conversationsRelations, add the messagesRelations definition:
export const messagesRelations = relations(messages, ({ one }) => ({
conversation: one(conversations, {
fields: [messages.conversationId],
references: [conversations.id],
}),
}));
What does messagesRelations define?
Each message belongs to exactly one conversation. The one() call maps messages.conversationId back to conversations.id. This completes the bidirectional link so Drizzle can navigate from message to conversation and vice versa.
- Save src/db/schema.ts.
- Verify the tables exist by connecting to your database using your preferred client (psql, pgAdmin, or the VS Code database extension) and listing the tables.
- You should see conversations and messages alongside your existing users and experiments tables.
Tables not showing up?
If you only see users and experiments, the migration may not have run successfully. Go back and re-run npx drizzle-kit generate followed by npx drizzle-kit migrate. Check the terminal output for any error messages.
help me find out why my Drizzle migration did not create the new tables
✔️ Awesome, I've got everything!
Great. Double check you have saved src/db/schema.ts and that both new tables are visible in your database.
ⓧ I'd like to double check the full code
import { pgTable, serial, varchar, text, integer, timestamp } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
// Existing from Parts 1 & 2
export const users = pgTable('users', {
id: serial('id').primaryKey(),
email: varchar('email', { length: 255 }).notNull().unique(),
password: varchar('password', { length: 255 }).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
export const experiments = pgTable('experiments', {
id: serial('id').primaryKey(),
title: varchar('title', { length: 255 }).notNull(),
hypothesis: text('hypothesis'),
protocol: text('protocol'),
observations: text('observations'),
results: text('results'),
status: varchar('status', { length: 50 }).defaultTo('draft'),
userId: integer('user_id').notNull().references(() => users.id),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
// New in Part 3
export const conversations = pgTable('conversations', {
id: serial('id').primaryKey(),
userId: integer('user_id').notNull().references(() => users.id),
title: varchar('title', { length: 255 }),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
export const messages = pgTable('messages', {
id: serial('id').primaryKey(),
conversationId: integer('conversation_id').notNull().references(() => conversations.id),
role: varchar('role', { length: 20 }).notNull(),
content: text('content').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
// Relations
export const usersRelations = relations(users, ({ many }) => ({
experiments: many(experiments),
conversations: many(conversations),
}));
export const experimentsRelations = relations(experiments, ({ one }) => ({
user: one(users, {
fields: [experiments.userId],
references: [users.id],
}),
}));
export const conversationsRelations = relations(conversations, ({ one, many }) => ({
user: one(users, {
fields: [conversations.userId],
references: [users.id],
}),
messages: many(messages),
}));
export const messagesRelations = relations(messages, ({ one }) => ({
conversation: one(conversations, {
fields: [messages.conversationId],
references: [conversations.id],
}),
}));
Your database is ready to persist full chat histories. Next up, you will build the AI context engine that pulls experiment data into prompts so the LLM can give grounded, relevant answers.
Build the AI Context Engine
Your database is ready to persist conversations. Now it is time to build the intelligence layer that connects your experiment data to the LLM.
An LLM produces better answers when it has relevant context. In this step, you will build a service that pulls your experiment entries from PostgreSQL and formats them into a structured prompt that Groq's LLM can reason over.
In this step, get ready to:
- Create an AI service module that queries your experiments and formats them as LLM context.
- Write a system prompt and a streaming chat function powered by Groq.
Create the experiment context builder
For the AI to give grounded answers about your lab work, it needs to see your actual experiment data. The context builder queries your 10 most recent experiments and formats each one into a readable text block the LLM can reference.
- Create a new src/services directory and a file called ai.ts inside it by running these commands:
mkdir -p src/services
touch src/services/ai.ts
- Open src/services/ai.ts in your editor and add the following code:
import Groq from 'groq-sdk';
import { db } from '../db';
import { experiments } from '../db/schema';
import { eq, desc } from 'drizzle-orm';
// Initialize the Groq client with our API key
const groq = new Groq({
apiKey: process.env.GROQ_API_KEY,
});
// Query the user's recent experiments and format them for the LLM
export async function buildExperimentContext(userId: number): Promise<string> {
// Fetch the 10 most recent experiments for this user
const entries = await db
.select()
.from(experiments)
.where(eq(experiments.userId, userId))
.orderBy(desc(experiments.createdAt))
.limit(10);
if (entries.length === 0) {
return 'No experiments logged yet.';
}
// Format each experiment into a structured text block
const context = entries
.map(
(e) => `Experiment: ${e.title}
Date: ${e.createdAt.toISOString().split('T')[0]}
Hypothesis: ${e.hypothesis || 'Not recorded'}
Protocol: ${e.protocol || 'Not recorded'}
Observations: ${e.observations || 'Not recorded'}
Results: ${e.results || 'Not recorded'}
Status: ${e.status || 'draft'}`
)
.join('\n---\n');
return context;
}
What does this code do?
The groq client is initialized with your API key from the environment. This single instance handles all requests to Groq's API.
The buildExperimentContext function uses Drizzle ORM to fetch the authenticated user's 10 most recent experiments. It formats each one into a human-readable text block with title, date, hypothesis, protocol, observations, results, and status. Entries are separated by --- dividers so the LLM can clearly distinguish between experiments.
Why plain text instead of JSON?
LLMs perform better with natural-language-formatted context than raw JSON. Structured text is also more token-efficient, which means you can fit more experiment data into the context window.
- Save src/services/ai.ts.
- Check that your editor shows no red squiggly lines on the imports or function body. If your dev server is running, confirm no TypeScript errors appear in the terminal.
Seeing import errors?
If TypeScript cannot resolve '../db', confirm that your src/db/index.ts file exports the db instance (this was set up in Part 1). If 'groq-sdk' is unresolved, run npm install groq-sdk again to confirm the package is installed.
Help me debug my import errors in ai.ts
Write the system prompt and chat function
The context builder gives the LLM raw data. Now you need instructions that tell the LLM how to use that data. A system prompt sets the AI's persona and rules, while the chat function assembles everything and streams the response.
- In src/services/ai.ts, add the following code below the buildExperimentContext function:
// System prompt that instructs the AI on persona and behavior
const SYSTEM_PROMPT_TEMPLATE = `You are a bioengineering lab assistant. You help researchers analyze their experiments, troubleshoot protocols, and identify patterns in their data.
Here is the researcher's experiment history:
{EXPERIMENT_CONTEXT}
Instructions:
- Use this context to answer their questions. Reference specific experiments by title and date when relevant.
- If the answer is not in their experiment data, say so clearly rather than guessing.
- Be concise and scientifically precise.
- When identifying trends, cite the specific data points you are comparing.`;
// Assemble the full message array and stream the Groq response
export async function streamChat(
userMessage: string,
experimentContext: string,
conversationHistory: Array<{ role: 'user' | 'assistant'; content: string }>
) {
// Inject experiment data into the system prompt
const systemPrompt = SYSTEM_PROMPT_TEMPLATE.replace(
'{EXPERIMENT_CONTEXT}',
experimentContext
);
// Build the messages array: system prompt + past conversation + new user message
const messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }> = [
{ role: 'system', content: systemPrompt },
...conversationHistory,
{ role: 'user', content: userMessage },
];
// Return a streaming completion from Groq
return groq.chat.completions.create({
messages,
model: 'llama-3.3-70b-versatile',
temperature: 0.7,
max_completion_tokens: 1024,
stream: true,
});
}
What does this code do?
The SYSTEM_PROMPT_TEMPLATE defines the AI's persona as a bioengineering lab assistant. The {EXPERIMENT_CONTEXT} placeholder gets replaced with the formatted experiment data at runtime.
The streamChat function assembles the full message array (system prompt, conversation history, and the new user message) then calls Groq with stream: true. This returns an async iterable that yields chunks as the LLM generates them, rather than waiting for the entire response to finish.
The model llama-3.3-70b-versatile has a 131,072-token context window. This gives ample room for the system prompt, experiment context, and conversation history combined.
- Save src/services/ai.ts.
- Confirm no TypeScript errors appear in your editor or terminal. The file should compile cleanly with two exported functions (buildExperimentContext and streamChat).
TypeScript complaining about the message types?
If you see a type error on the messages array, make sure the type annotation matches exactly: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>. TypeScript needs the explicit union type so it does not widen role to string.
Help me fix the TypeScript type errors in my streamChat function
✔️ Awesome, I've got everything!
Great. Double check you have saved src/services/ai.ts with both functions.
ⓧ I'd like to double check the full code
import Groq from 'groq-sdk';
import { db } from '../db';
import { experiments } from '../db/schema';
import { eq, desc } from 'drizzle-orm';
const groq = new Groq({
apiKey: process.env.GROQ_API_KEY,
});
export async function buildExperimentContext(userId: number): Promise<string> {
const entries = await db
.select()
.from(experiments)
.where(eq(experiments.userId, userId))
.orderBy(desc(experiments.createdAt))
.limit(10);
if (entries.length === 0) {
return 'No experiments logged yet.';
}
const context = entries
.map(
(e) => `Experiment: ${e.title}
Date: ${e.createdAt.toISOString().split('T')[0]}
Hypothesis: ${e.hypothesis || 'Not recorded'}
Protocol: ${e.protocol || 'Not recorded'}
Observations: ${e.observations || 'Not recorded'}
Results: ${e.results || 'Not recorded'}
Status: ${e.status || 'draft'}`
)
.join('\n---\n');
return context;
}
const SYSTEM_PROMPT_TEMPLATE = `You are a bioengineering lab assistant. You help researchers analyze their experiments, troubleshoot protocols, and identify patterns in their data.
Here is the researcher's experiment history:
{EXPERIMENT_CONTEXT}
Instructions:
- Use this context to answer their questions. Reference specific experiments by title and date when relevant.
- If the answer is not in their experiment data, say so clearly rather than guessing.
- Be concise and scientifically precise.
- When identifying trends, cite the specific data points you are comparing.`;
export async function streamChat(
userMessage: string,
experimentContext: string,
conversationHistory: Array<{ role: 'user' | 'assistant'; content: string }>
) {
const systemPrompt = SYSTEM_PROMPT_TEMPLATE.replace(
'{EXPERIMENT_CONTEXT}',
experimentContext
);
const messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }> = [
{ role: 'system', content: systemPrompt },
...conversationHistory,
{ role: 'user', content: userMessage },
];
return groq.chat.completions.create({
messages,
model: 'llama-3.3-70b-versatile',
temperature: 0.7,
max_completion_tokens: 1024,
stream: true,
});
}
Your AI service module is complete. Next up, you will wire these functions into a streaming HTTP endpoint so your API can accept chat messages and deliver real-time responses.
Create the Chat Endpoint
Your AI service can build context and stream responses from Groq. Now you need an HTTP endpoint that ties everything together so a client can actually send a question and receive the streamed answer.
In this step, you will create a POST /api/chat route that validates input, manages conversations, and streams the AI response back as Server-Sent Events. You will also add endpoints to list and retrieve past conversations.
In this step, get ready to:
- Build a streaming POST endpoint that validates, persists, and streams an AI chat response.
- Register the chat routes behind your authentication middleware.
- Add GET endpoints to list conversations and retrieve message history.
Build the streaming POST /api/chat route
This endpoint orchestrates the full chat flow: validate the request, manage the conversation record, save the user message, call the AI, stream the response, then save the AI's reply. That is a lot of coordination for one handler, but each piece flows naturally into the next.
- Create a new file at src/routes/chat.ts.
- Add the following imports, router setup, and Zod validation schema:
import { Router, Request, Response } from 'express';
import { z } from 'zod';
import { db } from '../db';
import { conversations, messages } from '../db/schema';
import { eq, asc, desc } from 'drizzle-orm';
import { buildExperimentContext, streamChat } from '../services/ai';
const router = Router();
// Validate incoming chat requests: message is required, conversationId is optional
const chatSchema = z.object({
message: z.string().min(1).max(2000),
conversationId: z.number().optional(),
});
What does this code do?
The imports pull in your database connection, the conversation and messages tables, Drizzle query helpers, and the two AI service functions you built in the previous step.
The chatSchema uses Zod to enforce that every request includes a non-empty message (max 2000 characters) and optionally references an existing conversation.
- Below the schema, add the POST handler that streams an AI response:
// POST /api/chat - Send a message and stream AI response
router.post('/', async (req: Request, res: Response) => {
const userId = req.userId!;
const parsed = chatSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
const { message, conversationId } = parsed.data;
try {
// Get or create conversation
let convId = conversationId;
if (!convId) {
const [newConv] = await db
.insert(conversations)
.values({
userId,
title: message.slice(0, 100),
})
.returning();
convId = newConv.id;
} else {
// Verify conversation belongs to user
const [conv] = await db
.select()
.from(conversations)
.where(eq(conversations.id, convId));
if (!conv || conv.userId !== userId) {
return res.status(404).json({ error: 'Conversation not found' });
}
}
// Save user message
await db.insert(messages).values({
conversationId: convId,
role: 'user',
content: message,
});
// Load conversation history (excluding the message we just saved)
const history = await db
.select()
.from(messages)
.where(eq(messages.conversationId, convId))
.orderBy(asc(messages.createdAt));
const conversationHistory = history.slice(0, -1).map((m) => ({
role: m.role as 'user' | 'assistant',
content: m.content,
}));
// Build experiment context from user's lab data
const experimentContext = await buildExperimentContext(userId);
// Set SSE headers for streaming
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
// Stream response from Groq
const stream = await streamChat(message, experimentContext, conversationHistory);
let fullResponse = '';
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content || '';
if (content) {
fullResponse += content;
res.write(`data: ${JSON.stringify({ content })}\n\n`);
}
}
// Save complete assistant response
await db.insert(messages).values({
conversationId: convId,
role: 'assistant',
content: fullResponse,
});
// Send done event with conversation ID
res.write(`data: ${JSON.stringify({ done: true, conversationId: convId })}\n\n`);
res.end();
} catch (error) {
console.error('Chat error:', error);
if (!res.headersSent) {
res.status(500).json({ error: 'Failed to process chat message' });
} else {
res.write(`data: ${JSON.stringify({ error: 'Stream interrupted' })}\n\n`);
res.end();
}
}
});
What does this handler do?
- Conversation management: If no conversationId is provided, it creates a new conversation using the first 100 characters of the message as the title. If one is provided, it verifies ownership.
- Message persistence: The user message is saved immediately. After streaming completes, the full assistant response is saved too. This gives you a complete audit trail.
- SSE streaming: The three headers (Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive) tell the client to expect a long-lived stream of events rather than a single JSON response.
- Error handling: If the error happens before streaming starts, return a normal JSON error. If it happens mid-stream, send an error event and close the connection.
Why Server-Sent Events instead of WebSockets?
SSE is simpler for a unidirectional stream (server to client). It works over standard HTTP with no upgrade handshake. The chat pattern here is request-response: the user asks, the AI streams back. There is no need for bidirectional real-time communication.
- Now register the chat routes in your main server file. Open src/index.ts.
- Find this line near the top of your imports:
import authRoutes from './routes/auth';
- Add a new import directly below it:
import chatRoutes from './routes/chat';
- Then find the existing route registrations:
app.use('/api/experiments', requireAuth, experimentRoutes);
- Add the chat routes directly below that line:
app.use('/api/chat', requireAuth, chatRoutes);
What does this registration do?
The requireAuth middleware runs before any chat route handler. This means only authenticated users (with a valid JWT) can access the chat. All routes defined in chatRoutes are mounted under the /api/chat path prefix.
- Save both src/routes/chat.ts and src/index.ts.
- Test the streaming endpoint by running this curl command (replace the token with a valid JWT from your auth endpoint):
curl -N -X POST http://localhost:3000/api/chat \
-H "Content-Type: application/json" \
-H "Authorization: Bearer your-jwt-token-here" \
-d '{"message": "What experiments have I run recently?"}'
- You should see SSE data chunks streaming in your terminal, each formatted as data: {"content":"..."} followed by a final data: {"done":true,"conversationId":1} event.
Not seeing streamed output?
- Make sure you included the -N flag in your curl command. Without it, curl buffers the response and waits for the connection to close before displaying anything.
- Check that your GROQ_API_KEY is set correctly in your .env file. A missing or invalid key returns an authentication error.
- Confirm your JWT token is valid. An expired or malformed token returns a 401 before the handler runs.
Help me debug my streaming chat endpoint.
Add conversation listing endpoints
Users need a way to see their past conversations and revisit message history. These two GET endpoints provide that read access.
- In src/routes/chat.ts, add the conversations listing handler below the POST handler's closing );:
// GET /api/chat/conversations - List user's conversations
router.get('/conversations', async (req: Request, res: Response) => {
const userId = req.userId!;
// Return all conversations for this user, newest first
const userConversations = await db
.select()
.from(conversations)
.where(eq(conversations.userId, userId))
.orderBy(desc(conversations.createdAt));
res.json(userConversations);
});
What does this endpoint do?
This queries all conversations belonging to the authenticated user and returns them sorted by most recent. The requireAuth middleware already ran before this handler, so req.userId is guaranteed to exist.
- Below the conversations listing, add the message history handler:
// GET /api/chat/conversations/:id/messages - Get messages for a conversation
router.get('/conversations/:id/messages', async (req: Request, res: Response) => {
const userId = req.userId!;
const convId = parseInt(req.params.id);
if (isNaN(convId)) {
return res.status(400).json({ error: 'Invalid conversation ID' });
}
// Verify conversation belongs to user
const [conv] = await db
.select()
.from(conversations)
.where(eq(conversations.id, convId));
if (!conv || conv.userId !== userId) {
return res.status(404).json({ error: 'Conversation not found' });
}
// Return all messages in chronological order
const convMessages = await db
.select()
.from(messages)
.where(eq(messages.conversationId, convId))
.orderBy(asc(messages.createdAt));
res.json(convMessages);
});
export default router;
Why the ownership check?
Before returning messages, the handler verifies that the conversation belongs to the requesting user. Without this check, any authenticated user could read another user's conversations just by guessing the ID. This is a basic authorization pattern you should apply to any user-scoped resource.
- Save src/routes/chat.ts.
- Test the conversations listing by running:
curl http://localhost:3000/api/chat/conversations \
-H "Authorization: Bearer your-jwt-token-here"
- You should see a JSON array containing the conversation you created in the previous test.
- Test the message history by running (replace 1 with your actual conversation ID from the listing response):
curl http://localhost:3000/api/chat/conversations/1/messages \
-H "Authorization: Bearer your-jwt-token-here"
- You should see both the user message and the assistant response stored in chronological order.
Getting an empty array or 404?
- An empty array from the conversations listing means no conversations exist for your user yet. Make sure the POST /api/chat test from earlier completed successfully.
- A 404 on the messages endpoint means either the conversation ID does not exist or it belongs to a different user. Double-check the ID from your conversations listing response.
Help me debug my conversation listing endpoints.
✔️ Awesome, I've got everything!
Great. Double check you have saved both files before moving on.
ⓧ I'd like to double check the full code
Here is the complete src/routes/chat.ts:
import { Router, Request, Response } from 'express';
import { z } from 'zod';
import { db } from '../db';
import { conversations, messages } from '../db/schema';
import { eq, asc, desc } from 'drizzle-orm';
import { buildExperimentContext, streamChat } from '../services/ai';
const router = Router();
const chatSchema = z.object({
message: z.string().min(1).max(2000),
conversationId: z.number().optional(),
});
router.post('/', async (req: Request, res: Response) => {
const userId = req.userId!;
const parsed = chatSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
const { message, conversationId } = parsed.data;
try {
let convId = conversationId;
if (!convId) {
const [newConv] = await db
.insert(conversations)
.values({
userId,
title: message.slice(0, 100),
})
.returning();
convId = newConv.id;
} else {
const [conv] = await db
.select()
.from(conversations)
.where(eq(conversations.id, convId));
if (!conv || conv.userId !== userId) {
return res.status(404).json({ error: 'Conversation not found' });
}
}
await db.insert(messages).values({
conversationId: convId,
role: 'user',
content: message,
});
const history = await db
.select()
.from(messages)
.where(eq(messages.conversationId, convId))
.orderBy(asc(messages.createdAt));
const conversationHistory = history.slice(0, -1).map((m) => ({
role: m.role as 'user' | 'assistant',
content: m.content,
}));
const experimentContext = await buildExperimentContext(userId);
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
const stream = await streamChat(message, experimentContext, conversationHistory);
let fullResponse = '';
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content || '';
if (content) {
fullResponse += content;
res.write(`data: ${JSON.stringify({ content })}\n\n`);
}
}
await db.insert(messages).values({
conversationId: convId,
role: 'assistant',
content: fullResponse,
});
res.write(`data: ${JSON.stringify({ done: true, conversationId: convId })}\n\n`);
res.end();
} catch (error) {
console.error('Chat error:', error);
if (!res.headersSent) {
res.status(500).json({ error: 'Failed to process chat message' });
} else {
res.write(`data: ${JSON.stringify({ error: 'Stream interrupted' })}\n\n`);
res.end();
}
}
});
router.get('/conversations', async (req: Request, res: Response) => {
const userId = req.userId!;
const userConversations = await db
.select()
.from(conversations)
.where(eq(conversations.userId, userId))
.orderBy(desc(conversations.createdAt));
res.json(userConversations);
});
router.get('/conversations/:id/messages', async (req: Request, res: Response) => {
const userId = req.userId!;
const convId = parseInt(req.params.id);
if (isNaN(convId)) {
return res.status(400).json({ error: 'Invalid conversation ID' });
}
const [conv] = await db
.select()
.from(conversations)
.where(eq(conversations.id, convId));
if (!conv || conv.userId !== userId) {
return res.status(404).json({ error: 'Conversation not found' });
}
const convMessages = await db
.select()
.from(messages)
.where(eq(messages.conversationId, convId))
.orderBy(asc(messages.createdAt));
res.json(convMessages);
});
export default router;
And here is the complete src/index.ts:
import express from 'express';
import dotenv from 'dotenv';
import experimentRoutes from './routes/experiments';
import authRoutes from './routes/auth';
import chatRoutes from './routes/chat';
import { requireAuth } from './middleware/auth';
dotenv.config();
const app = express();
const PORT = process.env.PORT || 3000;
app.use(express.json());
app.use('/api/auth', authRoutes);
app.use('/api/experiments', requireAuth, experimentRoutes);
app.use('/api/chat', requireAuth, chatRoutes);
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
export default app;
Your AI chat gateway is fully operational. Authenticated users can ask questions, receive streamed responses grounded in their experiment data, and revisit past conversations. Next up, you will tackle the Secret Mission and make the context engine smarter.
Secret mission
Smart Context Retrieval
Your chat endpoint currently sends the 10 most recent experiments as context every time, regardless of what the user actually asked. That means a question about gel electrophoresis might include irrelevant PCR experiments while missing an older, directly relevant gel run. Build a keyword-relevance filter that surfaces the experiments most likely to answer the user's question.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
This project runs entirely locally with Groq's free tier, so there are no ongoing costs.
Resources you used:
- Express dev server (running in your terminal).
- PostgreSQL database with conversations and messages tables.
- Groq API key (stored in .env and on console.groq.com).
- Project source files (src/services/ai.ts, src/routes/chat.ts, schema and index modifications).
Keep everything running
No action needed. Choose this if you're still actively building or plan to continue with the BioLab Notebook series.
- Your Express dev server stays running in the terminal.
- Your PostgreSQL database keeps all experiment, conversation, and message data intact.
- Your Groq API key remains active. The free tier never charges, so there is no billing risk.
Pause - I'll come back to this later
Shut down running processes to free up system resources while keeping all your files and data intact.
- Stop the Express dev server by pressing Ctrl+C in the terminal where it is running.
- Optionally stop PostgreSQL if you want to free up memory. Use brew services stop postgresql (macOS) or sudo systemctl stop postgresql (Linux/WSL).
- Your project files, database data, and Groq API key all remain ready for when you come back.
Delete - I don't want to use this again
Remove all project resources created during this part and start fresh.
- Stop the Express dev server by pressing Ctrl+C in the terminal.
- Drop the Part 3 database tables by connecting to your PostgreSQL database and running:
psql -d biolab
DROP TABLE messages;
DROP TABLE conversations;
- If you want to remove the entire database (including tables from Parts 1 and 2), run:
psql
DROP DATABASE biolab;
- Revoke your Groq API key by logging in to console.groq.com, navigating to API Keys, and deleting the key you created for this project.
- Remove the GROQ_API_KEY line from your .env file.
- If you want to delete the entire project from your machine, run:
macOS
cd ~/Desktop
rm -rf biolab-notebook
Windows
cd ~\Desktop
rmdir /s /q biolab-notebook
Nice Work!
Nice Work!
You just built a fully streaming AI research assistant into your lab notebook API. Your BioLab Notebook now has a conversational layer that answers bioengineering questions grounded in real experiment data.
You've learned how to:
- Built a context engine that queries experiment data from PostgreSQL and formats it into structured prompts for an LLM.
- Implemented a streaming /api/chat endpoint using Server-Sent Events that delivers AI responses in real-time via Groq and llama-3.3-70b-versatile.
- Created conversations and messages tables with Drizzle ORM to persist full multi-turn chat history per user.
- Secret Mission: Upgraded from naive recency-based context to keyword-relevance scoring that surfaces the most relevant experiments for each question.
Ready to quiz yourself?