Node types¶
nae ships four node types. All are callables (__call__(state) -> delta)
invoked by LangGraph with the shared state; each returns only the keys it
updates and never mutates state in place, which is what keeps checkpointing,
parallel-branch merges, and subgraph composition correct.
The two LLM-backed nodes (AgentNode, DecisionNode) share a common
LLMNode base that owns the model binding, the
tool-call loop, and token accounting — subclass it if you build your own
LLM-backed node type.
Note
The snippets below assume llm = ... is any LangChain chat model, plus
from nae import AgentNode, DecisionNode, ConditionNode, HumanNode,
AgenticGraph. Full signatures live in the API reference, linked from each
section.
AgentNode¶
Reach for AgentNode for any "have a model do
something" step. It prepends your node_prompt as a system message, calls the
LLM, and returns the result. Give it tools=[...] — bare callables are wrapped
as StructuredTools — and it runs a built-in loop: execute each tool call,
append the result, re-invoke the model, until the model stops asking.
def word_count(text: str) -> int:
"""Count the words in a text."""
return len(text.split())
agent = AgentNode(llm=llm, tools=[word_count],
node_prompt="Answer, calling tools when they help.")
graph = AgenticGraph(start_node=agent, end_nodes={agent})
print(graph.invoke(message="How many words in 'to be or not to be'?")["messages"][-1].content)
Typed IO is on the same node: reads=[...] interpolates scalar keys into the
prompt ({key} placeholders), and writes={"score": int} returns native-typed
structured output instead of appending to the conversation. An optional
reasoning_effort ("low"/"medium"/"high") applies to gpt-5.x models.
DecisionNode¶
Use DecisionNode when the model should pick
the path — classify, triage, route by intent. It constrains the model's output
to a Literal of your choices, so it can only return a valid branch; you
wire each branch by indexing the choice (decide > x is an error by design).
cheer = AgentNode(llm=llm, node_prompt="Celebrate the good news with the user.")
console = AgentNode(llm=llm, node_prompt="Acknowledge the problem; offer one next step.")
decide = DecisionNode(llm=llm, choices=["positive", "negative"],
node_prompt="Is the sentiment of the message positive or negative?")
decide["positive"] > cheer
decide["negative"] > console
graph = AgenticGraph(start_node=decide, end_nodes={cheer, console})
out = graph.invoke(message="We closed our first enterprise customer!") # -> cheer
The chosen label goes to a private per-node state channel, never into the
conversation (see the defaults below). DecisionNode also accepts
tools=[...]: it runs a tool-gathering loop first, then routes via structured
output — the two never share a call.
ConditionNode¶
ConditionNode is deterministic branching with
no LLM. Reach for it when the branch is decidable in plain Python — a
length check, a loop counter, a flag another node wrote. Routing is free,
reproducible, and prompt-less, which makes it the natural way to cap loops.
def is_short(state) -> str:
return "short" if len(state["messages"][-1].content) < 20 else "long"
route = ConditionNode(condition=is_short, choices=["short", "long"])
brief = AgentNode(llm=llm, node_prompt="Give a one-line answer.")
detailed = AgentNode(llm=llm, node_prompt="Give a thorough answer.")
route["short"] > brief
route["long"] > detailed
graph = AgenticGraph(start_node=route, end_nodes={brief, detailed})
out = graph.invoke(message="Hi") # -> brief
A loop-capping gate that counts rounds in the log channel is shown in
Examples.
HumanNode¶
HumanNode is resumable human-in-the-loop, backed by
LangGraph's interrupt(). The run pauses and surfaces a payload for review —
by default the previous message (n=1), or anything you pass as show. Invoke
again with Command(resume=...) on the same thread and the reply flows back
into state (into messages, or any key a router can gate on, via into=).
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command
draft = AgentNode(llm=llm, node_prompt="Draft a reply.")
review = HumanNode(node_prompt="Approve or edit this draft:", n=1)
finalize = AgentNode(llm=llm, node_prompt="Incorporate the human feedback.")
draft > review > finalize
graph = AgenticGraph(start_node=draft, end_nodes={finalize},
checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "demo-1"}}
first = graph.invoke("Reply to the customer.", config=config) # pauses at `review`
final = graph.invoke(Command(resume="Looks good, ship it."), config=config)
Pause/resume needs a checkpointer on the graph and a thread_id in the
config — without them there is nowhere to store the paused run.
Defaults and shared knobs¶
max_tool_iterations=25caps the tool-call loop onAgentNode(andDecisionNode's gathering phase), so a confused model cannot loop forever.- Routing channels are per-node. Each
DecisionNode/ConditionNodewrites its choice to its owndecision_{name}key (e.g.out["decision_route"]), auto-registered in the schema — parallel routers never clobber each other. cache_ttl/retryare accepted by every node type:cache_ttlwraps the node in a LangGraphCachePolicy(the graph auto-provides anInMemoryCache), andretryattaches aRetryPolicyfor exceptions.- Graphs compose as nodes. An
AgenticGraphcan be wired inside a larger one (graph_0 > graph_1) — seeexamples/basics/10_graph_composition.py.
RAG tools¶
make_retriever_tool wraps any LangChain retriever (anything with
.invoke(query) -> docs) as a tool you hand to AgentNode(tools=[...]); with
the [rag] extra, ChromaRAG bundles a Chroma store behind the same
interface. See the RAG tools API and the end-to-end
pipeline in
examples/nlp/rag_qa.py.
See every node in action, one file at a time, in the Examples gallery.