Module 8.1 — What is an AI Agent & The ReAct Pattern

How agents think, plan, and act — the foundation of everything in Phase 8


The Core Difference — LLM vs Agent

You have been using LLMs in two ways so far:

Chain (what you built in Phase 6 and 7):
User question → fixed steps → answer
Steps decided at BUILD time by you

Agent (what Phase 8 is about):
User question → LLM decides steps → answer
Steps decided at RUNTIME by the LLM itself

A chain is like a train on fixed tracks. An agent is like a car — it decides where to go based on what it sees.


What an Agent Actually Is

An agent is just an LLM in a loop with access to tools.

┌─────────────────────────────────────┐
│            AGENT LOOP               │
│                                     │
│  Input                              │
│    ↓                                │
│  LLM thinks:                        │
│  "What should I do next?"           │
│    ↓                                │
│  Option A → Call a tool             │
│    ↓                                │
│  Tool runs → result comes back      │
│    ↓                                │
│  LLM thinks again:                  │
│  "Do I have enough info now?"       │
│    ↓                                │
│  Option B → Give final answer       │
│    ↓                                │
│  Output                             │
└─────────────────────────────────────┘

The LLM keeps looping — think, act, observe — until it decides it has enough information to give a final answer.


The ReAct Pattern

ReAct = Reasoning + Acting

Introduced in a 2023 research paper at ICLR. It is the most widely used agent pattern today. Every major agent framework — LangChain, LangGraph, AutoGen — implements ReAct under the hood.

The pattern has three steps that repeat:

THOUGHT  → LLM reasons about what to do next
ACTION   → LLM calls a specific tool with specific inputs
OBSERVATION → Tool result comes back to the LLM

Repeat until:
THOUGHT  → "I now have enough information"
ANSWER   → LLM gives the final response

A Real Example — Step by Step

User asks: "What is the weather in Delhi today and should I carry an umbrella?"

Watch how a ReAct agent handles this:

THOUGHT 1:
"The user wants weather info for Delhi.
I need to call the weather tool first."

ACTION 1:
Tool: get_weather
Input: { city: "Delhi" }

OBSERVATION 1:
"Delhi: 32°C, humidity 85%, rain expected at 4pm"

THOUGHT 2:
"I have the weather data. It will rain.
The user also asked about an umbrella.
I have enough info to answer now."

ANSWER:
"Today in Delhi it's 32°C with high humidity
and rain expected around 4 PM. Yes, you should
definitely carry an umbrella."

The LLM went: Think → Act → Observe → Think → Answer.


Why ReAct is Better Than a Simple Chain

Simple chain approach:
You pre-decide: "always call weather tool, then answer"
Works for: simple, predictable tasks
Breaks when: task requires different steps based on input

ReAct approach:
LLM decides: "what tool do I need right now?"
Works for: complex, unpredictable tasks
Handles: multi-step tasks, conditional logic, different paths

Real example of why this matters:

User 1: "What is 2+2?"
Simple chain: calls weather tool (wrong!) then answers
ReAct agent: realizes no tool needed, answers directly ✅

User 2: "What is the weather in Delhi and what is 25% of 4000?"
Simple chain: can only follow its fixed path
ReAct agent: calls weather tool, then calculator tool ✅

The Three Types of Agent Memory

Agents have different kinds of memory, just like humans:

1. IN-CONTEXT MEMORY (short-term)
   → The current conversation in the context window
   → Lost when conversation ends
   → Example: "earlier you told me your name is Sofia"

2. EXTERNAL MEMORY (long-term)
   → Stored in a database, vector store, or file
   → Persists across conversations
   → Example: Pinecone storing user preferences

3. PROCEDURAL MEMORY (how-to knowledge)
   → Baked into the system prompt
   → Instructions for how the agent behaves
   → Example: "always search before answering"

You have already used all three:

In-context  → MemorySaver with thread_id (Phase 7)
External    → Pinecone vector store (Phase 7.4)
Procedural  → systemPrompt in createAgent (Phase 7)

Agent vs Chain — When to Use Which

Use a CHAIN when:
→ Steps are always the same
→ Task is predictable and simple
→ You need maximum reliability
→ Examples: PDF chunking, text classification,
            data extraction

Use an AGENT when:
→ Steps depend on the input
→ Task requires reasoning and decisions
→ Multiple tools might be needed
→ Order of steps is not fixed
→ Examples: research assistant, customer support,
            coding helper, resume analyzer

What LangGraph Is — And Why It Matters Now

From the research — LangGraph is now recommended for production agents with its stateful graph architecture, human-in-the-loop interrupts, and time-travel debugging capabilities for 2025.

You have been using createAgent from langchain — which runs on LangGraph internally. LangGraph is the actual engine underneath.

createAgent (from "langchain")
       ↓
       uses LangGraph internally
       ↓
LangGraph (from "@langchain/langgraph")
       ↓
       stateful graph where each node = one agent step
       each edge = connection between steps

In Phase 8 we will use LangGraph directly for:

→ Building custom agent loops
→ Multi-agent systems (multiple agents collaborating)
→ Human-in-the-loop (agent asks human before acting)
→ Agent with persistent memory across sessions

Phase 8 — What You Will Build

Module 8.1 → What is an Agent + ReAct (this module)
Module 8.2 → Tool Calling + Function Calling deep dive
Module 8.3 → Agent Memory + Planning
Module 8.4 → Multi-Agent Systems with LangGraph
Module 8.5 → Project: Resume Analyzer Agent
Module 8.6 → Project: Research Agent

3-Line Summary

  1. An agent is an LLM in a loop — it thinks, calls a tool, observes the result, thinks again, and keeps looping until it has enough information to give a final answer — unlike a chain which always follows fixed steps you define at build time.
  2. The ReAct pattern is Reasoning + Acting — the LLM alternates between internal thought ("what do I need to do?") and external action (calling a tool) until it reaches a final answer — this is the foundation of every major agent framework in 2025.
  3. LangGraph is the production-grade engine underneath createAgent — it models agent workflows as a stateful graph where nodes are agent steps and edges define routing — in Phase 8 we use it directly to build multi-agent systems and custom agent loops.

Module 8.1 — Complete ✅

Phase 1: Foundations — Part 2 (Hands-On Setup)

Theory's done. Now we set up the two things everything else in this course depends on: your Okta account with a registered application, and the actual Next.js project we'll build on for the rest of the course.

Step 1: Create Your Free Okta Account

Okta's free developer offering used to be called "Developer Edition." That was retired in 2025 — if you see a tutorial mention "Developer Edition," it's outdated. The current free offering is called the Integrator Free Plan, and it's what we'll use throughout this course, at no cost.

  1. Go to developer.okta.com/signup/.
  2. You'll be shown a choice of products — select the Okta Platform tile.
  3. Click Sign up for Integrator Free Plan.
  4. Enter your name, location, and a business/work-style email address (personal Gmail addresses may are usually accepted too, but a work-style email avoids friction).
  5. Check your email for a verification link. Clicking it will also tell you your unique Org domain — something like dev-12345678.okta.com. Write this down; you'll paste it into your .env.local file soon.
  6. Follow the prompt to set your password and set up Okta Verify (Okta's own authenticator app) on your phone — this protects your admin login, separate from the end-user login flow we're about to build for TaskFlow.
  7. Once done, you'll land on the Okta Admin Console — this is your control panel for everything: users, groups, applications, security policies.

Keep this admin console open — we're about to use it.

Step 2: Register Your First Application

Remember from the last post: an "Application" in Okta is a registration representing one specific app that wants to use Okta for login. We're registering our TaskFlow Next.js app now.

  1. In the Admin Console, go to Applications → Applications in the left sidebar.
  2. Click Create App Integration.
  3. You'll be asked for a Sign-in method. Choose OIDC — OpenID Connect. (SAML is a different, older protocol mostly used for enterprise SSO between big companies — not what we're using.)
  4. You'll then be asked for an Application type. This is an important choice: select Web Application. This matters because Next.js has a real backend (Route Handlers running on the server), which means it can safely hold a Client Secret — unlike a pure browser-only Single-Page App, which can't. This choice unlocks a more secure version of the flow we covered last time.
  5. Click Next. Now you'll fill in the app's settings:
    • App integration name: TaskFlow (or whatever you want to call your project)
    • Grant type: Leave Authorization Code checked — this is the flow we walked through in detail last lecture. Leave the others unchecked for now.
    • Sign-in redirect URIs: This is where Okta is allowed to send the user back to after login, per step 5 of the flow we covered. Enter: http://localhost:3000/api/auth/callback
    • Sign-out redirect URIs: Where the user lands after logging out. Enter: http://localhost:3000
    • Assignments: Leave the default (Allow everyone in your organization to access) — fine for development. In a real production app you'd scope this to specific Groups.
  6. Click Save.

You'll land on your new Application's page. Two values here matter enormously and you'll paste them into your project shortly:

  • Client ID — visible directly on the page.
  • Client Secret — click the eye icon or Show button to reveal it.

