AI Research Agent with Agentic RAG

Build a multi-agent RAG pipeline with LangGraph, Groq, and ChromaDB.

Introduction

30 Second Summary

When you need to research a topic, you search through documents, pull out the relevant pieces, and synthesize an answer. Now imagine an AI agent that does this entire process autonomously, deciding what to search for and piecing together cited answers from your own knowledge base.

In this project, you will build a full-stack AI Research Agent that uses multiple AI agents working together to answer questions from documents you provide. You'll create a LangGraph pipeline that routes queries, retrieves relevant information from a vector database, and generates cited responses.

What You'll Build

A full-stack AI Research Agent powered by a multi-node LangGraph pipeline, ChromaDB vector search, and a Next.js chat interface that displays the agent's reasoning process in real-time.

By the end of this project, you'll have:

  • A FastAPI backend serving a LangGraph-powered agentic pipeline with tool-calling agents that search a knowledge base and format citations.
  • A RAG pipeline that ingests documents, chunks them, and stores them in ChromaDB for semantic search and retrieval.
  • A Next.js chat frontend that connects to your backend and shows the agent's step-by-step reasoning with live state badges for Routing, Retrieving, and Generating.
  • Secret Mission: Add a self-correction reviewer node that evaluates answer quality and triggers a re-retrieval loop if the response is insufficient.

Are there any prerequisites?

This project assumes you have Python and Node.js installed, basic familiarity with Python, and some experience working with APIs. You'll also need a free Groq API key, which you'll set up in Step 1.

Set Up the Python Backend Environment

Your AI Research Agent needs a solid foundation before it can reason about anything. The backend will house the LangGraph pipeline, the ChromaDB vector database, and the FastAPI server that ties everything together.

In this step, you will create an isolated Python environment, install all the dependencies your agent needs, and confirm that your Groq API key works.

In this step, get ready to:
  • Create the project directory and a Python virtual environment.
  • Install all backend dependencies from a requirements file.
  • Verify Groq API connectivity with a test call.
Create the project directory and virtual environment

A virtual environment keeps your project's packages isolated from other Python projects on your machine. This prevents version conflicts between projects.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the project folder, then the backend folder inside it, by running these commands:
mkdir ai-research-agent
cd ai-research-agent
mkdir backend
cd backend
  • Create a Python virtual environment inside the backend folder by running:
python3 -m venv venv

What is a virtual environment?

A virtual environment is a self-contained directory that holds its own Python interpreter and installed packages. The libraries you install here won't interfere with other Python projects on your system.

  • Activate the virtual environment.

macOS

source venv/bin/activate

Windows

venv\Scripts\activate

You should see (venv) appear at the beginning of your terminal prompt.

Don't see (venv) in your prompt?

Make sure you ran the activate command from inside the backend folder. On Windows, if you see a permissions error, run Set-ExecutionPolicy -Scope CurrentUser RemoteSigned first, then try the activate command again.

Help me activate my virtual environment

Install backend dependencies

Your backend needs several libraries. LangGraph handles the agent orchestration, ChromaDB stores document embeddings locally, and FastAPI serves the API.

  • Create a requirements.txt file inside your backend folder by running:
cat << 'EOF' > requirements.txt
langgraph
langchain-groq
langchain-core
chromadb
fastapi
uvicorn[standard]
python-dotenv
EOF

What are these packages?

  • langgraph builds the multi-agent state machine that routes queries.
  • langchain-groq connects to the Groq LLM API for fast inference.
  • langchain-core provides base abstractions like tool decorators and message types.
  • chromadb is the local vector database that stores and searches your documents.
  • fastapi and uvicorn[standard] serve the backend API with async support.
  • python-dotenv loads environment variables from your .env file.

Windows users

The cat << 'EOF' command is bash-only. On Windows, create a file called requirements.txt in the backend folder using VS Code and paste the seven package names into it (one per line).

  • Install all dependencies by running:
pip install -r requirements.txt

First run takes a moment

ChromaDB downloads its default embedding model (about 80MB) on first use, so expect a brief delay later when you first run a query. The pip install itself should complete in 1-2 minutes depending on your internet speed.

You should see a success message ending with something like "Successfully installed" followed by a list of package names.

Seeing a build error for chromadb on Windows?

On Windows, ChromaDB may need Microsoft Visual C++ Build Tools to compile its native extension. Download and install them from the Visual Studio Build Tools page, selecting the "Desktop development with C++" workload. Restart your terminal and try pip install -r requirements.txt again.

Help me fix this ChromaDB build error

  • Confirm the files exist by running:
ls

You should see requirements.txt and the venv folder listed in the output.

Create the .env file and verify Groq connectivity

Your agent uses the Groq API for fast LLM inference. You will store your API key in a .env file so it stays out of your code.

  • Create the .env file by running:
cat << 'EOF' > .env
GROQ_API_KEY=your-groq-api-key-here
EOF
  • Open .env in your text editor and replace your-groq-api-key-here with your actual Groq API key (it starts with gsk_).
  • Save the file.

Where do I find my Groq API key?

Go to console.groq.com, sign in, and navigate to the API Keys section. Create a new key and copy it. Groq's free tier gives you generous usage for development.

  • Verify your API key works by running this test script:
python3 -c "
import os
from dotenv import load_dotenv
from langchain_groq import ChatGroq
from langchain_core.messages import HumanMessage

load_dotenv()

llm = ChatGroq(
    model='llama-3.3-70b-versatile',
    api_key=os.getenv('GROQ_API_KEY'),
    temperature=0.1
)

response = llm.invoke([HumanMessage(content='Say hello in one sentence.')])
print(response.content)
"

What does this test do?

This script loads your API key from .env, creates a ChatGroq client pointing at the llama-3.3-70b-versatile model, and sends a simple message. If your key is valid, you will see a short greeting printed in the terminal.

You should see a one-sentence greeting from the model printed in your terminal.

Seeing an authentication or API error?

Double-check that your .env file contains the correct key with no extra spaces or quotes around the value. The key should start with gsk_.

Make sure you saved the .env file and that you are running the command from the backend folder (where .env lives).

Help me fix this Groq API error

✔️ Awesome, I've got everything!

Great. Double check you have saved both requirements.txt and .env in your backend folder.

ⓧ I'd like to double check the full code

Here is what each file should look like at this point.

GROQ_API_KEY=your-groq-api-key-here
langgraph
langchain-groq
langchain-core
chromadb
fastapi
uvicorn[standard]
python-dotenv

Your backend environment is ready with all the tools your AI agent needs. Next up, you will scaffold the Next.js frontend that will become the chat interface for your research agent.

Scaffold the Next.js Frontend

Your backend is ready. Now you need a frontend for users to interact with the AI research agent.

You will use Next.js to build a chat interface that communicates with your FastAPI backend. In this step, you will scaffold the frontend project and install Tailwind CSS for styling.

In this step, get ready to:
  • Scaffold a Next.js application with JavaScript and the Pages Router.
  • Install and configure Tailwind CSS for styling.
  • Verify the frontend runs on localhost:3000.
Create the Next.js frontend application

Next.js provides file-based routing, server-side rendering, and a great developer experience out of the box. You will use the Pages Router (not App Router) because it is simpler and keeps the learning curve focused on AI concepts.

  • Open a new terminal window. Your backend virtual environment terminal can stay open separately.
  • Navigate to your project's parent directory (the folder that contains your backend/ folder) by running this command:
cd ~/Desktop/ai-research-agent
  • Scaffold a new Next.js app by running this command:
npx create-next-app@latest frontend

What prompts will I see?

The CLI will ask you a series of configuration questions. Answer each one as listed below.

  • Select No, customize settings when asked about recommended defaults.
  • Would you like to use TypeScript? Select No.
  • Which linter would you like to use? Select None.
  • Would you like to use Tailwind CSS? Select No.
  • Would you like your code inside a src/ directory? Select No.
  • Would you like to use App Router? Select No.
  • Would you like to customize the import alias? Select No.

Why skip Tailwind in create-next-app?

