TL;DR:
- An AI agent = LLM + tool loop; LangGraph makes the loop explicit via a state machine you control
- The four building blocks: State (working memory), Nodes (where work happens), Edges (routing logic), and Tools (external capabilities)
- A typical research run costs roughly $0.04–$0.08 with Claude Sonnet at current pricing
An AI agent is an LLM that can take actions in a loop, not just respond once. A standard chatbot gets a prompt and returns a completion — one round trip, done. An agent receives a goal, executes an action (search the web, call an API), observes the result, and decides what to do next — until the goal is achieved.
In this walkthrough, you’ll build a working research agent using LangGraph. Given a question, it searches the web and produces a structured JSON summary with key claims and sources. Every step produces working output — no pseudocode, no placeholder functions.
Prerequisites and Stack
You’ll need Python 3.11+, an Anthropic API key (claude-sonnet-4-5 or newer) or OpenAI API key (gpt-4o), and a Tavily API key for web search (free tier: 1,000 searches/month).
We’re using LangGraph because its explicit state model makes agents easier to reason about, debug, and extend. The control flow is visible in your code — no magic happening inside a pre-built agent class.
uv init research-agent && cd research-agent
uv add langgraph langchain-anthropic tavily-python langchain-core python-dotenv
Create a .env file with your ANTHROPIC_API_KEY and TAVILY_API_KEY.
The Four Building Blocks
Before writing code, understand the structure of every LangGraph agent.
State is a typed dictionary flowing through the entire graph — the agent’s working memory for one run. Nodes are Python functions that receive the current state and return an updated state; this is where work happens. Edges define which node runs next; conditional edges implement the “loop until done” logic. Tools are Python functions the LLM can invoke; the LLM generates a structured tool call, your executor node runs it.
Step 1: Define State and the Tool
from typing import Annotated, TypedDict
from langchain_core.messages import BaseMessage
from langchain_core.tools import tool
from langgraph.graph.message import add_messages
from tavily import TavilyClient
from dotenv import load_dotenv
import os
load_dotenv()
class AgentState(TypedDict):
messages: Annotated[list[BaseMessage], add_messages]
iteration_count: int
tavily = TavilyClient(api_key=os.getenv("TAVILY_API_KEY"))
@tool
def web_search(query: str) -> str:
"""Search the web for information. Returns a summary and source URLs."""
results = tavily.search(query=query, search_depth="advanced", max_results=5, include_answer=True)
output = f"Answer: {results.get('answer', 'No direct answer')}\n\nSources:\n"
for r in results.get("results", []):
output += f"- [{r['title']}]({r['url']})\n {r['content'][:300]}...\n\n"
return output
The docstring matters — LangGraph passes it to the LLM as the tool description. Be specific about what the tool returns.
Step 2: Build the Agent Node and Routing Logic
from langchain_anthropic import ChatAnthropic
from langchain_core.messages import AIMessage, SystemMessage
model = ChatAnthropic(model="claude-sonnet-4-5-20250514", temperature=0,
api_key=os.getenv("ANTHROPIC_API_KEY"))
model_with_tools = model.bind_tools([web_search])
SYSTEM_PROMPT = """You are a research agent. Answer questions using web search.
When done, produce a JSON object with "summary", "key_claims" (list), and "sources" (list of URLs).
Do not guess. Only include claims supported by search results."""
def agent_node(state: AgentState) -> dict:
messages = state["messages"]
if not any(isinstance(m, SystemMessage) for m in messages):
messages = [SystemMessage(content=SYSTEM_PROMPT)] + messages
response = model_with_tools.invoke(messages)
return {"messages": [response], "iteration_count": state.get("iteration_count", 0) + 1}
def should_continue(state: AgentState) -> str:
if state.get("iteration_count", 0) >= 10:
return "end"
last = state["messages"][-1]
if isinstance(last, AIMessage) and last.tool_calls:
return "tools"
return "end"
should_continue is the core control loop — it routes to the tool executor if the LLM made tool calls, or exits if it produced a final answer.
Step 3: Wire the Graph
from langgraph.graph import StateGraph, END
from langgraph.prebuilt import ToolNode
from langgraph.checkpoint.memory import MemorySaver
tool_node = ToolNode([web_search])
workflow = StateGraph(AgentState)
workflow.add_node("agent", agent_node)
workflow.add_node("tools", tool_node)
workflow.set_entry_point("agent")
workflow.add_conditional_edges("agent", should_continue, {"tools": "tools", "end": END})
workflow.add_edge("tools", "agent")
app = workflow.compile(checkpointer=MemorySaver())
The graph shape: [START] → agent → (conditional) → tools → agent → ... → END. Every LangGraph agent follows this structure.
Running It
from langchain_core.messages import HumanMessage
def run_research_agent(question: str, thread_id: str = "default") -> str:
config = {"configurable": {"thread_id": thread_id}}
result = app.invoke(
{"messages": [HumanMessage(content=question)], "iteration_count": 0},
config=config
)
return result["messages"][-1].content
if __name__ == "__main__":
print(run_research_agent("What are the main AI agent frameworks in 2026?"))
A healthy run calls web_search 2–4 times before producing a JSON final answer. A broken agent calls the same query repeatedly, or hits the iteration limit every run.
Common Failure Modes
Infinite loops — the LLM never stops calling tools. Fix this with explicit guidance in the system prompt (“After 3–4 searches, produce your final answer”) and a hard iteration guard.
Tool errors crashing the graph — wrap tool logic in try/except and return the error as a string rather than raising. The LLM can then decide to retry or fall back instead of crashing the whole run.
Prompt injection via tool results — a webpage can embed instructions like “Ignore previous instructions.” Wrap tool results in clear delimiters and truncate at a safe length (2,000 chars). Treat all tool outputs as untrusted in production.
What to Add Next
Swap MemorySaver for SqliteSaver to add persistent memory that survives process restarts — each thread_id becomes a persistent conversation. Compile with interrupt_before=["tools"] to pause before every tool execution for human review. Use app.stream(...) instead of app.invoke(...) to display tool calls and reasoning in real time.
Bottom Line
You now have a working LangGraph agent. The control loop — state flows into nodes, nodes update state, edges determine what runs next — is the entire foundation. Everything else in AI agent development is an elaboration of this pattern. Real agent work is more debugging than building; the framework handles the plumbing, your job is getting the agent to reason correctly about your specific problem.