Treat the Client Secret like a password. Never commit it to Git, never expose it in browser-side code. We'll store it in an untracked .env.local file in a moment.

One more thing to check while you're here, because it's a change specific to the Integrator Free Plan and will bite you in Phase 2 otherwise: go to Security → API → Authorization Servers, click on the one named default. This is the default Custom Authorization Server we discussed last time — the one we'll actually use, since it supports the custom claims we need later. On Integrator Free Plan orgs, this server is created for you, but without a basic access policy attached by default. Click the Access Policies tab on that page — if it's empty, click Add New Access Policy, give it any name and description, assign it to "All clients," then add a rule inside it (the default rule settings are fine) and make sure it's set to Active. Skipping this step is a common reason people get a confusing "access denied" error the first time they try to log in, so it's worth doing now.

That's Okta fully configured for local development. Everything else in this course happens by tweaking settings here or writing code — no more from-scratch app setup needed.

Step 3: Create the Next.js Project

Now the actual codebase. Requirements first: you need Node.js 20.9 or later installed. Check with node -v in your terminal; if it's older, update Node first.

Run:

npx create-next-app@latest taskflow --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"

Here's exactly what each flag does, so you're not just copy-pasting blindly:

  • --typescript — scaffolds the project with TypeScript instead of plain JavaScript, including a pre-configured tsconfig.json.
  • --tailwind — installs and wires up Tailwind CSS automatically. One important thing to know: as of Tailwind CSS v4 (what create-next-app installs today), configuration works differently than older tutorials show. There's no tailwind.config.js to edit anymore for basic theming — customization happens directly inside globals.css using an @theme block. We'll touch this when we get to styling our login UI in Phase 2, so don't worry about it yet.
  • --eslint — adds linting with Next.js's recommended rules, catching mistakes as you code.
  • --app — uses the App Router (the app/ directory), not the older Pages Router (pages/ directory). This matters a lot for us — Route Handlers, Middleware, and Server Components (all things we depend on throughout this course) are App Router features. If you ever find a tutorial using pages/api/..., it's using the older pattern and the code won't map directly to what we're doing.
  • --src-dir — puts your app code inside a src/ folder, keeping it separate from config files at the project root. Purely organizational, but it's what we'll use for consistency.
  • --import-alias "@/*" — lets you write import { something } from "@/components/x" instead of relative paths like ../../../components/x.

Once it finishes scaffolding, move into the project and start the dev server:

cd taskflow
npm run dev

Visit http://localhost:3000 — you should see the default Next.js welcome page, styled with Tailwind. If it loads, your project is working correctly.

Step 4: Store Your Okta Credentials Safely

Before we touch any auth code (that starts next lecture, in Phase 2), let's set up where your Okta credentials will live. In the root of your taskflow project, create a file named .env.local:

OKTA_ORG_URL=https://dev-12345678.okta.com
OKTA_CLIENT_ID=your_client_id_here
OKTA_CLIENT_SECRET=your_client_secret_here

Replace the values with your actual Org domain, Client ID, and Client Secret from Step 2. create-next-app already adds .env*.local to .gitignore by default, so this file won't accidentally get committed to version control — but it's worth opening .gitignore once and confirming that line is actually there before you go any further.


Where You Stand Now

You have: a working Okta Org with a registered Web Application and a properly policy-enabled authorization server, and a fresh Next.js + TypeScript + Tailwind project with your credentials safely stored. Nothing logs in yet — that's intentional, Phase 1 is entirely about having correct foundations before writing flow logic.

That completes Phase 1 in full.

Module 7.4 — Pinecone Cloud Vector Database

Replace in-memory storage with a real production database that persists forever


Why Pinecone — The Problem With What We Have Now

Your RAG system currently uses MemoryVectorStore. It stores all data in RAM.

Current problems:
→ Restart the server → all data is lost
→ 10 users load different PDFs → memory gets full
→ Deploy to production → every restart requires re-indexing everything
→ 1 million document chunks → RAM overflow

Pinecone solution:
→ Data is stored permanently in the cloud
→ Restart the server → data remains available
→ Billions of vectors → search in milliseconds
→ Free tier available → perfect for development
→ Web dashboard → view your data visually (similar to MongoDB Atlas)


What is Pinecone?

Pinecone is a managed cloud vector database. Your workflow is simple:

  1. Create a Pinecone account (free)
  2. Create an index (similar to a table in SQL)
  3. Upsert vectors (store embeddings)
  4. Query vectors (search)

What Pinecone does:
→ Stores your data on AWS, GCP, or Azure
→ Automatically manages HNSW indexing
→ Performs millisecond searches across billions of vectors
→ Provides a web dashboard to view and manage your data


Step 1 — Pinecone Account Setup

1. Go to: https://app.pinecone.io

2. Sign up — free account, no credit card needed

3. Get API Key:

Database → API Keys → Create API Key
Copy the key → looks like: "pcsk_abc123..."

4. Index banao:

Database → Indexes → Create Index

Name:      pdf-chatbot
Dimensions: 1536        ← must match text-embedding-3-small
Metric:    cosine       ← best for text
Type:      Serverless   ← free, auto-scales
Cloud:     AWS
Region:    us-east-1

Click Create Index — done.


Step 2 — Install Packages

In langchain-production-rag folder:

npm install @pinecone-database/pinecone @langchain/pinecone
@pinecone-database/pinecone  → official Pinecone Node.js SDK v8.x
@langchain/pinecone          → LangChain's Pinecone integration provides PineconeStore class


    {
        "name": "langchain-production-rag",
        "version": "1.0.0",
        "type": "module",
        "main": "index.js",
        "scripts": {
            "start": "node src/rag.js",
            "test": "echo \"Error: no test specified\" && exit 1"
        },
        "keywords": [],
        "author": "",
        "license": "ISC",
        "description": "",
        "dependencies": {
            "@langchain/community": "^1.1.29",
            "@langchain/core": "^1.2.3",
            "@langchain/langgraph": "^1.4.8",
            "@langchain/openai": "^1.5.5",
            "@langchain/pinecone": "^1.0.3",
            "@langchain/textsplitters": "^1.0.1",
            "@pinecone-database/pinecone": "^8.1.0",
            "cors": "^2.8.6",
            "dotenv": "^16.4.5",
            "express": "^5.2.1",
            "langchain": "^1.5.3",
            "pdf-parse": "^2.4.5"
        }
    }


Step 3 — Update .env

OPENAI_API_KEY=sk-proj-your-openai-key
PINECONE_API_KEY=pcsk_your-pinecone-key
PINECONE_INDEX=pdf-chatbot

Step 4 — Create src/pineconeStore.js