The latest create-next-app installs Tailwind v4 by default, which uses a different configuration approach. This project uses Tailwind v3 because it has broader community support and a more explicit configuration style that is easier to understand.

You will install Tailwind v3 manually in the next substep.

  • Once the scaffolding finishes, move into the frontend directory by running:
cd frontend
  • Confirm the project was created by running:
ls

You should see files like package.json, pages/, and node_modules/ listed in the output.

Command not found or permission error?

Make sure Node.js is installed and accessible in your terminal. Run node --version to verify.

If npx asks to install the create-next-app package, type y to confirm.

Help me troubleshoot my create-next-app installation.

Install and configure Tailwind CSS

Tailwind CSS is a utility-first CSS framework that lets you style components using class names directly in your markup. You will install version 3 along with PostCSS and Autoprefixer.

  • Install Tailwind CSS v3 and its dependencies by running:
npm install -D tailwindcss@3 postcss autoprefixer
  • Generate the Tailwind and PostCSS configuration files by running:
npx tailwindcss init -p

What does this command do?

The -p flag tells Tailwind to also create a postcss.config.js file alongside tailwind.config.js. PostCSS processes your CSS at build time, and Autoprefixer adds vendor prefixes for cross-browser compatibility.

Now you need to tell Tailwind which files to scan for class names. You also need to add the Tailwind directives to your global stylesheet.

  • Open tailwind.config.js in your editor and replace its contents with the following:
/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./pages/**/*.{js,jsx}",
    "./components/**/*.{js,jsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
};

What does the content array do?

The content array tells Tailwind which files to scan for class names. It will only generate CSS for classes actually used in your pages/ and components/ directories. This keeps your production CSS small and fast.

  • Open styles/globals.css (created by Next.js) and replace its entire contents with the Tailwind directives:
@tailwind base;
@tailwind components;
@tailwind utilities;

What are these directives?

These three lines inject Tailwind's base reset styles, component classes, and utility classes into your CSS. At build time, Tailwind replaces them with the actual CSS your project needs.

  • Save both tailwind.config.js and styles/globals.css.
  • Confirm the postcss.config.js file was auto-generated correctly by opening it. It should contain:
module.exports = {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  },
};

If yours matches, no changes are needed. If the file looks different, replace its contents with the code above.

Don't see styles/globals.css?

If the file does not exist, create a styles folder inside frontend/ and add a globals.css file inside it. Then paste the three Tailwind directives.

Help me set up Tailwind CSS in my Next.js project.

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files: tailwind.config.js, postcss.config.js, and styles/globals.css.

ⓧ I'd like to double check the full code

Here are the three configuration files in full:

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./pages/**/*.{js,jsx}",
    "./components/**/*.{js,jsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
};
module.exports = {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  },
};
@tailwind base;
@tailwind components;
@tailwind utilities;
Verify the frontend runs on localhost:3000
  • Start the Next.js development server by running:
npm run dev
  • Open your browser and navigate to http://localhost:3000.

You should see the default Next.js welcome page. This confirms your frontend is running correctly with Tailwind CSS configured.

Page not loading?

Check your terminal for error messages. A common issue is port 3000 already being in use by another process. Stop any other servers running on that port and try again.

If you see a CSS-related error, make sure styles/globals.css is imported in pages/_app.js. The default Next.js scaffold includes this import automatically.

Help me fix my Next.js dev server.

Your frontend is scaffolded and styled. Next up, you will build the RAG pipeline that ingests documents and stores them in a vector database.

Build the RAG Pipeline with ChromaDB

Your agent needs a knowledge base to search through when answering questions. Without one, it can only rely on what the LLM already knows from its training data.

In this step, you will build the RAG (Retrieval-Augmented Generation) pipeline. This pipeline takes raw documents, breaks them into searchable chunks, stores them in a ChromaDB vector database, and retrieves the most relevant pieces when the agent needs them.

In this step, get ready to:
  • Create the document ingestion module that chunks text and stores it in ChromaDB.
  • Build a retrieval function that performs semantic search against the vector database.
  • Seed the knowledge base with sample documents and test retrieval.
Create the document ingestion module

The ingestion module handles two jobs: splitting documents into bite-sized chunks and storing those chunks as vector embeddings in ChromaDB. ChromaDB automatically generates embeddings using a local model, so you do not need a paid embedding API.

  • Create a new file called rag.py inside your backend folder.
  • Add the ChromaDB client setup functions by pasting this code into rag.py:
import chromadb
from chromadb.config import Settings


# Create a persistent ChromaDB client that saves data to disk
def get_chroma_client():
    client = chromadb.PersistentClient(
        path="./chroma_db",
        settings=Settings(anonymized_telemetry=False)
    )
    return client


# Get or create the vector collection with cosine similarity
def get_or_create_collection(client):
    collection = client.get_or_create_collection(
        name="research_knowledge_base",
        metadata={"hnsw:space": "cosine"}
    )
    return collection

What does this code do?

PersistentClient tells ChromaDB to save your vector database to the ./chroma_db folder on disk. This means your data persists between runs instead of disappearing when the script stops.

The collection is configured with "hnsw:space": "cosine" so that similarity search uses cosine distance. Cosine distance measures how similar two pieces of text are by comparing the angle between their vector representations.

  • You won't see a change yet. The next chunk adds the functions that actually process and store documents.
  • Add the text chunking and document ingestion functions below the existing code in rag.py:
# Split text into overlapping chunks for better retrieval
def chunk_text(text, chunk_size=500, overlap=50):
    chunks = []
    start = 0
    while start < len(text):
        end = start + chunk_size
        chunk = text[start:end]
        chunks.append(chunk)
        start = end - overlap
    return chunks


# Ingest a document by chunking it and storing in ChromaDB
def ingest_document(title, content):
    client = get_chroma_client()
    collection = get_or_create_collection(client)

    chunks = chunk_text(content)
    documents = []
    metadatas = []
    ids = []

    existing_count = collection.count()

    for i, chunk in enumerate(chunks):
        doc_id = f"{title.replace(' ', '_').lower()}_{existing_count + i}"
        documents.append(chunk)
        metadatas.append({"source": title, "chunk_index": i})
        ids.append(doc_id)

    collection.upsert(documents=documents, metadatas=metadatas, ids=ids)
    return len(chunks)

What does this code do?

chunk_text() splits a long document into 500-character pieces with a 50-character overlap. The overlap ensures that sentences at chunk boundaries are not lost.

ingest_document() takes a title and content, chunks the content, then stores each chunk in ChromaDB with metadata tracking its source. It uses upsert so re-ingesting the same document updates existing entries instead of creating duplicates.

  • Save rag.py.
  • Test the ingestion by running this command from your backend folder:
python -c "from rag import ingest_document; print(ingest_document('Test Doc', 'Artificial intelligence is a broad field of computer science focused on creating systems capable of performing tasks that typically require human intelligence.'))"
  • You should see a number printed (like 1), indicating how many chunks were stored.

First run may take a moment

ChromaDB automatically downloads its default embedding model (all-MiniLM-L6-v2) the first time you run it. This is a one-time download that happens automatically.

Seeing an import error?

Make sure your virtual environment is activated and that you installed all dependencies from requirements.txt in the previous step. On Windows, if chromadb failed to install, you may need Visual C++ Build Tools for the hnswlib dependency.

Help me fix my ChromaDB import error

Build the retrieval function

Now that documents can go into the database, you need a way to get relevant information back out. The retrieval function takes a question, converts it to a vector, and finds the most similar chunks.

  • Add the search function below the existing code in rag.py:
# Search the vector database for chunks matching a query
def search_documents(query, n_results=3):
    client = get_chroma_client()
    collection = get_or_create_collection(client)

    if collection.count() == 0:
        return []

    results = collection.query(
        query_texts=[query],
        n_results=n_results,
        include=["documents", "metadatas", "distances"]
    )

    formatted_results = []
    for i in range(len(results["ids"][0])):
        formatted_results.append({
            "id": results["ids"][0][i],
            "content": results["documents"][0][i],
            "source": results["metadatas"][0][i].get("source", "Unknown"),
            "distance": results["distances"][0][i]
        })

    return formatted_results

