Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Reviews LangGraph code for bugs, anti-patterns, and improvements. Use when reviewing code that uses StateGraph, nodes, edges, checkpointing, or other LangGraph features. Catches common mistakes in state management, graph structure, and async patterns.
.claude/skills/majiayu000-langgraph-code-review/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-10 | ✗→✓ | ▲ Improved | 137% | 0% |
| case-18 | ✗→✓ | ▲ Improved | 62% | 0% |
| case-01 | ✓→✓ | = Same ✓ | 60% | 0% |
| case-02 | ✓→✓ | = Same ✓ | 164% | 0% |
| case-03 | ✓→✓ | = Same ✓ | 106% | 0% |
When reviewing LangGraph code, check for these categories of issues.
python# BAD - mutates state directly def my_node(state: State) -> None: state["messages"].append(new_message) # Mutation! # GOOD - returns partial update def my_node(state: State) -> dict: return {"messages": [new_message]} # Let reducer handle it
python# BAD - no reducer, each node overwrites class State(TypedDict): messages: list # Will be overwritten, not appended! # GOOD - reducer appends class State(TypedDict): messages: Annotated[list, operator.add] # Or use add_messages for chat: messages: Annotated[list, add_messages]
python# BAD - returns invalid node name def router(state) -> str: return "nonexistent_node" # Runtime error! # GOOD - use Literal type hint for safety def router(state) -> Literal["agent", "tools", "__end__"]: if condition: return "agent" return END # Use constant, not string
python# BAD - interrupt without checkpointer def my_node(state): answer = interrupt("question") # Will fail! return {"answer": answer} graph = builder.compile() # No checkpointer! # GOOD - checkpointer required for interrupts graph = builder.compile(checkpointer=InMemorySaver())
python# BAD - no thread_id graph.invoke({"messages": [...]}) # Error with checkpointer! # GOOD - always provide thread_id config = {"configurable": {"thread_id": "user-123"}} graph.invoke({"messages": [...]}, config)
python# BAD - add_messages expects message-like objects class State(TypedDict): messages: Annotated[list, add_messages] def node(state): return {"messages": ["plain string"]} # May fail! # GOOD - use proper message types or tuples def node(state): return {"messages": [("assistant", "response")]} # Or: [AIMessage(content="response")]
python# BAD - returns entire state (may reset other fields) def my_node(state: State) -> State: return { "counter": state["counter"] + 1, "messages": state["messages"], # Unnecessary! "other": state["other"] # Unnecessary! } # GOOD - return only changed fields def my_node(state: State) -> dict: return {"counter": state["counter"] + 1}
python# BAD - Pydantic model without reducer loses append behavior class State(BaseModel): messages: list # No reducer! # GOOD - use Annotated even with Pydantic class State(BaseModel): messages: Annotated[list, add_messages]
python# BAD - no edge from START builder.add_node("process", process_fn) builder.add_edge("process", END) graph = builder.compile() # Error: no entrypoint! # GOOD - connect START builder.add_edge(START, "process")
python# BAD - orphan node builder.add_node("main", main_fn) builder.add_node("orphan", orphan_fn) # Never reached! builder.add_edge(START, "main") builder.add_edge("main", END) # Check with visualization print(graph.get_graph().draw_mermaid())
python# BAD - missing path in conditional def router(state) -> Literal["a", "b", "c"]: ... builder.add_conditional_edges("node", router, {"a": "a", "b": "b"}) # "c" path missing! # GOOD - include all possible returns builder.add_conditional_edges("node", router, {"a": "a", "b": "b", "c": "c"}) # Or omit path_map to use return values as node names
python# BAD - Command return without destinations (breaks visualization) def dynamic(state) -> Command[Literal["next", "__end__"]]: return Command(goto="next") builder.add_node("dynamic", dynamic) # Graph viz won't show edges # GOOD - declare destinations builder.add_node("dynamic", dynamic, destinations=["next", END])
python# BAD - async node called with sync invoke async def my_node(state): result = await async_operation() return {"result": result} graph.invoke(input) # May not await properly! # GOOD - use ainvoke for async graphs await graph.ainvoke(input) # Or provide both sync and async versions
python# BAD - blocking call in async node async def my_node(state): result = requests.get(url) # Blocks event loop! return {"result": result} # GOOD - use async HTTP client async def my_node(state): async with httpx.AsyncClient() as client: result = await client.get(url) return {"result": result}
python# BAD - AI message with tool_calls but no tool execution messages = [ HumanMessage(content="search for X"), AIMessage(content="", tool_calls=[{"id": "1", "name": "search", ...}]) # Missing ToolMessage! Next LLM call will fail ] # GOOD - always pair tool_calls with ToolMessage messages = [ HumanMessage(content="search for X"), AIMessage(content="", tool_calls=[{"id": "1", "name": "search", ...}]), ToolMessage(content="results", tool_call_id="1") ]
python# BAD - model may call multiple tools including interrupt model = ChatOpenAI().bind_tools([interrupt_tool, other_tool]) # If both called in parallel, interrupt behavior is undefined # GOOD - disable parallel tool calls before interrupt model = ChatOpenAI().bind_tools( [interrupt_tool, other_tool], parallel_tool_calls=False )
python# BAD - in-memory checkpointer loses state on restart graph = builder.compile(checkpointer=InMemorySaver()) # Testing only! # GOOD - use persistent storage in production from langgraph.checkpoint.postgres import PostgresSaver checkpointer = PostgresSaver.from_conn_string(conn_string) graph = builder.compile(checkpointer=checkpointer)
python# BAD - subgraph with explicit False prevents persistence subgraph = sub_builder.compile(checkpointer=False) # GOOD - use None to inherit parent's checkpointer subgraph = sub_builder.compile(checkpointer=None) # Inherits from parent # Or True for independent checkpointing subgraph = sub_builder.compile(checkpointer=True)
python# BAD - returning large data in every node def node(state): large_data = fetch_large_data() return {"large_field": large_data} # Checkpointed every step! # GOOD - use references or store from langgraph.store.memory import InMemoryStore def node(state, *, store: BaseStore): store.put(namespace, key, large_data) return {"data_ref": f"{namespace}/{key}"}
python# BAD - no protection against infinite loops def router(state): return "agent" # Always loops! # GOOD - check remaining steps or use RemainingSteps from langgraph.managed import RemainingSteps class State(TypedDict): messages: Annotated[list, add_messages] remaining_steps: RemainingSteps def check_limit(state): if state["remaining_steps"] < 2: return END return "continue"
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | pass→pass | 16,481 | 10,951 | -34% | 1 | 1 | 0% | 2,077 | 3,315 | +60% | 0 | 0 | — |
case-02 | pass→pass | 11,133 | 9,035 | -19% | 1 | 1 | 0% | 1,136 | 3,001 | +164% | 0 | 0 | — |
case-03 | pass→pass | 15,857 | 16,094 | +1% | 1 | 1 | 0% | 2,059 | 4,239 | +106% | 0 | 0 | — |
case-04 | pass→pass | 7,638 | 8,741 | +14% | 1 | 1 | 0% | 1,264 | 2,845 | +125% | 0 | 0 | — |
case-05 | pass→pass | 6,467 | 4,864 | -25% | 1 | 1 | 0% | 1,150 | 3,058 | +166% | 0 | 0 | — |
case-06 | pass→pass | 16,963 | 14,110 | -17% | 1 | 1 | 0% | 1,918 | 3,672 | +91% | 0 | 0 | — |
case-07 | pass→pass | 9,170 | 6,842 | -25% | 1 | 1 | 0% | 1,606 | 3,461 | +116% | 0 | 0 | — |
case-08 | pass→pass | 7,929 | 5,020 | -37% | 1 | 1 | 0% | 1,410 | 3,160 | +124% | 0 | 0 | — |
case-09 | pass→pass | 4,612 | 8,482 | +84% | 1 | 1 | 0% | 860 | 2,844 | +231% | 0 | 0 | — |
case-10 | fail→pass | 14,053 | 8,539 | -39% | 1 | 1 | 0% | 1,675 | 3,971 | +137% | 0 | 0 | — |
case-11 | pass→pass | 5,684 | 11,046 | +94% | 1 | 1 | 0% | 983 | 3,422 | +248% | 0 | 0 | — |
case-12 | pass→pass | 15,257 | 6,509 | -57% | 1 | 1 | 0% | 1,747 | 3,441 | +97% | 0 | 0 | — |
case-13 | pass→pass | 11,246 | 7,232 | -36% | 1 | 1 | 0% | 1,805 | 3,472 | +92% | 0 | 0 | — |
case-14 | pass→pass | 14,560 | 12,632 | -13% | 1 | 1 | 0% | 1,586 | 3,545 | +124% | 0 | 0 | — |
case-15 | pass→pass | 7,078 | 7,770 | +10% | 1 | 1 | 0% | 1,228 | 3,633 | +196% | 0 | 0 | — |
case-16 | pass→pass | 11,740 | 14,591 | +24% | 1 | 1 | 0% | 1,957 | 3,891 | +99% | 0 | 0 | — |
case-17 | pass→pass | 9,980 | 10,367 | +4% | 1 | 1 | 0% | 1,632 | 3,979 | +144% | 0 | 0 | — |
case-18 | fail→pass | 17,742 | 6,753 | -62% | 1 | 1 | 0% | 2,133 | 3,451 | +62% | 0 | 0 | — |
case-19 | pass→pass | 14,805 | 9,520 | -36% | 1 | 1 | 0% | 1,805 | 3,888 | +115% | 0 | 0 | — |
case-20 | pass→pass | 12,985 | 12,613 | -3% | 1 | 1 | 0% | 1,512 | 3,523 | +133% | 0 | 0 | — |
case-21 | pass→pass | 13,537 | 12,190 | -10% | 1 | 1 | 0% | 1,474 | 3,616 | +145% | 0 | 0 | — |
case-22 | pass→pass | 12,188 | 4,848 | -60% | 1 | 1 | 0% | 1,148 | 3,190 | +178% | 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. The headline lift of +9 percentage points is the difference between those two pass rates over the 22 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.