This file replaces MemoryVectorStore with PineconeStore:


    // ═══════════════════════════════════════════════════════════════════════════
    // IMPORTS
    // ═══════════════════════════════════════════════════════════════════════════

    import { Pinecone } from "@pinecone-database/pinecone";
    // Official Pinecone SDK (v8.x)
    // gives us a client to connect to, write to, and query a Pinecone vector database

    import { OpenAIEmbeddings } from "@langchain/openai";
    // OpenAIEmbeddings = converts text → a 1536-number vector
    // used both to embed the PDF chunks (once) and to embed the user's query (every search)

    import { Document } from "@langchain/core/documents";
    // Document = LangChain's standard document class
    // shape: { pageContent: "text...", metadata: { page: 0, source: "file.pdf" } }
    // keeping search results in this shape means the rest of the app (createRAGAgent,
    // searchTool in api.js) doesn't need to know or care that Pinecone is being used

    import * as dotenv from "dotenv";
    dotenv.config();
    // loads PINECONE_API_KEY and PINECONE_INDEX (and other secrets) from the .env file
    // into process.env, so they can be read below


    // ═══════════════════════════════════════════════════════════════════════════
    // SETUP
    // ═══════════════════════════════════════════════════════════════════════════

    const pineconeClient = new Pinecone({
        apiKey: process.env.PINECONE_API_KEY,
        // reads the API key from .env
        // example value: "pcsk_abc123..."
    });
    // pineconeClient = an authenticated connection object used to talk to Pinecone's cloud API

    const embeddings = new OpenAIEmbeddings({
        model: "text-embedding-3-small",
        // this model produces 1536-dimensional vectors
        // IMPORTANT: this must match the dimension configured when the Pinecone index was created,
        // otherwise upserts/queries will fail
    });


    // ═══════════════════════════════════════════════════════════════════════════
    // HELPER — Get Pinecone Index reference
    // Conceptually like opening a connection to one specific table in a database
    // ═══════════════════════════════════════════════════════════════════════════

    function getPineconeIndex() {
        const indexName = process.env.PINECONE_INDEX;
        // example value: "pdf-chatbot"

        if (!indexName) {
            throw new Error("PINECONE_INDEX is not set in .env file");
        }

        return pineconeClient.index(indexName);
        // returns an Index object pointing at the "pdf-chatbot" index
        // this does NOT create the index — it must already exist in the Pinecone dashboard
        //
        // example return value (internal object):
        // Index { name: "pdf-chatbot", host: "pdf-chatbot-xxx.svc.pinecone.io" }
    }


    // ═══════════════════════════════════════════════════════════════════════════
    // HELPER — Build a retriever object for a given namespace
    // Both buildPineconeVectorStore() and loadExistingVectorStore() need the exact
    // same "search this namespace" behavior, so it lives here once and gets reused.
    // ═══════════════════════════════════════════════════════════════════════════

    function createRetriever(pineconeIndex, namespace) {
        // pineconeIndex = the Index object returned by getPineconeIndex()
        // namespace     = which namespace inside that index to search within

        return {
            namespace,
            // exposed so callers can check/log which namespace this retriever is bound to

            invoke: async (query, k = 5) => {
                // invoke(query, k) = search for the top k most similar chunks to `query`
                // query = the search text (usually the user's question)
                // k     = how many results to return (defaults to 5)
                //
                // example call: retriever.invoke("what are the pension benefits?", 5)

                const queryVector = await embeddings.embedQuery(query);
                // embeds the query text using the SAME model used for the stored documents
                // this MUST match — otherwise the vectors are not comparable to each other
                // example queryVector: [0.19, -0.82, 0.38, ...] (1536 numbers total)

                const results = await pineconeIndex.namespace(namespace || "").query({
                    vector: queryVector,
                    // the query vector to compare against every stored vector

                    topK: k,
                    // return only the top k most similar vectors

                    includeMetadata: true,
                    // include the metadata we stored (text, source, page) in the results
                    // without this flag, Pinecone only returns ids + similarity scores
                });
                // results.matches = array of matching vectors, sorted by similarity score (highest first)
                // example results.matches:
                // [
                //   {
                //     id: "sample-chunk-2",
                //     score: 0.89,           // cosine similarity — 1.0 = identical meaning
                //     metadata: {
                //       text: "APY gives guaranteed pension...",
                //       source: "./sample.pdf",
                //       page: 0
                //     }
                //   },
                //   { id: "sample-chunk-7", score: 0.81, metadata: {...} },
                //   ...
                // ]

                return results.matches.map(match => new Document({
                    pageContent: match.metadata?.text || "",
                    // the original chunk text, pulled back out of the stored metadata

                    metadata: {
                        source: match.metadata?.source || "",
                        page: match.metadata?.page || 0,
                        score: match.score,
                        // similarity score for this match — higher means more relevant
                        // example: 0.89 (very relevant), 0.45 (weakly relevant)
                    },
                }));
                // returns an array of Document objects — same shape LangChain retrievers normally return
                // this is what lets createRAGAgent()'s searchTool work without any changes
            },
        };
        // example returned retriever object:
        // {
        //   namespace: "sample",
        //   invoke: async (query, k) => Document[]
        // }
    }


    // ═══════════════════════════════════════════════════════════════════════════
    // FUNCTION: buildPineconeVectorStore
    // Step 1: clear out any old vectors for this namespace
    // Step 2: embed all chunks using OpenAI
    // Step 3: upsert (insert/update) those vectors into Pinecone
    // Step 4: return a simple retriever interface for searching them
    // ═══════════════════════════════════════════════════════════════════════════

    export async function buildPineconeVectorStore(chunks, namespace) {
        // chunks    = array of LangChain Document objects produced by the text splitter
        //             example:
        //             [
        //               Document { pageContent: "APY gives pension...", metadata: { page: 0, source: "./sample.pdf" } },
        //               Document { pageContent: "subscriber gets Rs.1000...", metadata: { page: 1 } },
        //             ]
        //
        // namespace = string used to keep this PDF's vectors separate from other PDFs
        //             example: "sample" for sample.pdf
        //             all vectors for this PDF are written into this namespace only
        //             vectors belonging to other namespaces are never touched

        if (!chunks || chunks.length === 0) {
            throw new Error("No chunks provided to store in Pinecone");
        }

        console.log(`\n💾 Storing ${chunks.length} chunks in Pinecone...`);
        console.log(`   Index:     ${process.env.PINECONE_INDEX}`);
        console.log(`   Namespace: ${namespace || "default"}`);
        console.log(`   Embedding with OpenAI text-embedding-3-small...`);

        const pineconeIndex = getPineconeIndex();
        // get a reference to the "pdf-chatbot" index

        // ── STEP 1: Clear existing vectors in this namespace ────────────────────
        try {
            await pineconeIndex.namespace(namespace || "").deleteAll();
            // deletes ALL existing vectors currently in this namespace
            // this stops old PDF data from mixing together with newly uploaded PDF data
            // vectors in OTHER namespaces are completely unaffected
            console.log(`   Cleared existing vectors in namespace "${namespace}"`);
        } catch (err) {
            // this throws if the namespace is already empty / doesn't exist yet — that's fine, just continue
            console.log(`   Namespace was empty or doesn't exist yet — continuing`);
        }

        // ── STEP 2: Embed all chunks using OpenAI ───────────────────────────────
        const texts = chunks.map(chunk => chunk.pageContent);
        // pulls out just the plain text from each Document object
        // example texts:
        // [
        //   "APY gives guaranteed pension of Rs.1000-5000...",
        //   "Subscriber must be between 18-40 years of age...",
        //   ...12 more strings
        // ]

        const vectors = await embeddings.embedDocuments(texts);
        // embedDocuments() batch-embeds all texts in one (or a few) API calls
        // returns an array of vectors, one per input text, in the same order
        // example return value:
        // [
        //   [0.023, -0.089, 0.041, ...],  // 1536 numbers for chunk 0
        //   [0.071, 0.042, -0.013, ...],  // 1536 numbers for chunk 1
        //   ...
        // ]
        console.log(`   Got ${vectors.length} embeddings from OpenAI`);

        // ── STEP 3: Build records to upsert into Pinecone ───────────────────────
        const records = chunks.map((chunk, i) => ({
            id: `${namespace || "doc"}-chunk-${i}`,
            // unique ID for this vector inside Pinecone
            // example: "sample-chunk-0", "sample-chunk-1", "sample-chunk-2"
            // must be unique within the namespace

            values: vectors[i],
            // the 1536-number vector generated for this exact chunk
            // example: [0.023, -0.089, 0.041, ...]

            metadata: {
                text: chunk.pageContent,
                // stores the original chunk text so it can be retrieved again after a search
                // Pinecone stores this alongside the vector itself
                // example: "APY gives guaranteed pension of Rs.1000-5000..."

                source: chunk.metadata.source || "",
                // which file this chunk originally came from
                // example: "./sample.pdf"

                page: chunk.metadata.page ?? 0,
                // which page number in the PDF this chunk came from
                // example: 0, 1, 2, ...

                chunkIndex: i,
                // the position of this chunk within the overall sequence
                // example: 0, 1, 2, ..., 11
            },
        }));
        // records = array of plain objects ready to be sent to Pinecone
        // example single record:
        // {
        //   id: "sample-chunk-0",
        //   values: [0.023, -0.089, ...], // 1536 numbers
        //   metadata: {
        //     text: "APY gives guaranteed pension...",
        //     source: "./sample.pdf",
        //     page: 0,
        //     chunkIndex: 0
        //   }
        // }

        // ── STEP 4: Upsert records in batches ───────────────────────────────────
        const BATCH_SIZE = 100;
        // upserts 100 records at a time
        // Pinecone recommends batch sizes of roughly 100-200 for best performance
        // this also avoids "payload too large" errors on big documents with many chunks

        for (let i = 0; i < records.length; i += BATCH_SIZE) {
            const batch = records.slice(i, i + BATCH_SIZE);
            // takes the next 100 records (or fewer, for the final leftover batch)
            // example: records 0-99, then 100-199, then remaining ones, etc.

            await pineconeIndex.namespace(namespace || "").upsert({ records: batch });
            // .namespace(namespace) = targets this specific namespace inside the index
            // .upsert(batch)        = inserts new vectors or updates existing ones with the same id
            //
            // "upsert" = "update if it exists, insert if it doesn't"
            // if a vector with the same id already exists → it gets overwritten
            // if it doesn't exist yet → it gets inserted fresh

            console.log(`   Upserted batch ${Math.floor(i / BATCH_SIZE) + 1}: records ${i} to ${Math.min(i + BATCH_SIZE, records.length) - 1}`);
        }

        console.log(`   ✅ All ${records.length} chunks stored in Pinecone!\n`);

        // ── STEP 5: Return a simple retriever interface ─────────────────────────
        // we build our own retriever object (instead of using LangChain's PineconeStore wrapper)
        // so the exact same query/response shape is used everywhere in the app
        return createRetriever(pineconeIndex, namespace);
        // example returned retriever object:
        // {
        //   namespace: "sample",
        //   invoke: async (query, k) => Document[]
        // }
    }


    // ═══════════════════════════════════════════════════════════════════════════
    // FUNCTION: loadExistingVectorStore
    // Reconnects to data that was already indexed into Pinecone in a previous run.
    // Use this after a server restart — no re-embedding or re-uploading needed.
    // ═══════════════════════════════════════════════════════════════════════════

    export async function loadExistingVectorStore(namespace) {
        // namespace = which namespace to reconnect to
        // example: "sample"
        //
        // This is the key advantage of using Pinecone over an in-memory store:
        //   in-memory store → server restarts → data is gone → PDF must be re-indexed
        //   Pinecone store  → server restarts → data is still safe in the cloud → just reconnect

        console.log(`\n🔄 Reconnecting to Pinecone namespace: "${namespace}"`);

        const pineconeIndex = getPineconeIndex();
        // get a reference to the index — the data is already sitting there in the cloud

        const stats = await pineconeIndex.namespace(namespace || "").describeIndexStats();
        // checks how many vectors currently exist in this namespace
        // example stats: { namespaces: { "sample": { recordCount: 12 } }, dimension: 1536, totalRecordCount: 12 }

        console.log(`   Found vectors in Pinecone — reconnected\n`);

        // returns the exact same retriever interface used by buildPineconeVectorStore()
        return createRetriever(pineconeIndex, namespace);
    }


    // ═══════════════════════════════════════════════════════════════════════════
    // FUNCTION: getIndexStats
    // Shows how many vectors currently exist in Pinecone, broken down by namespace.
    // Conceptually similar to: SELECT COUNT(*) ... GROUP BY namespace in SQL
    // ═══════════════════════════════════════════════════════════════════════════

    export async function getIndexStats() {
        const pineconeIndex = getPineconeIndex();

        const stats = await pineconeIndex.describeIndexStats();
        // calls the Pinecone API to get index-level statistics
        //
        // example return value:
        // {
        //   namespaces: {
        //     "sample":       { recordCount: 12 },
        //     "my-document":  { recordCount: 25 }
        //   },
        //   dimension: 1536,
        //   indexFullness: 0.0,
        //   totalRecordCount: 37
        // }

        return {
            totalVectors: stats.totalRecordCount,
            // total number of vectors across every namespace combined
            // example: 37

            namespaces: stats.namespaces,
            // per-namespace breakdown
            // example: { "sample": { recordCount: 12 } }

            dimension: stats.dimension,
            // the vector dimension used by this index — should be 1536 for text-embedding-3-small
        };
    }


