An AI-powered research assistant built with Next.js, FastAPI, LangGraph, MCP, OpenRouter, and Server-Sent Events (SSE).
The project explores how to build a modular agentic research system capable of:
- Determining whether a user request actually requires research
- Handling casual conversation directly
- Breaking complex questions into focused research tasks
- Performing web research through MCP tools
- Tracking research progress in real time
- Synthesizing collected findings into a final answer
- Streaming the agent's execution back to the frontend
Work in progress — v1
This project is primarily an exploration of AI agent architecture, LangGraph workflows, MCP tool integration, intent-based routing, modular agent design, and production-oriented streaming systems.
The research workflow is built using LangGraph and follows a conditional agentic pipeline.
flowchart TD
A[User Query] --> B[Router]
B -->|needs_research = false| C[Casual]
C -->|Final Answer| I[SSE Stream]
B -->|needs_research = true| D[Planner]
D -->|Research Plan| E[Researcher]
E --> F[MCP Client]
F --> G[MCP Server]
G --> H[Web Search]
H --> G
G --> F
F --> E
E -->|Research Results| J[Synthesizer]
J -->|Final Answer| I
D -.->|Plan Events| I
E -.->|Progress Events| I
I --> K[Next.js Frontend]
User Query
│
▼
┌─────────────┐
│ Router │
└──────┬──────┘
│
┌─────────┴─────────┐
│ │
▼ ▼
needs_research needs_research
= false = true
│ │
▼ ▼
┌──────────┐ ┌─────────────┐
│ Casual │ │ Planner │
└────┬─────┘ └──────┬──────┘
│ │
│ ▼
│ ┌─────────────┐
│ │ Researcher │
│ └──────┬──────┘
│ │
│ │ MCP
│ ▼
│ ┌─────────────┐
│ │ MCP Client │
│ └──────┬──────┘
│ │
│ ▼
│ ┌─────────────┐
│ │ MCP Server │
│ └──────┬──────┘
│ │
│ ▼
│ ┌─────────────┐
│ │ Web Search │
│ └──────┬──────┘
│ │
│ │ Research Results
│ ▼
│ ┌─────────────┐
│ │ Synthesizer │
│ └──────┬──────┘
│ │
└──────────┬─────────┘
│
▼
Final Answer
│
▼
SSE Stream
│
▼
Next.js Frontend
The agent is divided into independent responsibilities rather than using a single monolithic LLM call.
The Router is the first node in the LangGraph workflow.
Its responsibility is to determine whether the user's message requires web research.
For example:
User:
"Hey, what's up?"
Router:
needs_research = false
│
▼
Casual
While a research-oriented request follows a different path:
User:
"What are the latest developments in AI agents?"
Router:
needs_research = true
│
▼
Planner
The router uses the LLM to classify the user's intent and writes the result into the LangGraph state as:
{
"needs_research": True
}or:
{
"needs_research": False
}This decision is then used by LangGraph's conditional routing to determine which node executes next.
The Router prevents unnecessary research operations for conversations that do not require external information.
The Casual node handles normal conversation when the Router determines that research is unnecessary.
Examples include:
"Hey, what's up?"
"How are you?"
"Tell me a joke."
"Thanks!"
The Casual node directly generates a response using the configured LLM.
User Query
│
▼
Router
│
│ needs_research = false
▼
Casual
│
▼
Final Answer
The casual response is streamed through the same final_answer SSE event used by the research workflow.
This means the frontend does not need a separate response mechanism for casual conversation.
For queries that require research, the Planner receives the user's question and breaks it into several concrete research tasks.
For example:
User:
"Compare Python and TypeScript for building AI-powered web applications."
Planner:
├── Investigate AI/ML libraries and frameworks
├── Compare ecosystem maturity
├── Compare performance characteristics
├── Compare web-development ecosystems
└── Evaluate developer experience
The Planner uses structured output with a Pydantic schema:
class ResearchPlan(BaseModel):
tasks: list[str]The generated tasks are stored in the LangGraph state and passed to the Researcher.
The Planner is responsible only for decomposing the problem.
It does not:
- Perform web searches
- Collect research
- Generate the final answer
This separation keeps planning independent from execution and synthesis.
The Researcher receives the generated plan and executes each research task.
For every task, it:
- Retrieves the available MCP tools.
- Selects the MCP
searchtool. - Sends the research task to the search tool.
- Collects the returned research.
- Emits progress events while the task is running.
- Continues until all planned tasks have been researched.
Example progress:
⟳ Investigate AI/ML libraries and frameworks
✓ Investigate AI/ML libraries and frameworks
⟳ Compare ecosystem maturity
✓ Compare ecosystem maturity
⟳ Compare performance characteristics
✓ Compare performance characteristics
Once all tasks are complete, the collected research is passed to the Synthesizer.
The Researcher does not generate the final response. Its responsibility is research execution and evidence collection.
Web search is separated from the main agent through an MCP server.
┌────────────────────┐
│ LangGraph │
│ Researcher │
└─────────┬──────────┘
│
▼
┌────────────────────┐
│ MCP Client │
└─────────┬──────────┘
│
▼
┌────────────────────┐
│ MCP Server │
│ │
│ search() │
└─────────┬──────────┘
│
▼
┌────────────────────┐
│ Web Search │
│ Tavily │
└────────────────────┘
The MCP server currently exposes a search tool backed by Tavily.
The main research agent communicates with the MCP server through the LangChain MCP adapters.
This separation keeps external capabilities modular.
Additional tools can be exposed through MCP without tightly coupling them to the core LangGraph workflow.
After all research tasks are completed, the collected research is passed to a dedicated Synthesizer node.
Its responsibility is to:
- Analyze the collected research
- Combine information from multiple searches
- Use the research as the source of truth
- Produce a coherent response
- Avoid inventing unsupported facts
- Indicate when the collected research is insufficient
The workflow therefore separates:
Planning
│
▼
Research Execution
│
▼
Evidence Collection
│
▼
Synthesis
│
▼
Final Answer
This separation makes the system easier to reason about and extend.
The current LangGraph workflow uses conditional routing after the Router node.
┌─────────────┐
│ Router │
└──────┬──────┘
│
┌───────┴────────┐
│ │
False True
│ │
▼ ▼
┌─────────┐ ┌──────────┐
│ Casual │ │ Planner │
└────┬────┘ └────┬─────┘
│ │
│ ▼
│ ┌───────────┐
│ │ Researcher│
│ └─────┬─────┘
│ │
│ ▼
│ ┌───────────┐
│ │Synthesizer│
│ └─────┬─────┘
│ │
└───────┬────────┘
▼
Final Answer
This avoids the earlier approach where every message entered the Planner.
The agent now has an explicit decision layer before research execution.
The workflow shares information between nodes through a typed ResearchState.
class ResearchState(TypedDict):
query: str
needs_research: bool
plan: list[str]
research: list[str]
answer: strThe state represents the current execution context of the agent.
query
│
▼
Router
│
└── needs_research
│
▼
Planner
│
└── plan
│
▼
Researcher
│
└── research
│
▼
Synthesizer
│
└── answer
This shared state allows individual nodes to remain focused on a single responsibility.
The backend uses Server-Sent Events (SSE) to stream agent activity to the frontend.
LangGraph produces different event types during execution.
Router
│
└── routing decision
Planner
│
├── planning
└── plan
Researcher
│
├── task_started
└── task_completed
Synthesizer
│
├── synthesis_started
└── final_answer
Router
│
└── routing decision
Casual
│
└── final_answer
Both paths ultimately use the same final_answer SSE event.
This allows the existing frontend response handler to display both researched and casual responses without requiring a separate frontend response pipeline.
The Researcher uses LangGraph custom stream events to communicate execution progress.
Example:
Research progress
Research task: Investigate AI agent frameworks
Researching: Investigate AI agent frameworks
Completed: Investigate AI agent frameworks
Research task: Compare framework capabilities
Researching: Compare framework capabilities
Completed: Compare framework capabilities
Synthesizing...
The backend converts these internal LangGraph events into SSE events that can be consumed by the Next.js frontend.
This provides real-time visibility into the agent's execution instead of forcing the user to wait for the complete workflow.
LangGraph
│
├── updates
│ ├── router
│ ├── planner
│ └── synthesizer
│
└── custom
├── task_started
└── task_completed
│
▼
FastAPI SSE Endpoint
│
▼
EventSourceResponse
│
▼
Next.js Frontend
The frontend consumes the SSE response using the browser's streaming ReadableStream API.
- Next.js
- TypeScript
- Tailwind CSS
- App Router
- Fetch API
- ReadableStream
- Python
- FastAPI
- LangChain
- LangGraph
- Pydantic
- OpenRouter
- OpenAI-compatible API
- MCP
- Tavily
- LangChain MCP Adapters
- Server-Sent Events (SSE)
sse-starlette- LangGraph streaming
research-agent/
│
├── backend/
│ └── src/
│ └── backend/
│ ├── agent/
│ │ ├── agent.py
│ │ ├── router.py
│ │ ├── casual.py
│ │ ├── planner.py
│ │ ├── researcher.py
│ │ ├── state.py
│ │ └── synthesizer.py
│ │
│ ├── api/
│ │ └── routes/
│ │ └── research.py
│ │
│ ├── core/
│ │ └── config.py
│ │
│ ├── mcp/
│ │ └── client.py
│ │
│ ├── schemas/
│ │ └── research.py
│ │
│ └── tools/
│
├── client/
│ ├── app/
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ └── ...
│ │
│ ├── components/
│ │ └── ...
│ │
│ ├── public/
│ │ └── ...
│ │
│ ├── package.json
│ └── ...
│
├── mcp-server/
│ └── mcp_server/
│ ├── __init__.py
│ ├── config.py
│ ├── server.py
│ │
│ └── tools/
│ ├── __init__.py
│ └── search.py
│
└── README.md
The backend contains the core agent architecture and API.
agent/— LangGraph workflow and agent nodesagent/agent.py— LangGraph graph construction and conditional routingagent/router.py— LLM-based research/casual routingagent/casual.py— Casual conversation handleragent/planner.py— Research task decompositionagent/researcher.py— MCP-based research executionagent/synthesizer.py— Research synthesis and final answer generationagent/state.py— Shared LangGraph state definitionapi/routes/— FastAPI API endpointscore/— Application configurationmcp/— MCP client communicationschemas/— Pydantic schemastools/— Backend-side tools and utilities
The client is a Next.js App Router application responsible for:
- Providing the research interface
- Sending user queries to the FastAPI backend
- Consuming the SSE stream
- Displaying research progress
- Displaying final answers
The frontend uses the same final_answer event for both researched and casual responses.
The MCP server provides external tools to the research agent.
server.py— MCP server initialization and tool registrationconfig.py— MCP server configurationtools/search.py— Tavily-powered web search implementation
The current v1 architecture consists of:
- LLM-based intent router
- Conditional LangGraph routing
- Casual conversation handling
- Planner → Researcher → Synthesizer workflow
- Structured research planning with Pydantic
- MCP-based tool integration
- Tavily web search
- LangChain MCP adapters
- Custom LangGraph progress events
- FastAPI backend
- SSE-based event streaming
- Next.js frontend
- OpenRouter LLM integration
- Shared typed LangGraph state
The main architecture is intentionally separated into independent stages:
┌───────────┐
│ Router │
└─────┬─────┘
│
┌──────────┴──────────┐
│ │
Casual Research
│ │
│ ▼
│ ┌───────────┐
│ │ Planner │
│ └─────┬─────┘
│ │
│ Research Plan
│ │
│ ▼
│ ┌───────────┐
│ │ Researcher│
│ └─────┬─────┘
│ │
│ MCP Search
│ │
│ ▼
│ ┌───────────┐
│ │ Synthesizer│
│ └─────┬─────┘
│ │
└──────────┬───────────┘
▼
Final Answer
│
▼
SSE Stream
│
▼
Next.js Frontend
The current version improves on the original linear workflow in several ways.
Previously, every query entered the research pipeline.
The Router now determines whether research is necessary before invoking the Planner.
Before:
User → Planner → Researcher → Synthesizer
Now:
User → Router
│
├── Casual
│
└── Planner → Researcher → Synthesizer
This makes the workflow more efficient and introduces a clear decision-making layer.
Each node now has a specific responsibility:
Router → Decide what type of execution is required
Casual → Handle normal conversation
Planner → Decompose research questions
Researcher → Execute research tasks
Synthesizer → Generate the final answer
This makes individual components easier to test, replace, and extend.
Casual conversation is not determined through a hardcoded list of phrases.
Instead, the LLM Router makes the decision dynamically.
This allows the system to handle a wider variety of natural user inputs without maintaining a growing collection of keyword rules.
Both execution paths ultimately produce:
final_answer
through the SSE layer.
This keeps the frontend architecture simple while allowing the backend workflow to become more sophisticated.
The long-term goal is to build a reliable, modular research agent that can autonomously:
Understand User Intent
↓
Decide Whether Research Is Needed
↓
┌──────┴──────┐
│ │
Casual Research
│ │
│ ▼
│ Plan Research
│ ↓
│ Execute Research
│ ↓
│ Collect Evidence
│ ↓
│ Synthesize Findings
│ │
└──────┬──────┘
↓
Generate Answer
↓
Stream to User
while providing the user with real-time visibility into what the agent is doing.