Multi-Agent Research Assistant
Build a LangGraph supervisor system with human-in-the-loop approval.
Introduction
30 Second Summary
Think about how you research a topic today. You search, read, decide if the sources are good enough, and only then start writing.
In this project, you will build a multi-agent research assistant using LangGraph where a Supervisor agent coordinates a Researcher and a Writer, with a human approval gate that lets you review research before any writing begins.
What You'll Build
You'll type a topic into your terminal, watch agents search Wikipedia and synthesize notes, pause to approve or reject the research, and see a polished summary appear only after you give the green light.
By the end of this project, you'll have:
- A supervisor-orchestrated pipeline where you type any topic and watch the supervisor delegate research and writing tasks to specialized agents in real time.
- A tool-calling researcher that queries Wikipedia's API and synthesizes findings into structured notes you can read and evaluate.
- A human-in-the-loop approval gate that pauses execution mid-pipeline, shows you the research, and lets you approve it or send it back for revision with feedback.
- Secret Mission: Add a Fact Checker agent that cross-references the writer's draft against research notes to catch hallucinations before final output.
Are there any prerequisites?
You should be comfortable with basic Python (functions, dictionaries, imports). A little experience with LangChain is helpful but not required.
This project uses the OpenRouter API for LLM calls, so you'll need an OpenRouter API key with a small amount of credit (around $0.50 to $1.00 for the full project).
Before We Start
Before We Start...
Before we dive in, let's lock in what you're about to build and why it matters. This step is your moment to commit to the goal and think about why human oversight in AI pipelines is essential.
Set Up Your Environment
Your multi-agent research assistant needs two core packages before you can build anything. LangGraph handles the orchestration graph and agent routing. The langchain-openrouter package connects your agents to OpenRouter's LLM API.
In this step, you'll create your project folder, install the dependencies, and confirm your LLM connection works with a test script.
In this step, get ready to:
- Create your project folder and install LangGraph and langchain-openrouter.
- Verify your OpenRouter API connection with a test script.
Create your project folder and install packages
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Visual Studio Code and press Enter to open it.
- Open the integrated terminal by pressing Ctrl+` (backtick key, same shortcut on both macOS and Windows).
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir multi-agent-research
cd multi-agent-research
- Confirm the folder exists by running:
ls ~/Desktop | grep multi-agent
You should see multi-agent-research in the output.
- Install the project dependencies by running:
pip install langgraph==1.2.9 langchain-openrouter==0.2.6 requests
What does this command do?
- langgraph==1.2.9 is the orchestration framework that lets you build agent graphs with nodes, edges, and conditional routing.
- langchain-openrouter==0.2.6 provides ChatOpenRouter, the LangChain-compatible model class that talks to OpenRouter's API.
- requests is the HTTP library your researcher agent will use to query the Wikipedia API.
You'll see pip download and install several packages. This takes about 30 seconds depending on your connection.
Seeing a permission error?
If you see "Permission denied" or "Could not install packages", try running the command with pip install --user instead of just pip install.
If you see "pip: command not found", try pip3 instead of pip.
Help me fix my pip installation error
- Verify the packages imported correctly by running:
python -c "import langgraph; from langchain_openrouter import ChatOpenRouter; print('Ready!')"
You should see Ready! printed to the terminal.
Seeing ModuleNotFoundError?
If you see an import error, the install may have gone to a different Python version. Try python3 instead of python in the command above.
Help me resolve my Python import error
Set up your environment variable and verify connection
Your agents need an OpenRouter API key to call the LLM. You'll set this inside a test script that confirms the connection works.
- Open the multi-agent-research folder in VS Code by clicking File in the top menu bar.
- Click Open Folder.
- Select the multi-agent-research folder on your Desktop, then click Open.
Why open the folder?
Opening the folder as a workspace means VS Code's file explorer and terminal both point to your project directory. Any files you create appear in the sidebar, and the terminal starts in the right location.
- Open the integrated terminal again with Ctrl+` if it closed when the folder opened.
- Create a new file called test_connection.py by running:
touch test_connection.py
- Open test_connection.py in the editor by clicking on it in the Explorer sidebar on the left.
- Paste the following code into test_connection.py:
import os
from langchain_openrouter import ChatOpenRouter
os.environ.setdefault("OPENROUTER_API_KEY", "your-openrouter-api-key-here")
model = ChatOpenRouter(model="openai/gpt-4o-mini", temperature=0.7)
response = model.invoke("Say hello!")
print(response.content)
What does this code do?
- os.environ.setdefault sets the API key as an environment variable so the OpenRouter client can find it.
- ChatOpenRouter creates a LangChain-compatible model that routes requests through OpenRouter to openai/gpt-4o-mini.
- model.invoke("Say hello!") sends a single message to the model and returns its response.
- Find your-openrouter-api-key-here in the code and replace it with your actual OpenRouter API key.
Where to find your API key
Your OpenRouter API key starts with sk-or- and can be found on your OpenRouter dashboard under Keys.
- Save test_connection.py by pressing Cmd+S (macOS) or Ctrl+S (Windows).
Before you run this, what do you think the output will look like? The model is receiving "Say hello!" as a prompt.
- Run the test script in your terminal:
python test_connection.py
You should see a friendly greeting from the model printed to your terminal. The exact wording varies each run, but any coherent response confirms your connection works.
Seeing an authentication error?
Double-check that you replaced your-openrouter-api-key-here with your actual key. Make sure there are no extra spaces or quotes around the key value.
If you see a connection timeout, check that your internet connection is active and that OpenRouter's status page shows no outages.
Help me debug my OpenRouter connection error
✔️ Awesome, I've got everything!
Great. Double check you've saved test_connection.py and confirmed a response from the LLM in your terminal.
ⓧ I'd like to double check the full code
import os
from langchain_openrouter import ChatOpenRouter
os.environ.setdefault("OPENROUTER_API_KEY", "your-openrouter-api-key-here")
model = ChatOpenRouter(model="openai/gpt-4o-mini", temperature=0.7)
response = model.invoke("Say hello!")
print(response.content)
Your environment is ready and talking to the LLM. Next up, you'll build your first agent that searches Wikipedia and produces real research notes.
Build the Researcher Agent
Your environment is set up and your LLM connection is verified. Now it is time to build something that actually does work.
Before you can orchestrate multiple agents, you need a single working agent that calls a tool and produces useful output. In this step, you will build a researcher agent that searches Wikipedia for real information on any topic you give it.
In this step, get ready to:
- Define a search tool that queries Wikipedia's REST API.
- Create the shared state that agents will pass data through.
- Build and run a researcher agent that returns real research notes.
Define the search tool and state
Every agent needs tools to interact with the outside world. Your researcher agent will use a Wikipedia search tool to fetch real information instead of relying on the LLM's training data alone.
- Create a new file called main.py in your multi-agent-research folder by running:
touch main.py
- Open main.py in VS Code and add the imports, configuration, and search tool by copying this code:
import os
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START, END
from langchain_openrouter import ChatOpenRouter
from langchain_core.messages import HumanMessage, SystemMessage, ToolMessage
from langchain_core.tools import tool
import requests
# --- Configuration ---
os.environ.setdefault("OPENROUTER_API_KEY", "your-openrouter-api-key-here")
model = ChatOpenRouter(model="openai/gpt-4o-mini", temperature=0.7)
# --- Tools ---
@tool
def search_web(query: str) -> str:
"""Search Wikipedia for information on a topic."""
response = requests.get(
f"https://en.wikipedia.org/api/rest_v1/page/summary/{query.replace(' ', '_')}"
)
if response.status_code == 200:
data = response.json()
return data.get("extract", "No results found.")
return f"No results found for: {query}"
What does this code do?
- The imports bring in LangGraph graph components, the ChatOpenRouter LLM wrapper, message types for tool-calling conversations, and the requests library for HTTP calls.
- The @tool decorator turns search_web into a structured tool that an LLM can decide to call. The docstring becomes the tool description the model reads when choosing which tool to use.
- The function queries Wikipedia's free REST API and returns the article summary for whatever topic the LLM searches.
Next, define the shared state that all agents in the system will read from and write to.
- Add the AgentState class below the search_web function in main.py:
# --- State ---
class AgentState(TypedDict):
topic: str
research_notes: str
draft: str
next_agent: str
What is a TypedDict state?
In LangGraph, the state is a dictionary that flows through every node in the graph. Each node reads what it needs, does its work, and returns updates. TypedDict defines the exact shape so every agent knows what fields are available.
Right now only topic and research_notes matter. The draft and next_agent fields are reserved for agents you will add in the next step.
- Save main.py.
- Confirm everything parses correctly by running:
python -c "from main import search_web, AgentState; print('Tools and state ready!')"
You should see Tools and state ready! printed with no errors.
Seeing an ImportError?
- Make sure you are running the command from inside your multi-agent-research folder where main.py lives.
- Check that langgraph and langchain-openrouter are installed. Run pip install langgraph langchain-openrouter requests again if needed.
- Confirm there are no typos in the import lines at the top of main.py.
help me fix an ImportError in my LangGraph main.py file
Build the researcher node and wire the graph
The tool and state are ready. Now you need a node function that actually uses the tool to research a topic, plus a graph that runs the whole thing end-to-end.
- Add the beginning of the researcher_node function below the AgentState class in main.py:
# --- Nodes ---
def researcher_node(state: AgentState) -> dict:
"""Researcher agent that searches Wikipedia for information."""
researcher_model = model.bind_tools([search_web])
messages = [
SystemMessage(
content=(
"You are a research assistant. Use the search_web tool to find "
"information about the given topic. Make 1-2 searches for the "
"main topic and a key subtopic."
)
),
HumanMessage(content=f"Research this topic: {state['topic']}"),
]
response = researcher_model.invoke(messages)
What does this code do?
- model.bind_tools([search_web]) creates a version of the LLM that knows about the search_web tool. When invoked, the model can choose to call that tool instead of (or before) generating text.
- The SystemMessage instructs the model to act as a research assistant and use the tool. The HumanMessage passes in the user's topic.
- After invoking, the response may contain tool_calls if the model decided to search Wikipedia. You will handle those calls next.
- Complete the researcher_node function and add the graph wiring by adding this code directly below the previous block (still inside main.py):
if response.tool_calls:
messages.append(response)
for tc in response.tool_calls:
result = search_web.invoke(tc["args"])
messages.append(ToolMessage(content=result, tool_call_id=tc["id"]))
synthesis = model.invoke(
messages
+ [
HumanMessage(
content="Synthesize all findings into clear, detailed research notes."
)
]
)
return {"research_notes": synthesis.content}
return {"research_notes": response.content}
# --- Build Graph ---
workflow = StateGraph(AgentState)
workflow.add_node("researcher", researcher_node)
workflow.add_edge(START, "researcher")
workflow.add_edge("researcher", END)
app = workflow.compile()
What does this code do?
- The if response.tool_calls block executes when the LLM decided to search Wikipedia. It runs each tool call, appends the results as ToolMessage objects, then asks the model to synthesize everything into research notes.
- If the model did not call any tools (the else path), it returns whatever the model generated directly as research notes.
- The graph section creates a simple StateGraph with one node: START goes to the researcher, and the researcher goes to END. This is the simplest possible agent graph.
- Save main.py and verify the graph compiles by running:
python -c "from main import app; print('Graph compiled!')"
You should see Graph compiled! with no errors.
Seeing a SyntaxError or IndentationError?
- The if response.tool_calls: block must be indented inside researcher_node. Make sure it lines up at 4 spaces of indentation.
- The return {"research_notes": response.content} line at the bottom of the function should also be at 4 spaces, matching the if statement above it.
- The graph code (workflow = StateGraph(...)) should be at zero indentation, outside the function.
help me fix a SyntaxError or IndentationError in my researcher_node function
- Add the run loop at the bottom of main.py so you can test the researcher interactively:
# --- Run ---
if __name__ == "__main__":
print("=== Multi-Agent Research Assistant ===\n")
topic = input("Enter a research topic: ")
print("\nStarting research...\n")
result = app.invoke({"topic": topic, "research_notes": "", "draft": "", "next_agent": ""})
print("Research Notes:")
print(result["research_notes"])
What does this code do?
- The __main__ block only runs when you execute the file directly with python main.py.
- app.invoke() starts the graph with initial state. The researcher node runs, calls Wikipedia, synthesizes findings, and returns the updated state with populated research_notes.
- Save main.py.
Before you run this, what do you think the output will look like? Will you see raw Wikipedia text, or something the LLM has processed?
- Run your researcher agent:
python main.py
- When prompted, type a topic like quantum computing and press Enter.
You should see synthesized research notes printed to the terminal. The notes contain real information from Wikipedia, processed by the LLM into a coherent summary.
Seeing an authentication error?
- Make sure your OPENROUTER_API_KEY environment variable is set correctly. Run python test_connection.py again to confirm the LLM connection still works.
- If you see a 401 or 403 from the LLM, check that your OpenRouter API key has credits available.
- If you see empty research notes, the LLM may not have triggered the tool. Try a more specific topic like artificial intelligence.
help me debug my researcher agent not returning Wikipedia results
✔️ Awesome, I've got everything!
Great. Double check you have saved main.py and that your research notes printed real Wikipedia-sourced content.
ⓧ I'd like to double check the full code
Here is the complete main.py at this point. Compare yours against it:
import os
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START, END
from langchain_openrouter import ChatOpenRouter
from langchain_core.messages import HumanMessage, SystemMessage, ToolMessage
from langchain_core.tools import tool
import requests
# --- Configuration ---
os.environ.setdefault("OPENROUTER_API_KEY", "your-openrouter-api-key-here")
model = ChatOpenRouter(model="openai/gpt-4o-mini", temperature=0.7)
Followed by the tool and state:
# --- Tools ---
@tool
def search_web(query: str) -> str:
"""Search Wikipedia for information on a topic."""
response = requests.get(
f"https://en.wikipedia.org/api/rest_v1/page/summary/{query.replace(' ', '_')}"
)
if response.status_code == 200:
data = response.json()
return data.get("extract", "No results found.")
return f"No results found for: {query}"
# --- State ---
class AgentState(TypedDict):
topic: str
research_notes: str
draft: str
next_agent: str
Followed by the researcher node and graph:
# --- Nodes ---
def researcher_node(state: AgentState) -> dict:
"""Researcher agent that searches Wikipedia for information."""
researcher_model = model.bind_tools([search_web])
messages = [
SystemMessage(
content=(
"You are a research assistant. Use the search_web tool to find "
"information about the given topic. Make 1-2 searches for the "
"main topic and a key subtopic."
)
),
HumanMessage(content=f"Research this topic: {state['topic']}"),
]
response = researcher_model.invoke(messages)
if response.tool_calls:
messages.append(response)
for tc in response.tool_calls:
result = search_web.invoke(tc["args"])
messages.append(ToolMessage(content=result, tool_call_id=tc["id"]))
synthesis = model.invoke(
messages
+ [
HumanMessage(
content="Synthesize all findings into clear, detailed research notes."
)
]
)
return {"research_notes": synthesis.content}
return {"research_notes": response.content}
And finally the graph and run loop:
# --- Build Graph ---
workflow = StateGraph(AgentState)
workflow.add_node("researcher", researcher_node)
workflow.add_edge(START, "researcher")
workflow.add_edge("researcher", END)
app = workflow.compile()
# --- Run ---
if __name__ == "__main__":
print("=== Multi-Agent Research Assistant ===\n")
topic = input("Enter a research topic: ")
print("\nStarting research...\n")
result = app.invoke({"topic": topic, "research_notes": "", "draft": "", "next_agent": ""})
print("Research Notes:")
print(result["research_notes"])
You now have a single agent that searches Wikipedia and synthesizes findings into useful notes. Next up, you will add a writer agent and a supervisor to orchestrate the full research-to-draft pipeline.
Add the Writer and Supervisor
Right now your system has one agent that researches a topic and prints notes. That is just a chatbot with a tool attached. The real power of LangGraph is orchestrating multiple specialized agents where a supervisor decides who acts next.
In this step, you will add a writer agent and a supervisor agent that coordinates the entire flow. You will run the full pipeline and observe an important gap: the system passes research straight to the writer with zero human oversight.
In this step, get ready to:
- Add a supervisor node that examines state and routes to the correct agent.
- Add a writer node that transforms research into a polished draft.
- Wire the full multi-agent graph with conditional routing.
Add the supervisor and writer nodes
The supervisor pattern works like a team lead. The supervisor looks at the current state of work, decides which specialist should act next, and delegates. It does not do the work itself.
- In main.py, find the # --- Nodes --- comment.
- Add the following supervisor_node function directly below that comment, above the existing researcher_node:
def supervisor_node(state: AgentState) -> dict:
"""Supervisor decides which agent to call next."""
system_prompt = """You are a supervisor managing a research team. Based on the current state, decide the next step.
Rules:
- If research_notes is empty or "None", respond with: researcher
- If research_notes exist but draft is empty or "None", respond with: writer
- If draft exists, respond with: FINISH
Respond with ONLY one word: researcher, writer, or FINISH"""
context = (
f"Topic: {state['topic']}\n"
f"Research notes: {state.get('research_notes') or 'None'}\n"
f"Draft: {state.get('draft') or 'None'}"
)
response = model.invoke([
SystemMessage(content=system_prompt),
HumanMessage(content=context),
])
next_agent = response.content.strip().lower()
if "researcher" in next_agent:
next_agent = "researcher"
elif "writer" in next_agent:
next_agent = "writer"
else:
next_agent = "FINISH"
return {"next_agent": next_agent}
What does this code do?
The supervisor examines the current state and makes a routing decision. It does not call tools or produce content itself.
- The system prompt gives the LLM clear rules: no research yet means send to researcher, research exists but no draft means send to writer, draft exists means finish.
- The response parsing uses in checks (not exact matches) to handle minor formatting variations from the LLM.
- It returns only next_agent in the state dictionary, telling the graph where to route next.
Now add the writer agent. The writer takes research notes and produces polished content from them.
- In main.py, scroll down past the researcher_node function.
- Add the following writer_node function below researcher_node:
def writer_node(state: AgentState) -> dict:
"""Writer agent that creates polished content from research."""
response = model.invoke([
SystemMessage(
content=(
"You are a skilled writer. Create a well-structured, engaging "
"summary based on the research notes. Write 2-3 clear paragraphs."
)
),
HumanMessage(
content=(
f"Topic: {state['topic']}\n\n"
f"Research Notes:\n{state['research_notes']}\n\n"
"Write a polished summary:"
)
),
])
return {"draft": response.content}
What does this code do?
The writer is a focused agent with one job: turn raw research into readable prose. It receives the topic and research notes via state, then prompts the LLM to write 2-3 paragraphs. The result goes into the draft field of state.
- Save main.py.
- Confirm the new nodes parse correctly by running this command:
python -c "from main import supervisor_node, writer_node; print('Nodes defined!')"
You should see Nodes defined! printed with no errors.
Seeing a SyntaxError?
- Check that the triple-quoted string in supervisor_node uses three double quotes at both the start and end.
- Make sure there is a blank line between the end of supervisor_node and the start of researcher_node.
Why can't Python find my new node functions?
Wire the full multi-agent graph
With three node functions defined, you need a graph that routes between them. The supervisor outputs a next_agent value, and a conditional edge reads that value to pick the next node.
- In main.py, find the # --- Build Graph --- section. It currently looks like this:
# --- Build Graph ---
workflow = StateGraph(AgentState)
workflow.add_node("researcher", researcher_node)
workflow.add_edge(START, "researcher")
workflow.add_edge("researcher", END)
app = workflow.compile()
- Replace that entire section (from # --- Build Graph --- through app = workflow.compile()) with the following routing function and new graph:
# --- Routing ---
def route_after_supervisor(state: AgentState) -> str:
next_agent = state.get("next_agent", "researcher")
if next_agent == "FINISH":
return "end"
return next_agent
# --- Build Graph ---
workflow = StateGraph(AgentState)
workflow.add_node("supervisor", supervisor_node)
workflow.add_node("researcher", researcher_node)
workflow.add_node("writer", writer_node)
workflow.add_edge(START, "supervisor")
workflow.add_conditional_edges(
"supervisor",
route_after_supervisor,
{"researcher": "researcher", "writer": "writer", "end": END},
)
workflow.add_edge("researcher", "supervisor")
workflow.add_edge("writer", END)
app = workflow.compile()
What does this graph do?
- The graph starts at the supervisor. The supervisor looks at state and decides the next agent.
- A conditional edge reads the next_agent value from state and routes to the matching node (researcher, writer, or end).
- After the researcher finishes, it loops back to the supervisor. The supervisor sees research notes now exist and routes to the writer.
- After the writer finishes, the graph ends. The full flow is: supervisor -> researcher -> supervisor -> writer -> END.
Run the autonomous pipeline
The last piece is updating the run block to print the final draft instead of raw research notes.
- In main.py, find the # --- Run --- section at the bottom of the file. It currently looks like this:
# --- Run ---
if __name__ == "__main__":
print("=== Multi-Agent Research Assistant ===\n")
topic = input("Enter a research topic: ")
print("\nStarting research...\n")
result = app.invoke({"topic": topic, "research_notes": "", "draft": "", "next_agent": ""})
print("Research Notes:")
print(result["research_notes"])
- Replace the entire # --- Run --- section with this updated version:
# --- Run ---
if __name__ == "__main__":
print("=== Multi-Agent Research Assistant ===\n")
topic = input("Enter a research topic: ")
print("\nStarting research...\n")
result = app.invoke(
{"topic": topic, "research_notes": "", "draft": "", "next_agent": ""}
)
print("\n" + "=" * 50)
print("FINAL DRAFT")
print("=" * 50)
print(result.get("draft", "No draft generated."))
What changed?
Instead of printing raw research notes, the run block now prints the writer's polished draft. The graph handles the full flow internally. You just invoke it with a topic and get a finished draft back.
- Save main.py.
Before you run this, think about what will happen. The supervisor examines state, routes to the researcher, the researcher searches Wikipedia, then what? Do you think you will get a chance to review the research before the writer uses it?
- Run the full pipeline:
python main.py
- When prompted, enter a topic like quantum computing.
You will see a polished draft appear under the FINAL DRAFT heading. The supervisor routed to the researcher, the researcher pulled Wikipedia content, the supervisor then routed to the writer, and the writer produced your summary.
But notice what did NOT happen. You had no chance to review the research before the writer used it. The entire pipeline ran autonomously from start to finish. If the research was inaccurate or incomplete, the writer happily turned bad research into a polished-looking draft.
Why is this a problem?
In production AI systems, autonomous pipelines without human checkpoints are dangerous. A confident-sounding draft built on bad research misleads users. The next step fixes this gap by adding a human-in-the-loop approval gate between research and writing.
Not seeing a draft?
- If you see No draft generated., the supervisor may not be routing correctly. Check that your supervisor_node returns {"next_agent": next_agent} and not a plain string.
- If you see a connection or API error, confirm your OPENROUTER_API_KEY environment variable is still set.
- If you see a KeyError, make sure the route_after_supervisor mapping includes all three keys: researcher, writer, and end.
My multi-agent graph is not producing a final draft. Help me debug the supervisor routing.
✔️ Awesome, I've got everything!
Great. Double check you have saved main.py with all your changes.
ⓧ I'd like to double check the full code
Scroll up through your work and confirm each section is present in this order:
- The # --- Configuration --- section with imports and model setup (unchanged from before).
- The # --- Tools --- section with search_web (unchanged).
- The # --- State --- section with AgentState (unchanged).
- The # --- Nodes --- section with supervisor_node first, then researcher_node, then writer_node.
- The # --- Routing --- section with route_after_supervisor.
- The # --- Build Graph --- section with three nodes, conditional edges from supervisor, and app = workflow.compile().
- The # --- Run --- section printing FINAL DRAFT.
You now have a working multi-agent pipeline where a supervisor coordinates a researcher and a writer. Next up, you will add a human-in-the-loop gate that pauses execution between research and writing so you can approve or reject the research before it reaches the writer.
Add Human-in-the-Loop Approval
In the last step, you ran the full autonomous pipeline and watched the supervisor hand research straight to the writer with no pause in between. You had zero opportunity to check whether the research was accurate before the writer turned it into a polished draft.
In production AI systems, that blind handoff is dangerous. In this step, you will add an interrupt gate that pauses execution after research is complete, lets you review the findings, and only proceeds to writing after you explicitly approve.
In this step, get ready to:
- Add a human review node that pauses the graph mid-execution using LangGraph's interrupt() function.
- Rewire the graph so the supervisor routes through the review gate before writing.
- Update the run loop to detect interrupts, display research, and resume with your feedback.
Add the human review node with interrupt
LangGraph's interrupt() function pauses a running graph and sends a payload back to the caller. When the caller resumes the graph with Command(resume=value), that value becomes the return value of interrupt() inside the node.
- In main.py, find the existing langgraph.graph import line:
from langgraph.graph import StateGraph, START, END
- Add these two new import lines directly below it:
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command, interrupt
What do these imports do?
- InMemorySaver is a checkpointer that stores the graph's state in memory so execution can pause and resume.
- interrupt pauses a running node and sends a value to the caller.
- Command is used to resume an interrupted graph with a value the node receives.
- Now add the human_review_node function below the researcher_node function (above writer_node). Copy this code into main.py:
def human_review_node(state: AgentState) -> dict:
"""Pause for human review of research before writing."""
feedback = interrupt({
"research_notes": state["research_notes"],
"message": (
"Review the research above. Type 'approve' to proceed to writing, "
"or provide feedback for the researcher to revise."
),
})
if feedback.strip().lower() == "approve":
return {"next_agent": "writer"}
return {
"topic": f"{state['topic']} (Revision guidance: {feedback})",
"research_notes": "",
"next_agent": "researcher",
}
What does this code do?
- interrupt() sends a payload (the research notes and a prompt) to the caller, then pauses the entire graph.
- When the graph resumes, the value passed via Command(resume=...) becomes the return value of interrupt(), stored in feedback.
- If the user typed "approve", the node routes to the writer.
- Any other input clears the research, appends the feedback to the topic as revision guidance, and routes back to the supervisor (which will re-assign the researcher).
- Save main.py.
- Confirm the new imports have no typos by running this quick check:
python -c "from langgraph.checkpoint.memory import InMemorySaver; from langgraph.types import Command, interrupt; print('Imports OK')"
You should see Imports OK printed with no errors.
Seeing an ImportError?
- Make sure you installed langgraph==1.2.9 in Step 1. Run pip install langgraph==1.2.9 again if needed.
- Check that the import paths match exactly: langgraph.checkpoint.memory and langgraph.types.
Why can't I import InMemorySaver or interrupt from langgraph?
Rewire the graph with the HITL gate
The new node exists, but the graph does not know about it yet. You need to update the routing logic so the supervisor sends traffic through the review gate instead of directly to the writer.
- In main.py, scroll down to the # --- Routing --- section and find the route_after_supervisor function. It currently looks like this:
def route_after_supervisor(state: AgentState) -> str:
next_agent = state.get("next_agent", "researcher")
if next_agent == "FINISH":
return "end"
return next_agent
- Replace the entire # --- Routing --- section (the route_after_supervisor function) with these two functions:
# --- Routing ---
def route_after_supervisor(state: AgentState) -> str:
next_agent = state.get("next_agent", "researcher")
if next_agent == "FINISH":
return "end"
if next_agent == "writer":
return "human_review"
return next_agent
def route_after_review(state: AgentState) -> str:
if state.get("next_agent") == "researcher":
return "supervisor"
return "writer"
What changed in the routing?
- route_after_supervisor now intercepts the "writer" decision and redirects to "human_review" instead. The supervisor still thinks it is routing to the writer, but the graph sends traffic through the review gate first.
- route_after_review checks what the human review node decided. If the user rejected the research, it routes back to the supervisor (which will re-assign the researcher). If approved, it routes to the writer.
- Now replace the entire # --- Build Graph --- section (everything from workflow = StateGraph(AgentState) through app = workflow.compile()) with this updated graph wiring:
# --- Build Graph ---
workflow = StateGraph(AgentState)
workflow.add_node("supervisor", supervisor_node)
workflow.add_node("researcher", researcher_node)
workflow.add_node("human_review", human_review_node)
workflow.add_node("writer", writer_node)
workflow.add_edge(START, "supervisor")
workflow.add_conditional_edges(
"supervisor",
route_after_supervisor,
{"researcher": "researcher", "human_review": "human_review", "end": END},
)
workflow.add_edge("researcher", "supervisor")
workflow.add_conditional_edges(
"human_review",
route_after_review,
{"supervisor": "supervisor", "writer": "writer"},
)
workflow.add_edge("writer", END)
checkpointer = InMemorySaver()
app = workflow.compile(checkpointer=checkpointer)
What does this code do?
- The graph now has four nodes: supervisor, researcher, human_review, and writer.
- The supervisor's conditional edges route to "human_review" instead of directly to "writer".
- human_review has its own conditional edges: back to supervisor (rejection) or forward to writer (approval).
- InMemorySaver() is passed as the checkpointer. Without a checkpointer, interrupt() raises a MISSING_CHECKPOINTER error because LangGraph needs somewhere to save state while paused.
- Save main.py.
- Run the program to see what happens when the interrupt fires but the run loop does not handle it yet:
python main.py
Enter any topic (like quantum computing). The researcher runs, but then the program prints No draft generated. and exits.
Why did it skip the draft?
The graph hit the interrupt() call inside human_review_node and paused. The invoke() call returned with the state frozen at that point. Since nobody resumed the graph, the writer never ran and draft stayed empty. You will fix this in the next substep by adding a loop that detects and handles interrupts.
Update the run loop to handle interrupts
The final piece is a run loop that detects when the graph has paused, displays the interrupt payload, collects your input, and resumes the graph.
- In main.py, replace the entire # --- Run --- section (everything under if __name__ == "__main__":) with this interrupt-handling loop:
# --- Run ---
if __name__ == "__main__":
config = {"configurable": {"thread_id": "session-1"}}
print("=== Multi-Agent Research Assistant ===\n")
topic = input("Enter a research topic: ")
print("\nStarting research...\n")
result = app.invoke(
{"topic": topic, "research_notes": "", "draft": "", "next_agent": ""},
config,
)
snapshot = app.get_state(config)
while snapshot.tasks:
for task in snapshot.tasks:
if task.interrupts:
payload = task.interrupts[0].value
print("\n" + "=" * 50)
print("HUMAN REVIEW REQUIRED")
print("=" * 50)
print(f"\nResearch Notes:\n{payload['research_notes']}")
print(f"\n{payload['message']}")
feedback = input("\nYour response: ")
result = app.invoke(Command(resume=feedback), config)
snapshot = app.get_state(config)
print("\n" + "=" * 50)
print("FINAL DRAFT")
print("=" * 50)
print(result.get("draft", "No draft generated."))
What does this code do?
- config includes a thread_id so the checkpointer knows which conversation to save and resume.
- After the first invoke(), the code checks app.get_state(config).tasks for pending interrupts.
- While tasks exist, it displays the interrupt payload (research notes and the review prompt), collects your input, and resumes the graph with Command(resume=feedback).
- Once no more tasks are pending, the graph has completed. The loop exits and prints the final draft.
- Save main.py.
Before you run this, what do you expect to happen differently compared to the last run? Think about when the graph will pause and what you will see in the terminal.
- Run the full human-in-the-loop pipeline:
python main.py
- Enter a topic like quantum computing.
- Wait for the researcher to finish. You should see a HUMAN REVIEW REQUIRED banner followed by the research notes and a prompt asking you to approve or provide feedback.
- Type approve and press Enter.
- The writer agent runs and you see FINAL DRAFT followed by the polished summary.
What should I see?
The terminal shows the research notes with real Wikipedia content, pauses for your input, and only after you type approve does the writer produce the draft. You now have full control over what the writer receives.
- Run the program again to test the rejection path:
python main.py
- Enter a topic.
- When the review prompt appears, type some feedback like focus more on practical applications instead of approve.
- Watch the system loop back to the researcher with your guidance appended to the topic.
- When the updated research appears, type approve to let the writer proceed.
Graph exits immediately without pausing?
- Make sure you compiled the graph with checkpointer=InMemorySaver(). Without a checkpointer, interrupt() cannot pause execution.
- Verify you pass config as the second argument to both app.invoke() calls. The checkpointer needs the thread_id to track state.
- Check that human_review_node is registered as a node in the graph with workflow.add_node("human_review", human_review_node).
My graph doesn't pause for human review. How do I debug the interrupt and checkpointer setup?
✔️ Awesome, I've got everything!
Great. Double check you have saved main.py and confirmed both the approve and reject paths work.
ⓧ I'd like to double check the full code
Your main.py should now have these sections in order. Scroll through your file and confirm each one is present:
- Imports: os, TypedDict, StateGraph/START/END, InMemorySaver, Command/interrupt, ChatOpenRouter, messages, tool, requests.
- Configuration: OPENROUTER_API_KEY env var and model instance.
- Tools: search_web function with @tool decorator.
- State: AgentState TypedDict with topic, research_notes, draft, next_agent.
- Nodes: supervisor_node, researcher_node, human_review_node, writer_node.
- Routing: route_after_supervisor (with the "human_review" redirect) and route_after_review.
- Build Graph: four nodes registered, conditional edges from supervisor and human_review, compiled with checkpointer=InMemorySaver().
- Run: __main__ block with config, snapshot.tasks while-loop, and Command(resume=feedback) resume logic.
You now have a production-style human-in-the-loop gate. The graph pauses mid-execution, shows you exactly what the researcher found, and only proceeds when you are satisfied with the quality. This is the foundation for building trustworthy AI workflows where autonomous agents need human oversight at critical decision points.
Secret mission
Add a Fact Checker Agent
Your pipeline trusts the writer to stay faithful to the research. But LLMs hallucinate. What if the writer embellishes beyond what the research supports? Add a Fact Checker agent that cross-references the draft against research notes before anything reaches the user.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
This project runs entirely locally, so there are no ongoing costs. OpenRouter only charges when you actively run the script and make API calls.
Resources you used:
- The multi-agent-research project folder on your Desktop (contains main.py and test_connection.py).
- Python packages installed via pip: langgraph, langchain-openrouter, requests.
- Your OpenRouter API key (stored as an environment variable or hardcoded in the script).
Keep everything running
No action needed. Choose this if you're still actively building or want to experiment with different topics and agent configurations.
- Your project folder stays on your Desktop, ready to run anytime.
- Your installed packages remain available for future LangGraph projects.
- Remember that each run costs a small amount in OpenRouter API credits (typically a few cents per research session).
Pause - I'll come back to this later
Since this is a local project with no running processes, there is nothing to pause. Your files and packages will be waiting for you when you return.
- No servers or containers are running, so no memory or CPU is being consumed.
- Your OpenRouter API key is only used when you actively run the script.
- To resume later, open a terminal, navigate to ~/Desktop/multi-agent-research, and run python main.py.
Delete - I don't want to use this again
Remove all project files and uninstall the packages you installed for this project.
- Delete the project folder by running this command in your terminal:
macOS
rm -rf ~/Desktop/multi-agent-research
Windows
rmdir /s /q %USERPROFILE%\Desktop\multi-agent-research
- Uninstall the Python packages by running:
pip uninstall langgraph langchain-openrouter requests -y
- If you exported OPENROUTER_API_KEY in your shell profile (e.g. .bashrc or .zshrc), open that file and remove the export line.
- If you only relied on the os.environ.setdefault() line inside the script, deleting the project folder already handles this.
Nice Work!
Nice Work!
Nice work! You just built a production-style multi-agent research assistant that orchestrates specialized AI agents, calls real tools, and pauses for human oversight before producing output.
You've learned how to:
- Build a multi-agent system with a supervisor orchestrating researcher and writer agents using LangGraph's StateGraph.
- Implement tool-calling with a real Wikipedia search tool bound to the researcher agent using bind_tools and the @tool decorator.
- Add production-style human-in-the-loop using LangGraph's interrupt() and Command(resume=) pattern with conditional routing and checkpointing to create a dynamic, pausable agent graph.
- Secret Mission: Added a fact-checker agent that verifies draft accuracy against research notes before final output.
Ready to quiz yourself?