Step 5 — Update src/api.js


    // ═══════════════════════════════════════════════════════════════════════════
    // IMPORTS
    // ═══════════════════════════════════════════════════════════════════════════

    import express from "express";
    // express = Node.js web framework used to build the HTTP server
    // provides methods like app.get(), app.post() to define API routes
    // when a request comes in → express routes it to the correct handler function

    import cors from "cors";
    // cors = Cross-Origin Resource Sharing middleware
    // browsers block requests between different origins by default
    // example: Next.js on localhost:3000 calling Express on localhost:3001 → blocked without cors
    // cors middleware adds the right headers so the browser allows the request

    import { createAgent, tool } from "langchain";
    // createAgent = builds a complete LangGraph agent (LLM + tools + memory + system prompt)
    // tool        = wraps any JavaScript function into a tool the agent can call

    import { MemorySaver } from "@langchain/langgraph";
    // MemorySaver = stores agent conversation state as checkpoints in memory
    // each unique thread_id gets its own saved state
    // when the same thread_id is used again → agent loads that state and remembers previous messages

    import { ChatOpenAI } from "@langchain/openai";
    // ChatOpenAI = LangChain wrapper around OpenAI's chat completion API
    // internally calls: POST https://api.openai.com/v1/chat/completions

    import { buildPineconeVectorStore, loadExistingVectorStore, getIndexStats } from "./pineconeStore.js";
    // buildPineconeVectorStore = embeds text chunks and stores their vectors in Pinecone (cloud DB)
    // loadExistingVectorStore  = reconnects to previously stored vectors (used after server restart)
    // getIndexStats            = fetches how many vectors currently exist in the Pinecone index

    import { PDFLoader } from "@langchain/community/document_loaders/fs/pdf";
    // PDFLoader = reads a PDF file from disk and extracts its text content
    // returns an array of Document objects — one Document per page
    // each Document looks like: { pageContent: "text...", metadata: { source, page } }

    import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
    // RecursiveCharacterTextSplitter = splits large Document text into smaller chunks
    // tries these separators in order: "\n\n" → "\n" → ". " → " " → ""
    // this means it prefers clean paragraph splits over cutting mid-sentence
    // preserves metadata (like page number) from the original Document into every chunk it creates

    import { z } from "zod";
    // zod = schema validation library
    // used to define exactly what inputs each tool accepts
    // example: z.string() means the input must be a string
    // if the LLM passes the wrong type → zod throws an error before the tool runs

    import { validateQuestion, formatErrorResponse } from "./errorHandling.js";
    // validateQuestion    = checks user input before sending it to the AI
    //                       throws custom errors if question is empty / too short / too long
    // formatErrorResponse = converts any error into a clean JSON object for the frontend
    //                       example output: { success: false, error: "Please type a question", code: "EMPTY_QUERY" }

    import * as dotenv from "dotenv";
    dotenv.config();
    // dotenv.config() reads the .env file and loads its values into process.env
    // after this line runs: process.env.OPENAI_API_KEY = "sk-proj-..."
    // LangChain and OpenAI clients automatically read credentials from process.env


    // ═══════════════════════════════════════════════════════════════════════════
    // EXPRESS APP SETUP
    // ═══════════════════════════════════════════════════════════════════════════

    const app = express();
    // app = the main Express application object
    // all routes and middleware are registered on this object

    const PORT = process.env.PORT || 3001;
    // PORT = which TCP port the server will listen on
    // process.env.PORT = set this automatically in production (e.g. Render, Railway)
    // fallback: 3001 so it doesn't clash with Next.js which usually runs on 3000
    // example value: 3001

    app.use(cors({
        origin: ["http://localhost:3000", "http://localhost:3001"],
        // origin = list of allowed frontend URLs that are permitted to call this API
        // the browser checks this — if the request comes from a different origin → it gets blocked
        // in production, replace these with your real domain, e.g. "https://yourdomain.com"
    }));
    // cors() returns a middleware function
    // app.use() registers that middleware to run on every incoming request
    // it adds this header to every response: Access-Control-Allow-Origin: http://localhost:3000

    app.use(express.json());
    // express.json() = middleware that parses the incoming request body as JSON
    // without this: req.body = undefined
    // with this:    req.body = { question: "what is this doc about?", sessionId: "session_123" }


    // ═══════════════════════════════════════════════════════════════════════════
    // GLOBAL STATE
    // These variables are shared across all incoming HTTP requests.
    // They live in server memory (RAM) for as long as the process is running.
    // ═══════════════════════════════════════════════════════════════════════════

    let vectorStore = null;
    // vectorStore = reference to the currently active Pinecone vector store / namespace info
    // null  = no PDF has been loaded yet
    // after /api/load-pdf succeeds, it is set to the object returned by buildPineconeVectorStore()
    // example value once set: { namespace: "sample" }

    let isIndexing = false;
    // isIndexing = flag used to prevent two PDF loads from running at the same time
    // false = server is idle, ready to accept a new load request
    // true  = server is currently loading/embedding a PDF
    // if a second /api/load-pdf request arrives while this is true → server responds with 409 Conflict

    let ragAgent = null;
    // ragAgent = the LangGraph agent created by createRAGAgent()
    // null  = no PDF loaded yet → agent cannot answer questions
    // after /api/load-pdf succeeds (or after auto-connect on startup):
    //   ragAgent = CompiledStateGraph { ... }   (a ready-to-use LangGraph agent object)
    // this same agent instance is reused for every /api/chat and /api/chat/stream request


    // ═══════════════════════════════════════════════════════════════════════════
    // AGENT FACTORY FUNCTION
    // Called once whenever a PDF is loaded (or existing data is auto-connected).
    // Builds and returns a fully configured RAG (Retrieval-Augmented Generation) agent.
    // ═══════════════════════════════════════════════════════════════════════════

    function createRAGAgent(retriever) {
        // retriever = an object with an .invoke(query, k) method
        // calling retriever.invoke("some query", 5) returns the 5 most relevant Document chunks
        // example return value of retriever.invoke("pension benefits", 5):
        // [
        //   Document { pageContent: "subscribers get pension of 1000...", metadata: { page: 0 } },
        //   Document { pageContent: "pension continues to spouse...",     metadata: { page: 1 } },
        //   ...3 more
        // ]

        // ── TOOL DEFINITION ──────────────────────────────────────────────────────
        // A tool = a JavaScript function that the LLM can decide to call on its own.
        // The agent reads the tool's `name` + `description` to decide WHEN to use it.
        // The `schema` defines exactly what inputs the LLM must provide when calling it.

        const searchTool = tool(
            async ({ query }) => {
                // this function body runs whenever the LLM decides to call this tool
                // query = the search string chosen by the LLM
                // example values: "pension benefits for subscriber", "how to file a complaint"

                const docs = await retriever.invoke(query, 5);
                // retriever.invoke(query, 5) does the following internally:
                //   1. calls the embeddings API to convert the query text → a vector
                //   2. compares that vector against all stored chunk vectors in Pinecone
                //   3. returns the top 5 most similar Document chunks
                //
                // example docs value:
                // [
                //   Document { pageContent: "Subscriber gets Rs.1000-5000 pension...", metadata: { page: 0 } },
                //   Document { pageContent: "Pension continues to spouse after...",    metadata: { page: 1 } },
                //   Document { pageContent: "Nomination details must be provided...",  metadata: { page: 0 } },
                //   ...
                // ]

                if (!docs || docs.length === 0) {
                    return "No relevant information found in the document.";
                    // this string is sent back to the LLM as the tool's result
                    // the LLM then uses it to tell the user that nothing relevant was found
                }

                return docs
                    .map((doc, i) =>
                        `Source ${i + 1} (Page ${(doc.metadata.page || 0) + 1}):\n${doc.pageContent.trim()}`
                    )
                    // .map() loops through every returned Document and formats it as a labeled source
                    // i                    = index in the array (0, 1, 2, 3, 4)
                    // doc.metadata.page    = 0-indexed page number from the PDF → +1 makes it human-readable
                    // doc.pageContent.trim() = the actual chunk text with leading/trailing whitespace removed
                    //
                    // example single formatted source:
                    // "Source 1 (Page 1):\nSubscriber gets Rs.1000-5000 guaranteed pension..."
                    .join("\n\n");
                // joins all formatted sources into one string, separated by a blank line
                //
                // example final return value (what the LLM receives as the tool result):
                // "Source 1 (Page 1):\nSubscriber gets Rs.1000-5000...\n\nSource 2 (Page 2):\nPension continues..."
            },
            {
                name: "search_document",
                // name = the identifier the LLM uses internally when it decides to call this tool
                // example LLM call: { "tool": "search_document", "args": { "query": "..." } }

                description: `Search the indexed document for information relevant to a query.
    ALWAYS use this tool before answering any question about the document.
    The tool returns relevant text sections — READ them and write your answer in your own words.
    Do not copy-paste the sections — summarize and explain.`,
                // description = what the LLM reads to decide WHEN to call this tool
                // a clear, specific description makes the LLM call it at the right time
                // "ALWAYS use this tool" prevents the LLM from answering purely from its own training data

                schema: z.object({
                    query: z.string().describe("search query to find relevant information"),
                    // z.string()   = the "query" input must be a text string
                    // .describe()  = tells the LLM what this field is for
                    // the LLM must always provide this field when calling the tool
                    // example: { query: "what are the pension benefits?" }
                }),
            }
        );
        // searchTool = the final LangChain Tool object
        // it bundles the function above together with its name, description, and input schema
        // the agent keeps a reference to this tool and can call it during reasoning


        // ── MEMORY SETUP ─────────────────────────────────────────────────────────

        const checkpointer = new MemorySaver();
        // MemorySaver = an in-memory checkpoint store for conversation history
        // internally it behaves like a Map: thread_id → saved conversation state
        // example internal state after two conversation turns for one session:
        // {
        //   "session_123": {
        //     messages: [
        //       HumanMessage { content: "what is this doc about?" },
        //       AIMessage    { content: "This document is about..." },
        //       HumanMessage { content: "what are the benefits?" },
        //       AIMessage    { content: "The benefits include..." }
        //     ]
        //   }
        // }
        // when the same thread_id is used again → the agent loads this state and keeps full context


        // ── CREATE AND RETURN THE AGENT ───────────────────────────────────────────

        return createAgent({
            model: new ChatOpenAI({
                model: "gpt-4o",
                // "gpt-4o" = GPT-4 Omni model — supports tool calling, fast, high quality answers
                temperature: 0.1,
                // temperature = controls how random/deterministic the output is
                // 0.0 = always picks the highest-probability token → same answer every time
                // 0.1 = nearly deterministic with very slight variation → good for factual Q&A
                // 1.0 = highly random → good for creative writing, bad for document Q&A
            }),

            tools: [searchTool],
            // array of tools this agent is allowed to call
            // the agent reads each tool's name + description to know what's available
            // during reasoning, it decides which tool (if any) to call based on the user's question

            checkpointer,
            // connects the MemorySaver defined above to this agent
            // every time agent.invoke() or agent.stream() runs:
            //   BEFORE running: loads the conversation history for this thread_id from checkpointer
            //   AFTER running:  saves the updated conversation history back into checkpointer

            systemPrompt: `You are a helpful document assistant.

    STRICT RULES:
    1. ALWAYS call the search_document tool first to find relevant information
    2. Try multiple different search queries if the first search returns nothing useful
    For example: search "document content", "main topic", "introduction", "overview"
    3. After getting tool results — write a clear, natural answer IN YOUR OWN WORDS
    4. Do NOT output the raw source text — only write your own summary
    5. Write like ChatGPT — conversational, clear, well-structured
    6. At the very end mention sources used: (Source 1, Source 2, etc.)
    7. If document doesn't contain the answer — say so clearly
    8. Keep answers concise — 3 to 5 sentences maximum for simple questions
    9. The document may be in any language — search in that language too if needed

    IMPORTANT: Your response should ONLY be your written answer.
    Never output the raw retrieved text chunks. That is internal data only.`,
            // systemPrompt = a hidden instruction sent to the LLM before every conversation turn
            // the user never sees this text — it shapes how the agent behaves and answers
            // Rule 1 makes sure the agent always retrieves data first (this prevents hallucination)
            // Rule 3/4 stop the agent from copy-pasting raw chunks straight into the answer
            // Rule 7 keeps answers short and focused, instead of overwhelming the user
        });
        // the return value is a compiled LangGraph agent object with two main methods:
        //   .invoke(input, config) = run the agent, wait for the full response, then return it
        //   .stream(input, config) = run the agent, yielding output tokens as they are generated
    }


    // ═══════════════════════════════════════════════════════════════════════════
    // ROUTE 1 — Health Check
    // GET /api/health
    // Frontend calls this to check if the server is running and whether a PDF is loaded
    // ═══════════════════════════════════════════════════════════════════════════

    app.get("/api/health", (req, res) => {
        res.json({
            status: "ok",
            documentLoaded: vectorStore !== null && ragAgent !== null,
            // true only when BOTH vectorStore AND ragAgent are ready to use
            // ragAgent can become ready in two different ways:
            //   a) a new PDF was loaded through POST /api/load-pdf
            //   b) the server auto-connected to existing Pinecone data on startup
            isIndexing,
            // lets the frontend show a "still processing..." state if a PDF is currently being indexed
            timestamp: new Date().toISOString(),
            // current server time in ISO format, e.g. "2026-07-28T10:15:30.000Z"
        });
    });


    // ═══════════════════════════════════════════════════════════════════════════
    // ROUTE 2 — Load PDF
    // POST /api/load-pdf
    // Body: { pdfPath: "./sample.pdf" }
    // Loads the PDF from disk, splits it into chunks, embeds and stores those chunks
    // in Pinecone, then builds the RAG agent. Must be called before any chat requests.
    // ═══════════════════════════════════════════════════════════════════════════

    app.post("/api/load-pdf", async (req, res) => {

        const { pdfPath } = req.body;
        // pdfPath = the file path string sent in the request body
        // example req.body: { "pdfPath": "./sample.pdf" }
        // example pdfPath value: "./sample.pdf"

        if (!pdfPath) {
            return res.status(400).json({
                success: false,
                error: "pdfPath is required in request body",
            });
            // 400 = HTTP Bad Request — client sent an incomplete request
            // "return" stops the function here — nothing below this line runs
        }

        if (isIndexing) {
            return res.status(409).json({
                success: false,
                error: "Already indexing a document. Please wait.",
            });
            // 409 = HTTP Conflict — another indexing operation is already running
            // this guards against a race condition where two requests try to index at once
        }

        isIndexing = true;
        // set the flag to true BEFORE starting the async work below
        // any request that arrives now will see isIndexing = true and immediately get a 409

        try {
            console.log(`\n📄 Loading PDF: ${pdfPath}`);

            const loader = new PDFLoader(pdfPath, { splitPages: true });
            // PDFLoader opens the file located at pdfPath
            // splitPages: true  = each PDF page becomes its own separate Document object
            // splitPages: false = the entire PDF becomes a single Document (harder to chunk well)

            const docs = await loader.load();
            // reads the PDF from disk and extracts the text content of every page
            // example docs value for a 3-page PDF:
            // [
            //   Document { pageContent: "page 1 text content...", metadata: { source: "./sample.pdf", page: 0 } },
            //   Document { pageContent: "page 2 text content...", metadata: { source: "./sample.pdf", page: 1 } },
            //   Document { pageContent: "page 3 text content...", metadata: { source: "./sample.pdf", page: 2 } },
            // ]

            const splitter = new RecursiveCharacterTextSplitter({
                chunkSize: 800,
                // target size of each chunk, measured in characters
                // 800 characters ≈ 150–200 words ≈ roughly 1–2 paragraphs
                // smaller chunkSize = more precise retrieval but more chunks & more embedding calls
                // larger chunkSize  = less precise retrieval but fewer chunks & fewer embedding calls

                chunkOverlap: 150,
                // number of characters repeated between two consecutive chunks
                // this prevents losing context right at the chunk boundary
                // example: the last 150 characters of chunk 1 = the first 150 characters of chunk 2
            });

            const chunks = await splitter.splitDocuments(docs);
            // splits each full-page Document into several smaller chunks
            // automatically copies metadata (like page number) from the parent Document into every chunk
            // example chunks produced from a single page:
            // [
            //   Document { pageContent: "first 800 chars of page 1...", metadata: { source: "./sample.pdf", page: 0 } },
            //   Document { pageContent: "overlap + next 800 chars...",  metadata: { source: "./sample.pdf", page: 0 } },
            //   Document { pageContent: "start of page 2...",           metadata: { source: "./sample.pdf", page: 1 } },
            //   ...
            // ]

            const namespace = pdfPath
                .split("/").pop()
                .split("\\").pop()
                .replace(/\.[^/.]+$/, "")
                .replace(/[^a-zA-Z0-9-_]/g, "-")
                .toLowerCase()
                .substring(0, 50);
            // namespace = a clean, URL/Pinecone-safe version of the filename
            // used to keep this PDF's vectors separate from other PDFs stored in the same Pinecone index
            //
            // example transformations:
            // "./sample.pdf"            → "sample"
            // "./my document.pdf"       → "my-document"
            // "./APY_Brochure_2025.pdf" → "apy-brochure-2025"
            //
            // step by step breakdown:
            // .split("/").pop()                 → keeps only the filename, e.g. "sample.pdf"
            // .split("\\").pop()                 → handles Windows-style paths too
            // .replace(/\.[^/.]+$/, "")          → removes the file extension, e.g. "sample"
            // .replace(/[^a-zA-Z0-9-_]/g, "-")   → replaces any special/space characters with "-"
            // .toLowerCase()                     → converts to lowercase
            // .substring(0, 50)                  → caps the length at 50 characters

            vectorStore = await buildPineconeVectorStore(chunks, namespace);
            // embeds every chunk using the OpenAI embeddings model
            // stores the resulting vectors in Pinecone under the given namespace
            // this data persists in Pinecone's cloud storage even after the server restarts

            ragAgent = createRAGAgent(vectorStore);
            // builds a brand-new RAG agent wired up to this freshly indexed data

            console.log(`✅ Indexed ${chunks.length} chunks from ${docs.length} pages`);
            // example log line: "✅ Indexed 25 chunks from 7 pages"

            res.json({
                success: true,
                message: "PDF loaded and indexed successfully",
                stats: {
                    pages: docs.length,
                    // total number of pages found in the PDF, e.g. 7

                    chunks: chunks.length,
                    // total number of chunks created after splitting, e.g. 25

                    fileName: pdfPath.split("/").pop(),
                    // extracts just the filename from the full path
                    // example: "./sample.pdf".split("/").pop() = "sample.pdf"
                },
            });
            // example full response body:
            // { "success": true, "message": "PDF loaded...", "stats": { "pages": 7, "chunks": 25, "fileName": "sample.pdf" } }

        } catch (error) {
            console.error("PDF load error:", error);
            vectorStore = null;
            ragAgent = null;
            // reset both back to null so the server is left in a clean, consistent state
            // this allows the next /api/load-pdf request to try again from scratch

            const response = formatErrorResponse(error);
            res.status(500).json(response);
            // 500 = HTTP Internal Server Error
            // example response: { "success": false, "error": "Something went wrong...", "code": "UNKNOWN_ERROR" }

        } finally {
            isIndexing = false;
            // always reset this flag — whether the load succeeded or failed
            // without this line, the server would get permanently stuck if an error happened mid-indexing
        }
    });


    // ═══════════════════════════════════════════════════════════════════════════
    // ROUTE 3 — Chat (Non-Streaming)
    // POST /api/chat
    // Body: { question: "...", sessionId: "..." }
    // Waits for the complete answer to be generated, then returns it all at once.
    // Use this for simple integrations that don't need word-by-word streaming.
    // ═══════════════════════════════════════════════════════════════════════════

    app.post("/api/chat", async (req, res) => {

        const { question, sessionId } = req.body;
        // question  = the text string the user typed
        //             example: "what are the pension benefits?"
        // sessionId = a unique conversation identifier sent by the frontend
        //             example: "session_1752672000000"
        //             used to load/save the correct conversation history in MemorySaver

        if (!ragAgent) {
            return res.status(400).json({
                success: false,
                error: "No document loaded. Call /api/load-pdf first.",
                code: "NO_DOCUMENT",
            });
            // 400 = Bad Request — user tried to chat before any PDF was loaded
        }

        let validatedQuestion;
        try {
            validatedQuestion = validateQuestion(question);
            // validateQuestion performs these checks in order:
            //   null / undefined / non-string value → throws EmptyQueryError
            //   empty string ""                      → throws EmptyQueryError
            //   shorter than 3 characters             → throws RAGError (QUERY_TOO_SHORT)
            //   longer than 2000 characters            → throws RAGError (QUERY_TOO_LONG)
            //   all checks pass                        → returns the trimmed question string
            //
            // example: validateQuestion("  what is APY?  ") returns "what is APY?"
        } catch (error) {
            return res.status(400).json(formatErrorResponse(error));
            // example: { "success": false, "error": "Please type a question...", "code": "EMPTY_QUERY" }
        }

        try {
            const config = {
                configurable: {
                    thread_id: sessionId || "default",
                    // thread_id = which saved conversation history to load from MemorySaver
                    // example: "session_1752672000000"
                    // if sessionId is missing → falls back to "default" (all such users share one history)
                },
            };
            // example config value:
            // { configurable: { thread_id: "session_1752672000000" } }

            const result = await ragAgent.invoke(
                { messages: [{ role: "user", content: validatedQuestion }] },
                // input given to the agent:
                //   messages = an array of message objects in OpenAI chat format
                //   role: "user" = this message is from the human
                //   content = the actual question text
                // example: { messages: [{ role: "user", content: "what are the pension benefits?" }] }

                config
                // tells the agent which thread_id's memory to load and update
            );
            // ragAgent.invoke() runs the full agent loop from start to finish:
            //   1. loads the conversation history from MemorySaver for this thread_id
            //   2. the LLM reads the system prompt + history + the current question
            //   3. the LLM decides to call the search_document tool
            //   4. the tool searches the vector store and returns relevant chunks
            //   5. the LLM reads those chunks and writes a grounded answer
            //   6. the updated conversation (all messages) is saved back into MemorySaver
            //   7. the function returns { messages: [...every message generated in this run...] }
            //
            // example result value:
            // {
            //   messages: [
            //     HumanMessage  { content: "what are the pension benefits?" },
            //     AIMessage     { content: "", tool_calls: [{ name: "search_document", args: { query: "pension benefits" } }] },
            //     ToolMessage   { content: "Source 1 (Page 1):\nSubscriber gets pension..." },
            //     AIMessage     { content: "The APY scheme provides a guaranteed pension of Rs.1000 to Rs.5000..." }
            //   ]
            // }

            const lastMessage = result.messages[result.messages.length - 1];
            // result.messages.length - 1 = index of the final message in the array
            // the last message is always the LLM's final text response to the user
            // example lastMessage value:
            // AIMessage { content: "The APY scheme provides a guaranteed monthly pension..." }

            res.json({
                success: true,
                answer: lastMessage.content,
                // lastMessage.content = the actual answer text as a string
                // example: "The APY scheme provides a guaranteed monthly pension of Rs.1000..."

                sessionId: sessionId || "default",
                // echoes the sessionId back so the frontend can reuse it in future requests
            });
            // example full response body:
            // {
            //   "success": true,
            //   "answer": "The APY scheme provides a guaranteed monthly pension...",
            //   "sessionId": "session_1752672000000"
            // }

        } catch (error) {
            console.error("Chat error:", error);
            const response = formatErrorResponse(error);
            res.status(500).json(response);
        }
    });


    // ═══════════════════════════════════════════════════════════════════════════
    // ROUTE 4 — Chat with Streaming
    // POST /api/chat/stream
    // Body: { question: "...", sessionId: "..." }
    // Sends the response tokens one by one using Server-Sent Events (SSE).
    //
    // HOW SSE WORKS:
    // Normal HTTP: client requests → server sends one full response → connection closes
    // SSE:         client requests → server keeps the connection open
    //              → server writes small data chunks one at a time
    //              → client receives each chunk immediately as it arrives
    //              → this creates the word-by-word "typing" effect seen in ChatGPT
    // ═══════════════════════════════════════════════════════════════════════════

    app.post("/api/chat/stream", async (req, res) => {

        const { question, sessionId } = req.body;
        // question  = the user's question string, e.g. "what is the document about?"
        // sessionId = a unique conversation ID from the frontend, e.g. "session_1752672000000"
        //             typically generated on the client as: `session_${Date.now()}`

        if (!ragAgent) {
            return res.status(400).json({
                success: false,
                error: "No document loaded. Call /api/load-pdf first.",
            });
        }

        let validatedQuestion;
        try {
            validatedQuestion = validateQuestion(question);
            // identical validation logic to the /api/chat route above
            // example output: "what is the document about?"
        } catch (error) {
            return res.status(400).json(formatErrorResponse(error));
        }

        // ── SET SSE RESPONSE HEADERS ──────────────────────────────────────────────
        // these headers tell the browser: "this is a streaming connection — keep it open"

        res.setHeader("Content-Type", "text/event-stream");
        // "text/event-stream" is the MIME type that marks this as an SSE connection
        // the browser will NOT close the connection after the first chunk arrives
        // the browser will parse every incoming chunk as a separate SSE event

        res.setHeader("Cache-Control", "no-cache");
        // tells the browser and any proxies in between: do not cache this response
        // streaming responses must always be delivered fresh — caching would break them

        res.setHeader("Connection", "keep-alive");
        // HTTP/1.1 normally closes the connection after each response
        // "keep-alive" keeps the underlying TCP connection open so we can keep writing data to it

        res.setHeader("Access-Control-Allow-Origin", "*");
        // a CORS header specifically for this SSE endpoint
        // allows a frontend running on any origin to read this streaming response


        // ── SSE EVENT HELPER ──────────────────────────────────────────────────────

        function sendEvent(eventType, data) {
            // eventType = a label describing what kind of event this is
            //             examples: "start", "token", "done", "error"
            //
            // data = a JavaScript object containing the event's payload
            //        examples:
            //          { message: "Starting response..." }
            //          { content: "This" }
            //          { message: "Response complete" }
            //          { success: false, error: "Something went wrong" }

            res.write(`event: ${eventType}\n`);
            // res.write() sends data to the client WITHOUT closing the connection
            // (unlike res.send() / res.json(), which close the connection immediately)
            //
            // SSE protocol: the "event:" line sets this event's type name
            // the browser's EventSource API reads this to know which handler to trigger
            // example line written to the stream: "event: token\n"

            res.write(`data: ${JSON.stringify(data)}\n\n`);
            // JSON.stringify(data) converts the JavaScript object into a JSON string
            // example: JSON.stringify({ content: "This" }) = '{"content":"This"}'
            //
            // SSE protocol: the "data:" line carries the actual event payload
            // the double newline (\n\n) marks the end of this complete SSE event
            //
            // example complete SSE event written to the stream:
            // "event: token\n"
            // 'data: {"content":"This"}\n\n'
            //
            // the browser receives these two lines and fires an event with data: '{"content":"This"}'
            // the frontend parses that JSON and appends "This" to the message being displayed
        }


        try {
            const config = {
                configurable: { thread_id: sessionId || "default" },
            };
            // same purpose as in /api/chat — tells the agent which conversation history to use
            // example: { configurable: { thread_id: "session_1752672000000" } }

            sendEvent("start", { message: "Starting response..." });
            // the first SSE event — tells the frontend that the agent has started processing
            // the frontend can use this to show a "thinking..." indicator
            // written to the stream:
            // "event: start\n"
            // 'data: {"message":"Starting response..."}\n\n'

            console.log("\n🔍 Starting stream for:", validatedQuestion);

            for await (const [token, metadata] of await ragAgent.stream(
                { messages: [{ role: "user", content: validatedQuestion }] },
                // same input format used by ragAgent.invoke() in the non-streaming route

                { ...config, streamMode: "messages" }
                // streamMode: "messages" makes the agent yield one [token, metadata] tuple per LLM token
                // instead of waiting for the full response, it yields pieces as they are generated
                //
                // each yielded item is a tuple:
                //   token    = an AIMessageChunk — one small piece of the LLM's output
                //   metadata = { langgraph_node: "..." } — which internal node produced this chunk
            )) {
                // token = an AIMessageChunk object
                // possible token.content values seen across iterations:
                //   "This"                         → a plain text token — should be sent to the frontend
                //   " document"                    → another plain text token — should be sent
                //   ""                              → empty string — should be skipped
                //   [{ type: "tool_use", ... }]    → an array — this is a tool call, not text — skip
                //   "Source 1 (Page 1):\ntext..."  → raw text but from a TOOL result — skip via getType()
                //
                // example metadata: { langgraph_node: "agent", langgraph_step: 3 }

                console.log(
                    "node:", metadata?.langgraph_node,
                    "| type:", typeof token?.content,
                    "| value:", JSON.stringify(token?.content)?.substring(0, 80)
                );
                // debug log — useful during development, safe to remove in production
                // example console output while an agent run is streaming:
                // node: tools | type: string | value: "Source 1 (Page 1):\nSubscriber gets..."
                // node: agent | type: string | value: "The"
                // node: agent | type: string | value: " APY"
                // node: agent | type: string | value: " scheme"

                const content = token?.content;
                // optional chaining (?.) means this returns undefined instead of throwing if token is null
                // content = the actual text payload of this streamed chunk

                if (token?.getType && token.getType() === "tool") continue;
                // token.getType() returns the message type: "human", "ai", "tool", or "system"
                // "tool" = this chunk is a ToolMessage (i.e. the raw result returned by search_document)
                //          we do NOT want to stream raw tool results to the user — they are internal data
                //          "continue" skips the rest of this loop iteration and moves to the next token

                if (Array.isArray(content)) {
                    // an Array content value means the LLM is making a tool call, not writing text
                    // example: [{ type: "tool_use", name: "search_document", input: { query: "..." } }]
                    // this is an internal action, not user-facing text — so it is skipped
                    continue;
                }

                if (typeof content === "string" && content.length > 0) {
                    // typeof content === "string" → confirms this is a real text token, not a tool-call array
                    // content.length > 0          → confirms it actually has characters (not an empty "")
                    //
                    // example content values that PASS this check and get sent: "The", " APY", " scheme"
                    // example values that FAIL this check and are skipped: "" (empty), [] (an array)

                    sendEvent("token", { content });
                    // sends this text token to the frontend immediately
                    // written to the HTTP stream:
                    // "event: token\n"
                    // 'data: {"content":"The"}\n\n'
                    //
                    // the frontend receives this event and appends `content` to the message on screen
                    // the user visually sees "The" appear, then " APY", then " scheme", one after another
                }
            }
            // the for-await loop ends automatically once the agent finishes generating its full response
            // by this point every text token has already been streamed to the frontend


            sendEvent("done", { message: "Response complete" });
            // the final SSE event — tells the frontend that the entire response has been sent
            // written to the stream:
            // "event: done\n"
            // 'data: {"message":"Response complete"}\n\n'
            //
            // when the frontend receives this it typically:
            //   sets isStreaming = false
            //   removes the blinking cursor from the message
            //   re-enables the input box for the next question

            res.end();
            // closes the HTTP connection
            // without this call, the browser would keep the connection open, waiting for more events

        } catch (error) {
            console.error("Stream error:", error);
            // logs the full error details on the server, for debugging purposes

            const errorResponse = formatErrorResponse(error);
            // converts the raw error into a clean, user-friendly format
            // example: { success: false, error: "Something went wrong. Please try again.", code: "UNKNOWN_ERROR" }

            sendEvent("error", errorResponse);
            // sends an "error" event to the frontend so it can display an error message to the user
            // written to the stream:
            // "event: error\n"
            // 'data: {"success":false,"error":"Something went wrong..."}\n\n'

            res.end();
            // always close the connection on error too
            // otherwise the browser would hang, waiting for events that will never arrive
        }
    });


    // ═══════════════════════════════════════════════════════════════════════════
    // ROUTE 5 — Pinecone Stats
    // GET /api/stats
    // Shows how many vectors are currently stored in Pinecone.
    // Conceptually similar to running: SELECT COUNT(*) FROM your_table in SQL.
    // ═══════════════════════════════════════════════════════════════════════════

    app.get("/api/stats", async (req, res) => {
        try {
            const stats = await getIndexStats();
            // calls the Pinecone API to fetch current index statistics
            //
            // example stats value:
            // {
            //   totalVectors: 25,
            //   namespaces: { "sample": { recordCount: 25 } },
            //   dimension: 1536
            // }

            res.json({
                success: true,
                pineconeIndex: process.env.PINECONE_INDEX,
                // which Pinecone index this server is configured to use
                // example: "pdf-chatbot"

                ...stats,
                // spreads every field from `stats` directly into the response body:
                //   totalVectors: 25
                //   namespaces: { "sample": { recordCount: 25 } }
                //   dimension: 1536
            });

            // example full response:
            // {
            //   "success": true,
            //   "pineconeIndex": "pdf-chatbot",
            //   "totalVectors": 25,
            //   "namespaces": { "sample": { "recordCount": 25 } },
            //   "dimension": 1536
            // }

        } catch (error) {
            res.status(500).json({ success: false, error: error.message });
        }
    });


    // ═══════════════════════════════════════════════════════════════════════════
    // AUTO-CONNECT ON STARTUP
    // Checks whether Pinecone already has data from a previous session.
    // If it does → automatically rebuilds the agent so the user doesn't have to
    // re-upload the same PDF after every server restart.
    // ═══════════════════════════════════════════════════════════════════════════

    async function autoConnectIfDataExists() {
        try {
            console.log("\n🔍 Checking Pinecone for existing data...");

            const stats = await getIndexStats();
            // fetches vector counts from Pinecone
            // example stats: { totalVectors: 12, namespaces: { "sample": { recordCount: 12 } } }

            if (stats.totalVectors === 0) {
                console.log("   No existing data found — please load a PDF\n");
                return;
                // nothing to connect to yet — exit early and wait for a manual /api/load-pdf call
            }

            const namespaces = Object.keys(stats.namespaces);
            // extracts all namespace names found inside the index
            // example: ["sample"]  or  ["my-document", "apy-guide"]

            if (namespaces.length === 0) {
                console.log("   No namespaces found\n");
                return;
            }

            const namespace = namespaces[0];
            // simply picks the first namespace found
            // example: "sample"

            console.log(`   Found ${stats.totalVectors} vectors in namespace "${namespace}"`);
            console.log("   Auto-connecting to existing data...");

            const retriever = await loadExistingVectorStore(namespace);
            // reconnects to the existing vectors already stored in Pinecone
            // no re-embedding happens here — this just re-establishes the connection

            ragAgent = createRAGAgent(retriever);
            // builds a working agent using the existing, already-indexed data

            vectorStore = { namespace };
            // marks vectorStore as non-null so the /api/health route reports documentLoaded: true

            console.log("   ✅ Auto-connected! Ready to answer questions\n");

        } catch (err) {
            console.log("   Could not auto-connect:", err.message, "\n");
            // any failure here is non-fatal — the server still starts up normally
            // the user can simply load a PDF manually via /api/load-pdf instead
        }
    }


    // ═══════════════════════════════════════════════════════════════════════════
    // START THE SERVER
    // ═══════════════════════════════════════════════════════════════════════════

    app.listen(PORT, async () => {
        console.log("\n" + "=".repeat(55));
        console.log(`🚀 RAG API Server running on port ${PORT}`);
        console.log(`   Health check:   http://localhost:${PORT}/api/health`);
        console.log(`   Load PDF:       POST http://localhost:${PORT}/api/load-pdf`);
        console.log(`   Chat:           POST http://localhost:${PORT}/api/chat`);
        console.log(`   Stream Chat:    POST http://localhost:${PORT}/api/chat/stream`);
        console.log(`   Pinecone Stats: GET  http://localhost:${PORT}/api/stats`);
        console.log("=".repeat(55) + "\n");

        await autoConnectIfDataExists();
        // runs once, right after the server starts listening
        // if Pinecone already has data → the agent becomes ready without needing a PDF reload
    });


