Build your first AI agent: step-by-step tutorial with LangGraph (2026)
A complete tutorial for building your first AI agent in 2026 using LangGraph and Claude. Covers tools, memory, human-in-the-loop, and a full working research assistant.
This tutorial builds a real AI agent from scratch. By the end, you will have a research assistant that can search the web, summarize findings, remember context across turns, and ask for human approval before taking actions.
Install dependencies first:
pip install -U langgraph langchain langchain-anthropic duckduckgo-search python-dotenv1. What is an AI agent and what can it actually do
An AI agent is an LLM system that can:
- Decide what to do next.
- Use tools to gather data or take actions.
- Keep context and state over multiple turns.
- Follow control rules from your application.
In simple terms: a chatbot answers once; an agent plans, uses tools, and iterates.
2. The agent we are building: a research assistant that searches and summarises
Our agent workflow:
- User asks a research question.
- Agent decides whether to use web search.
- Human approves tool execution.
- Agent runs search tool and reads results.
- Agent returns a concise summary with sources.
- Memory keeps thread context for follow-up questions.
We will implement this in one file: agent.py.
3. What you need: prerequisites and setup
You need:
- Python 3.10+.
- Anthropic API key.
- Terminal access.
Create a .env file:
ANTHROPIC_API_KEY=your_key_here
# Keep default if unsure, update if Anthropic changes naming
ANTHROPIC_MODEL=claude-sonnet-4-6Project structure:
your-project/
agent.py
.env4. Step 1: Setting up your environment and API keys
Create agent.py with this starter:
from dotenv import load_dotenv
import os
from langchain_anthropic import ChatAnthropic
load_dotenv()
MODEL_NAME = os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-6")
API_KEY = os.getenv("ANTHROPIC_API_KEY")
if not API_KEY:
raise RuntimeError("Missing ANTHROPIC_API_KEY in environment.")
llm = ChatAnthropic(
model=MODEL_NAME,
temperature=0.1,
max_tokens=900,
)
if __name__ == "__main__":
print(f"Loaded model: {MODEL_NAME}")Run:
python agent.py5. Step 2: Building the tool functions (web search, summarisation)
Now add tool functions to agent.py:
from dotenv import load_dotenv
import os
from duckduckgo_search import DDGS
from langchain_anthropic import ChatAnthropic
from langchain_core.tools import tool
load_dotenv()
MODEL_NAME = os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-6")
llm = ChatAnthropic(model=MODEL_NAME, temperature=0.1, max_tokens=900)
@tool
def web_search(query: str) -> str:
"""Search the web and return top 5 results with title + URL + snippet."""
rows = []
with DDGS() as ddgs:
for r in ddgs.text(query, max_results=5):
title = r.get("title", "No title")
href = r.get("href", "No URL")
body = r.get("body", "").strip()
rows.append(f"- {title}\n URL: {href}\n Snippet: {body}")
return "\n".join(rows) if rows else "No results found."
@tool
def summarize_text(text: str) -> str:
"""Summarize text into concise bullet points with key takeaways."""
prompt = (
"Summarize the following research notes into:\n"
"1) 5 bullet points\n2) Key risks\n3) Actionable next steps\n\n"
f"Text:\n{text}"
)
return llm.invoke(prompt).content
if __name__ == "__main__":
print(web_search.invoke({"query": "latest open-source LLM serving options"}))6. Step 3: Creating the agent with LangGraph
Replace agent.py with this full LangGraph agent:
from dotenv import load_dotenv
import os
from duckduckgo_search import DDGS
from typing import Literal
from langchain_anthropic import ChatAnthropic
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from langchain_core.tools import tool
from langgraph.graph import MessagesState, StateGraph, START, END
from langgraph.prebuilt import ToolNode
load_dotenv()
MODEL_NAME = os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-6")
llm = ChatAnthropic(model=MODEL_NAME, temperature=0.1, max_tokens=900)
@tool
def web_search(query: str) -> str:
"""Search the web and return top 5 results."""
rows = []
with DDGS() as ddgs:
for r in ddgs.text(query, max_results=5):
rows.append(
f"- {r.get('title', 'No title')}\n"
f" URL: {r.get('href', 'No URL')}\n"
f" Snippet: {r.get('body', '').strip()}"
)
return "\n".join(rows) if rows else "No results found."
@tool
def summarize_text(text: str) -> str:
"""Summarize text into bullets, risks, and next steps."""
prompt = (
"Summarize these notes as:\n"
"1) 5 bullets\n2) risks\n3) next steps\n\n"
f"{text}"
)
return llm.invoke(prompt).content
TOOLS = [web_search, summarize_text]
llm_with_tools = llm.bind_tools(TOOLS)
tool_node = ToolNode(TOOLS)
def assistant_node(state: MessagesState):
system = HumanMessage(
content=(
"You are a research assistant. Use tools when needed. "
"Cite source URLs from web_search results."
)
)
response = llm_with_tools.invoke([system] + state["messages"])
return {"messages": [response]}
def route_after_assistant(state: MessagesState) -> Literal["tools", "__end__"]:
last = state["messages"][-1]
if isinstance(last, AIMessage) and last.tool_calls:
return "tools"
return "__end__"
builder = StateGraph(MessagesState)
builder.add_node("assistant", assistant_node)
builder.add_node("tools", tool_node)
builder.add_edge(START, "assistant")
builder.add_conditional_edges("assistant", route_after_assistant, {"tools": "tools", "__end__": END})
builder.add_edge("tools", "assistant")
graph = builder.compile()
if __name__ == "__main__":
question = "Research the latest best practices for semantic caching in LLM apps."
result = graph.invoke({"messages": [HumanMessage(content=question)]})
print(result["messages"][-1].content)7. Step 4: Adding memory so the agent remembers context
Update the graph compile and runner to use checkpoint memory:
from langgraph.checkpoint.memory import MemorySaver
from langchain_core.messages import HumanMessage
# Keep previous code unchanged above this section
memory = MemorySaver()
graph = builder.compile(checkpointer=memory)
def ask(thread_id: str, text: str) -> str:
result = graph.invoke(
{"messages": [HumanMessage(content=text)]},
config={"configurable": {"thread_id": thread_id}},
)
return result["messages"][-1].content
if __name__ == "__main__":
tid = "demo-user-1"
print(ask(tid, "Research top vector databases for RAG in 2026."))
print(ask(tid, "Now compare them in 3 bullets based on your previous findings."))Memory now works per thread_id.
8. Step 5: Adding human-in-the-loop approval before actions
Add a gate node before tool execution. This is a simple and effective production control.
from typing import Literal
from langchain_core.messages import AIMessage, ToolMessage
def approval_node(state: MessagesState):
last = state["messages"][-1]
if not isinstance(last, AIMessage) or not last.tool_calls:
return {"messages": []}
call = last.tool_calls[0]
tool_name = call["name"]
args = call.get("args", {})
print(f"\n[APPROVAL REQUIRED] Tool call -> {tool_name}({args})")
decision = input("Approve? (y/n): ").strip().lower()
if decision == "y":
return {"messages": []}
denial = ToolMessage(
content="Tool execution denied by human reviewer.",
tool_call_id=call["id"],
)
return {"messages": [denial]}
def route_after_approval(state: MessagesState) -> Literal["tools", "assistant"]:
last = state["messages"][-1]
if isinstance(last, ToolMessage) and "denied" in last.content.lower():
return "assistant"
return "tools"
# Graph wiring changes
builder = StateGraph(MessagesState)
builder.add_node("assistant", assistant_node)
builder.add_node("approval", approval_node)
builder.add_node("tools", tool_node)
builder.add_edge(START, "assistant")
builder.add_conditional_edges("assistant", route_after_assistant, {"tools": "approval", "__end__": END})
builder.add_conditional_edges("approval", route_after_approval, {"tools": "tools", "assistant": "assistant"})
builder.add_edge("tools", "assistant")This ensures every tool action is reviewed before execution.
9. Step 6: Running and testing your agent
Use this complete final agent.py:
from dotenv import load_dotenv
import os
from typing import Literal
from duckduckgo_search import DDGS
from langchain_anthropic import ChatAnthropic
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from langchain_core.tools import tool
from langgraph.graph import MessagesState, StateGraph, START, END
from langgraph.prebuilt import ToolNode
from langgraph.checkpoint.memory import MemorySaver
load_dotenv()
MODEL_NAME = os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-6")
llm = ChatAnthropic(model=MODEL_NAME, temperature=0.1, max_tokens=900)
@tool
def web_search(query: str) -> str:
"""Search the web and return top 5 results with URL snippets."""
rows = []
with DDGS() as ddgs:
for r in ddgs.text(query, max_results=5):
rows.append(
f"- {r.get('title', 'No title')}\n"
f" URL: {r.get('href', 'No URL')}\n"
f" Snippet: {r.get('body', '').strip()}"
)
return "\n".join(rows) if rows else "No results found."
@tool
def summarize_text(text: str) -> str:
"""Summarize text into bullets, risks, and next steps."""
prompt = (
"Summarize these notes as:\n"
"1) 5 bullets\n2) risks\n3) next steps\n\n"
f"{text}"
)
return llm.invoke(prompt).content
TOOLS = [web_search, summarize_text]
llm_with_tools = llm.bind_tools(TOOLS)
tool_node = ToolNode(TOOLS)
def assistant_node(state: MessagesState):
sys = HumanMessage(
content=(
"You are a careful research assistant. "
"Use tools when needed and cite URLs from search results."
)
)
response = llm_with_tools.invoke([sys] + state["messages"])
return {"messages": [response]}
def route_after_assistant(state: MessagesState) -> Literal["approval", "__end__"]:
last = state["messages"][-1]
if isinstance(last, AIMessage) and last.tool_calls:
return "approval"
return "__end__"
def approval_node(state: MessagesState):
last = state["messages"][-1]
if not isinstance(last, AIMessage) or not last.tool_calls:
return {"messages": []}
call = last.tool_calls[0]
print(f"\n[APPROVAL REQUIRED] {call['name']} args={call.get('args', {})}")
decision = input("Approve tool call? (y/n): ").strip().lower()
if decision == "y":
return {"messages": []}
denied = ToolMessage(content="Tool execution denied by human reviewer.", tool_call_id=call["id"])
return {"messages": [denied]}
def route_after_approval(state: MessagesState) -> Literal["tools", "assistant"]:
last = state["messages"][-1]
if isinstance(last, ToolMessage) and "denied" in last.content.lower():
return "assistant"
return "tools"
builder = StateGraph(MessagesState)
builder.add_node("assistant", assistant_node)
builder.add_node("approval", approval_node)
builder.add_node("tools", tool_node)
builder.add_edge(START, "assistant")
builder.add_conditional_edges("assistant", route_after_assistant, {"approval": "approval", "__end__": END})
builder.add_conditional_edges("approval", route_after_approval, {"tools": "tools", "assistant": "assistant"})
builder.add_edge("tools", "assistant")
memory = MemorySaver()
graph = builder.compile(checkpointer=memory)
def chat(thread_id: str, text: str) -> str:
out = graph.invoke(
{"messages": [HumanMessage(content=text)]},
config={"configurable": {"thread_id": thread_id}},
)
return out["messages"][-1].content
if __name__ == "__main__":
thread_id = "research-demo-1"
print("Agent ready. Type 'exit' to stop.")
while True:
user_text = input("\nYou: ").strip()
if user_text.lower() in {"exit", "quit"}:
break
answer = chat(thread_id, user_text)
print("\nAgent:", answer)Run:
python agent.pyTest prompts:
Research top three AI observability tools and summarize differences.Now only give me the tradeoffs for a small startup budget.Give me links you used.
10. What to build next: ideas to extend your agent
Great next upgrades:
- Add structured output with JSON schema validation.
- Replace terminal approval with a web dashboard approval queue.
- Add long-term memory in a database instead of in-memory checkpointing.
- Add evaluation traces with Langfuse or LangSmith.
- Add policy filters for unsafe requests.
- Add multi-agent routing for planning + retrieval + verification.
If you build these in order, you move from a beginner tutorial agent to a production-ready agent architecture.
Related articles
AI agent frameworks compared: LangGraph vs CrewAI vs AutoGen (2026)
An honest comparison of the top AI agent frameworks in 2026. Covers LangGraph, CrewAI, AutoGen, and OpenAI Agents SDK with code examples and a clear decision framework.
14 min read
Embeddings explained: how they work and which to use in 2026
A practical guide to embeddings for AI builders. Covers how embeddings work, the best models in 2026, and working Python code for generating and searching embeddings.
13 min read
Fine-tuning LLMs: complete guide to LoRA, QLoRA, and when to fine-tune (2026)
A practical guide to fine-tuning large language models in 2026. Covers LoRA, QLoRA, dataset creation, and an honest framework for when fine-tuning beats RAG.
16 min read