---
title: "Plain LangChain"
description: "Route LangChain code that isn't a graph, such as a chain or your own loop: describe the agent, bind each chat model, and what binding records and refuses."
source: "Guardian Agent SDK"
---
For LangChain code that isn't a graph, such as a chain or your own loop, describe the agent with `guardian.agent()`, which takes LangChain chat models and tools as they are, then `bind()` each chat model it uses. A graph is better described by [`describe_graph`](./langgraph-graphs.mdx). It needs the `langchain` extra ([Choose an integration](./index.mdx#install)).

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

```python
from langchain_core.messages import HumanMessage, SystemMessage, ToolMessage
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI

import guardian_agent as ga

INSTRUCTIONS = "You research questions using the internal docs."


@tool
def search_docs(query: str) -> list[str]:
    """Search the internal documentation."""
    return []


# Describe the agent with the chat model and tools themselves, so they match
llm = ChatOpenAI(model="gpt-4.1", temperature=0)
researcher = ga.Guardian().agent(
    "researcher", model=llm, instructions=INSTRUCTIONS, tools=[search_docs]
)

# Bind first, then add tools: every call the model makes is signed
model = researcher.bind(llm).bind_tools([search_docs])

messages = [SystemMessage(INSTRUCTIONS), HumanMessage("Summarise the release notes.")]
reply = model.invoke(messages)
while reply.tool_calls:
    messages.append(reply)
    for call in reply.tool_calls:
        result = search_docs.invoke(call["args"])
        messages.append(ToolMessage(str(result), tool_call_id=call["id"]))
    reply = model.invoke(messages)
print(reply.content)
```

{/* end generated */}

`bind()` takes only a model the agent's definition describes exactly: the same provider, model and settings, or for a sub-agent's route, one of the sub-agent's models. Its requests are signed under that definition, so a model it doesn't describe would be signed as a configuration it isn't. The simplest way to match is to describe the agent with the model you bind, as above.

As with any model client you call yourself, sending the instructions and tools the definition records is your code's job: the example sends both ([Send what the definition says](../guides/run-an-agent.mdx#send-what-the-definition-says)). A bound model checks its settings on each request, but not its prompts or tools: one bound with a tool the definition doesn't list still sends it.

## What binding routes

- **`ChatOpenAI` and `ChatAnthropic`,** each returned as a copy; your model is left as it was.
- **Every call the copy makes:** sync or async, streamed or not, the Responses API, and through `bind_tools()` and `with_structured_output()`. Bind the model before those, not after.
- **Settings passed per call** are checked as each request is built, so a value such as `invoke(..., temperature=0.9)`, `model.bind(temperature=...)` or a configurable field that sends settings the definition doesn't describe is refused before it's sent. Retries and timeouts set on the model aren't sent, and are free. A timeout passed per call goes into the request, and is refused.

Bind every chat model the agent calls, including any you give to middleware (`SummarizationMiddleware(model=...)`, say): a model that isn't bound sends its requests unsigned ([Calls the SDK can't see](../guides/run-an-agent.mdx#calls-the-sdk-cant-see)).

## How a model is described

- **By its provider and model,** and the settings that change its answers, read from the request LangChain builds for it: temperature, top p, top k, max tokens, stop sequences, seed, frequency and presence penalties, reasoning effort and summary, Anthropic's thinking, effort and `betas`, and OpenAI's service tier. A request may carry fewer of the recorded betas, since ChatAnthropic adds some only with the tools that need them, but no others.
- **Left out:** settings that don't change answers (`store`, `logprobs`, `user` and the like), and never its key, other headers, clients, or the retries and timeouts set on the model.
- **A model that sends anything else is refused,** naming the setting: one a definition can't record (`verbosity`, `n`, `logit_bias`, `truncation`, a beta header in `default_headers`, any `default_query`). For Anthropic's beta header, give the model `betas=[...]` instead. `model_kwargs` and `extra_body` are read, so a setting given through them is recorded like any other, and one a definition can't record is refused, naming `model_kwargs` or `extra_body` rather than the key. A call can't give `betas` in `extra_body`: Anthropic's client sends them as a header, so the body wouldn't show what's sent. Its requests would otherwise be signed under a definition that doesn't describe them.
- **`ChatAnthropic` always sends `max_tokens`,** its own default if you set none, so it's recorded. A hand-written `ga.Model("anthropic", "claude-sonnet-5")` without `max_tokens` doesn't match it, and `bind()` refuses it: describe the agent with the model itself.

A LangChain tool is described as the model sees it: its name, description and input schema, with a fingerprint of the code it runs. A decorator's wrapper around the tool's function or coroutine runs too, so it's fingerprinted with the function it wraps, each layer in order, when the decorator records what it wraps (as `functools.wraps` does). A tool class is fingerprinted with the classes of yours it inherits from. Code from LangChain, LangGraph and Deep Agents, such as an MCP adapter's or a wrapper of theirs, isn't fingerprinted, and neither is Python's own or LangSmith's (its tracing, such as `@traceable`): a function is judged by the module it's defined in, not the one `functools.wraps` copies. Other libraries' code is, so the fingerprint changes with their versions.

## What binding refuses

With what to do instead:

- **A model the definition doesn't describe exactly, one that sends a setting no definition can record, or one aimed at the proxy's address with the wrong path:** `ValueError`, naming the settings, never their values.
- **A call that adds or removes tools in its system content,** as Anthropic's `tool_addition` and `tool_removal` blocks do: `ValueError`, before it's sent, whatever betas the model records. Give the agent its tools when it's described.
- **A model aimed somewhere other than its provider's API or the proxy,** such as a custom `base_url`, `OPENAI_BASE_URL` or Azure: `ga.ModelNotRoutedThroughProxy`.
- **A model already wrapped** by `bind_tools()`, `with_structured_output()`, `with_retry()`, `with_fallbacks()` or `with_config()`, or made with `init_chat_model(..., configurable_fields=...)`, which builds a new model on every call: `TypeError`. Bind the model, then wrap it.
- **A `ChatOpenAI` given its own `client` or `async_client`,** which `bind()` can't route: `TypeError`. Give it `http_client` or `http_async_client` instead.
- **A model name, or a chat model other than `ChatOpenAI` and `ChatAnthropic`:** `TypeError`. A name such as `"openai:gpt-4.1"` is resolved inside LangChain, where `bind()` can't reach it, so build the model yourself (`init_chat_model("openai:gpt-4.1")`) and bind that.

A chat model that takes an `httpx` client can be given `agent.httpx_client()` or `agent.httpx_async_client()` instead ([Any httpx client](../guides/run-an-agent.mdx#any-httpx-client)). A bound model can't be pickled, since it holds the agent's connection to the proxy: pickle the model, and bind it after loading.