Step 6 — Run and Test

node src/api.js

Load a PDF:

curl -X POST http://localhost:3001/api/load-pdf \
  -H "Content-Type: application/json" \
  -d '{"pdfPath": "./sample.pdf"}'

Check Pinecone stats:

curl http://localhost:3001/api/stats

Expected output:

{
  "success": true,
  "pineconeIndex": "pdf-chatbot",
  "totalVectors": 25,
  "namespaces": {
    "sample": { "recordCount": 25 }
  },
  "dimension": 1536
}

Now restart the server — ask a question — data still works!

# Stop server (Ctrl+C)
# Restart
node src/api.js

frontend file changes

update usePDFLoader.js file


    "use client";

    import { useState, useEffect } from "react";

    export function usePDFLoader() {

        const [isLoading, setIsLoading] = useState(false);
        const [isLoaded, setIsLoaded] = useState(false);
        const [error, setError] = useState(null);
        const [pdfStats, setPdfStats] = useState(null);

        // ── CHECK ON STARTUP ────────────────────────────────────────
        // When frontend loads — ask server if document is already ready
        // This handles the case where server has Pinecone data from before
        useEffect(() => {
            async function checkServerStatus() {
                try {
                    const response = await fetch(
                        `${process.env.NEXT_PUBLIC_API_URL}/api/health`
                    );
                    const data = await response.json();
                    // example data: { status: "ok", documentLoaded: true, isIndexing: false }

                    if (data.documentLoaded) {
                        // server already has a document loaded (from Pinecone auto-connect)
                        setIsLoaded(true);
                        setPdfStats({
                            fileName: "Previously loaded document",
                            pages: "",
                            chunks: "",
                            // we don't have exact stats for auto-connected docs
                            // show generic message
                        });
                        console.log("Server already has document loaded — ready to chat");
                    }
                } catch (err) {
                    // server might not be running yet — ignore silently
                    console.log("Could not reach server:", err.message);
                }
            }

            checkServerStatus();
            // runs once when component mounts (page loads)
        }, []);
        // empty dependency array = run only once on mount


        async function loadPDF(pdfPath) {
            if (!pdfPath.trim()) {
                setError("Please enter a PDF file path");
                return;
            }

            setIsLoading(true);
            setError(null);

            try {
                const response = await fetch(
                    `${process.env.NEXT_PUBLIC_API_URL}/api/load-pdf`,
                    {
                        method: "POST",
                        headers: { "Content-Type": "application/json" },
                        body: JSON.stringify({ pdfPath }),
                    }
                );

                const data = await response.json();

                if (!response.ok || !data.success) {
                    throw new Error(data.error || "Failed to load PDF");
                }

                setPdfStats(data.stats);
                setIsLoaded(true);

            } catch (err) {
                setError(err.message);
                setIsLoaded(false);
            } finally {
                setIsLoading(false);
            }
        }

        function reset() {
            setIsLoaded(false);
            setPdfStats(null);
            setError(null);
        }

        return { isLoading, isLoaded, error, pdfStats, loadPDF, reset };
    }


