Plain LangChain
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.
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. It needs the langchain extra (Choose an integration).
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)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). 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
ChatOpenAIandChatAnthropic, 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()andwith_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).
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,userand 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 indefault_headers, anydefault_query). For Anthropic’s beta header, give the modelbetas=[...]instead.model_kwargsandextra_bodyare read, so a setting given through them is recorded like any other, and one a definition can’t record is refused, namingmodel_kwargsorextra_bodyrather than the key. A call can’t givebetasinextra_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. ChatAnthropicalways sendsmax_tokens, its own default if you set none, so it’s recorded. A hand-writtenga.Model("anthropic", "claude-sonnet-5")withoutmax_tokensdoesn’t match it, andbind()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_additionandtool_removalblocks 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_URLor Azure:ga.ModelNotRoutedThroughProxy. - A model already wrapped by
bind_tools(),with_structured_output(),with_retry(),with_fallbacks()orwith_config(), or made withinit_chat_model(..., configurable_fields=...), which builds a new model on every call:TypeError. Bind the model, then wrap it. - A
ChatOpenAIgiven its ownclientorasync_client, whichbind()can’t route:TypeError. Give ithttp_clientorhttp_async_clientinstead. - A model name, or a chat model other than
ChatOpenAIandChatAnthropic:TypeError. A name such as"openai:gpt-4.1"is resolved inside LangChain, wherebind()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). 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.