Module 04 · ~75 min

LangGraph core: state, nodes, edges

In Module 3 you used a prebuilt agent. Now we lift the hood. By the end of this module you'll have built the same ReAct loop from scratch as an explicit graph — and you'll understand exactly what every line is doing.

1. The three concepts

A LangGraph application is built from three concepts:

  • State — a typed dictionary (Python TypedDict / TS object) that flows through the graph. Every node reads the state and returns a partial update.
  • Nodes — plain functions: (state) => partial_state_update. That's it. A node can call the LLM, call a tool, mutate a counter, anything.
  • Edges — wires from node to node. Two kinds: regular ("after A always go to B") and conditional ("after A, ask this function which node to go to next").

You assemble nodes and edges into a StateGraph, compile it, and you get back a runnable object with the same .invoke(), .stream(), .batch() interface as any other LangChain runnable.

2. State and reducers

State is a TypedDict. By default, when a node returns {"foo": "bar"}, the graph replaces state["foo"] with "bar". That's fine for most fields. But for the message history we want append, not replace — every node adds to the list, none of them replaces it.

That's what a reducer is: a function that combines the old value and the new value when a node returns an update. LangGraph ships an add_messages reducer specifically for the messages list. You attach it via an Annotated type hint.

from typing import Annotated, TypedDict
from langchain_core.messages import AnyMessage
from langgraph.graph.message import add_messages

class JarvisState(TypedDict):
    messages: Annotated[list[AnyMessage], add_messages]   # reducer = append
    user_id: str                                          # no reducer = replace
import { Annotation, messagesStateReducer } from "@langchain/langgraph";
import type { BaseMessage } from "@langchain/core/messages";

const JarvisState = Annotation.Root({
  messages: Annotation<BaseMessage[]>({
    reducer: messagesStateReducer,    // append, not replace
    default: () => [],
  }),
  user_id: Annotation<string>(),       // no reducer = replace
});
Why reducers matter

Without add_messages, the second node would overwrite the first node's message list and your conversation history would vanish. Use it on every messages field, every time.

3. Nodes — just functions

A node takes the state and returns a partial update. Here are the two nodes we need to rebuild the ReAct loop: one that calls the LLM, one that executes any tools the LLM asked for.

from langchain_anthropic import ChatAnthropic
from langchain_core.messages import SystemMessage, ToolMessage

model = ChatAnthropic(model="claude-sonnet-4-6", temperature=0)
TOOLS = [open_it_ticket, find_room, schedule_meeting]   # from Module 3
TOOLS_BY_NAME = {t.name: t for t in TOOLS}
model_with_tools = model.bind_tools(TOOLS)

SYSTEM = SystemMessage(content="You are Jarvis, Acme Robotics' internal assistant. Use tools when needed.")

def call_model(state: JarvisState):
    """LLM node: read history, return AI message (maybe with tool calls)."""
    ai = model_with_tools.invoke([SYSTEM] + state["messages"])
    return {"messages": [ai]}            # add_messages will append

def run_tools(state: JarvisState):
    """Tool node: execute every tool the last AI message asked for."""
    last_ai = state["messages"][-1]
    results = []
    for call in last_ai.tool_calls:
        tool_fn = TOOLS_BY_NAME[call["name"]]
        output = tool_fn.invoke(call["args"])
        results.append(ToolMessage(content=str(output), tool_call_id=call["id"]))
    return {"messages": results}
import { ChatAnthropic } from "@langchain/anthropic";
import { SystemMessage, ToolMessage } from "@langchain/core/messages";

const model = new ChatAnthropic({ model: "claude-sonnet-4-6", temperature: 0 });
const TOOLS = [openItTicket, findRoom, scheduleMeeting];   // from Module 3
const TOOLS_BY_NAME = Object.fromEntries(TOOLS.map(t => [t.name, t]));
const modelWithTools = model.bindTools(TOOLS);

const SYSTEM = new SystemMessage("You are Jarvis, Acme Robotics' internal assistant. Use tools when needed.");

async function callModel(state: typeof JarvisState.State) {
  const ai = await modelWithTools.invoke([SYSTEM, ...state.messages]);
  return { messages: [ai] };
}

async function runTools(state: typeof JarvisState.State) {
  const lastAi = state.messages[state.messages.length - 1] as any;
  const results: ToolMessage[] = [];
  for (const call of lastAi.tool_calls ?? []) {
    const output = await TOOLS_BY_NAME[call.name].invoke(call.args);
    results.push(new ToolMessage({ content: String(output), tool_call_id: call.id }));
  }
  return { messages: results };
}

Notice: both functions are just functions. They take state, they return a partial update. There's no magic.

4. Edges — wiring it up

After call_model, we need to ask: did the LLM ask for a tool? If yes, run the tools and loop back. If no, the LLM produced a final answer and we're done.

from langgraph.graph import StateGraph, START, END

def should_continue(state: JarvisState) -> str:
    last = state["messages"][-1]
    return "tools" if last.tool_calls else END

graph = StateGraph(JarvisState)
graph.add_node("model", call_model)
graph.add_node("tools", run_tools)

