Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Build a correct LangGraph 1.0 ReAct agent with `create_react_agent` — typed tools, error propagation, recursion caps, and stop conditions that actually stop. Use when writing your first tool-calling agent, migrating from `AgentExecutor` / `initialize_agent`, or diagnosing an agent that loops on vague prompts. Trigger with "langgraph agent", "create_react_agent", "langgraph tool calling", "AgentExecutor migration", "agent loop cost".
.claude/skills/jeremylongshore-langchain-langgraph-agents/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 121% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 82% | 0% |
| case-07 | ✗→✓ | ▲ Improved | 129% | 0% |
| case-15 | ✗→✓ | ▲ Improved | 374% | 0% |
| case-21 | ✗→✓ | ▲ Improved | 719% | 0% |
Two failure modes hit every team writing their first LangGraph 1.0 ReAct agent:
Loop-to-cap on vague prompts (P10). create_react_agent defaults to recursion_limit=25. A prompt like "help me with my account" never converges — the model calls a retrieval tool, gets irrelevant results, calls another tool, and repeats until GraphRecursionError: Recursion limit of 25 reached without hitting a stop condition fires. Cost dashboards show the damage after the fact: $5-$15 per runaway loop on Sonnet with a 3-tool agent, assuming no tool is itself expensive.
Silent tool errors on legacy AgentExecutor (P09). The legacy executor defaults handle_parsing_errors=True and catches tool exceptions, feeding the error string back as the next observation. When the error serializes to empty (e.g., a ValueError("") or an HTTP 500 with no body), the loop continues with no signal. The agent says "I couldn't find the answer" — which was actually a silent crash three tool calls ago.
This skill walks through defining typed tools with @tool + Pydantic; building an agent with create_react_agent(model, tools, checkpointer=MemorySaver()); invoking with {"messages": [...]} and a thread-scoped config; setting recursion_limit per expected agent depth (5-10 interactive, 20-30 planner); adding middleware for a per-session token budget; and raise-by-default error propagation. Pin: langgraph >= 1.0, < 2.0, langchain-core >= 1.0, < 2.0. Pain-catalog anchors: P09, P10, P11, P32, P41, P42, P63.
langgraph >= 1.0, < 2.0 and langchain-core >= 1.0, < 2.0pip install langchain-anthropic or langchain-openailangchain-langgraph-basics (L25) — you already know StateGraph,MessagesState, and checkpointers
ANTHROPIC_API_KEY or OPENAI_API_KEYpythonfrom typing import Annotated from pydantic import BaseModel, Field from langchain_core.tools import tool class LookupAccountArgs(BaseModel): account_id: str = Field(..., description="Account UUID. No email addresses.") @tool("lookup_account", args_schema=LookupAccountArgs) def lookup_account(account_id: str) -> dict: """Fetch an account record by UUID. Returns status, plan, and owner email.""" if not account_id: raise ValueError("account_id is required") # raised → agent sees real error return {"id": account_id, "status": "active", "plan": "pro", "owner": "a@b.co"}
Two rules that catch teams off-guard:
1024 chars (P11). Anthropic truncates at ~1024; OpenAI's effective cap is softer but still bites on tool descriptions over ~2KB. Long docstrings with examples should move into a system prompt, not the tool description.
AgentExecutor, LangGraph'screate_react_agent does not silently swallow tool errors — the exception propagates and surfaces in your observability layer. See Step 6.
For async tools, use @tool on an async def — LangGraph invokes it via await. For structured return types, annotate the return with a Pydantic model.
See Tool Definition Patterns for the @tool vs tool() decision, async tools, and the args_schema vs auto-inferred trade-off.
create_react_agentpythonfrom langgraph.prebuilt import create_react_agent from langgraph.checkpoint.memory import MemorySaver from langchain_anthropic import ChatAnthropic model = ChatAnthropic( model="claude-sonnet-4-6", temperature=0, timeout=30, max_retries=2, ) agent = create_react_agent( model=model, tools=[lookup_account], checkpointer=MemorySaver(), # required for stateful invocations )
create_react_agent is the LangGraph 1.0 replacement for the removed initialize_agent factory (P41). Under the hood it builds a StateGraph with a model node and a ToolNode, plus a conditional edge that routes to END when the model emits no tool calls. The checkpointer persists state per-thread — required for multi-turn conversations and for resuming after interruption.
pythonconfig = {"configurable": {"thread_id": "user-42"}} result = agent.invoke( {"messages": [{"role": "user", "content": "look up account uuid-abc"}]}, config=config, ) print(result["messages"][-1].content)
Key contracts:
{"messages": [...]} — a list of message dicts or LangChainHumanMessage / SystemMessage objects. You append to this list across turns.
thread_id scopes the checkpointer. Reusing it resumes the conversation.result["messages"] is the completemessage list; the final assistant message is at index -1.
recursion_limit to your expected agent depthcreate_react_agent defaults to recursion_limit=25. In LangGraph one "recursion step" is one node visit, and each tool round-trip is two visits (model node + tool node), so 25 means ~12 tool calls. For most workloads this is too generous and hides bugs:
| Agent kind | Suggested recursion_limit | Rationale | |---|---|---| | Interactive chat with 1-3 tools | 5-10 | One tool call + one final answer is 3 visits. Cap low to expose loops. | | Task-completion (e.g., booking flow) | 10-15 | 3-5 tool calls + final answer. | | Planner / research agent | 20-30 | Expect multiple retrieval + synthesis rounds. | | Multi-agent supervisor | 40+ | Coordinator + worker rounds. Budget tokens separately. |
Apply it on invocation, not at construction time:
pythonresult = agent.invoke( {"messages": [...]}, config={"configurable": {"thread_id": "user-42"}, "recursion_limit": 10}, )
When the limit fires, LangGraph raises GraphRecursionError — catch it and surface a user-facing message; do not retry without a cost guard.
recursion_limit alone does not bound cost. A single tool call that returns a large document and triggers a long model response can cost more than 10 cheap tool calls. Cap tokens explicitly:
pythonfrom langchain_core.callbacks import BaseCallbackHandler class TokenBudget(BaseCallbackHandler): def __init__(self, max_tokens: int = 50_000): self.used = 0 self.max = max_tokens def on_llm_end(self, response, **kwargs): usage = getattr(response, "llm_output", {}).get("token_usage", {}) or {} self.used += usage.get("total_tokens", 0) if self.used > self.max: raise RuntimeError(f"Token budget exceeded: {self.used}/{self.max}") budget = TokenBudget(max_tokens=50_000) result = agent.invoke( {"messages": [...]}, config={ "configurable": {"thread_id": "user-42"}, "recursion_limit": 10, "callbacks": [budget], }, )
A per-session budget of 50K tokens on Sonnet is roughly $0.25 — a safe cap for interactive agents. For background planners raise to 200K-500K. See Loop Caps and Budgets for a repeated-tool-call early-stop node and a middleware pattern that terminates on the N-th identical call.
LangGraph's default is to raise. Legacy AgentExecutor(handle_parsing_errors=True) swallowed everything. The new defaults are safer but different:
python# Tool raises → the exception propagates out of agent.invoke() try: result = agent.invoke({"messages": [{"role": "user", "content": "..."}]}, config=config) except ValueError as e: # Your tool's own ValueError — log + user-facing message ...
When you want tolerant behavior (e.g., the tool is a flaky third-party API and you want the model to try a different approach), wrap the tool itself:
pythonfrom langchain_core.tools import tool @tool def search_kb(query: str) -> str: """Search the internal knowledge base. Returns hits or a 'no results' string.""" try: return _real_search(query) except HTTPError as e: return f"search_kb unavailable: {e.response.status_code}. Try a different query."
The key insight: the tool decides to degrade gracefully by returning a string the model can reason about. The agent never silently drops an error. See Error Propagation for a custom error-handler node that routes tool failures to a fallback tool.
create_react_agent vs custom StateGraph vs legacy| Decision | Use | Why | |---|---|---| | Single agent, tool-calling loop | create_react_agent | Correct defaults, provider-native tool calling, smallest code surface | | Multi-stage pipeline (plan → execute → review) | Custom StateGraph | You need named nodes, explicit conditional edges, typed state | | Multi-agent supervisor | create_supervisor + workers built with create_react_agent | Built-in routing, per-worker checkpointing | | New code in 2026+ | Never use AgentExecutor or initialize_agent | Removed / deprecated in 1.0 (P41); shape changes in intermediate_steps (P42) |
For a single forced-tool single-shot (e.g., "always classify into one of these buckets"), skip agents entirely: use model.bind_tools([Schema], tool_choice={"type": "tool", "name": "Schema"}). But never loop a forced tool_choice (P63) — the model cannot emit stop_reason="end_turn" under forced tool_choice, so the agent never terminates.
create_react_agent(model, tools, checkpointer=MemorySaver())@tool + Pydantic args_schema, docstrings under 1024 chars{"configurable": {"thread_id": ...}, "recursion_limit": N}TokenBudget callback enforces per-session cost ceilingexplicit (return-a-string, not silent-swallow)
create_react_agent vs custom StateGraph vssupervisor vs legacy
| Error | Cause | Fix | |-------|-------|-----| | GraphRecursionError: Recursion limit of 25 reached without hitting a stop condition | Vague prompt never converges; default cap too high (P10) | Lower recursion_limit to 5-10 interactive; add repeated-tool-call early-stop node | | ImportError: cannot import name 'initialize_agent' from 'langchain.agents' | Legacy 0.2 agent factory removed (P41) | from langgraph.prebuilt import create_react_agent | | AttributeError: 'ToolCall' object has no attribute 'tool' | Old code accessing step.tool on new intermediate step shape (P42) | Use step.tool_name (or step["name"] on dict form); check isinstance(step, ToolCall) | | Agent says "couldn't find answer" but tool actually raised | Legacy AgentExecutor handle_parsing_errors=True silently swallowed exception (P09) | Migrate to create_react_agent; errors raise by default | | Agent loops when tool_choice={"type": "tool", "name": "X"} is set | Forced tool_choice blocks stop_reason="end_turn" (P63) | Use tool_choice="auto" for agent loops; reserve forced choice for one-shot calls | | Agent hallucinates a tool name like exec that is not in tools=[...] | Older free-text ReAct parser accepts any string (P32) | Use create_react_agent — it relies on provider-native tool calling; the allowlist is wire-enforced | | RuntimeError: Token budget exceeded | Your TokenBudget callback fired | Working as intended; raise the cap or shorten the agent's scope | | Tool description truncated, model calls with wrong args | Docstring exceeded 1024-char cap (P11) | Shorten docstring; move examples into system prompt |
AgentExecutor agentBefore (LangChain 0.2):
pythonfrom langchain.agents import initialize_agent, AgentType agent = initialize_agent( tools, llm, agent=AgentType.OPENAI_FUNCTIONS, handle_parsing_errors=True, return_intermediate_steps=True, ) result = agent.invoke({"input": "..."}) for action, observation in result["intermediate_steps"]: print(action.tool, observation) # .tool attribute
After (LangGraph 1.0):
pythonfrom langgraph.prebuilt import create_react_agent from langgraph.checkpoint.memory import MemorySaver agent = create_react_agent(llm, tools, checkpointer=MemorySaver()) result = agent.invoke( {"messages": [{"role": "user", "content": "..."}]}, config={"configurable": {"thread_id": "t1"}, "recursion_limit": 10}, ) # intermediate steps are now ToolMessage entries in the messages list for m in result["messages"]: if m.type == "tool": print(m.name, m.content) # .name, not .tool
See AgentExecutor Migration for the full before/after including handle_parsing_errors, return_intermediate_steps, and max_iterations translations.
A customer-support agent with two tools, 10-step recursion cap, and a 30K token budget. See Loop Caps and Budgets for the full example with a repeated-tool-call early-stop node.
create_react_agent reference@tool decoratordocs/pain-catalog.md (entries P09, P10, P11, P32, P41, P42, P63)| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-04 | pass→pass | 25,619 | 28,517 | +11% | 1 | 1 | 0% | 3,284 | 8,523 | +160% | 0 | 0 | — |
case-11 | pass→pass | 26,210 | 19,310 | -26% | 1 | 1 | 0% | 4,295 | 6,910 | +61% | 0 | 0 | — |
case-05 | pass→pass | 10,638 | 12,310 | +16% | 1 | 1 | 0% | 2,108 | 5,465 | +159% | 0 | 0 | — |
case-01 | fail→pass | 22,480 | 22,268 | -1% | 1 | 1 | 0% | 3,512 | 7,745 | +121% | 0 | 0 | — |
case-02 | fail→pass | 26,066 | 21,034 | -19% | 1 | 1 | 0% | 4,023 | 7,312 | +82% | 0 | 0 | — |
case-03 | pass→pass | 26,682 | 17,104 | -36% | 1 | 1 | 0% | 4,930 | 7,384 | +50% | 0 | 0 | — |
case-06 | pass→pass | 21,490 | 16,012 | -25% | 1 | 1 | 0% | 2,532 | 5,918 | +134% | 0 | 0 | — |
case-07 | fail→pass | 19,827 | 13,918 | -30% | 1 | 1 | 0% | 2,439 | 5,582 | +129% | 0 | 0 | — |
case-08 | pass→pass | 11,415 | 12,811 | +12% | 1 | 1 | 0% | 1,922 | 5,329 | +177% | 0 | 0 | — |
case-09 | pass→pass | 19,080 | 17,218 | -10% | 1 | 1 | 0% | 2,365 | 6,242 | +164% | 0 | 0 | — |
case-10 | pass→pass | 12,444 | 16,152 | +30% | 1 | 1 | 0% | 2,240 | 6,073 | +171% | 0 | 0 | — |
case-12 | pass→pass | 16,403 | 10,909 | -33% | 1 | 1 | 0% | 2,000 | 6,116 | +206% | 0 | 0 | — |
case-13 | pass→pass | 14,174 | 26,592 | +88% | 1 | 1 | 0% | 1,658 | 5,269 | +218% | 0 | 0 | — |
case-14 | pass→pass | 14,463 | 9,088 | -37% | 1 | 1 | 0% | 1,336 | 5,260 | +294% | 0 | 0 | — |
case-15 | fail→pass | 32,653 | 9,333 | -71% | 1 | 1 | 0% | 1,216 | 5,763 | +374% | 0 | 0 | — |
case-21 | fail→pass | 8,186 | 3,435 | -58% | 1 | 1 | 0% | 541 | 4,432 | +719% | 0 | 0 | — |
case-16 | pass→pass | 17,693 | 12,046 | -32% | 1 | 1 | 0% | 1,730 | 5,268 | +205% | 0 | 0 | — |
case-17 | pass→pass | 15,995 | 16,104 | +1% | 1 | 1 | 0% | 2,004 | 5,938 | +196% | 0 | 0 | — |
case-18 | fail→pass | 18,447 | 11,876 | -36% | 1 | 1 | 0% | 2,386 | 5,856 | +145% | 0 | 0 | — |
case-19 | pass→pass | 19,469 | 16,990 | -13% | 1 | 1 | 0% | 2,767 | 6,265 | +126% | 0 | 0 | — |
case-20 | fail→pass | 19,954 | 17,251 | -14% | 1 | 1 | 0% | 2,394 | 6,168 | +158% | 0 | 0 | — |
case-22 | pass→pass | 8,880 | 9,415 | +6% | 1 | 1 | 0% | 1,664 | 5,768 | +247% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted, and 21 counted toward the lift figure. The other 1 produced results that are not comparable between the two arms, so they are excluded from the headline rather than averaged into it. The headline lift of +32 percentage points is the difference between those two pass rates over the 21 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.