What does this code do?

search_documents() performs semantic search. It sends your query text to ChromaDB, which converts it to a vector and finds the closest matching chunks using cosine similarity.

The distance field tells you how similar each result is to your query. Lower values mean higher similarity. The function returns the top 3 results by default, each with its content, source document title, and similarity score.

  • Save rag.py.
  • Test the search function by running this command:
python -c "from rag import search_documents; results = search_documents('What is AI?'); print(results[0]['content'] if results else 'No results')"
  • You should see the text chunk from the test document you ingested earlier, because it matches your query about AI.

Getting an empty result?

If you see No results, make sure the ingestion test from the previous substep ran successfully. You can confirm by checking that a chroma_db folder was created inside your backend directory.

Help me debug my ChromaDB search returning empty results

✔️ Awesome, I've got everything!

Great. Double check you have saved rag.py with all five functions.

ⓧ I'd like to double check the full code

import chromadb
from chromadb.config import Settings


def get_chroma_client():
    client = chromadb.PersistentClient(
        path="./chroma_db",
        settings=Settings(anonymized_telemetry=False)
    )
    return client


def get_or_create_collection(client):
    collection = client.get_or_create_collection(
        name="research_knowledge_base",
        metadata={"hnsw:space": "cosine"}
    )
    return collection


def chunk_text(text, chunk_size=500, overlap=50):
    chunks = []
    start = 0
    while start < len(text):
        end = start + chunk_size
        chunk = text[start:end]
        chunks.append(chunk)
        start = end - overlap
    return chunks


def ingest_document(title, content):
    client = get_chroma_client()
    collection = get_or_create_collection(client)

    chunks = chunk_text(content)
    documents = []
    metadatas = []
    ids = []

    existing_count = collection.count()

    for i, chunk in enumerate(chunks):
        doc_id = f"{title.replace(' ', '_').lower()}_{existing_count + i}"
        documents.append(chunk)
        metadatas.append({"source": title, "chunk_index": i})
        ids.append(doc_id)

    collection.upsert(documents=documents, metadatas=metadatas, ids=ids)
    return len(chunks)


def search_documents(query, n_results=3):
    client = get_chroma_client()
    collection = get_or_create_collection(client)

    if collection.count() == 0:
        return []

    results = collection.query(
        query_texts=[query],
        n_results=n_results,
        include=["documents", "metadatas", "distances"]
    )

    formatted_results = []
    for i in range(len(results["ids"][0])):
        formatted_results.append({
            "id": results["ids"][0][i],
            "content": results["documents"][0][i],
            "source": results["metadatas"][0][i].get("source", "Unknown"),
            "distance": results["distances"][0][i]
        })

    return formatted_results
Seed the knowledge base with sample documents

Your RAG pipeline works, but it only has one tiny test document. To properly test the agent later, you need a richer knowledge base with multiple documents on different topics.

  • Seed the knowledge base with sample documents by running this command:
python -c "
from rag import ingest_document

doc1 = ingest_document('Machine Learning Basics', 'Machine learning is a subset of artificial intelligence that enables systems to learn and improve from experience without being explicitly programmed. Supervised learning uses labeled training data to make predictions. Unsupervised learning finds hidden patterns in data without labels. Reinforcement learning trains agents through rewards and penalties.')
print(f'Doc 1: {doc1} chunks')

doc2 = ingest_document('Neural Networks', 'Neural networks are computing systems inspired by biological neural networks in the human brain. They consist of layers of interconnected nodes called neurons. Deep learning uses neural networks with many hidden layers to process complex patterns. Convolutional neural networks excel at image recognition tasks. Recurrent neural networks handle sequential data like text and time series.')
print(f'Doc 2: {doc2} chunks')

doc3 = ingest_document('Natural Language Processing', 'Natural language processing is a field of AI focused on enabling computers to understand and generate human language. Key tasks include text classification, named entity recognition, and machine translation. Large language models like GPT use transformer architectures to generate coherent text. Retrieval augmented generation combines LLMs with external knowledge bases for more accurate answers.')
print(f'Doc 3: {doc3} chunks')

print('Knowledge base seeded successfully!')
"

What does this command do?

This ingests three sample documents covering machine learning, neural networks, and natural language processing. Each document gets chunked and stored as vector embeddings in your local ChromaDB database.

  • You should see output showing the chunk counts for each document, followed by Knowledge base seeded successfully!.
  • Verify semantic search works across the full knowledge base by running:
python -c "from rag import search_documents; results = search_documents('How do transformers work in NLP?'); [print(f'{r[\"source\"]}: {r[\"content\"][:80]}...') for r in results]"
  • You should see results pulled from the Natural Language Processing document, because that content is most semantically similar to your query about transformers and NLP.

Seeing unexpected results?

Semantic search returns results based on meaning, not exact keyword matching. If the results seem loosely related but not exact, that is normal behavior for vector search. The key is that relevant documents rank higher (lower distance scores) than unrelated ones.

Help me understand my ChromaDB search results

Your RAG pipeline is fully operational. Next up, you will build the LangGraph agent that uses this pipeline to answer questions intelligently.

Build the LangGraph Agentic Graph

Your RAG pipeline can retrieve relevant documents from ChromaDB. Now you need an intelligent agent that knows when to search the knowledge base and how to synthesize retrieved context into a coherent answer.

This is where LangGraph comes in. LangGraph lets you define AI workflows as state machines where each node performs one job and edges route between them. In this step, you will create tool functions that the agent can call, define the agent's state schema, and wire everything into a graph that loops between reasoning and tool execution.

In this step, get ready to:
  • Create LangChain tool functions that wrap your RAG retrieval logic.
  • Define the agent state schema and the main agent node.
  • Wire a StateGraph with conditional edges that route between the agent and its tools.
Create the tool functions

LangGraph agents interact with the outside world through tools. A tool is a Python function decorated with @tool that the LLM can choose to call when it needs information.

  • Create a new file called tools.py inside your backend folder.
  • Add the following code to define the search_knowledge_base tool:
from langchain_core.tools import tool
from rag import search_documents


@tool
def search_knowledge_base(query: str) -> str:
    """Search the knowledge base for relevant information about a topic.
    Use this tool when you need to find specific information from ingested documents."""
    results = search_documents(query, n_results=3)

    if not results:
        return "No relevant documents found in the knowledge base."

    context_parts = []
    for r in results:
        context_parts.append(f"[Source: {r['source']}]\n{r['content']}")

    return "\n\n---\n\n".join(context_parts)

What does this code do?

The @tool decorator from LangChain turns a regular function into something an LLM can call. The docstring is critical because the LLM reads it to decide when to use the tool.

This function calls your existing search_documents() function from rag.py and formats the results with source labels so the agent can cite them.

  • Add the format_citations tool below the first function in tools.py:
@tool
def format_citations(sources: str) -> str:
    """Format source citations for the final answer.
    Use this after retrieving information to properly cite sources."""
    source_list = [s.strip() for s in sources.split(",")]
    formatted = "\n".join([f"- {s}" for s in source_list])
    return f"\nSources:\n{formatted}"

What does this code do?

This second tool takes a comma-separated string of source names and formats them into a clean citation list. The agent calls this after retrieving information to attach proper references to its answer.

  • Save tools.py.

Seeing an import error?

Make sure tools.py is in the same backend folder as rag.py. The from rag import search_documents line expects both files to live side by side.

help me debug an import error in tools.py

Define the agent state and build the agent node

Every LangGraph workflow has a state object that flows between nodes. Your agent needs to track the conversation messages and a list of reasoning steps so the frontend can display what the agent is doing.

  • Create a new file called agent.py inside your backend folder.
  • Add the imports, LLM initialization, and state schema by pasting this code:
import os
from typing import Annotated, TypedDict
from dotenv import load_dotenv

from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langgraph.prebuilt import ToolNode, tools_condition
from langchain_groq import ChatGroq
from langchain_core.messages import SystemMessage

