---
title: "LangGraph graphs"
description: "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."
source: "Guardian Agent SDK"
---
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](./index.mdx#install)).

{/* generated file sdks/python/snippets/langgraph_graph.py python */}

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

{/* end generated */}

- **`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](./mcp-tools.mdx)). This example runs the docs server's tools, as [MCP tools](./mcp-tools.mdx) does:

{/* generated file sdks/python/snippets/langgraph_mcp_tools.py python */}

```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())
```

{/* end generated */}

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](./plain-langchain.mdx#how-a-model-is-described)).
- **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](./langchain-agents.mdx#what-the-definition-records)). 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](#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](./langchain-agents.mdx#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](./langchain-agents.mdx#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](#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](./mcp-tools.mdx#whats-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](./langchain-agents.mdx#how-far-the-search-goes)).
- **Models from more than one provider** in `models` ([One provider per agent](./index.mdx#one-provider-per-agent-for-now)).
- **A model in `models` that binding refuses** ([What binding refuses](./plain-langchain.mdx#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](../guides/run-an-agent.mdx#calls-the-sdk-cant-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](./multiple-agents.mdx)).

## 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](../guides/definition-fields.mdx)).
- **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).