Recall Module 3’s opening list of state fields — user_query, documents, tool_result, draft_answer. Every one of those fields gets set by something actually doing work. That something is a node. This module is worth taking seriously for one specific, important reason: it’s genuinely easy, coming from an AI-focused background, to assume a “node” means “a call to a language model.” It doesn’t, and believing it does will quietly limit how you design real workflows.
The one honest definition
A node is any Python callable that takes the current state and returns an update to it. That’s the complete definition. Nothing in it requires AI, a model, or even a network call.
Let’s prove this claim directly, with seven genuinely different kinds of real work, each one a completely valid node.
Node 1: pure Python, no AI at all
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
class State(TypedDict):
user_query: str
is_valid: bool
def validate(state: State) -> dict:
query = state["user_query"].strip()
return {"is_valid": len(query) > 0 and len(query) < 500}
builder = StateGraph(State)
builder.add_node("validate", validate)
builder.add_edge(START, "validate")
builder.add_edge("validate", END)
graph = builder.compile()
print(graph.invoke({"user_query": "Where's my refund?", "is_valid": False}))
No model, no API call, nothing beyond ordinary Python string logic. This is a completely legitimate, genuinely common node — real workflows are full of validation, formatting, and simple transformation steps that don’t need any intelligence at all.
Node 2: an actual LLM call
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langchain.chat_models import init_chat_model
class State(TypedDict):
user_query: str
category: str
model = init_chat_model("openai:gpt-4o-mini")
def classify(state: State) -> dict:
response = model.invoke(f"Classify this as billing, technical, or account: {state['user_query']}")
return {"category": response.content.strip().lower()}
builder = StateGraph(State)
builder.add_node("classify", classify)
builder.add_edge(START, "classify")
builder.add_edge("classify", END)
graph = builder.compile()
print(graph.invoke({"user_query": "I was charged twice this month", "category": ""}))
This is genuinely the node type most people picture first — and it’s exactly as simple as it looks: model.invoke(...) inside an ordinary function, reading from state, returning an update. Nothing about wrapping a model call in a node requires special syntax.
Node 3: a tool, called directly
Recall tools from your LangChain course — a node can call one directly, without needing an entire agent loop around it.
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langchain.tools import tool
class State(TypedDict):
order_id: str
payment_result: str
@tool
def retry_payment(order_id: str) -> str:
"""Retry a failed payment."""
return f"Payment for order {order_id} retried successfully."
def run_payment_retry(state: State) -> dict:
result = retry_payment.invoke({"order_id": state["order_id"]})
return {"payment_result": result}
builder = StateGraph(State)
builder.add_node("run_payment_retry", run_payment_retry)
builder.add_edge(START, "run_payment_retry")
builder.add_edge("run_payment_retry", END)
graph = builder.compile()
print(graph.invoke({"order_id": "o_42", "payment_result": ""}))
Notice there’s no model deciding whether to call this tool — the node calls it directly and deterministically, every single time it runs. This is a genuine, real difference from the LangChain agent loop you already know: in a graph, you decide exactly which tool runs where, rather than leaving that decision entirely to a model’s own judgment.
Node 4: retrieval
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
class State(TypedDict):
user_query: str
documents: list[str]
# a stand-in for a real vector store retriever from your RAG course
def retrieve(state: State) -> dict:
if "refund" in state["user_query"].lower():
return {"documents": ["Refunds are processed within 5-7 business days."]}
return {"documents": []}
builder = StateGraph(State)
builder.add_node("retrieve", retrieve)
builder.add_edge(START, "retrieve")
builder.add_edge("retrieve", END)
graph = builder.compile()
print(graph.invoke({"user_query": "How long until my refund?", "documents": []}))
A retriever node is genuinely no different in shape from any other node — it reads state, does its real work (here, a stand-in for the vector store search from your RAG course), and returns an update. The graph doesn’t treat retrieval as a special case.
Node 5: a genuine business API call
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
class State(TypedDict):
customer_id: str
account_status: str
def check_account_status(state: State) -> dict:
# in a real application, this would be a genuine HTTP call to an internal service
accounts = {"c_1": "active", "c_2": "suspended"}
return {"account_status": accounts.get(state["customer_id"], "unknown")}
builder = StateGraph(State)
builder.add_node("check_account_status", check_account_status)
builder.add_edge(START, "check_account_status")
builder.add_edge("check_account_status", END)
graph = builder.compile()
print(graph.invoke({"customer_id": "c_1", "account_status": ""}))
This is genuinely one of the most common real node types in production workflows — a call out to an actual internal system, with no AI involvement at all in this specific step.
Node 6: a human decision point
Recall from Module 1 the honest gap — “pause and wait” wasn’t a real capability in a simple agent loop. A node can genuinely represent the point where a human decision belongs, even before you’ve learned the full interrupt mechanism that actually pauses execution there.
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
class State(TypedDict):
refund_amount: float
requires_human_approval: bool
def flag_for_approval(state: State) -> dict:
return {"requires_human_approval": state["refund_amount"] > 100}
builder = StateGraph(State)
builder.add_node("flag_for_approval", flag_for_approval)
builder.add_edge(START, "flag_for_approval")
builder.add_edge("flag_for_approval", END)
graph = builder.compile()
print(graph.invoke({"refund_amount": 250.0, "requires_human_approval": False}))
This node doesn’t yet pause anything — it just decides, based on real state, whether a pause should happen. The actual mechanism for genuinely stopping execution and waiting for a human belongs to a dedicated module later in this course. For now, notice that even “should a human be involved” is itself just ordinary logic living inside a node.
Node 7: an entire agent, as a single node
This is worth genuinely sitting with, because it’s a powerful, real pattern: an entire create_agent instance from your LangChain course can itself be one single node in a larger graph.
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langchain.chat_models import init_chat_model
from langchain.tools import tool
from langchain.agents import create_agent
class State(TypedDict):
user_query: str
agent_answer: str
@tool
def get_order_status(order_id: str) -> str:
"""Look up an order's status."""
return "Shipped"
research_agent = create_agent(model=init_chat_model("openai:gpt-4o-mini"), tools=[get_order_status])
def run_research_agent(state: State) -> dict:
result = research_agent.invoke({"messages": [{"role": "user", "content": state["user_query"]}]})
return {"agent_answer": result["messages"][-1].content}
builder = StateGraph(State)
builder.add_node("run_research_agent", run_research_agent)
builder.add_edge(START, "run_research_agent")
builder.add_edge("run_research_agent", END)
graph = builder.compile()
print(graph.invoke({"user_query": "What's the status of order o_42?", "agent_answer": ""}))
Notice what just happened: everything you learned about building agents in your LangChain course didn’t get thrown away — it became one real, reusable component, dropped directly into a node. This is genuinely one of the most important ideas in this entire course, and it’ll return properly once multi-agent systems are covered: a graph’s nodes can themselves be entire agents, not just single, isolated function calls.
Seeing all seven feed into one shape
flowchart TD
A[Pure Python] --> N[A Node]
B[LLM call] --> N
C[Tool call] --> N
D[Retriever] --> N
E[Business API] --> N
F[Human decision point] --> N
G[Entire agent] --> N
N --> H[Same interface: read state, return an update]
Every single one of these seven, despite being genuinely different kinds of work, honors the exact same contract: read state in, return an update out. The graph itself doesn’t know or care which of the seven kinds a given node actually is.
Why this flexibility is a genuine, documented advantage, not just convenience
It’s worth naming the real, practical cost teams face without this flexibility. A commonly reported pattern among engineers who built multi-step agent systems on plain microservices, before adopting an explicit graph structure, is what’s sometimes called “coordination hell”: orchestrating a multi-step reasoning flow across several separate services genuinely requires custom state machines, message queues, and hand-built coordination logic — with one widely shared account describing teams spending real months building exactly this infrastructure before ever getting to their actual AI features. Recall this module’s seven node types, all sharing one identical interface — that shared contract is precisely what removes the need to hand-build that coordination logic yourself; the graph itself already knows how to sequence, and reliably hand off between, genuinely different kinds of work.
Common mistakes worth avoiding
Assuming every node needs a model call to be “doing real work.” Recall Node 1 and Node 5 — pure validation and a plain API lookup are both genuinely legitimate nodes. Forcing an unnecessary model call into a step that’s really just deterministic logic adds real cost and real latency for no genuine benefit.
Making one node do too many genuinely separate jobs. A node that classifies the query, retrieves documents, and drafts an answer all in one function is harder to test, harder to debug, and harder to route around, compared to three separate, focused nodes doing each of those three, genuinely distinct jobs.
Forgetting that a node calling a tool directly is fundamentally different from an agent deciding whether to call it. Recall Node 3 — a graph node that calls retry_payment.invoke(...) runs it every single time, unconditionally. If you actually want a model to decide whether that tool is needed, that decision belongs in an LLM node’s own logic, or in the routing layer covered next, not silently assumed inside the tool-calling node itself.
What you should take away from this module
- A node is any Python callable taking state and returning an update — genuinely nothing more specific than that.
- Pure Python, LLM calls, direct tool calls, retrieval, business APIs, human-decision points, and even entire agents are all legitimate, real node types.
- A graph node calling a tool directly is deterministic — it always runs; this is a real, meaningful difference from an agent’s own internal decision to call (or not call) that same tool.
- An entire
create_agentinstance can be embedded as a single node — everything from your LangChain course remains genuinely reusable here, not replaced.
Where this goes next
The next module covers Edges — how these nodes actually connect into a real, working sequence, before the next module adds genuine, state-driven routing on top.
- Author
- TechByteByByte Editorial Team
- Reviewed by
- TechByteByByte Admin
- Published
- Last reviewed