from tools import search_knowledge_base, format_citations

load_dotenv()

# Initialize the Groq LLM with tool-calling support
llm = ChatGroq(
    model="llama-3.3-70b-versatile",
    api_key=os.getenv("GROQ_API_KEY"),
    temperature=0.1
)

# Bind both tools so the LLM knows it can call them
tools = [search_knowledge_base, format_citations]
llm_with_tools = llm.bind_tools(tools)


# Define the state that flows between graph nodes
class AgentState(TypedDict):
    messages: Annotated[list, add_messages]
    agent_steps: list

What does this code do?

  • ChatGroq connects to Groq's API using the llama-3.3-70b-versatile model, which has reliable tool-calling support.
  • bind_tools() attaches your two tool functions to the LLM so it knows what tools are available and can choose to call them.
  • AgentState uses TypedDict to define the shape of data flowing through the graph. The add_messages annotation tells LangGraph to append new messages rather than overwrite them.

Why Groq with Llama 3.3 70B?

Groq's free tier gives you access to production-quality models at high speed (280 tokens per second). The llama-3.3-70b-versatile model specifically supports function calling, which is required for tool-calling agents. Older models have known issues with tool calling.

  • Below the AgentState class, add the system prompt and the agent node function:
SYSTEM_PROMPT = """You are an AI Research Assistant with access to a knowledge base.
Your job is to answer questions accurately using information from your knowledge base.

Guidelines:
1. Always search the knowledge base first when answering factual questions.
2. Cite your sources clearly in your response.
3. If the knowledge base does not contain relevant information, say so honestly.
4. Provide comprehensive, well-structured answers.
5. Use the format_citations tool to properly format your sources."""


def agent_node(state: AgentState):
    messages = state["messages"]
    system_msg = SystemMessage(content=SYSTEM_PROMPT)
    response = llm_with_tools.invoke([system_msg] + messages)

    # Track what the agent decided to do for the frontend
    steps = state.get("agent_steps", [])
    if response.tool_calls:
        tool_names = [tc["name"] for tc in response.tool_calls]
        steps.append({"type": "tool_call", "tools": tool_names})
    else:
        steps.append({"type": "response", "status": "complete"})

    return {"messages": [response], "agent_steps": steps}

What does this code do?

The SYSTEM_PROMPT instructs the LLM to always check the knowledge base first and cite its sources. This guides the agent's behavior every time it receives a question.

The agent_node function is the brain of the graph. It invokes the LLM with the conversation history, then checks whether the LLM decided to call a tool or produce a final answer. Either way, it logs the decision into agent_steps so your frontend can visualize the reasoning process.

  • Save agent.py.

Seeing a NameError for ChatGroq?

Double-check that your virtual environment is activated and that you installed all packages from requirements.txt. The langchain-groq package provides the ChatGroq class.

help me fix a NameError for ChatGroq

Wire the StateGraph with conditional edges

The graph needs to know how nodes connect. Your agent follows a loop: it reasons, optionally calls a tool, gets the tool result back, and reasons again until it has a final answer.

  • At the bottom of agent.py, add the graph-building function and compile the graph:
def build_agent_graph():
    graph_builder = StateGraph(AgentState)

    # Add nodes: one for the agent's reasoning, one for tool execution
    graph_builder.add_node("agent", agent_node)
    graph_builder.add_node("tools", ToolNode(tools=tools))

    # START -> agent (every query begins with the agent reasoning)
    graph_builder.add_edge(START, "agent")
    # Agent -> tools OR END (conditional based on whether tools were called)
    graph_builder.add_conditional_edges("agent", tools_condition)
    # Tools -> agent (after a tool runs, the agent reasons about the result)
    graph_builder.add_edge("tools", "agent")

    graph = graph_builder.compile()
    return graph


agent_graph = build_agent_graph()

What does this code do?

  • StateGraph(AgentState) creates a new graph that uses your state schema to pass data between nodes.
  • add_conditional_edges("agent", tools_condition) is the key routing logic. The prebuilt tools_condition function checks if the agent's response contains tool calls. If yes, it routes to the tools node. If no, it routes to END.
  • The tools -> agent edge creates the loop. After a tool executes, control returns to the agent so it can incorporate the result.

What is a StateGraph?

A StateGraph is LangGraph's core primitive. It models AI workflows as directed graphs where nodes are functions and edges define the flow. Unlike simple chains, a StateGraph supports loops, branching, and conditional routing. This makes it perfect for agents that need to reason, act, and iterate.

  • Save agent.py.
  • Verify the graph compiles by running this command from your backend folder:
python -c "from agent import agent_graph; print('Graph compiled:', agent_graph)"

You should see output confirming the graph object was created without errors.

Seeing a ModuleNotFoundError?

Make sure you run the command from inside the backend folder where both agent.py and tools.py live. Also confirm your .env file contains a valid GROQ_API_KEY value.

help me debug a ModuleNotFoundError in my agent graph

✔️ Awesome, I've got everything!

Great. Double check you have saved both tools.py and agent.py.

ⓧ I'd like to double check the full code

Here is the complete tools.py:

from langchain_core.tools import tool
from rag import search_documents


@tool
def search_knowledge_base(query: str) -> str:
    """Search the knowledge base for relevant information about a topic.
    Use this tool when you need to find specific information from ingested documents."""
    results = search_documents(query, n_results=3)

    if not results:
        return "No relevant documents found in the knowledge base."

    context_parts = []
    for r in results:
        context_parts.append(f"[Source: {r['source']}]\n{r['content']}")

    return "\n\n---\n\n".join(context_parts)


@tool
def format_citations(sources: str) -> str:
    """Format source citations for the final answer.
    Use this after retrieving information to properly cite sources."""
    source_list = [s.strip() for s in sources.split(",")]
    formatted = "\n".join([f"- {s}" for s in source_list])
    return f"\nSources:\n{formatted}"

Here is the complete agent.py:

import os
from typing import Annotated, TypedDict
from dotenv import load_dotenv

from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langgraph.prebuilt import ToolNode, tools_condition
from langchain_groq import ChatGroq
from langchain_core.messages import SystemMessage

from tools import search_knowledge_base, format_citations

load_dotenv()

llm = ChatGroq(
    model="llama-3.3-70b-versatile",
    api_key=os.getenv("GROQ_API_KEY"),
    temperature=0.1
)

tools = [search_knowledge_base, format_citations]
llm_with_tools = llm.bind_tools(tools)


class AgentState(TypedDict):
    messages: Annotated[list, add_messages]
    agent_steps: list


SYSTEM_PROMPT = """You are an AI Research Assistant with access to a knowledge base.
Your job is to answer questions accurately using information from your knowledge base.

Guidelines:
1. Always search the knowledge base first when answering factual questions.
2. Cite your sources clearly in your response.
3. If the knowledge base does not contain relevant information, say so honestly.
4. Provide comprehensive, well-structured answers.
5. Use the format_citations tool to properly format your sources."""


def agent_node(state: AgentState):
    messages = state["messages"]
    system_msg = SystemMessage(content=SYSTEM_PROMPT)
    response = llm_with_tools.invoke([system_msg] + messages)

    steps = state.get("agent_steps", [])
    if response.tool_calls:
        tool_names = [tc["name"] for tc in response.tool_calls]
        steps.append({"type": "tool_call", "tools": tool_names})
    else:
        steps.append({"type": "response", "status": "complete"})

    return {"messages": [response], "agent_steps": steps}


def build_agent_graph():
    graph_builder = StateGraph(AgentState)

    graph_builder.add_node("agent", agent_node)
    graph_builder.add_node("tools", ToolNode(tools=tools))

    graph_builder.add_edge(START, "agent")
    graph_builder.add_conditional_edges("agent", tools_condition)
    graph_builder.add_edge("tools", "agent")

    graph = graph_builder.compile()
    return graph


agent_graph = build_agent_graph()

Your agentic graph is compiled and ready to reason. Next up, you will wrap it in a FastAPI server so the frontend can send questions and receive answers over HTTP.

