Why Claude API + LangGraph for Stateful AI Agents?
Claude models from Anthropic excel in reasoning, safety, and handling complex instructions, making them ideal for AI agents. LangGraph, an extension of the LangChain ecosystem, enables building resilient, stateful multi-actor applications as graphs. Unlike stateless chains, LangGraph supports cycles, branching, and persistence, perfect for agents that maintain context over long-running tasks like research, planning, or customer support.
This tutorial walks you through creating a persistent conversational research agent using Claude 3.5 Sonnet via the Anthropic API. The agent will:
- Accept user queries (e.g., "Research Python web frameworks").
- Break tasks into steps.
- Simulate tool calls (search, summarize).
- Persist state across sessions using LangGraph's checkpointer.
By the end, you'll have a production-ready agent handling multi-turn interactions with full memory.
Prerequisites
- Python 3.10+
- Anthropic API key (get one at console.anthropic.com)
- Basic familiarity with LangChain concepts
Set your API key as an environment variable:
export ANTHROPIC_API_KEY='your-api-key-here'
Installation
Install the required packages:
pip install langgraph langchain-anthropic langchain-core langchain-community pydantic
langgraph: Core library for graphs.langchain-anthropic: Claude integration.langchain-core: Shared abstractions.pydantic: For state schemas.
Defining the Agent State
LangGraph uses a typed state to track progress. For our research agent, we'll use a message-based state with conversation history.
import operator
from typing import Annotated, TypedDict
from langchain_core.messages import BaseMessage
from langgraph.graph import StateGraph, END
from langgraph.checkpoint.memory import MemorySaver
class AgentState(TypedDict):
messages: Annotated[list[BaseMessage], operator.add]
This state appends messages immutably, enabling persistence.
Setting Up the Claude Model
Initialize Claude 3.5 Sonnet, optimized for agentic workflows:
from langchain_anthropic import ChatAnthropic
model = ChatAnthropic(model="claude-3-5-sonnet-20240620", temperature=0)
Claude's large context window (200K tokens) shines here, handling extensive histories without truncation issues common in other models.
Defining Agent Nodes
Nodes are functions that update state. We'll create:
call_model: Invokes Claude to decide actions.tool_node: Simulates research tools.should_continue: Router to loop or end.
First, bind tools to the model (using LangChain's tool-calling format, which Claude supports natively):
def research_tool(state):
"""Simulate web research."""
last_message = state['messages'][-1].content
return "Research results: [Dummy data on " + last_message + "]: Django and FastAPI are top Python frameworks...]"
def summarize_tool(state):
"""Summarize findings."""
return "Summary: Django for full-stack, FastAPI for APIs."
from langchain_core.tools import tool
@tool
def research(query: str) -> str:
"""Conduct research on a topic."""
return research_tool({'messages': [type('msg', (), {'content': query})()]})
@tool
def summarize_findings(text: str) -> str:
"""Summarize research text."""
return summarize_tool({'messages': [type('msg', (), {'content': text})()]})
tools = [research, summarize_findings]
model_with_tools = model.bind_tools(tools)
Now, the nodes:
def call_model(state):
messages = state['messages']
response = model_with_tools.invoke(messages)
return {"messages": [response]}
def tool_node(state):
outputs = []
for tool_call in state["messages"][-1].tool_calls:
tool_name = tool_call["name"]
tool_args = tool_call["args"]
if tool_name == "research":
output = research(tool_args["query"])
elif tool_name == "summarize_findings":
output = summarize_findings(tool_args["text"])
tool_message = {
"role": "tool",
"tool_call_id": tool_call["id"],
"content": output
}
outputs.append(tool_message)
return {"messages": outputs}
def should_continue(state):
last_message = state['messages'][-1]
if last_message.tool_calls:
return "tools"
return END
Compiling the Stateful Graph
Connect nodes into a graph with persistence:
checkpointer = MemorySaver()
workflow = StateGraph(state_schema=AgentState)
workflow.add_node("agent", call_model)
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=checkpointer)
MemorySaver persists state in memory (use SqliteSaver for disk persistence in production).
Running the Agent with Persistence
Interact via thread_id for stateful sessions:
config = {"configurable": {"thread_id": "agent-thread-1"}}
# First interaction
input_message = {"messages": [("user", "Research top Python web frameworks.")]}
for chunk in app.stream(input_message, config, stream_mode="values"):
chunk["messages"][-1].pretty_print()
# Subsequent interaction - state persists!
input_message = {"messages": [("user", "Summarize the findings and compare Django vs FastAPI.")]}
for chunk in app.stream(input_message, config, stream_mode="values"):
chunk["messages"][-1].pretty_print()
Output example:
================================ Run 1 ================================
User: Research top Python web frameworks.
Assistant: I need to research top Python web frameworks. [Invokes research tool]
================================ Tool =================================
Research results: [Dummy data... Django and FastAPI...]
================================ Run 2 ================================
User: Summarize...
Assistant: [Uses prior context] Django excels in batteries-included apps...
Claude retains full history, reasoning over past tools seamlessly.
Advanced Features
Custom Tools with Real APIs
Replace dummies with real tools, e.g., Tavily search:
pip install tavily-python
import os
os.environ["TAVILY_API_KEY"] = "your-key"
from tavily import TavilyClient
client = TavilyClient(api_key=os.getenv("TAVILY_API_KEY"))
@tool
def real_research(query: str) -> str:
results = client.search(query)
return str(results)
Claude's tool-calling precision outperforms GPT-4o in benchmarks for structured outputs.
Human-in-the-Loop
Add approval nodes:
def human_review(state):
print("Approve? (y/n)")
return {"messages": [("human", input())]}
workflow.add_node("human", human_review)
workflow.add_conditional_edges("agent", lambda s: "human" if "review" in s['messages'][-1].content else "tools")
Streaming and Async
For production, use astream:
async for chunk in app.astream(input_message, config):
# Handle real-time UI updates
pass
Deployment
- LangGraph Platform: Host on LangGraph Cloud.
- FastAPI: Wrap in an API server.
from fastapi import FastAPI
app = FastAPI()
@app.post("/invoke")
def invoke(body: dict):
return app.invoke(body["input"], {"configurable": {"thread_id": body["thread_id"]}})
Best Practices for Claude Agents
- Prompt Engineering: Use XML tags for Claude:
<thinking>reason</thinking><action>tool</action>. - Error Handling: Wrap nodes in try-except, retry with Claude's
max_tokens. - State Pruning: Compress history with Claude summaries to fit context.
- Monitoring: Log checkpoints for debugging.
- Cost Optimization: Use Haiku for simple nodes, Sonnet for reasoning.
Real-World Use Cases
- HR: Resume screening agent with persistent applicant state.
- Sales: Lead qualification bot remembering prior calls.
- Engineering: Code review agent tracking iterations.
| Feature | Claude + LangGraph | GPT + LangGraph |
|---|---|---|
| Context | 200K tokens | 128K |
| Tool Precision | 95%+ | 90% |
| Safety | Constitutional AI | Guardrails needed |
Conclusion
You've built a stateful research agent leveraging Claude's strengths and LangGraph's flexibility. Extend it with MCP servers for native tools or integrate into n8n/Zapier. Check Anthropic's API docs for updates.
Source code: [GitHub repo link placeholder]
Experiment—fork and deploy your agent today!
Stay ahead of the AI curve
The most important updates, news, and content — delivered in one weekly newsletter.