If you started a LangChain project this year and hit the "middleware or LangGraph?" fork, here is the thing nobody says up front: it isn't a fork between two frameworks. LangChain 1.0's create_agent runs on top of the LangGraph runtime. Middleware is composable sugar over a graph LangChain builds for you. So the real question is which layer your logic belongs in — and that has a clean answer.

Both hit 1.0 in October 2025, published to PyPI the same day (2025-10-17). This is the mature, no-breaking-changes-until-2.0 line, so the shapes below are stable. (If you're still choosing a framework at all, start one level up with Agno vs. LangGraph vs. CrewAI; this piece assumes you've landed on the LangChain stack.)

Middleware: behavior around the loop#

create_agent gives you the standard agent loop — call the model, run the tools it asked for, call the model again. Middleware lets you wrap that loop at defined points without rewriting it. The real hooks on the AgentMiddleware base class are:

Every one has an async twin (abefore_model, awrap_tool_call, and so on). Note what is deliberately missing: there is no before_tool/after_tool and no on_error. Tool interception and error handling both happen inside the wrap_* wrappers. If you went looking for on_error, that's why you didn't find it.

You author a middleware two ways — a decorator for the quick case, or a subclass for anything stateful:

from langchain.agents import create_agent
from langchain.agents.middleware import before_model

@before_model
def log_turn(state, runtime):
    print(f"calling model with {len(state['messages'])} messages")
    return None  # return a dict to update state, or None to pass through

agent = create_agent(model="openai:gpt-4o", tools=[...], middleware=[log_turn])
from langchain.agents.middleware import AgentMiddleware

class LogTurn(AgentMiddleware):
    def before_model(self, state, runtime):
        print(len(state["messages"]))
        return None

Middleware is a Sequence you pass to the middleware= parameter of create_agent. Stack several; they run in order on the way in and reverse order on the way out — the classic onion. And before you write your own, check the shelf: SummarizationMiddleware, PIIMiddleware, HumanInTheLoopMiddleware, ModelCallLimitMiddleware, ToolCallLimitMiddleware, ModelFallbackMiddleware, ModelRetryMiddleware, ToolRetryMiddleware, ToolErrorMiddleware, and more ship in the box. Most "I need to add X around my agent" tasks are already a one-liner.

Nodes: the shape of the computation#

A LangGraph node is a plain function that takes state and returns a partial update. You register nodes on a StateGraph, wire them with edges, and compile to a runnable:

from langgraph.graph import StateGraph, START, END

def retrieve(state):   return {"docs": search(state["query"])}
def answer(state):     return {"reply": llm(state["query"], state["docs"])}

g = StateGraph(State)
g.add_node("retrieve", retrieve)
g.add_node("answer", answer)
g.add_edge(START, "retrieve")
g.add_edge("retrieve", "answer")
g.add_edge("answer", END)
app = g.compile()

The primitives — state, nodes, edges — are the same as they've always been. What you get that middleware can't give you is control over topology: conditional edges, cycles, fan-out/fan-in, multiple cooperating agents as distinct nodes, and per-node checkpoint/replay.

Middleware customizes behavior within an agent loop. Nodes define the shape of the computation. When the logic is the shape, you want a graph.

The rule#

Start with create_agent + middleware. Reach for it whenever your app is fundamentally one agent and you want cross-cutting behavior around it — logging, guardrails, PII redaction, summarization, retries, model fallback, call limits, human approval. Most of it is prebuilt.

Drop to a StateGraph when you're fighting the loop:

If you catch yourself stuffing routing decisions into a before_model hook, that's the signal: the shape of your computation has outgrown the loop, and it's time to draw the graph instead of wrapping it.