Create the FastAPI Backend

Your agent graph can reason, retrieve, and generate answers. But right now there's no way for the outside world to talk to it.

In this step, you'll wrap the LangGraph agent in a FastAPI web server. This creates HTTP endpoints that your frontend (and any other client) can call to send questions and ingest documents.

In this step, get ready to:
  • Build a FastAPI app with a /chat endpoint that invokes the LangGraph agent.
  • Add a /ingest endpoint for uploading documents into ChromaDB.
  • Test both endpoints with curl to confirm they work.
Set up the FastAPI app and CORS middleware

The FastAPI app is the entry point for all HTTP requests. You also need CORS middleware so your Next.js frontend (running on port 3000) can make requests to the backend (running on port 8000).

  • Create a new file called main.py inside your backend folder.
  • Add the imports, app instance, and CORS configuration by pasting this code:
import os
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from dotenv import load_dotenv
from langchain_core.messages import HumanMessage

from agent import agent_graph
from rag import ingest_document, search_documents

# Load environment variables from .env
load_dotenv()

# Create the FastAPI application
app = FastAPI(title="AI Research Agent API")

# Allow the Next.js frontend to call this backend
app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:3000"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

What does this code do?

The FastAPI() call creates your web server. The CORSMiddleware block tells the server to accept requests from http://localhost:3000 (where your Next.js frontend will run).

Without CORS, browsers block cross-origin requests for security. This middleware adds the right headers so your frontend and backend can communicate.

Next, define the request and response shapes so FastAPI knows what data to expect and return.

  • Add the Pydantic models below the CORS middleware section:
# Define the shape of incoming and outgoing data
class ChatRequest(BaseModel):
    message: str


class IngestRequest(BaseModel):
    title: str
    content: str


class ChatResponse(BaseModel):
    response: str
    agent_steps: list


class IngestResponse(BaseModel):
    message: str
    chunks_created: int

What are Pydantic models?

Pydantic models define the exact shape of data your API accepts and returns. FastAPI uses them to automatically validate incoming requests and generate API documentation.

For example, ChatRequest ensures every chat request includes a message string. If a client sends something else, FastAPI rejects it with a clear error.

Now add the core endpoint that connects your LangGraph agent to the outside world.

  • Add the /chat endpoint below the Pydantic models:
@app.post("/chat", response_model=ChatResponse)
async def chat(request: ChatRequest):
    try:
        # Build the initial state for the LangGraph agent
        initial_state = {
            "messages": [HumanMessage(content=request.message)],
            "agent_steps": [{"type": "start", "query": request.message}]
        }

        # Run the agent graph to completion
        result = agent_graph.invoke(initial_state)

        # Extract the final answer and reasoning steps
        final_message = result["messages"][-1].content
        steps = result.get("agent_steps", [])

        return ChatResponse(response=final_message, agent_steps=steps)
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

What does the /chat endpoint do?

  • It receives a user message, wraps it as a HumanMessage, and passes it as the initial state to agent_graph.invoke().
  • The agent runs its full reasoning loop (routing, tool calls, generation) and returns the final state.
  • The endpoint extracts the last AI message and the list of agent steps, then sends them back to the client.
  • Save main.py.

You won't test this yet. The next chunk adds the remaining endpoints, then you'll test everything together.

Add the /ingest and /health endpoints

The /ingest endpoint lets you add new documents to the knowledge base through the API. The /health endpoint gives you a quick way to check if the server is running.

  • Add both endpoints below the /chat endpoint in main.py:
@app.post("/ingest", response_model=IngestResponse)
async def ingest(request: IngestRequest):
    try:
        # Chunk and store the document in ChromaDB
        chunks_count = ingest_document(request.title, request.content)
        return IngestResponse(
            message=f"Successfully ingested '{request.title}'",
            chunks_created=chunks_count
        )
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))


@app.get("/health")
async def health():
    return {"status": "healthy", "agent": "ready"}

What do these endpoints do?

  • /ingest takes a document title and content, passes them to the ingest_document() function you built in the RAG step, and returns how many chunks were created.
  • /health returns a simple JSON response confirming the server is up. This is useful for quick smoke tests and for the frontend to check connectivity.
  • Save main.py.

✔️ Awesome, I've got everything!

Great. Double check you've saved main.py before moving on to testing.

ⓧ I'd like to double check the full code

import os
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from dotenv import load_dotenv
from langchain_core.messages import HumanMessage

from agent import agent_graph
from rag import ingest_document, search_documents

load_dotenv()

app = FastAPI(title="AI Research Agent API")

app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:3000"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)


class ChatRequest(BaseModel):
    message: str


class IngestRequest(BaseModel):
    title: str
    content: str


class ChatResponse(BaseModel):
    response: str
    agent_steps: list


class IngestResponse(BaseModel):
    message: str
    chunks_created: int


@app.post("/chat", response_model=ChatResponse)
async def chat(request: ChatRequest):
    try:
        initial_state = {
            "messages": [HumanMessage(content=request.message)],
            "agent_steps": [{"type": "start", "query": request.message}]
        }

        result = agent_graph.invoke(initial_state)

        final_message = result["messages"][-1].content
        steps = result.get("agent_steps", [])

        return ChatResponse(response=final_message, agent_steps=steps)
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))


@app.post("/ingest", response_model=IngestResponse)
async def ingest(request: IngestRequest):
    try:
        chunks_count = ingest_document(request.title, request.content)
        return IngestResponse(
            message=f"Successfully ingested '{request.title}'",
            chunks_created=chunks_count
        )
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))


@app.get("/health")
async def health():
    return {"status": "healthy", "agent": "ready"}
Test the endpoints with curl

Your API code is complete. Now start the server and verify each endpoint responds correctly.

  • Make sure your terminal is inside the backend folder with your virtual environment activated.
  • Start the FastAPI development server by running:
uvicorn main:app --reload

What does this command do?

uvicorn is the ASGI server that runs your FastAPI app. main:app tells it to look for the app object inside main.py. The --reload flag restarts the server automatically when you change your code.

You should see output indicating the server is running on http://127.0.0.1:8000.

Server won't start?

  • If you see ModuleNotFoundError, make sure your virtual environment is activated and all packages from requirements.txt are installed.
  • If you see a port conflict, another process is using port 8000. Stop it or add --port 8001 to the command.

Help me debug why my FastAPI server won't start.

Open a second terminal window (keep the server running in the first one). Test the health endpoint first.

  • Check the server is alive by running:
curl http://localhost:8000/health

You should see the response:

{"status":"healthy","agent":"ready"}
  • Test the /ingest endpoint by sending a sample document:
curl -X POST http://localhost:8000/ingest -H "Content-Type: application/json" -d '{"title": "AI Overview", "content": "Artificial intelligence is the simulation of human intelligence by machines. Machine learning is a subset of AI that learns from data without explicit programming."}'

You should see a response confirming the document was ingested with a chunk count.

  • Test the /chat endpoint by asking the agent a question about the document you just ingested:
curl -X POST http://localhost:8000/chat -H "Content-Type: application/json" -d '{"message": "What is machine learning?"}'

The response includes a response field with the agent's answer and an agent_steps array showing the reasoning process (routing, tool calls, final response).

Automatic API documentation

FastAPI generates interactive docs at http://localhost:8000/docs. Open this URL in your browser to explore and test your endpoints with a visual interface.

Getting a 500 error on /chat?

  • Check that your .env file contains a valid GROQ_API_KEY. The agent needs this to call the Groq LLM.
  • If you see rate limit errors, wait a moment and retry. The Groq free tier has request limits.
  • Make sure agent.py, rag.py, and tools.py are all saved in the same backend folder as main.py.

Help me debug a 500 error from my /chat endpoint.

Your backend API is live and serving the LangGraph agent over HTTP. Next up, you'll build the Next.js frontend that connects to these endpoints and displays the agent's reasoning in real time.

Build the Next.js Chat Frontend

Your backend API is running and ready to handle chat requests and document ingestion. Now you need a way for users to actually interact with the agent.