graph.add_edge(START, "model")             # always start at model
graph.add_conditional_edges(
    "model",
    should_continue,
    {"tools": "tools", END: END},          # route based on the function's return
)
graph.add_edge("tools", "model")           # after tools, loop back to model

agent = graph.compile()
import { StateGraph, START, END } from "@langchain/langgraph";

function shouldContinue(state: typeof JarvisState.State) {
  const last = state.messages[state.messages.length - 1] as any;
  return last.tool_calls?.length ? "tools" : END;
}

const graph = new StateGraph(JarvisState)
  .addNode("model", callModel)
  .addNode("tools", runTools)
  .addEdge(START, "model")
  .addConditionalEdges("model", shouldContinue, { tools: "tools", [END]: END })
  .addEdge("tools", "model");

const agent = graph.compile();
START
model
tools
END
The full ReAct agent. Two nodes, three edges (one of which is conditional), and a loop. That's it.

This is exactly what create_react_agent built for you in Module 3. You now own the implementation.

5. Running and streaming

Compiled graphs behave like any LangChain runnable: .invoke() returns the final state, .stream() yields incremental updates as nodes run.

# Single shot — returns the full final state
final = agent.invoke({"messages": [("user", "Printer floor 3 jammed, also book Priya at 2pm tomorrow")], "user_id": "priya@acme.com"})

# Streaming — yields the partial state after each node executes
for step in agent.stream({"messages": [("user", "...")], "user_id": "priya@acme.com"}):
    for node, update in step.items():
        print(f"{node} ->", update)
// Single shot
const final = await agent.invoke({
  messages: [{ role: "user", content: "Printer floor 3 jammed, also book Priya at 2pm tomorrow" }],
  user_id: "priya@acme.com",
});

// Streaming
for await (const step of await agent.stream({
  messages: [{ role: "user", content: "..." }],
  user_id: "priya@acme.com",
})) {
  for (const [node, update] of Object.entries(step)) console.log(node, "->", update);
}

6. Conditional routing — beyond ReAct

Conditional edges are how you express any branching logic — the LLM doesn't have to be the router. Sometimes you want a classifier-then-route pattern: first decide what kind of request this is, then send it to a specialised handler.

from typing import Literal

def classify(state):
    """Have the LLM classify the request type."""
    res = model.with_structured_output(RequestType).invoke(state["messages"])
    return {"kind": res.kind}

def route(state) -> Literal["it_node", "hr_node", "calendar_node"]:
    return f"{state['kind']}_node"

graph.add_node("classify", classify)
graph.add_node("it_node", handle_it)
graph.add_node("hr_node", handle_hr)
graph.add_node("calendar_node", handle_calendar)

graph.add_edge(START, "classify")
graph.add_conditional_edges("classify", route, {
    "it_node": "it_node",
    "hr_node": "hr_node",
    "calendar_node": "calendar_node",
})
async function classify(state: any) {
  const res = await model.withStructuredOutput(RequestType).invoke(state.messages);
  return { kind: res.kind };
}

function route(state: any) {
  return `${state.kind}_node`;
}

graph
  .addNode("classify", classify)
  .addNode("it_node", handleIt)
  .addNode("hr_node", handleHr)
  .addNode("calendar_node", handleCalendar)
  .addEdge(START, "classify")
  .addConditionalEdges("classify", route, {
    it_node: "it_node",
    hr_node: "hr_node",
    calendar_node: "calendar_node",
  });

That's a teaser for Module 7 (multi-agent), where each "kind_node" is itself a small agent.

7. Parallel branches

If two nodes both have edges to a third node, they run in parallel by default. Useful when you want to fan out (e.g. call the IT knowledge base and the HR knowledge base at the same time) and then merge results.

Tip

When fanning out, the reducer on the merged field decides how parallel updates combine. add_messages is safe — it just appends both. For your own fields, define a reducer (e.g. a list concat or a dict merge) or you'll get clobbered state.

8. Visualising your graph

Once compiled, you can render the graph as Mermaid/PNG — useful for debugging and for sticking in design docs:

print(agent.get_graph().draw_mermaid())
# Renders as a Mermaid diagram. In a notebook:
# from IPython.display import Image; Image(agent.get_graph().draw_mermaid_png())
const drawable = agent.getGraph();
console.log(drawable.drawMermaid());

9. What you can now do

  • Define a typed graph state with reducers.
  • Write node functions that read state and return partial updates.
  • Wire nodes with regular and conditional edges.
  • Build, compile, run, and stream a graph.
  • Read someone else's LangGraph code without it feeling like magic.

Every later module is just more nodes, more state, fancier edges. The mechanics don't change.

★ Jarvis status

Jarvis is now an explicit graph you control. Next we make those fake tools real, so Jarvis can actually do things.

Quick check

1. What is a reducer in LangGraph?

2. You forget to annotate messages with add_messages. What happens?

3. How does the graph decide whether to go from model to tools or to END after the LLM runs?

4. Two nodes both have an outgoing edge to a third node. By default LangGraph will: