LangGraph graphs
Describe and route a LangGraph graph you wire yourself with describe_graph: what it records, what each request is checked for, and what it refuses.
A graph you wire yourself, with your own nodes and routers, is described by guardian_agent.langgraph.describe_graph. Give it the function that builds your graph and the chat models the graph uses. It calls that function once, with each model routed through the proxy as the agent, and returns the graph it built. It needs the langchain extra, which brings langgraph 1.1.10 or later (Choose an integration).
from typing import Literal
from langchain_core.messages import AIMessage
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI
from langgraph.graph import START, MessagesState, StateGraph
from langgraph.prebuilt import ToolNode
from guardian_agent.langchain import agent_of
from guardian_agent.langgraph import describe_graph
INSTRUCTIONS = "You research questions using the internal docs."
@tool
def search_docs(query: str) -> list[str]:
"""Search the internal documentation."""
return []
# describe_graph calls this once, with each model routed through the proxy, and
# compiles the graph it returns
def build(llm: ChatOpenAI) -> StateGraph[MessagesState]:
model = llm.bind_tools([search_docs])
def answer(state: MessagesState) -> MessagesState:
return {"messages": [model.invoke(state["messages"])]}
# A Literal return type tells LangGraph, and the definition, where it can go
def route(state: MessagesState) -> Literal["tools", "__end__"]:
last = state["messages"][-1]
return "tools" if isinstance(last, AIMessage) and last.tool_calls else "__end__"
graph = StateGraph(MessagesState)
graph.add_node("answer", answer)
graph.add_node("tools", ToolNode([search_docs]))
graph.add_edge(START, "answer")
graph.add_conditional_edges("answer", route)
graph.add_edge("tools", "answer")
return graph
app = describe_graph(
build,
name="researcher",
models={"llm": ChatOpenAI(model="gpt-4.1-mini", temperature=0)},
instructions=INSTRUCTIONS,
)
document = agent_of(app).definition.document("researcher")
print(document["graph"])
result = app.invoke(
{
"messages": [
{"role": "system", "content": INSTRUCTIONS},
{"role": "user", "content": "Summarise the release notes."},
]
}
)
print(result["messages"][-1].content)modelsmaps each ofbuild’s parameters to a chat model, or a"provider:model"name built withinit_chat_model.buildgets the routed copies, by name, so every call its nodes make is signed as the agent.buildreturns a compiled graph, or aStateGraph, as above, whichdescribe_graphcompiles as it is.- The other arguments are
tools,instructions,description,version,sources,excludeandguardian, asguardian.agent()takes them. The graph’s nodes send whatever prompts your code gives them, so send the instructions you record, as the example does. agent_of(app)returns the SDK’s agent, as forcreate_agent.
MCP tools
A ToolNode takes tools loaded with langchain-mcp-adapters as it takes any other (it needs the mcp extra). Each brings its server, which the definition records in mcpServers, with which tool calls which in extensions.guardian.mcpTools, and what each tool calls is fixed when the graph is described (MCP tools). This example runs the docs server’s tools, as MCP tools does:
import asyncio
from typing import Literal
from langchain_core.messages import AIMessage
from langchain_mcp_adapters.client import MultiServerMCPClient
from langchain_openai import ChatOpenAI
from langgraph.graph import START, MessagesState, StateGraph
from langgraph.prebuilt import ToolNode
from guardian_agent.langchain import agent_of
from guardian_agent.langgraph import describe_graph
INSTRUCTIONS = "You research questions using the internal docs."
async def main() -> None:
client = MultiServerMCPClient(
{
"docs": {
"transport": "stdio",
"command": "python",
"args": ["servers/docs_server.py"],
},
}
)
tools = await client.get_tools()
def build(llm: ChatOpenAI) -> StateGraph[MessagesState]:
model = llm.bind_tools(tools)
async def answer(state: MessagesState) -> MessagesState:
return {"messages": [await model.ainvoke(state["messages"])]}
def route(state: MessagesState) -> Literal["tools", "__end__"]:
last = state["messages"][-1]
return (
"tools"
if isinstance(last, AIMessage) and last.tool_calls
else "__end__"
)
graph = StateGraph(MessagesState)
graph.add_node("answer", answer)
# Each MCP tool in a ToolNode brings its server, which the definition records
graph.add_node("tools", ToolNode(tools))
graph.add_edge(START, "answer")
graph.add_conditional_edges("answer", route)
graph.add_edge("tools", "answer")
return graph
app = describe_graph(
build,
name="researcher",
models={"llm": ChatOpenAI(model="gpt-4.1-mini", temperature=0)},
instructions=INSTRUCTIONS,
)
document = agent_of(app).definition.document("researcher")
print(document["mcpServers"])
print(document["extensions"]["guardian"]["mcpTools"])
result = await app.ainvoke(
{
"messages": [
{"role": "system", "content": INSTRUCTIONS},
{"role": "user", "content": "Summarise the release notes."},
]
}
)
print(result["messages"][-1].content)
asyncio.run(main())It prints the server and which tool calls it:
[{'name': 'docs', 'transport': 'stdio', 'command': 'python', 'args': ['servers/docs_server.py']}]
{'search_docs': 'docs'}A node of your own that runs an MCP tool directly, rather than through a ToolNode, can too: pass the tool in tools=, so the model is offered it and its server is recorded. A node or router holding an MCP tool the graph doesn’t offer is refused.
What the definition records
- The models you gave, in that order, described as
guardian.agent()describes a model (Plain LangChain). - The graph’s nodes and edges, conditional ones marked, read from the compiled graph. A subgraph’s nodes are included, named
outer:inner, as LangGraph names them. The example prints each edge as[from, to, conditional]. - Each node and router as code, under
extensions.langgraph: a fingerprint of your function (both, for a node with a sync and an async function), and the values it closes over or defaults to, written as[name, value]pairs as middleware settings are, with a routed model as its place inmodels({"@model": 0}). Editing a node or a router gives a new definition, and so does building the same node function with a different prompt. A node’s retry and cache policy, timeout,deferand error handler (error_handler_node, a node the graph draws no edge to) are recorded with it. So is the state a node or router is given, when it isn’t the graph’s own: one passed asinput_schema=, or the type its first parameter names, which LangGraph reads; a router without either is given its source node’s, with the keys that node returns, which its record marks (from_node). It’s recorded as the keys it sees, its bases’ included, with a fingerprint of your schema class for their types. A schema from an installed package, or one without source of its own (one made as the code runs, such asTypedDict("Name", {...})), is recorded by name instead, as node code is. Types and reducers defined outside the schema class aren’t in that fingerprint. - The tools of each
ToolNode, plus any you pass astools=, as a model sees them, each with a fingerprint of its code. A provider’s own tool you pass astools=, such as Anthropic’s web search ({"type": "web_search_20260209", "name": "web_search"}), is recorded whole underextensions.langgraph.builtinTools, and a request may offer it only as recorded. One the graph’s model would send otherwise, as another provider’s function, is refused when the graph is described, as forcreate_agent(LangChain agents). Each model is recorded with the beta features its client adds for any of the graph’s tools (web-fetch-2025-09-10for an older web fetch, say), since which tools it’s offered is your graph’s code; a request may carry fewer, never others. EachToolNode’swrap_tool_callandawrap_tool_call, how it handles a tool’s error, and its messages key are recorded too, and an MCP tool’s server (MCP tools). - The framework,
langgraph, with the versions oflanggraph,langgraph-prebuilt(which hasToolNode),langchain-coreand your provider’s package. - The graph’s own
interrupt_beforeandinterrupt_after.
What each request is checked for
Each request is checked as it’s built, and refused with ValueError before it’s sent if it:
- sends model settings the definition doesn’t describe;
- offers a tool the definition doesn’t describe as the model sees it, or a provider’s own tool that isn’t in
tools=as it’s sent. A tool bound withstrict=Truecan’t be described yet; - asks for a response format, which a graph’s definition can’t describe yet. For structured output, use
with_structured_output(Schema, method="function_calling")and passtools=[Schema]; - comes from a routed model before
describe_graphreturns (a call insidebuild), or after it failed.
How tools are offered (tool_choice, parallel_tool_calls) is each call’s own, and isn’t checked. Neither are the prompts your nodes send.
What it refuses
With TypeError or ValueError, and what to do instead:
- A model a node, router or
ToolNodetool holds that isn’t routed: one it closes over, defaults to, reaches throughself, or reads from a module global or a module’s attribute by name (models.llm), in its own code or a function it calls. The search goes as far ascreate_agent’s does for a tool (How far the search goes). Pass the model throughmodels, and use the copybuildis given. - A provider’s client a node, router or
ToolNodetool can reach that isn’t routed, or something holding one such asOpenAIEmbeddings, as forcreate_agent(Tools that call a model). Bind the client to an agent, or build it on an agent’s httpx client. - A router whose destinations LangGraph can’t see: give it a
Literalreturn type, asrouteabove, or apath_map. Otherwise the compiled graph records only some of the places it can go, and the definition would describe a different graph from the one that runs. - A value a node holds that can’t be written as JSON, such as a client, naming the node. Build it inside the node, or leave the node out with
exclude=["/extensions/langgraph/nodes/<node>"], which leaves out its fingerprint too. - A node running a graph it holds (
sub.invoke(state)): add the graph as a node of its own (graph.add_node("research", sub)), and it’s described as a subgraph. - A node or router that holds an MCP tool the graph doesn’t offer, in no
ToolNodeand not intools=, whateverexcludesays (MCP tools). - An MCP tool a definition can’t record, or that holds a chat model that isn’t routed, as for
create_agent(What’s refused). - A model in
modelswhose callbacks, rate limiter or token counter reach a chat model, a provider’s client or an MCP tool that isn’t routed or offered, checked when the graph is described, however a node comes by the model, as forcreate_agent(How far the search goes). - Models from more than one provider in
models(One provider per agent). - A model in
modelsthat binding refuses (What binding refuses).
A model built or looked up while the graph runs can’t be seen. If its call reaches the proxy without naming an agent, --require-agent-header refuses it, rather than the proxy signing it as itself. A call straight to a provider never reaches the proxy, so only the network can stop it (Calls the SDK can’t see).
Agents inside a graph
An agent made by this SDK’s create_agent or create_deep_agent, as a node or called from one, is another agent: it signs under its own name and definition, and this graph records it by name: {"agent": "researcher"} for an agent that is a node, and {"@agent": "researcher"} for one a node calls. Its nodes still appear in the graph (Multiple agents).
What isn’t recorded
- Values your nodes read from module globals, such as a prompt constant: a node’s fingerprint covers its own code, not the module’s. Fingerprint the module with
sources=[ga.Source("graph.py")], a path relative to the directory you run from (Definition fields). - Where a node sends work with
Command(goto=...), unless its return type names the destinations (-> Command[Literal["research"]]) or you give them asadd_node(..., destinations=...), which LangGraph then draws as edges. LangGraph doesn’t hold the node to that type, so a node can still go elsewhere, and a router’sSenddoesn’t appear as an edge either. Name every destination. Two more limits of the edges LangGraph draws: a static edge from a node to one of its declared destinations is drawn only as the conditional edge, so the definition doesn’t show that it’s always taken; and a subgraph with a node no edge reaches isn’t drawn inside, so none of its own edges are recorded. - Changes after
describe_graphreturns: the values a node captured are read once, when it’s described, and a graph compiled again fromapp.builderwith nodes added is still routed as the agent. Change the graph throughbuild. - A subgraph’s own compile options, the graph’s state schema, checkpointer and store, and run history (which nodes ran, with what).