In this step, you will build a Next.js chat interface that sends messages to your FastAPI backend and displays the agent's reasoning process in real-time. The UI includes a sidebar showing each step the agent takes (routing, tool calls, and final response).

In this step, get ready to:
  • Create the chat interface component with message history and input form.
  • Connect the frontend to the FastAPI /chat and /ingest endpoints.
  • Style the UI with Tailwind CSS and add agent state badges showing the reasoning pipeline.
Create the chat component with state and submission logic

The chat interface needs to track several pieces of state: the conversation messages, the current input, whether the agent is thinking, and the steps the agent takes while processing a query.

  • In VS Code, open the frontend/pages folder inside your project.
  • Create a new file called index.js inside frontend/pages/.
  • Add the imports, state declarations, and chat submission handler by pasting this code:
import { useState } from "react";
import Head from "next/head";

export default function Home() {
  // Track conversation messages, input field, loading state, and agent reasoning steps
  const [messages, setMessages] = useState([]);
  const [input, setInput] = useState("");
  const [loading, setLoading] = useState(false);
  const [agentSteps, setAgentSteps] = useState([]);
  const [showIngest, setShowIngest] = useState(false);
  const [docTitle, setDocTitle] = useState("");
  const [docContent, setDocContent] = useState("");

  // Send the user's message to the backend /chat endpoint
  async function handleSubmit(e) {
    e.preventDefault();
    if (!input.trim()) return;

    const userMessage = { role: "user", content: input };
    setMessages((prev) => [...prev, userMessage]);
    setInput("");
    setLoading(true);
    setAgentSteps([{ type: "start", query: input }]);

    try {
      const res = await fetch("http://localhost:8000/chat", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ message: input }),
      });

      const data = await res.json();

      setMessages((prev) => [
        ...prev,
        { role: "assistant", content: data.response },
      ]);
      setAgentSteps(data.agent_steps || []);
    } catch (error) {
      setMessages((prev) => [
        ...prev,
        { role: "assistant", content: "Error: Could not reach the agent." },
      ]);
    }

    setLoading(false);
  }

What does this code do?

The seven useState hooks track everything the UI needs: the chat history, current input text, whether the agent is processing, each step the agent takes, and the document ingestion form fields.

The handleSubmit function POSTs the user's message to http://localhost:8000/chat. It immediately shows the user's message in the chat, then appends the agent's response once it arrives. The agent_steps from the response update the sidebar to show the agent's reasoning pipeline.

Add document ingestion and agent step helpers

The frontend also needs to let users add documents to the knowledge base and display color-coded badges showing what the agent is doing at each step.

  • In frontend/pages/index.js, add the following code directly below the closing brace of the handleSubmit function:
  // Send document content to the backend /ingest endpoint
  async function handleIngest(e) {
    e.preventDefault();
    if (!docTitle.trim() || !docContent.trim()) return;

    try {
      const res = await fetch("http://localhost:8000/ingest", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ title: docTitle, content: docContent }),
      });

      const data = await res.json();
      alert(data.message + " (" + data.chunks_created + " chunks)");
      setDocTitle("");
      setDocContent("");
      setShowIngest(false);
    } catch (error) {
      alert("Error ingesting document");
    }
  }

  // Return a Tailwind color class based on the agent step type
  function getStepBadge(step) {
    switch (step.type) {
      case "start":
        return "bg-blue-100 text-blue-800";
      case "tool_call":
        return "bg-yellow-100 text-yellow-800";
      case "response":
        return "bg-green-100 text-green-800";
      default:
        return "bg-gray-100 text-gray-800";
    }
  }

  // Return a human-readable label for each agent step
  function getStepLabel(step) {
    switch (step.type) {
      case "start":
        return "Routing Query";
      case "tool_call":
        return "Calling: " + (step.tools || []).join(", ");
      case "response":
        return "Answer Generated";
      default:
        return step.type;
    }
  }

What do these functions do?

  • handleIngest POSTs a document title and content to the /ingest endpoint, which chunks and stores it in ChromaDB. On success, it shows an alert with the number of chunks created.
  • getStepBadge maps each agent step type to a Tailwind color class: blue for routing, yellow for tool calls, green for the final response.
  • getStepLabel converts raw step types into human-readable labels like "Routing Query" and "Answer Generated" so you can see exactly what the agent is doing.
Build the UI layout with Tailwind CSS and agent state badges

The UI has three main areas: a header with the document ingestion toggle, the central chat area with message bubbles and input form, and a right sidebar showing the agent's step-by-step reasoning.

  • In frontend/pages/index.js, add the following JSX return statement directly below the getStepLabel function (still inside the Home component):
  return (
    <>
      <Head>
        <title>AI Research Agent</title>
      </Head>
      <div className="min-h-screen bg-gray-50 flex flex-col">
        {/* Header with document ingestion toggle */}
        <header className="bg-white border-b px-6 py-4 flex items-center justify-between">
          <h1 className="text-xl font-bold text-gray-900">
            AI Research Agent
          </h1>
          <button
            onClick={() => setShowIngest(!showIngest)}
            className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 text-sm"
          >
            {showIngest ? "Close" : "Add Document"}
          </button>
        </header>

        {/* Document ingestion form - only visible when toggled */}
        {showIngest && (
          <div className="bg-white border-b px-6 py-4">
            <form onSubmit={handleIngest} className="space-y-3 max-w-2xl">
              <input
                type="text"
                value={docTitle}
                onChange={(e) => setDocTitle(e.target.value)}
                placeholder="Document title"
                className="w-full px-3 py-2 border rounded-lg text-sm"
              />
              <textarea
                value={docContent}
                onChange={(e) => setDocContent(e.target.value)}
                placeholder="Paste document content here..."
                rows={4}
                className="w-full px-3 py-2 border rounded-lg text-sm"
              />
              <button
                type="submit"
                className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 text-sm"
              >
                Ingest Document
              </button>
            </form>
          </div>
        )}

        <div className="flex-1 flex">
          {/* Main chat area */}
          <div className="flex-1 flex flex-col max-w-4xl mx-auto w-full">
            <div className="flex-1 overflow-y-auto p-6 space-y-4">
              {/* Welcome message when no conversation exists */}
              {messages.length === 0 && (
                <div className="text-center text-gray-500 mt-20">
                  <p className="text-lg font-medium">Welcome to AI Research Agent</p>
                  <p className="text-sm mt-2">
                    Add documents to the knowledge base, then ask questions.
                  </p>
                </div>
              )}
              {/* Render each message as a chat bubble */}
              {messages.map((msg, i) => (
                <div
                  key={i}
                  className={
                    msg.role === "user"
                      ? "flex justify-end"
                      : "flex justify-start"
                  }
                >
                  <div
                    className={
                      msg.role === "user"
                        ? "bg-indigo-600 text-white rounded-lg px-4 py-2 max-w-lg"
                        : "bg-white border rounded-lg px-4 py-2 max-w-lg shadow-sm"
                    }
                  >
                    <p className="text-sm whitespace-pre-wrap">{msg.content}</p>
                  </div>
                </div>
              ))}
              {/* Loading indicator while agent processes */}
              {loading && (
                <div className="flex justify-start">
                  <div className="bg-white border rounded-lg px-4 py-2 shadow-sm">
                    <p className="text-sm text-gray-500 animate-pulse">
                      Agent is thinking...
                    </p>
                  </div>
                </div>
              )}
            </div>

            {/* Chat input form */}
            <form
              onSubmit={handleSubmit}
              className="border-t bg-white px-6 py-4"
            >
              <div className="flex gap-3">
                <input
                  type="text"
                  value={input}
                  onChange={(e) => setInput(e.target.value)}
                  placeholder="Ask the research agent a question..."
                  className="flex-1 px-4 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
                  disabled={loading}
                />
                <button
                  type="submit"
                  disabled={loading}
                  className="px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 text-sm disabled:opacity-50"
                >
                  Send
                </button>
              </div>
            </form>
          </div>

          {/* Agent Steps sidebar - shows reasoning pipeline */}
          <div className="w-72 border-l bg-white p-4 hidden lg:block overflow-y-auto">
            <h2 className="text-sm font-semibold text-gray-700 mb-3">
              Agent Steps
            </h2>
            {agentSteps.length === 0 ? (
              <p className="text-xs text-gray-400">
                Ask a question to see agent reasoning
              </p>
            ) : (
              <div className="space-y-2">
                {agentSteps.map((step, i) => (
                  <div
                    key={i}
                    className={`px-3 py-2 rounded-md text-xs font-medium ${getStepBadge(step)}`}
                  >
                    {getStepLabel(step)}
                  </div>
                ))}
              </div>
            )}
          </div>
        </div>
      </div>
    </>
  );
}

What does this JSX render?

  • The header contains the app title and an "Add Document" button that toggles the ingestion form.
  • The ingestion panel (conditionally rendered) has a title input, a textarea for content, and a submit button.
  • The chat area renders messages as bubbles. User messages appear right-aligned in indigo. Agent responses are left-aligned with a white background.
  • The right sidebar maps over agentSteps and renders each one as a color-coded badge using getStepBadge and getStepLabel. This gives you a live view of the agent's reasoning process.
  • Save frontend/pages/index.js.
  • Start the frontend dev server by running this command from the frontend/ directory:
npm run dev
  • Open http://localhost:3000 in your browser. You should see a white header with "AI Research Agent" on the left and an indigo "Add Document" button on the right. The center shows the welcome message "Welcome to AI Research Agent" and a chat input at the bottom.

Seeing a blank page or error?

  • Check your terminal for a syntax error. A missing closing brace or misplaced comma in JSX will crash the page.
  • Make sure the file is saved at frontend/pages/index.js (not frontend/src/pages/index.js).
  • Confirm that styles/globals.css is imported in your _app.js file. Without it, Tailwind classes will not apply.

Why isn't my Next.js chat page rendering?

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

Here is the complete frontend/pages/index.js file for comparison:

import { useState } from "react";
import Head from "next/head";

export default function Home() {
  const [messages, setMessages] = useState([]);
  const [input, setInput] = useState("");
  const [loading, setLoading] = useState(false);
  const [agentSteps, setAgentSteps] = useState([]);
  const [showIngest, setShowIngest] = useState(false);
  const [docTitle, setDocTitle] = useState("");
  const [docContent, setDocContent] = useState("");

  async function handleSubmit(e) {
    e.preventDefault();
    if (!input.trim()) return;

    const userMessage = { role: "user", content: input };
    setMessages((prev) => [...prev, userMessage]);
    setInput("");
    setLoading(true);
    setAgentSteps([{ type: "start", query: input }]);

    try {
      const res = await fetch("http://localhost:8000/chat", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ message: input }),
      });

      const data = await res.json();

      setMessages((prev) => [
        ...prev,
        { role: "assistant", content: data.response },
      ]);
      setAgentSteps(data.agent_steps || []);
    } catch (error) {
      setMessages((prev) => [
        ...prev,
        { role: "assistant", content: "Error: Could not reach the agent." },
      ]);
    }

    setLoading(false);
  }

  async function handleIngest(e) {
    e.preventDefault();
    if (!docTitle.trim() || !docContent.trim()) return;

    try {
      const res = await fetch("http://localhost:8000/ingest", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ title: docTitle, content: docContent }),
      });

      const data = await res.json();
      alert(data.message + " (" + data.chunks_created + " chunks)");
      setDocTitle("");
      setDocContent("");
      setShowIngest(false);
    } catch (error) {
      alert("Error ingesting document");
    }
  }

  function getStepBadge(step) {
    switch (step.type) {
      case "start":
        return "bg-blue-100 text-blue-800";
      case "tool_call":
        return "bg-yellow-100 text-yellow-800";
      case "response":
        return "bg-green-100 text-green-800";
      default:
        return "bg-gray-100 text-gray-800";
    }
  }

  function getStepLabel(step) {
    switch (step.type) {
      case "start":
        return "Routing Query";
      case "tool_call":
        return "Calling: " + (step.tools || []).join(", ");
      case "response":
        return "Answer Generated";
      default:
        return step.type;
    }
  }

  return (
    <>
      <Head>
        <title>AI Research Agent</title>
      </Head>
      <div className="min-h-screen bg-gray-50 flex flex-col">
        <header className="bg-white border-b px-6 py-4 flex items-center justify-between">
          <h1 className="text-xl font-bold text-gray-900">
            AI Research Agent
          </h1>
          <button
            onClick={() => setShowIngest(!showIngest)}
            className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 text-sm"
          >
            {showIngest ? "Close" : "Add Document"}
          </button>
        </header>

        {showIngest && (
          <div className="bg-white border-b px-6 py-4">
            <form onSubmit={handleIngest} className="space-y-3 max-w-2xl">
              <input
                type="text"
                value={docTitle}
                onChange={(e) => setDocTitle(e.target.value)}
                placeholder="Document title"
                className="w-full px-3 py-2 border rounded-lg text-sm"
              />
              <textarea
                value={docContent}
                onChange={(e) => setDocContent(e.target.value)}
                placeholder="Paste document content here..."
                rows={4}
                className="w-full px-3 py-2 border rounded-lg text-sm"
              />
              <button
                type="submit"
                className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 text-sm"
              >
                Ingest Document
              </button>
            </form>
          </div>
        )}

        <div className="flex-1 flex">
          <div className="flex-1 flex flex-col max-w-4xl mx-auto w-full">
            <div className="flex-1 overflow-y-auto p-6 space-y-4">
              {messages.length === 0 && (
                <div className="text-center text-gray-500 mt-20">
                  <p className="text-lg font-medium">Welcome to AI Research Agent</p>
                  <p className="text-sm mt-2">
                    Add documents to the knowledge base, then ask questions.
                  </p>
                </div>
              )}
              {messages.map((msg, i) => (
                <div
                  key={i}
                  className={
                    msg.role === "user"
                      ? "flex justify-end"
                      : "flex justify-start"
                  }
                >
                  <div
                    className={
                      msg.role === "user"
                        ? "bg-indigo-600 text-white rounded-lg px-4 py-2 max-w-lg"
                        : "bg-white border rounded-lg px-4 py-2 max-w-lg shadow-sm"
                    }
                  >
                    <p className="text-sm whitespace-pre-wrap">{msg.content}</p>
                  </div>
                </div>
              ))}
              {loading && (
                <div className="flex justify-start">
                  <div className="bg-white border rounded-lg px-4 py-2 shadow-sm">
                    <p className="text-sm text-gray-500 animate-pulse">
                      Agent is thinking...
                    </p>
                  </div>
                </div>
              )}
            </div>

            <form
              onSubmit={handleSubmit}
              className="border-t bg-white px-6 py-4"
            >
              <div className="flex gap-3">
                <input
                  type="text"
                  value={input}
                  onChange={(e) => setInput(e.target.value)}
                  placeholder="Ask the research agent a question..."
                  className="flex-1 px-4 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
                  disabled={loading}
                />
                <button
                  type="submit"
                  disabled={loading}
                  className="px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 text-sm disabled:opacity-50"
                >
                  Send
                </button>
              </div>
            </form>
          </div>

          <div className="w-72 border-l bg-white p-4 hidden lg:block overflow-y-auto">
            <h2 className="text-sm font-semibold text-gray-700 mb-3">
              Agent Steps
            </h2>
            {agentSteps.length === 0 ? (
              <p className="text-xs text-gray-400">
                Ask a question to see agent reasoning
              </p>
            ) : (
              <div className="space-y-2">
                {agentSteps.map((step, i) => (
                  <div
                    key={i}
                    className={`px-3 py-2 rounded-md text-xs font-medium ${getStepBadge(step)}`}
                  >
                    {getStepLabel(step)}
                  </div>
                ))}
              </div>
            )}
          </div>
        </div>
      </div>
    </>
  );
}

Your frontend is live and styled. Next up, you will connect both servers and test the full end-to-end flow: ingesting a document, asking the agent a question, and watching the reasoning pipeline unfold in real-time.