Pinecone Dashboard — Visual View

Pinecone's dashboard shows many of the same things that MongoDB Atlas does.

app.pinecone.io → your project → Indexes → pdf-chatbot

┌─────────────────────────────────────────────────────┐
│  Index: pdf-chatbot                                 │
│  Dimension: 1536  │  Metric: cosine  │  Serverless  │
├─────────────────────────────────────────────────────┤
│  Total Vectors: 25                                  │
│                                                     │
│  Namespaces:                                        │
│  → sample        25 vectors                         │
│  → my-document   12 vectors                         │
├─────────────────────────────────────────────────────┤
│  [Browse Vectors]  [Query Console]  [Metrics]       │
└─────────────────────────────────────────────────────┘

Browse Vectors → View each vector's ID, metadata, and vector values.


MemoryVectorStore vs PineconeStore

Feature

MemoryVectorStore

PineconeStore

Storage

RAM (temporary)

Cloud (permanent)

Server restart

Data gone

Data safe

Max vectors

~50K (RAM limited)

Billions

Cost

Free

Free tier available

Setup

Zero setup

Account + index required

Speed

Fast (local)

~50–100 ms (network)

Dashboard

None

Full web UI

Production ready

No

Yes

Code change needed

N/A

Just swap the import


3-Line Summary

  1. Pinecone is a managed cloud vector database — vectors stored permanently in the cloud so data survives server restarts, scales to billions of vectors, and comes with a web dashboard to visually browse your stored data.
  2. The code change is minimal — replace MemoryVectorStore.fromDocuments() with PineconeStore.fromDocuments() and add a namespace parameter — everything else (retriever, agent, chat routes) stays exactly the same.
  3. Namespaces in Pinecone work like folders — different PDFs go into different namespaces within the same index so their vectors never mix, and you can delete one PDF's data without affecting others.

Module 7.4 — Complete ✅

Phase 7 — Complete 🎉

✅ Module 7.1 — Production RAG System with LangChain
✅ Module 7.2 — Streaming, Error Handling & Express API
✅ Module 7.3 — Next.js Frontend
✅ Module 7.4 — Pinecone Cloud Vector Database

Coming up — Phase 8: AI Agents

Advanced agent concepts including the ReAct pattern, multi-agent systems, agent memory, and real-world projects such as a Resume Analyzer and a Research Agent.

Module 8.1 — What is an AI Agent & The ReAct Pattern

How agents think, plan, and act — the foundation of everything in Phase 8 The Core Difference — LLM vs Agent You have been using LLMs in two...