EQTY LabDocs

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.

View as Markdown

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).

python
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)
  • models maps each of build’s parameters to a chat model, or a "provider:model" name built with init_chat_model. build gets the routed copies, by name, so every call its nodes make is signed as the agent.
  • build returns a compiled graph, or a StateGraph, as above, which describe_graph compiles as it is.
  • The other arguments are tools, instructions, description, version, sources, exclude and guardian, as guardian.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 for create_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:

python
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:

python
[{'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 in models ({"@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, defer and 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 as input_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 as TypedDict("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 as tools=, as a model sees them, each with a fingerprint of its code. A provider’s own tool you pass as tools=, such as Anthropic’s web search ({"type": "web_search_20260209", "name": "web_search"}), is recorded whole under extensions.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 for create_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-10 for an older web fetch, say), since which tools it’s offered is your graph’s code; a request may carry fewer, never others. Each ToolNode’s wrap_tool_call and awrap_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 of langgraph, langgraph-prebuilt (which has ToolNode), langchain-core and your provider’s package.
  • The graph’s own interrupt_before and interrupt_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 with strict=True can’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 pass tools=[Schema];
  • comes from a routed model before describe_graph returns (a call inside build), 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 ToolNode tool holds that isn’t routed: one it closes over, defaults to, reaches through self, 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 as create_agent’s does for a tool (How far the search goes). Pass the model through models, and use the copy build is given.
  • A provider’s client a node, router or ToolNode tool can reach that isn’t routed, or something holding one such as OpenAIEmbeddings, as for create_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 Literal return type, as route above, or a path_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 ToolNode and not in tools=, whatever exclude says (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 models whose 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 for create_agent (How far the search goes).
  • Models from more than one provider in models (One provider per agent).
  • A model in models that 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 as add_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’s Send doesn’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_graph returns: the values a node captured are read once, when it’s described, and a graph compiled again from app.builder with nodes added is still routed as the agent. Change the graph through build.
  • A subgraph’s own compile options, the graph’s state schema, checkpointer and store, and run history (which nodes ran, with what).