Connect and Test End-to-End

You have a fully built backend with a LangGraph agent pipeline and a Next.js frontend ready to visualize agent reasoning.

Now it is time to run both servers together and watch the full system in action. You will ingest a document, ask questions, and observe how the agent routes between direct responses and RAG-powered answers.

In this step, get ready to:
  • Start both backend and frontend servers simultaneously.
  • Test the full flow by ingesting a document and asking questions.
  • Verify the agent correctly routes between direct answers and RAG-powered responses.
Start both servers simultaneously

Your application has two parts that need to run at the same time. The FastAPI backend serves the agent on port 8000, and the Next.js frontend runs the chat UI on port 3000.

  • Open two separate terminal windows. You will use one for the backend and one for the frontend.
  • In the first terminal, navigate to the backend folder and activate your virtual environment, then start the backend server by running:
cd ~/Desktop/ai-research-agent/backend
source venv/bin/activate
uvicorn main:app --reload

What does this command do?

uvicorn main:app --reload starts the FastAPI application defined in main.py. The --reload flag auto-restarts the server when you save file changes.

The server defaults to port 8000, which matches the URL your frontend uses to call the API.

  • You should see output showing the uvicorn server running at http://127.0.0.1:8000.

Windows virtual environment

On Windows, activate your virtual environment with venv\Scripts\activate instead of source venv/bin/activate.

  • In the second terminal, navigate to the frontend folder and start the Next.js development server by running:
cd ~/Desktop/ai-research-agent/frontend
npm run dev
  • You should see output confirming the Next.js server is running at http://localhost:3000.
  • Verify the backend is healthy by opening a new browser tab and navigating to http://localhost:8000/health.
  • You should see a JSON response: {"status": "healthy", "agent": "ready"}.

Backend not starting?

  • If you see a ModuleNotFoundError, make sure your virtual environment is activated (you should see (venv) in your terminal prompt).
  • If port 8000 is already in use, close any other server running on that port before retrying.
  • Check that your .env file has a valid GROQ_API_KEY value (it should start with gsk_).

Help me debug my backend server startup error

Test the full flow with a document and questions

With both servers running, you can now test the complete pipeline. You will add a document to the knowledge base and then ask the agent questions about it.

  • Open your browser and navigate to http://localhost:3000.
  • You should see the AI Research Agent interface with a welcome message and an Add Document button in the header.
  • Click the Add Document button in the top-right corner.
  • In the Document title field, enter Solar System Facts.
  • In the content text area, paste the following sample text:
The Solar System consists of the Sun and everything that orbits it. There are eight planets: Mercury, Venus, Earth, Mars, Jupiter, Saturn, Uranus, and Neptune. Jupiter is the largest planet with a mass more than twice that of all other planets combined. Mars is known as the Red Planet due to iron oxide on its surface. The asteroid belt between Mars and Jupiter contains millions of rocky objects.
  • Click the Ingest Document button.
  • You should see a browser alert confirming the ingestion with a message like "Successfully ingested 'Solar System Facts' (1 chunks)".

What just happened?

Your frontend sent the document text to the /ingest endpoint on your backend. The backend chunked the text, generated embeddings using ChromaDB's default model, and stored the vectors in your local chroma_db folder.

  • Now type a question in the chat input at the bottom of the page: What is the largest planet in our solar system?
  • Press **Enter** or click Send.

Watch the right sidebar labeled Agent Steps. You should see the agent's reasoning pipeline appear as colored badges.

  • A blue badge labeled "Routing Query" appears first, showing the agent received your question.
  • A yellow badge labeled "Calling: search_knowledge_base" appears next, showing the agent is retrieving from ChromaDB.
  • A green badge labeled "Answer Generated" appears last, confirming the agent synthesized a response.

The agent's response should mention Jupiter and cite the Solar System Facts document as its source.

Seeing "Error: Could not reach the agent"?

  • Confirm your backend terminal still shows the uvicorn server running. If it crashed, check the error message and restart it.
  • Make sure both servers are on the correct ports: backend on 8000 and frontend on 3000. If your frontend is on a different port, the CORS policy will block requests.
  • If you see a 429 rate limit error in the backend terminal, wait 30 seconds and try again. Groq's free tier has rate limits.

Help me debug the connection between my frontend and backend

Verify the agent routes between direct answers and RAG responses

The agent's router decides whether to search the knowledge base or respond directly. You can observe this difference by asking two types of questions.

  • In the chat input, ask a general knowledge question that is NOT in your document: What is 2 + 2?
  • Press Send and watch the Agent Steps sidebar.

For a simple question like this, the agent may still call the knowledge base tool (because the system prompt instructs it to always search first). The key difference is in the response quality. When the knowledge base has relevant information, the agent cites sources. When it does not, the agent acknowledges this honestly.

  • Now ask a question directly about your ingested document: Why is Mars called the Red Planet?
  • Compare the two responses. The Mars question should include a citation referencing "Solar System Facts" as its source.

Why does the sidebar matter?

The Agent Steps sidebar shows you the internal state machine transitions of your LangGraph agent. In a production application, this kind of observability is critical for debugging and understanding why an agent made certain decisions.

Each badge represents a node in your StateGraph firing: the agent node deciding what to do, the tools node executing retrieval, and the agent node again generating the final answer.

  • Try ingesting a second document by clicking Add Document again. Use any topic you like (a recipe, a technical concept, meeting notes).
  • Ask a question that spans both documents to see how the agent retrieves from multiple sources.

Secret mission

Add a Self-Correction Reviewer Node

Your agent generates answers, but how do you know they're good enough? In production AI systems, a separate reviewer evaluates output quality and triggers re-retrieval when answers fall short. Add a self-correction loop that makes your agent refine its own work.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally with a free-tier Groq API, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Local project files (your backend/ and frontend/ directories with all source code, the Python virtual environment, and node_modules).
  • ChromaDB local database (the chroma_db/ folder inside backend/).
  • Groq API key (stored in backend/.env, free tier with no charges).

Keep everything running

No action needed. Choose this if you are still actively building or want to keep experimenting with your AI Research Agent.

  • Your project files remain on your Desktop, ready to use.
  • Your Python virtual environment stays active for future runs.
  • Your Groq API key remains in backend/.env for continued use.
  • The ChromaDB database in chroma_db/ retains all ingested documents.

Pause - I'll come back to this later

Shut down running processes to free up system memory. Your files stay intact for when you return.

  • Stop the FastAPI backend server by pressing Ctrl+C in the terminal where uvicorn is running.
  • Stop the Next.js frontend server by pressing Ctrl+C in the terminal where npm run dev is running.
  • Deactivate your Python virtual environment by running:
deactivate

When you come back, reactivate the virtual environment, start both servers, and you are good to go.

Delete - I don't want to use this again

Remove all project files and start fresh. This is permanent.

  • Stop both servers by pressing Ctrl+C in each terminal.
  • Deactivate your Python virtual environment by running:
deactivate
  • Navigate to your Desktop and delete the project folder by running:

macOS

cd ~/Desktop
rm -rf backend frontend

Windows

cd ~\Desktop
rmdir /s /q backend
rmdir /s /q frontend
  • Your Groq API key still exists in your Groq account. Since the free tier has no charges, you can leave it as-is or delete it from the API Keys page at console.groq.com.

Nice Work!

Nice Work!

You just built a production-grade AI Research Agent from scratch. This full-stack application combines multi-agent orchestration, vector search, and a modern web frontend into a single cohesive system.

You've learned how to:

  • Build a multi-agent LangGraph pipeline with conditional routing and tool calling to orchestrate intelligent AI workflows.
  • Implement a full RAG pipeline with ChromaDB document ingestion, chunking, and semantic retrieval.
  • Create a FastAPI backend and a Next.js frontend that visualizes agent reasoning in real-time.
  • Secret Mission: Add a self-correction reviewer node that evaluates answer quality and triggers re-retrieval loops for insufficient responses.

Ready to quiz yourself?