Build an Omnichannel RAG Support Engine
Build a streaming AI support agent with Next.js, Pinecone, and OpenAI.
Introduction
30 Second Summary
Every business gets the same customer questions asked over and over. What if you could build an AI agent that pulls accurate answers directly from your company's own documentation and serves them instantly across web, WhatsApp, and Messenger?
In this project, you will build a production-grade RAG customer support engine that ingests company knowledge into a vector database, retrieves relevant context at query time, and streams AI-augmented responses through a polished chat interface.
What You'll Build
You'll create a Next.js 16 monorepo with a decoupled architecture separating the channel-agnostic RAG core from the streaming web UI.
By the end of this project, you'll have:
- A knowledge ingestion pipeline that chunks, embeds, and stores markdown documentation in Pinecone.
- A standalone RAG engine that retrieves contextually relevant chunks and streams OpenAI-augmented responses to any channel.
- A streaming chat interface built with React 19, shadcn/ui components, and the Vercel AI SDK useChat hook, deployed to Vercel with webhook skeleton routes for WhatsApp and Messenger.
- Secret Mission: Add conversation memory with sliding-window context and source citation rendering in the chat UI.
Are there any prerequisites?
This project assumes you have an active OpenAI API key, a Pinecone account with an API key, a Vercel Pro account, Node.js 22 installed, and pnpm installed.
You should also be familiar with TypeScript and the Next.js App Router pattern.
Set Up the Omnichannel Architecture
Every omnichannel support engine starts with a clean separation of concerns. The web chat is just one channel. WhatsApp, Messenger, and email are others. If you bake web-specific logic into your AI core, adding channels later means rewriting everything.
In this step, you will scaffold a Next.js 16 project with a folder structure designed for this separation. The src/core/ directory holds all channel-agnostic RAG logic. The src/app/ directory holds the web channel. This architecture means adding a new channel is just adding a new route handler that calls the same core service.
In this step, get ready to:
- Scaffold a Next.js 16 project and install all production dependencies.
- Initialize shadcn/ui with a deep blue/cyan design system and install UI components.
- Configure environment variables and verify connectivity to OpenAI and Pinecone.
Initialize the Next.js 16 project and install dependencies
The project needs a Next.js 16 App Router scaffold with a src/ directory to keep the folder structure organized. You will also install the AI SDK, Pinecone client, and Zod for runtime validation.
- Move to your Desktop by running this command:
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your Next.js 16 project with pnpm by running this command:
pnpm create next-app@latest omnichannel-rag --yes --src-dir
What does this command do?
The --yes flag accepts all recommended defaults: TypeScript, Tailwind CSS, ESLint, App Router, and Turbopack.
The --src-dir flag places your application code inside a src/ directory, which keeps the project root clean and gives you space to create the src/core/ and src/lib/ directories alongside src/app/.
- Move into your new project folder by running:
cd omnichannel-rag
- Install all production dependencies by running:
pnpm add ai @ai-sdk/openai @ai-sdk/react @pinecone-database/pinecone zod
What are these packages?
- ai is the Vercel AI SDK v6 core. It exports streamText, embed, and embedMany for LLM and embedding operations.
- @ai-sdk/openai is the OpenAI provider for AI SDK. It gives you openai('gpt-4o-mini') for chat and openai.textEmbeddingModel('text-embedding-3-small') for embeddings.
- @ai-sdk/react provides the useChat hook for streaming responses to the browser.
- @pinecone-database/pinecone is the Pinecone SDK for vector database operations.
- zod provides runtime schema validation for webhook payloads.
- Create the decoupled folder structure by running:
mkdir -p src/core src/lib
- Confirm the folders exist by running:
ls src
- You should see app, core, and lib in the output.
Why separate src/core from src/app?
The src/core/ directory holds all RAG logic: ingestion, embedding, retrieval, and the engine itself. None of it depends on Next.js or the web.
The src/app/ directory is just one channel adapter. When you add a WhatsApp webhook later, it calls the same core functions. This is the API-first decoupled architecture that makes omnichannel possible.
Command not found when running pnpm create?
- Confirm pnpm is installed by running pnpm --version. If you see a version number, try closing and reopening your terminal.
- Confirm Node.js 22 is available by running node --version. AI SDK v6 requires Node.js 22+.
Why isn't pnpm create working on my machine?
Initialize shadcn/ui and install components
The chat interface uses shadcn/ui components for a polished look without heavy dependencies. The shadcn CLI adds component source code directly to your project, giving you full control over styling.
- Initialize shadcn/ui in your project by running:
pnpm dlx shadcn@latest init
What will the CLI ask?
The CLI detects your Next.js project and configures CSS variables, the cn utility, and the @/ import alias automatically. Accept the defaults when prompted. It also adds @import "shadcn/tailwind.css" to your global CSS for Tailwind v4 utilities.
- Install the four UI components you need for the chat interface by running:
pnpm dlx shadcn@latest add card scroll-area input button
- Confirm the components were added by running:
ls src/components/ui
- You should see files including card.tsx, scroll-area.tsx, input.tsx, and button.tsx.
Now configure the deep blue/cyan design system. Open your project in your code editor.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Cursor (or VS Code) and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Select the omnichannel-rag folder on your Desktop, then click Open.
- Open src/app/layout.tsx in the Explorer sidebar and replace all its contents with the following code:
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: 'Tech Launch - AI Support',
description: 'Omnichannel RAG Customer Support Engine',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className="dark">
<body className={inter.className}>{children}</body>
</html>
);
}
What does this layout do?
- className="dark" on the <html> tag activates dark mode for all shadcn/ui components. This gives you the deep blue/slate background automatically.
- The Inter font loads via Next.js font optimization for clean, modern typography.
- The metadata sets the page title and description for the Tech Launch support agent.
- Save src/app/layout.tsx.
shadcn init not detecting your project?
- Make sure you are running the command from inside the omnichannel-rag folder. The CLI needs a package.json with Next.js listed as a dependency.
- If you see peer dependency warnings with pnpm, they are safe to ignore. The shadcn CLI works with pnpm without any extra flags.
Why is shadcn not detecting my project correctly?
Set up environment variables and verify connectivity
The RAG engine needs API keys for OpenAI (embeddings and chat completions) and Pinecone (vector storage). You will store these in a .env.local file that Next.js loads automatically at dev time.
- Create a .env.local file in the project root by running:
touch .env.local
- Open .env.local in your editor and add the following three variables:
OPENAI_API_KEY=your-openai-api-key
PINECONE_API_KEY=your-pinecone-api-key
PINECONE_INDEX_HOST=your-pinecone-index-host
Where do I find these values?
- OPENAI_API_KEY: Go to platform.openai.com, click API keys in the sidebar, and copy your key.
- PINECONE_API_KEY: Go to your Pinecone console, click API Keys in the left sidebar, and copy your default key.
- PINECONE_INDEX_HOST: In the Pinecone console, create a serverless index (dimension 1536, metric cosine), then copy the host URL shown on the index page (it looks like your-index-abc1234.svc.aped-1234.pinecone.io).
- Replace each placeholder value with your actual credentials.
- Save .env.local.
Now verify that both services are reachable. Create a quick health check script.
- Create the health check file by running:
touch src/lib/health-check.ts
- Open src/lib/health-check.ts and paste the following code:
import { openai } from '@ai-sdk/openai';
import { embed } from 'ai';
import { Pinecone } from '@pinecone-database/pinecone';
async function healthCheck() {
// Test OpenAI connectivity with a small embedding
console.log('Testing OpenAI...');
const { embedding } = await embed({
model: openai.textEmbeddingModel('text-embedding-3-small'),
value: 'hello world',
});
console.log(` OpenAI OK - embedding dimension: ${embedding.length}`);
// Test Pinecone connectivity
console.log('Testing Pinecone...');
const pc = new Pinecone({ apiKey: process.env.PINECONE_API_KEY! });
const index = pc.index({ host: process.env.PINECONE_INDEX_HOST! });
const stats = await index.describeIndexStats();
console.log(` Pinecone OK - total vectors: ${stats.totalRecordCount}`);
console.log('\nAll services connected successfully.');
}
healthCheck().catch(console.error);
What does this script do?
- It sends a test string to OpenAI's text-embedding-3-small model and checks that a 1536-dimensional vector comes back.
- It connects to your Pinecone index using the host URL and fetches basic stats to confirm the index exists and is reachable.
- Save src/lib/health-check.ts.
- Run the health check script to verify connectivity:
pnpm tsx src/lib/health-check.ts
- You should see output confirming both services connected, including an embedding dimension of 1536 and a Pinecone vector count.
Seeing authentication errors?
- Double-check that your .env.local file has no spaces around the = sign and no trailing whitespace on each line.
- Make sure you copied the full Pinecone host URL including the .pinecone.io suffix.
- If your OpenAI key starts with sk-proj-, verify that the project has billing enabled in your OpenAI dashboard.
Why is my health check failing to authenticate?
- Start the development server to confirm everything runs together:
pnpm dev
- Open your browser and navigate to http://localhost:3000.
- You should see the Next.js default page with a dark background (from the dark class on the <html> element) and the custom blue/cyan theme from shadcn/ui's dark mode.
✔️ Awesome, I've got everything!
Great. Double check you have saved all your files and the dev server is running at localhost:3000.
ⓧ I'd like to double check the full code
Here is the complete src/app/layout.tsx file:
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: 'Tech Launch - AI Support',
description: 'Omnichannel RAG Customer Support Engine',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className="dark">
<body className={inter.className}>{children}</body>
</html>
);
}
And here is the .env.local file (with your real keys substituted):
OPENAI_API_KEY=<your-openai-api-key>
PINECONE_API_KEY=<your-pinecone-api-key>
PINECONE_INDEX_HOST=<your-pinecone-index-host>
Your environment is ready with all dependencies, services connected, and a clean separation between your RAG core and web channel. Next up, you will build the knowledge ingestion pipeline that turns markdown documents into searchable vectors.
Build the Knowledge Ingestion Pipeline
Your RAG engine needs a knowledge base to draw answers from. Right now, the project has no way to process company documents into a format that a vector database can search.
In this step, you'll build the ingestion pipeline that reads markdown documentation, splits it into semantically meaningful chunks, and prepares those chunks for embedding. This is the foundation of your retrieval system.
In this step, get ready to:
- Create a document chunker that splits markdown into overlapping segments with metadata.
- Write a CLI ingestion script that orchestrates the full pipeline.
- Set up sample knowledge base files and verify the chunking output.
Create the document chunker
The chunker is responsible for splitting long documents into smaller pieces that fit inside an embedding model's context window. Each chunk also carries metadata so you can trace answers back to their source.
- Create a new file at src/core/ingestion/chunker.ts by running:
mkdir -p src/core/ingestion
touch src/core/ingestion/chunker.ts
- Confirm the file exists by running:
ls src/core/ingestion/
You should see chunker.ts in the output.
- Open src/core/ingestion/chunker.ts in your editor and add the type definitions and configuration constants:
interface ChunkMetadata {
source: string;
chunkIndex: number;
heading: string;
}
interface TextChunk {
text: string;
metadata: ChunkMetadata;
}
// Target ~800 words per chunk with 200-word overlap for context continuity
const CHUNK_SIZE = 800;
const CHUNK_OVERLAP = 200;
// Extract the first markdown heading from a text block
function extractHeading(text: string): string {
const match = text.match(/^#{1,3}\s+(.+)$/m);
return match ? match[1].trim() : 'General';
}
What does this code do?
The ChunkMetadata interface tracks where each chunk came from: the source filename, its position index, and the heading it falls under.
The CHUNK_SIZE of 800 words keeps each chunk large enough to preserve meaning. The CHUNK_OVERLAP of 200 words ensures context isn't lost at chunk boundaries.
The extractHeading function uses a regex to find the first H1-H3 heading in a text section. If no heading exists, it defaults to 'General'.
- Below the extractHeading function, add the main chunking logic:
export function chunkDocument(
content: string,
source: string
): TextChunk[] {
const chunks: TextChunk[] = [];
// Split on markdown headings to preserve section boundaries
const sections = content.split(/(?=^#{1,3}\s)/m);
let chunkIndex = 0;
for (const section of sections) {
const heading = extractHeading(section);
const words = section.split(/\s+/);
// Sliding window through each section's words
let start = 0;
while (start < words.length) {
const end = Math.min(start + CHUNK_SIZE, words.length);
const chunkText = words.slice(start, end).join(' ').trim();
// Only keep chunks with meaningful content
if (chunkText.length > 50) {
chunks.push({
text: chunkText,
metadata: {
source,
chunkIndex,
heading,
},
});
chunkIndex++;
}
start += CHUNK_SIZE - CHUNK_OVERLAP;
}
}
return chunks;
}
What does this code do?
The chunkDocument function first splits the document on markdown headings so section boundaries are respected. Then it applies a sliding window over each section's words.
The overlap means consecutive chunks share 200 words of context. This prevents the retriever from missing relevant information that spans a chunk boundary. Chunks shorter than 50 characters are discarded as noise.
- Save chunker.ts.
Seeing red squiggles in the editor?
If your editor shows TypeScript errors, make sure you have the export keyword on chunkDocument but NOT on the interfaces or constants. Only chunkDocument needs to be exported since it's the public API of this module.
help me fix TypeScript errors in chunker.ts
Set up the knowledge base directory
The ingestion script reads from a knowledge/ directory at the project root. You need sample documentation files to test your chunker against.
- Create the knowledge/ directory and a sample file by running:
mkdir -p knowledge
- Create a file called knowledge/products.md and add sample company documentation:
# Tech Launch Products
Tech Launch offers three core products for small and medium businesses.
## Website Builder
Our drag-and-drop website builder lets businesses create professional sites without coding. Plans start at $29/month and include hosting, SSL certificates, and 24/7 support. Custom domains can be connected in under 5 minutes.
## Email Marketing Platform
Send up to 10,000 emails per month on our Starter plan. Features include A/B testing, automated drip campaigns, and detailed analytics dashboards. Integration with our Website Builder is automatic.
## AI Chat Assistant
Add an AI-powered support agent to any website built with Tech Launch. The assistant learns from your knowledge base and handles common customer questions. Setup takes less than 10 minutes with our guided wizard.
- Create a second file called knowledge/support-policies.md with more documentation:
# Support Policies
Tech Launch provides tiered support based on your plan level.
## Response Times
Starter plan customers receive email support with a 24-hour response window. Pro plan customers get priority email and live chat with a 4-hour response window. Enterprise customers have dedicated account managers and 1-hour response times.
## Refund Policy
All plans come with a 30-day money-back guarantee. Annual subscriptions can be cancelled within 14 days for a full refund. After 14 days, a prorated refund is issued for the remaining months.
## Escalation Process
If an issue is not resolved within the stated response time, it is automatically escalated to a senior support engineer. Customers can also request escalation at any time by replying with ESCALATE in the subject line.
- Save both files and confirm they exist by running:
ls knowledge/
You should see products.md and support-policies.md listed.
Create the ingestion CLI script
The ingestion script orchestrates the full pipeline: reading files, chunking them, embedding them, and upserting to Pinecone. You'll write the complete orchestration now. The embedding and upserting modules will be built in the next step.
- Create src/core/ingestion/ingest.ts and add the full pipeline script:
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { chunkDocument } from './chunker';
import { embedChunks } from './embedder';
import { upsertToPinecone } from './upserter';
async function main() {
// Read all markdown files from the knowledge directory
const knowledgeDir = join(process.cwd(), 'knowledge');
const files = readdirSync(knowledgeDir).filter((f) => f.endsWith('.md'));
console.log(`Found ${files.length} knowledge files`);
const allChunks: { text: string; metadata: { source: string; chunkIndex: number; heading: string } }[] = [];
// Process each file through the chunker
for (const file of files) {
const content = readFileSync(join(knowledgeDir, file), 'utf-8');
const chunks = chunkDocument(content, file);
allChunks.push(...chunks);
console.log(` ${file}: ${chunks.length} chunks`);
}
console.log(`\nTotal chunks: ${allChunks.length}`);
console.log('Generating embeddings...');
// Embed all chunks via OpenAI
const embedded = await embedChunks(allChunks);
console.log(`Generated ${embedded.length} embeddings`);
// Upsert to Pinecone vector database
console.log('Upserting to Pinecone...');
await upsertToPinecone(embedded);
console.log('Ingestion complete.');
}
main().catch(console.error);
What does this code do?
The main() function reads every .md file from the knowledge/ directory and passes each through chunkDocument. It logs the chunk count per file so you can verify the splitting logic.
After chunking, it calls embedChunks and upsertToPinecone to complete the pipeline. Those modules don't exist yet. You'll create them in the next step.
- Save ingest.ts.
Why write the full script now?
Writing the complete orchestration upfront gives you a clear picture of how the pipeline fits together. The embedder.ts and upserter.ts imports will resolve once you create those files in Step 3. Until then, your editor may show import errors for those two modules.
Seeing import errors for embedder or upserter?
This is expected. Your editor cannot resolve ./embedder and ./upserter because those files don't exist yet. The errors will disappear after you create them in the next step.
help me understand why my ingest.ts imports are showing errors
✔️ Awesome, I've got everything!
Great. Double check you've saved both chunker.ts and ingest.ts.
ⓧ I'd like to double check the full code
Here's the complete chunker.ts:
interface ChunkMetadata {
source: string;
chunkIndex: number;
heading: string;
}
interface TextChunk {
text: string;
metadata: ChunkMetadata;
}
const CHUNK_SIZE = 800;
const CHUNK_OVERLAP = 200;
function extractHeading(text: string): string {
const match = text.match(/^#{1,3}\s+(.+)$/m);
return match ? match[1].trim() : 'General';
}
export function chunkDocument(
content: string,
source: string
): TextChunk[] {
const chunks: TextChunk[] = [];
const sections = content.split(/(?=^#{1,3}\s)/m);
let chunkIndex = 0;
for (const section of sections) {
const heading = extractHeading(section);
const words = section.split(/\s+/);
let start = 0;
while (start < words.length) {
const end = Math.min(start + CHUNK_SIZE, words.length);
const chunkText = words.slice(start, end).join(' ').trim();
if (chunkText.length > 50) {
chunks.push({
text: chunkText,
metadata: {
source,
chunkIndex,
heading,
},
});
chunkIndex++;
}
start += CHUNK_SIZE - CHUNK_OVERLAP;
}
}
return chunks;
}
And the complete ingest.ts:
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { chunkDocument } from './chunker';
import { embedChunks } from './embedder';
import { upsertToPinecone } from './upserter';
async function main() {
const knowledgeDir = join(process.cwd(), 'knowledge');
const files = readdirSync(knowledgeDir).filter((f) => f.endsWith('.md'));
console.log(`Found ${files.length} knowledge files`);
const allChunks: { text: string; metadata: { source: string; chunkIndex: number; heading: string } }[] = [];
for (const file of files) {
const content = readFileSync(join(knowledgeDir, file), 'utf-8');
const chunks = chunkDocument(content, file);
allChunks.push(...chunks);
console.log(` ${file}: ${chunks.length} chunks`);
}
console.log(`\nTotal chunks: ${allChunks.length}`);
console.log('Generating embeddings...');
const embedded = await embedChunks(allChunks);
console.log(`Generated ${embedded.length} embeddings`);
console.log('Upserting to Pinecone...');
await upsertToPinecone(embedded);
console.log('Ingestion complete.');
}
main().catch(console.error);
Verify the chunker output
The full ingestion script won't run yet because the embedding and upserting modules don't exist. You can still verify that the chunking logic works correctly by testing chunkDocument directly.
- Install tsx as a dev dependency so you can run TypeScript files directly:
pnpm add -D tsx
- Test the chunker by running this command:
pnpm tsx -e "import { chunkDocument } from './src/core/ingestion/chunker'; const chunks = chunkDocument(require('fs').readFileSync('knowledge/products.md','utf-8'), 'products.md'); console.log('Chunks:', chunks.length); chunks.forEach(c => console.log(c.metadata.heading, '-', c.text.slice(0,60)+'...'))"
You should see output showing the chunk count and a preview of each chunk's heading and opening text.
What should I see?
The output should show 1-2 chunks from products.md since the sample file is short. Each chunk displays its heading (like "Tech Launch Products") and the first 60 characters of its text. The metadata confirms the source filename and chunk index are correctly assigned.
Getting a module resolution error?
Make sure you installed tsx with pnpm add -D tsx. If you see a "Cannot find module" error for the chunker, check that the path ./src/core/ingestion/chunker matches your file structure and that chunkDocument has the export keyword.
help me debug my chunker module resolution error
Your ingestion pipeline's foundation is solid. Next up, you'll connect it to OpenAI's embedding API and Pinecone to turn those text chunks into searchable vectors.
Generate Embeddings and Upsert to Pinecone
Your chunker splits documents into semantically meaningful segments, but text alone is not searchable by meaning. To find relevant answers at query time, you need to convert those text chunks into mathematical representations called vector embeddings.
In this step, you'll generate 1536-dimensional vectors for each chunk using OpenAI's text-embedding-3-small model and store them in Pinecone for fast similarity search at query time.
In this step, get ready to:
- Generate vector embeddings from text chunks using OpenAI's embedding API.
- Store embedded vectors in a Pinecone serverless index with metadata.
- Run the full ingestion pipeline and verify vectors in the Pinecone console.
Create the embedding module
Each text chunk needs to become a fixed-length array of numbers that captures its semantic meaning. The embedMany function from the AI SDK handles batching multiple texts through the embedding model in a single request.
- Create a new file at src/core/ingestion/embedder.ts and add the following code:
import { openai } from '@ai-sdk/openai';
import { embedMany } from 'ai';
const BATCH_SIZE = 100;
const MAX_RETRIES = 3;
export interface EmbeddedChunk {
id: string;
values: number[];
metadata: {
source: string;
chunkIndex: number;
heading: string;
text: string;
};
}
async function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function embedChunks(
chunks: { text: string; metadata: { source: string; chunkIndex: number; heading: string } }[]
): Promise<EmbeddedChunk[]> {
const results: EmbeddedChunk[] = [];
for (let i = 0; i < chunks.length; i += BATCH_SIZE) {
const batch = chunks.slice(i, i + BATCH_SIZE);
let retries = 0;
while (retries < MAX_RETRIES) {
try {
const { embeddings } = await embedMany({
model: openai.textEmbeddingModel('text-embedding-3-small'),
values: batch.map((c) => c.text),
});
for (let j = 0; j < batch.length; j++) {
results.push({
id: `${batch[j].metadata.source}-${batch[j].metadata.chunkIndex}`,
values: embeddings[j],
metadata: {
...batch[j].metadata,
text: batch[j].text,
},
});
}
break;
} catch (error) {
retries++;
if (retries >= MAX_RETRIES) throw error;
await sleep(2000 * retries);
}
}
}
return results;
}
What does this code do?
The EmbeddedChunk interface defines the shape of each vector record: an ID string, a values array holding the 1536-dimensional embedding, and metadata fields for retrieval context.
The embedChunks function processes chunks in batches of 100 to stay within API rate limits. If a request fails, the exponential backoff retry logic (sleep(2000 * retries)) waits progressively longer before retrying, up to 3 attempts.
- Save src/core/ingestion/embedder.ts.
- You won't see a change yet. The next file (the upserter) completes the pipeline so you can run and verify everything together.
Create the Pinecone upserter
Once chunks are embedded, you need to store those vectors somewhere they can be queried by similarity. The upserter connects to your Pinecone serverless index and writes vectors in batches of 100 with their metadata attached.
- Create a new file at src/core/ingestion/upserter.ts and add the following code:
import { Pinecone } from '@pinecone-database/pinecone';
import type { EmbeddedChunk } from './embedder';
const UPSERT_BATCH_SIZE = 100;
export async function upsertToPinecone(
embeddedChunks: EmbeddedChunk[]
): Promise<void> {
const pc = new Pinecone({ apiKey: process.env.PINECONE_API_KEY! });
const index = pc.index({ host: process.env.PINECONE_INDEX_HOST! });
for (let i = 0; i < embeddedChunks.length; i += UPSERT_BATCH_SIZE) {
const batch = embeddedChunks.slice(i, i + UPSERT_BATCH_SIZE);
await index.upsert({
records: batch.map((chunk) => ({
id: chunk.id,
values: chunk.values,
metadata: chunk.metadata,
})),
});
console.log(
`Upserted batch ${Math.floor(i / UPSERT_BATCH_SIZE) + 1} (${batch.length} vectors)`
);
}
}
What does this code do?
The Pinecone client initializes with your API key from .env.local. It targets your specific index using the host URL stored in PINECONE_INDEX_HOST.
Each upsert call sends a batch of records with their vector values and metadata (source filename, chunk index, heading, and the original text). The metadata travels with the vector so you can display it alongside retrieval results later.
- Save src/core/ingestion/upserter.ts.
Why a single namespace?
Pinecone's free tier gives you one serverless index. Using the default namespace keeps things simple for a single-product build. In a multi-tenant deployment (serving multiple clients), you would separate each client's data into its own namespace within the same index.
Seeing a TypeScript error in your editor?
If your editor shows import errors for @pinecone-database/pinecone, confirm the package is installed in your project. It should have been installed in Step 1.
help me debug a Pinecone import error in my upserter file
✔️ 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 embedder.ts:
import { openai } from '@ai-sdk/openai';
import { embedMany } from 'ai';
const BATCH_SIZE = 100;
const MAX_RETRIES = 3;
export interface EmbeddedChunk {
id: string;
values: number[];
metadata: {
source: string;
chunkIndex: number;
heading: string;
text: string;
};
}
async function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function embedChunks(
chunks: { text: string; metadata: { source: string; chunkIndex: number; heading: string } }[]
): Promise<EmbeddedChunk[]> {
const results: EmbeddedChunk[] = [];
for (let i = 0; i < chunks.length; i += BATCH_SIZE) {
const batch = chunks.slice(i, i + BATCH_SIZE);
let retries = 0;
while (retries < MAX_RETRIES) {
try {
const { embeddings } = await embedMany({
model: openai.textEmbeddingModel('text-embedding-3-small'),
values: batch.map((c) => c.text),
});
for (let j = 0; j < batch.length; j++) {
results.push({
id: `${batch[j].metadata.source}-${batch[j].metadata.chunkIndex}`,
values: embeddings[j],
metadata: {
...batch[j].metadata,
text: batch[j].text,
},
});
}
break;
} catch (error) {
retries++;
if (retries >= MAX_RETRIES) throw error;
await sleep(2000 * retries);
}
}
}
return results;
}
And the full upserter.ts:
import { Pinecone } from '@pinecone-database/pinecone';
import type { EmbeddedChunk } from './embedder';
const UPSERT_BATCH_SIZE = 100;
export async function upsertToPinecone(
embeddedChunks: EmbeddedChunk[]
): Promise<void> {
const pc = new Pinecone({ apiKey: process.env.PINECONE_API_KEY! });
const index = pc.index({ host: process.env.PINECONE_INDEX_HOST! });
for (let i = 0; i < embeddedChunks.length; i += UPSERT_BATCH_SIZE) {
const batch = embeddedChunks.slice(i, i + UPSERT_BATCH_SIZE);
await index.upsert({
records: batch.map((chunk) => ({
id: chunk.id,
values: chunk.values,
metadata: chunk.metadata,
})),
});
console.log(
`Upserted batch ${Math.floor(i / UPSERT_BATCH_SIZE) + 1} (${batch.length} vectors)`
);
}
}
Run the full ingestion pipeline
The ingest.ts CLI script you created in the previous step already imports both embedChunks and upsertToPinecone. Now that those modules exist, the full pipeline is ready to run.
- Run the ingestion pipeline against your knowledge base by running this command:
pnpm tsx src/core/ingestion/ingest.ts
What should I see?
The script logs each file it processes with a chunk count, then shows the total chunks, embedding generation progress, and upsert batch confirmations. A successful run ends with Ingestion complete. in your terminal.
- Confirm the terminal output shows chunk counts per file, embedding generation, upsert batch logs, and the final Ingestion complete. message.
Seeing a rate limit or authentication error?
- If you see an authentication error, double-check that OPENAI_API_KEY and PINECONE_API_KEY in .env.local are correct and have no extra spaces.
- If you see a connection error for Pinecone, verify that PINECONE_INDEX_HOST matches the host URL shown in your Pinecone console index detail page.
- If you hit an OpenAI rate limit, the retry logic handles it automatically up to 3 attempts. If it still fails, wait a minute and re-run the script.
help me debug my ingestion pipeline errors
- Open the Pinecone console at app.pinecone.io in your browser.
- Click on your index name to open its detail page.
- Verify the index shows dimension 1536 and a non-zero vector count matching what your terminal logged.
- Use the index browser to run a test query. Type a question relevant to your knowledge base content and confirm the results return semantically related chunks with metadata fields (source, chunkIndex, heading, text).
What is cosine similarity?
When you query Pinecone, it compares your query vector against all stored vectors using cosine similarity. This measures how closely two vectors point in the same direction. A score of 1.0 means identical meaning. A score near 0 means unrelated.
Your knowledge base is now searchable by meaning. Next up, you'll build the RAG core service that queries these vectors and streams AI-augmented answers to any channel.
Build the Agnostic RAG Core Service
Your ingestion pipeline has loaded your knowledge base into Pinecone as 1536-dimensional vectors. Now you need a way to actually use those vectors when a customer asks a question.
In this step, you will build the standalone RAG core service that accepts any user message, retrieves semantically relevant chunks from Pinecone, injects them into a prompt, and streams back an LLM-augmented response. Because the core service uses a generic (message, sessionId) contract, any channel (web, WhatsApp, Messenger) can call it without modification.
In this step, get ready to:
- Build a retriever that embeds a query and fetches the top-5 matching chunks from Pinecone.
- Build the RAG engine that composes retrieval, prompt construction, and streaming LLM response.
- Create a Next.js Route Handler that exposes the RAG engine as an API endpoint.
Create the retriever module
The retriever is the bridge between a user's natural-language question and your vector database. It embeds the question into the same 1536-dimensional space as your stored chunks, then asks Pinecone for the closest matches using cosine similarity.
- Create a new file at src/core/rag/retriever.ts and add the following code:
import { openai } from '@ai-sdk/openai';
import { embed } from 'ai';
import { Pinecone } from '@pinecone-database/pinecone';
export interface RetrievedContext {
text: string;
source: string;
heading: string;
score: number;
}
export async function retrieveContext(
query: string,
topK: number = 5
): Promise<RetrievedContext[]> {
// Embed the user's query into a vector
const { embedding } = await embed({
model: openai.textEmbeddingModel('text-embedding-3-small'),
value: query,
});
// Connect to Pinecone and target the index
const pc = new Pinecone({ apiKey: process.env.PINECONE_API_KEY! });
const index = pc.index({ host: process.env.PINECONE_INDEX_HOST! });
// Query for the top-K most similar vectors
const queryResponse = await index.query({
vector: embedding,
topK,
includeMetadata: true,
});
// Map Pinecone matches to a clean RetrievedContext shape
return (queryResponse.matches || []).map((match) => ({
text: (match.metadata?.text as string) || '',
source: (match.metadata?.source as string) || '',
heading: (match.metadata?.heading as string) || '',
score: match.score || 0,
}));
}
What does this code do?
The retrieveContext function handles the "R" in RAG. It converts the user's question into the same vector format as your stored knowledge, then queries Pinecone for the 5 closest matches.
The RetrievedContext interface structures the response so downstream code gets the chunk text, source filename, heading, and a relevance score (0 to 1).
- Save src/core/rag/retriever.ts.
Seeing a module not found error?
Make sure the file path is exactly src/core/rag/retriever.ts. The rag folder sits inside src/core/ alongside the existing ingestion folder.
If you see an error about @ai-sdk/openai or ai not being found, confirm your dependencies are installed by checking package.json.
Help me debug a module resolution error in my retriever
Create the RAG engine
The engine is the orchestrator. It calls the retriever, injects the retrieved context into a carefully crafted system prompt, and streams back the LLM's answer. This is the function that any channel adapter (web, WhatsApp, Messenger) calls.
- Create a new file at src/core/rag/engine.ts and add the following code:
import { openai } from '@ai-sdk/openai';
import { streamText } from 'ai';
import { retrieveContext } from './retriever';
interface RAGInput {
message: string;
sessionId: string;
}
export async function executeRAG({ message, sessionId }: RAGInput) {
// Retrieve relevant context chunks from the vector store
const contexts = await retrieveContext(message);
// Format retrieved chunks with source attribution
const contextBlock = contexts
.map(
(ctx, i) =>
`[Source ${i + 1}: ${ctx.source} - ${ctx.heading}]\n${ctx.text}`
)
.join('\n\n');
// Build a system prompt that constrains the LLM to retrieved context
const systemPrompt = `You are a knowledgeable customer support agent for our company. Answer questions accurately using ONLY the provided context. If the context does not contain enough information to answer, say so clearly. Be concise, professional, and helpful.\n\n## Retrieved Context\n${contextBlock}\n\n## Instructions\n- Base your answers strictly on the retrieved context above\n- If multiple sources are relevant, synthesize them into a coherent answer\n- Never fabricate information not present in the context\n- Keep responses concise and actionable\n- Session ID for tracking: ${sessionId}`;
// Stream the LLM response using gpt-4o-mini
const result = streamText({
model: openai('gpt-4o-mini'),
system: systemPrompt,
prompt: message,
});
return result;
}
What does this code do?
The executeRAG function is the heart of your support agent. It takes a plain { message, sessionId } input, making it channel-agnostic.
The system prompt uses strict behavioral instructions to prevent hallucination. The LLM only sees the retrieved context and must explicitly refuse if that context is insufficient.
The streamText function from the AI SDK returns a streaming result object. This means the client receives tokens as they are generated rather than waiting for the full response.
- Save src/core/rag/engine.ts.
TypeScript complaining about the retriever import?
The import './retriever' assumes both files live in the same src/core/rag/ directory. Double-check that retriever.ts is saved in the correct location.
Help me fix the import path between engine.ts and retriever.ts
Create the web channel route handler
The route handler is the web channel's adapter. It translates the useChat hook's UIMessage format into the generic contract that your RAG engine expects, and streams the response back in the format the frontend needs.
- Create the directory path src/app/api/chat/ and add a new file called route.ts with the following code:
import { type UIMessage, convertToModelMessages } from 'ai';
import { executeRAG } from '@/core/rag/engine';
export const maxDuration = 60;
export async function POST(req: Request) {
// Parse the incoming message array from the chat UI
const { messages }: { messages: UIMessage[] } = await req.json();
// Find the most recent user message
const lastUserMessage = messages
.filter((m) => m.role === 'user')
.pop();
if (!lastUserMessage) {
return new Response('No user message found', { status: 400 });
}
// Extract text content from the message parts
const userText =
lastUserMessage.parts
?.filter((p) => p.type === 'text')
.map((p) => p.text)
.join(' ') || '';
// Generate a unique session ID for tracking
const sessionId = crypto.randomUUID();
// Call the channel-agnostic RAG engine
const result = await executeRAG({
message: userText,
sessionId,
});
// Return the streaming response in UIMessage format
return result.toUIMessageStreamResponse();
}
What does this code do?
The maxDuration = 60 export tells Vercel to allow this function up to 60 seconds of execution time. RAG responses involve embedding, retrieval, and streaming, which can exceed the default timeout.
The handler extracts text from the UIMessage parts array (each message has a parts field with typed segments like { type: 'text', text: '...' }). It then delegates to executeRAG and returns the result as a streaming UI message response.
- Save src/app/api/chat/route.ts.
- Test the endpoint by running this curl command in a new terminal:
curl -X POST http://localhost:3000/api/chat \
-H "Content-Type: application/json" \
-d '{"messages":[{"id":"1","role":"user","parts":[{"type":"text","text":"What services do you offer?"}]}]}'
You should see a streamed response in your terminal with text arriving in chunks. The answer should reference information from your knowledge base files.
Getting an empty or error response?
Make sure pnpm dev is still running in your other terminal. The curl command hits your local dev server.
If you see a 500 error, check that your .env.local has valid values for OPENAI_API_KEY, PINECONE_API_KEY, and PINECONE_INDEX_HOST. All three are required for the RAG pipeline to work.
If the response is generic and does not reference your knowledge base, confirm that your Pinecone index has vectors from the ingestion step.
Help me debug my /api/chat route handler
Why a generic (message, sessionId) contract?
The RAG engine does not know or care whether the message came from a web browser, a WhatsApp webhook, or a Messenger bot. Each channel adapter translates its own message format into this simple contract before calling executeRAG.
This separation means adding a new channel later requires only a thin adapter route, not changes to the core intelligence.
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files before moving on.
ⓧ I'd like to double check the full code
Here is the complete code for each new file:
import { openai } from '@ai-sdk/openai';
import { embed } from 'ai';
import { Pinecone } from '@pinecone-database/pinecone';
export interface RetrievedContext {
text: string;
source: string;
heading: string;
score: number;
}
export async function retrieveContext(
query: string,
topK: number = 5
): Promise<RetrievedContext[]> {
const { embedding } = await embed({
model: openai.textEmbeddingModel('text-embedding-3-small'),
value: query,
});
const pc = new Pinecone({ apiKey: process.env.PINECONE_API_KEY! });
const index = pc.index({ host: process.env.PINECONE_INDEX_HOST! });
const queryResponse = await index.query({
vector: embedding,
topK,
includeMetadata: true,
});
return (queryResponse.matches || []).map((match) => ({
text: (match.metadata?.text as string) || '',
source: (match.metadata?.source as string) || '',
heading: (match.metadata?.heading as string) || '',
score: match.score || 0,
}));
}
import { openai } from '@ai-sdk/openai';
import { streamText } from 'ai';
import { retrieveContext } from './retriever';
interface RAGInput {
message: string;
sessionId: string;
}
export async function executeRAG({ message, sessionId }: RAGInput) {
const contexts = await retrieveContext(message);
const contextBlock = contexts
.map(
(ctx, i) =>
`[Source ${i + 1}: ${ctx.source} - ${ctx.heading}]\n${ctx.text}`
)
.join('\n\n');
const systemPrompt = `You are a knowledgeable customer support agent for our company. Answer questions accurately using ONLY the provided context. If the context does not contain enough information to answer, say so clearly. Be concise, professional, and helpful.\n\n## Retrieved Context\n${contextBlock}\n\n## Instructions\n- Base your answers strictly on the retrieved context above\n- If multiple sources are relevant, synthesize them into a coherent answer\n- Never fabricate information not present in the context\n- Keep responses concise and actionable\n- Session ID for tracking: ${sessionId}`;
const result = streamText({
model: openai('gpt-4o-mini'),
system: systemPrompt,
prompt: message,
});
return result;
}
import { type UIMessage, convertToModelMessages } from 'ai';
import { executeRAG } from '@/core/rag/engine';
export const maxDuration = 60;
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
const lastUserMessage = messages
.filter((m) => m.role === 'user')
.pop();
if (!lastUserMessage) {
return new Response('No user message found', { status: 400 });
}
const userText =
lastUserMessage.parts
?.filter((p) => p.type === 'text')
.map((p) => p.text)
.join(' ') || '';
const sessionId = crypto.randomUUID();
const result = await executeRAG({
message: userText,
sessionId,
});
return result.toUIMessageStreamResponse();
}
Your RAG core service is live and responding to queries with context-aware answers. Next up, you will build the streaming React chat interface that gives your users a polished way to interact with this engine.
Build the Streaming Chat Interface
Your RAG engine is fully operational on the server side. A POST to /api/chat retrieves context from Pinecone, augments the prompt, and streams an LLM response. But nobody can actually talk to it yet.
In this step, you will build the production chat UI that consumes the RAG service. The Vercel AI SDK provides a useChat hook that handles streaming, message state, and input management for you. You will pair it with the shadcn/ui components already installed to produce a polished, responsive interface.
In this step, get ready to:
- Build the ChatPanel client component with streaming message handling.
- Build the ChatMessage component with user/assistant styling.
- Integrate the chat into the main page and test the full question-to-answer flow.
Build the ChatPanel component
The ChatPanel is the container component that manages the entire chat experience. It wires together the AI SDK's useChat hook (which handles streaming state, message history, and form submission) with the visual layout from shadcn/ui.
- Create the file src/components/chat-panel.tsx and paste the following code:
'use client';
import { useChat } from '@ai-sdk/react';
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { ChatMessage } from './chat-message';
export function ChatPanel() {
// useChat manages messages, input state, and streaming for us
const { messages, input, handleInputChange, handleSubmit, isLoading } =
useChat();
return (
<Card className="flex h-[600px] w-full max-w-2xl flex-col border-border/50 bg-card">
<CardHeader className="border-b border-border/50 pb-4">
<CardTitle className="text-lg font-semibold text-foreground">
Support Agent
</CardTitle>
</CardHeader>
<CardContent className="flex-1 overflow-hidden p-0">
{/* ScrollArea keeps the message list scrollable */}
<ScrollArea className="h-full p-4">
<div className="flex flex-col gap-4">
{messages.map((message) => (
<ChatMessage key={message.id} message={message} />
))}
</div>
</ScrollArea>
</CardContent>
<CardFooter className="border-t border-border/50 p-4">
{/* Form submission is handled by useChat's handleSubmit */}
<form onSubmit={handleSubmit} className="flex w-full gap-2">
<Input
value={input}
onChange={handleInputChange}
placeholder="Ask a question..."
disabled={isLoading}
className="flex-1 bg-background"
/>
<Button type="submit" disabled={isLoading}>
Send
</Button>
</form>
</CardFooter>
</Card>
);
}
What does this code do?
The 'use client' directive makes this a client component. React hooks like useChat only work on the client side.
The useChat hook from @ai-sdk/react automatically POSTs to /api/chat by default. It manages the full message array, handles streaming token-by-token, and exposes isLoading so you can disable the input while a response streams in.
- Save src/components/chat-panel.tsx.
You won't see a change in the browser yet. The component imports ChatMessage which doesn't exist. You will create it next.
Build the ChatMessage component
Each message in the conversation needs distinct visual treatment. User messages appear right-aligned in a cyan bubble. Assistant responses appear left-aligned in a dark card.
- Create the file src/components/chat-message.tsx and paste the following code:
'use client';
import type { UIMessage } from 'ai';
import { cn } from '@/lib/utils';
interface ChatMessageProps {
message: UIMessage;
}
export function ChatMessage({ message }: ChatMessageProps) {
// Determine alignment based on who sent the message
const isUser = message.role === 'user';
return (
<div
className={cn('flex', isUser ? 'justify-end' : 'justify-start')}
>
<div
className={cn(
'max-w-[80%] rounded-lg px-4 py-2 text-sm',
isUser
? 'bg-cyan-600 text-white'
: 'bg-slate-800 text-slate-100 border border-slate-700'
)}
>
{/* Render each text part, preserving line breaks */}
{message.parts?.map((part, i) => {
if (part.type === 'text') {
return <p key={i} className="whitespace-pre-wrap">{part.text}</p>;
}
return null;
})}
</div>
</div>
);
}
What does this code do?
The UIMessage type from the AI SDK represents each message in the conversation. It has a role (user or assistant) and a parts array containing typed content blocks.
The cn() utility merges class names conditionally. User messages get bg-cyan-600 (the cyan bubble) and assistant messages get bg-slate-800 with a subtle border.
- Save src/components/chat-message.tsx.
The compile error should resolve now that both components exist. The next substep wires them into the page so you can see the result.
Integrate into the main page and test
The ChatPanel needs to be rendered on the page with proper layout and branding. You will replace the default Next.js homepage content with a full-height layout that centers the chat interface.
- Open src/app/page.tsx and replace its entire contents with the following:
import { ChatPanel } from '@/components/chat-panel';
export default function Home() {
return (
<main className="flex min-h-screen flex-col items-center justify-center bg-slate-950 p-4">
{/* Brand header above the chat panel */}
<header className="mb-8 text-center">
<h1 className="text-2xl font-bold text-white">Tech Launch</h1>
<p className="text-sm text-slate-400">AI Customer Support</p>
</header>
<ChatPanel />
</main>
);
}
What does this code do?
The bg-slate-950 class gives the page a deep dark background. The min-h-screen ensures the layout fills the viewport, and items-center justify-center centers the chat panel both horizontally and vertically.
This is a Server Component (no 'use client' directive). It imports ChatPanel which is a Client Component. Next.js handles the boundary automatically.
- Save src/app/page.tsx.
- Return to your browser at localhost:3000. You should see a dark page with a "Tech Launch" heading and a card-style chat panel below it.
- Type a question about your knowledge base into the input field and click Send.
- You should see your question appear as a right-aligned cyan bubble, followed by the assistant's response streaming in token-by-token in a dark left-aligned card.
Not seeing streamed responses?
- Check your terminal for errors. If you see a 400 response, make sure your .env.local has valid OPENAI_API_KEY and PINECONE_API_KEY values.
- If the page shows a compile error mentioning ChatMessage, confirm that src/components/chat-message.tsx exists and exports ChatMessage as a named export.
- If the input and button appear but nothing happens on submit, open your browser DevTools Network tab and look for the /api/chat request. A 500 status usually means a missing environment variable or a Pinecone index that has no vectors.
Help me debug my streaming chat interface
✔️ Awesome, I've got everything!
Great. Double check that all three files are saved before moving on.
ⓧ I'd like to double check the full code
Here is the complete code for all three files created or updated in this step.
'use client';
import { useChat } from '@ai-sdk/react';
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { ChatMessage } from './chat-message';
export function ChatPanel() {
const { messages, input, handleInputChange, handleSubmit, isLoading } =
useChat();
return (
<Card className="flex h-[600px] w-full max-w-2xl flex-col border-border/50 bg-card">
<CardHeader className="border-b border-border/50 pb-4">
<CardTitle className="text-lg font-semibold text-foreground">
Support Agent
</CardTitle>
</CardHeader>
<CardContent className="flex-1 overflow-hidden p-0">
<ScrollArea className="h-full p-4">
<div className="flex flex-col gap-4">
{messages.map((message) => (
<ChatMessage key={message.id} message={message} />
))}
</div>
</ScrollArea>
</CardContent>
<CardFooter className="border-t border-border/50 p-4">
<form onSubmit={handleSubmit} className="flex w-full gap-2">
<Input
value={input}
onChange={handleInputChange}
placeholder="Ask a question..."
disabled={isLoading}
className="flex-1 bg-background"
/>
<Button type="submit" disabled={isLoading}>
Send
</Button>
</form>
</CardFooter>
</Card>
);
}
'use client';
import type { UIMessage } from 'ai';
import { cn } from '@/lib/utils';
interface ChatMessageProps {
message: UIMessage;
}
export function ChatMessage({ message }: ChatMessageProps) {
const isUser = message.role === 'user';
return (
<div
className={cn('flex', isUser ? 'justify-end' : 'justify-start')}
>
<div
className={cn(
'max-w-[80%] rounded-lg px-4 py-2 text-sm',
isUser
? 'bg-cyan-600 text-white'
: 'bg-slate-800 text-slate-100 border border-slate-700'
)}
>
{message.parts?.map((part, i) => {
if (part.type === 'text') {
return <p key={i} className="whitespace-pre-wrap">{part.text}</p>;
}
return null;
})}
</div>
</div>
);
}
import { ChatPanel } from '@/components/chat-panel';
export default function Home() {
return (
<main className="flex min-h-screen flex-col items-center justify-center bg-slate-950 p-4">
<header className="mb-8 text-center">
<h1 className="text-2xl font-bold text-white">Tech Launch</h1>
<p className="text-sm text-slate-400">AI Customer Support</p>
</header>
<ChatPanel />
</main>
);
}
Your RAG engine now has a face. Users can type questions, see them appear as cyan bubbles, and watch the AI stream answers in real time. Next up, you will prepare the webhook skeleton for WhatsApp integration and deploy everything to Vercel.
Add Webhooks and Deploy to Vercel
Your RAG engine works locally, but it only serves one channel right now: the web interface on localhost:3000.
In this step, you will prepare the application for real omnichannel use by adding a Meta Business API webhook skeleton route. Then you will deploy the entire application to Vercel so it is live on the internet and ready to receive traffic from any channel.
In this step, get ready to:
- Create the Meta webhook verification and message-handling route.
- Configure environment variables and deploy to Vercel.
- Verify the live deployment responds to both chat and webhook requests.
Create the Meta webhook route
When you connect a WhatsApp or Messenger channel in the future, Meta sends a verification request to your server before it starts forwarding messages. Your route needs to handle both that initial handshake and the incoming message payloads.
- Create a new file at src/app/api/webhooks/meta/route.ts.
- Paste the GET handler that performs Meta webhook verification by running this code:
import { z } from 'zod';
import { executeRAG } from '@/core/rag/engine';
// Read the verify token from environment variables
const META_VERIFY_TOKEN = process.env.META_VERIFY_TOKEN!;
// GET handler: Meta sends a verification request when you register the webhook
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const mode = searchParams.get('hub.mode');
const token = searchParams.get('hub.verify_token');
const challenge = searchParams.get('hub.challenge');
// Only respond with the challenge if the token matches
if (mode === 'subscribe' && token === META_VERIFY_TOKEN) {
return new Response(challenge || '', { status: 200 });
}
return new Response('Forbidden', { status: 403 });
}
What does this code do?
When you register a webhook URL in the Meta Developer Portal, Meta sends a GET request with three query parameters: hub.mode, hub.verify_token, and hub.challenge.
Your handler checks that the token matches your secret, then echoes the challenge string back. If it does not match, Meta gets a 403 Forbidden and rejects the registration.
- Below the GET handler in the same file, add the Zod schema that validates incoming WhatsApp message payloads:
// Zod schema validates the nested Meta/WhatsApp payload structure
const MetaMessageSchema = z.object({
object: z.literal('whatsapp_business_account'),
entry: z.array(
z.object({
id: z.string(),
changes: z.array(
z.object({
value: z.object({
messages: z
.array(
z.object({
from: z.string(),
type: z.literal('text'),
text: z.object({ body: z.string() }),
})
)
.optional(),
}),
})
),
})
),
});
Why validate with Zod?
Meta's webhook payload is deeply nested. Zod gives you runtime type safety so malformed or spoofed payloads get rejected with a 400 before they reach your RAG engine.
The schema matches WhatsApp Business Account text messages only. You would extend it later to handle images, voice, and other message types.
- Below the schema definition, add the POST handler that processes incoming WhatsApp messages:
// POST handler: receives forwarded WhatsApp messages from Meta
export async function POST(req: Request) {
const body = await req.json();
const parsed = MetaMessageSchema.safeParse(body);
if (!parsed.success) {
return new Response('Invalid payload', { status: 400 });
}
// Flatten the nested structure to get individual messages
const messages = parsed.data.entry
.flatMap((e) => e.changes)
.flatMap((c) => c.value.messages || []);
for (const msg of messages) {
const result = await executeRAG({
message: msg.text.body,
sessionId: msg.from,
});
// TODO: Send response back via WhatsApp Business API
// await sendWhatsAppReply(msg.from, responseText);
console.log(`[Webhook] Processed message from ${msg.from}`);
}
return new Response('OK', { status: 200 });
}
What does this code do?
The POST handler validates the incoming payload against the Zod schema, then extracts each text message from Meta's nested structure.
Each message is passed to your existing executeRAG function with the sender's phone number as the sessionId. The TODO comment marks where you would send the response back via the WhatsApp Business API in a production deployment.
- Save src/app/api/webhooks/meta/route.ts.
Seeing a TypeScript error on the import?
Make sure zod is installed in your project. If it was included during the initial setup in Step 1, you are good. If not, run pnpm add zod and try again.
help me fix a TypeScript import error in my webhook route
✔️ Awesome, I've got everything!
Great. Make sure src/app/api/webhooks/meta/route.ts is saved before moving on.
ⓧ I'd like to double check the full code
import { z } from 'zod';
import { executeRAG } from '@/core/rag/engine';
const META_VERIFY_TOKEN = process.env.META_VERIFY_TOKEN!;
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const mode = searchParams.get('hub.mode');
const token = searchParams.get('hub.verify_token');
const challenge = searchParams.get('hub.challenge');
if (mode === 'subscribe' && token === META_VERIFY_TOKEN) {
return new Response(challenge || '', { status: 200 });
}
return new Response('Forbidden', { status: 403 });
}
const MetaMessageSchema = z.object({
object: z.literal('whatsapp_business_account'),
entry: z.array(
z.object({
id: z.string(),
changes: z.array(
z.object({
value: z.object({
messages: z
.array(
z.object({
from: z.string(),
type: z.literal('text'),
text: z.object({ body: z.string() }),
})
)
.optional(),
}),
})
),
})
),
});
export async function POST(req: Request) {
const body = await req.json();
const parsed = MetaMessageSchema.safeParse(body);
if (!parsed.success) {
return new Response('Invalid payload', { status: 400 });
}
const messages = parsed.data.entry
.flatMap((e) => e.changes)
.flatMap((c) => c.value.messages || []);
for (const msg of messages) {
const result = await executeRAG({
message: msg.text.body,
sessionId: msg.from,
});
// TODO: Send response back via WhatsApp Business API
// await sendWhatsAppReply(msg.from, responseText);
console.log(`[Webhook] Processed message from ${msg.from}`);
}
return new Response('OK', { status: 200 });
}
Configure environment variables and deploy to Vercel
Your webhook route reads a META_VERIFY_TOKEN secret from the environment. You need to add this locally and then configure all four variables on Vercel for production.
- Open .env.local and add the META_VERIFY_TOKEN line at the bottom:
OPENAI_API_KEY=<your-openai-api-key>
PINECONE_API_KEY=<your-pinecone-api-key>
PINECONE_INDEX_HOST=<your-pinecone-index-host>
META_VERIFY_TOKEN=<your-meta-verify-token>
What value should I use for META_VERIFY_TOKEN?
This is a secret string you choose yourself. When you register the webhook with Meta later, you will enter this same string in the Meta Developer Portal. For now, any random string works (e.g. my-secret-token-123).
- Save .env.local.
Your chat route already has export const maxDuration = 60 at the top of src/app/api/chat/route.ts. This tells Vercel Pro to allow up to 60 seconds for streaming responses instead of the default 10-second limit.
Now you need the Vercel CLI to deploy. Check if it is already installed by running this command:
- Check for the Vercel CLI by running:
vercel --version
✔️ I see a version number
The Vercel CLI is ready. Continue to the login step below.
ⓧ Command not found
- Install the Vercel CLI globally by running:
npm i -g vercel
- Confirm it installed by running vercel --version. You should now see a version number.
- Log in to Vercel from the terminal by running:
vercel login
What happens when I run this?
The CLI opens your browser for authentication via OAuth. Follow the browser prompts to authorize the CLI with your Vercel Pro account, then return to the terminal.
- Deploy the application to production by running:
vercel --prod
What does this command do?
The vercel --prod command builds your Next.js application and deploys it to a production URL on Vercel's edge network. If this is your first deploy, the CLI asks you to link or create a project. Accept the defaults.
The CLI will prompt you to configure the project. When it asks to link to an existing project, select N to create a new one. Accept the default project name and settings.
- After the deploy finishes, the CLI prints your production URL. Copy it. You will need it in the next substep.
Your environment variables are not on Vercel yet. The local .env.local file does not get uploaded during deployment.
- Add each environment variable to Vercel by running these commands one at a time:
vercel env add OPENAI_API_KEY
vercel env add PINECONE_API_KEY
vercel env add PINECONE_INDEX_HOST
vercel env add META_VERIFY_TOKEN
What should I enter for each prompt?
Each command asks for the variable value and which environments to apply it to (Production, Preview, Development). Paste the same values from your .env.local file and select all environments.
- Redeploy so Vercel picks up the new environment variables:
vercel --prod
- Wait for the deploy to finish. The CLI prints the production URL again when complete.
Deploy failing with a build error?
Check that all your imports resolve correctly. The most common cause is a missing dependency (zod or @pinecone-database/pinecone) in your package.json. Run pnpm install locally, commit, and redeploy.
help me debug a Vercel deployment build error in my Next.js RAG project
Verify the live deployment
You need to confirm two things: the chat UI works on the live URL, and the webhook verification endpoint responds correctly.
- Open your Vercel production URL in a browser.
- Type a question related to your knowledge base and press Send.
- Confirm the response streams in token by token with accurate answers.
Now test the webhook verification endpoint.
- Open a new browser tab and navigate to this URL (replace the domain with your Vercel URL and the token with your META_VERIFY_TOKEN value):
[[VERCEL_URL="your-vercel-production-url"]]/api/webhooks/meta?hub.mode=subscribe&hub.verify_token=[[META_TOKEN="your-meta-verify-token-value"]]&hub.challenge=test123
- You should see test123 displayed as plain text in the browser. This confirms your webhook verification handler works correctly.
What if I see Forbidden?
The hub.verify_token value in the URL must exactly match the META_VERIFY_TOKEN environment variable you set on Vercel. Double-check both values match character for character.
Chat UI loads but responses are empty?
This usually means your OPENAI_API_KEY or PINECONE_API_KEY environment variables are not set on Vercel. Run vercel env ls to check all four variables are present. If any are missing, add them and redeploy with vercel --prod.
help me debug empty streaming responses on my deployed Vercel RAG application
Secret mission
Conversation Memory and Source Citations
Your RAG engine answers individual questions well, but it forgets everything between messages. Real support agents remember what you just said. In this extension, you'll add sliding-window conversation memory and render source document citations so users can see exactly which knowledge base files powered each answer.
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 uses Pinecone's free tier and your Vercel Pro plan, so ongoing costs are minimal. OpenAI charges only per API call (typically under $0.10 for development and testing).
Resources you used:
- Pinecone serverless index (Free tier, no cost within 2GB/100K vectors).
- Vercel deployment (covered by your Pro plan, no additional cost).
- Local project files on your Desktop.
Keep everything running
No action needed. Choose this if you're still actively building or plan to extend the project with real WhatsApp integration.
- Your Vercel deployment persists at no additional cost on your Pro plan.
- Your Pinecone serverless index stays within the free tier and scales to zero when idle.
- OpenAI charges only when API calls are made. No idle cost.
Pause - I'll come back to this later
No manual action needed. Both Pinecone serverless and Vercel scale to zero automatically when not receiving traffic.
- Your Pinecone index remains available but consumes no compute when idle.
- Your Vercel deployment stays live but incurs no cost beyond your Pro plan.
- Stop your local dev server if it is still running by pressing Ctrl+C in the terminal.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow each step below to fully clean up.
- Delete your Pinecone index:
- Delete your Vercel project:
- Delete your local project files:
cd ~/Desktop
rm -rf omnichannel-rag
- On Windows, run:
cd ~/Desktop
Remove-Item -Recurse -Force omnichannel-rag
- Remove the environment variables from your shell profile if you exported them globally (check .bashrc, .zshrc, or your Windows environment variables).
That's a Wrap!
That's a Wrap!
Nice work. You just built and deployed a production-grade RAG customer support engine that answers questions from a proprietary knowledge base with real-time streaming responses.
You've learned how to:
- Architect a decoupled, API-first RAG system with a channel-agnostic core service that accepts a generic (message, sessionId) contract and streams LLM-augmented responses to any channel adapter.
- Build a production ingestion pipeline that chunks markdown knowledge, generates vector embeddings with OpenAI text-embedding-3-small, and upserts them into a Pinecone serverless index for semantic search retrieval.
- Ship a streaming chat interface with React 19, shadcn/ui components, and the Vercel AI SDK v6 useChat hook, deployed to Vercel Pro with extended function duration and a webhook skeleton for future WhatsApp/Meta integration.
- Secret Mission: Added conversation memory with a sliding-window context and source citation rendering below each assistant message.
Ready to quiz yourself?