Tool Binding & Execution
The three-party contract between model, tool schema, and runtime — defining typed tools, binding only what an agent may use, executing requests in a tool node, filtering returned data, and turning failures into observations the loop can handle.
Assumes you know
After this you should be able to
- Explain the model-schema-runtime contract behind tool execution
- Build and diagnose a bounded LangGraph tool loop
How a language model that can only produce text is given hands — the ability to hit a database, call an API, and run exact calculations — through the tool-calling contract, plus the graph-compilation rules that make the agent safe to run in production.
A language model is read-only: it turns tokens into tokens and can touch nothing else. Tool calling is the architecture that fixes this — the model requests a function by name and arguments, a framework (the tool node) actually runs it, and the result flows back into the model's context. The model is always the passenger, never the driver.
Notebook Summary#
Hand-copy this. Telegraphic recall notes — every point is derivable from the detailed notes below.
- builder.compile() → validates whole graph → returns immutable executable
- Checks: entry point real · every edge → registered node · router returns all covered
- Compiled = thread-safe → share across concurrent invokes; each .invoke() gets isolated state
- Principle: compile once, invoke many
- ⚠Define graph at module level, NOT inside the request fn → else recompiles every call (~50–200 ms wasted)
- ⚠Infinite loop → wire ≥1 branch to END + add retry-cap field to state (≤3)
- ⚠State mutation → return a new dict, never state[k]=v in place
- LLM = token in → token out. Everything else is emergent.
- 3 isolations:
- Temporal → frozen knowledge cutoff
- Environment → no FS / sockets / DB; hermetically sealed
- Deterministic passivity → can describe/recommend, cannot do
- 4 hard limits (debug checklist): no DB · no API · no real-time data · no deterministic exec
| Party | Job |
|---|---|
| Python fn | does the real work (DB/API/calc), returns value |
| LLM | decides when, which, what args |
| Tool node | intercepts, validates, executes, packages result |
- 3 ops: schema extraction (inspect signature) · docstring parse (→ description) · wrap (→ StructuredTool)
- Type hints = schema contract. Unannotated param → defaults to string
- Return a dict, not a string → serializable, LLM extracts fields
- Return only needed fields → context is finite & costly
- ⭐LLM never sees your source — only the JSON schema. Docstring = the translation layer
- llm.bind_tools([...]) = Python→JSON handshake. Returns a NEW model; sends no request yet
- Tool decided → AIMessage with .tool_calls = list of dicts
- Each call dict: name · args · id
- AIMessage may carry text + tool_calls, or tool_calls only (content empty)
- Sits between LLM and graph → runs fn → returns ToolMessage
- 3 wins: testability (unit-test fn alone) · error containment (catch at boundary) · observability (one place to log/trace)
- Tool name in call MUST match a name in node's list
- ⚠Rename fn but not bound tools → silent no-match failure
- ⚠Unhandled exception → crashes graph → user sees 500. Convert to error ToolMessage instead
Detailed Study Notes#
Your support agent reads a ticket — "I was charged twice this month" — and correctly labels it billing. Then it does nothing. It can't open the customer's account, can't check whether a payment really landed twice, can't issue the refund. It classified a problem it has no way to act on. That gap — between a model that describes and a system that does — is the whole subject of this lesson.
2.1Graph compilation & immutability#
Compiling a graph turns your definition into a frozen, validated, runnable object. In
LangGraph you first describe an agent as a set of nodes and edges on a builder. Calling
builder.compile() walks that whole definition once, checks it, and hands back an executable graph
you can no longer edit.
Analogy. Compiling is the pre-flight check. The ground crew verifies every door is wired to a real destination and no gangway leads off a cliff — and once the plane pushes back, nobody rewires it mid-air.
The compile step performs several structural checks: the declared entry point really points at a registered node; every node named in an edge actually exists; and every conditional edge's routing map covers all the values its router function can return. Miss a return value and there's a path through your graph with no destination. After these pass, the graph becomes immutable — you cannot add nodes, remove edges, or change routing at runtime.
Immutability is a deliberate design choice, not a limitation. Because the compiled graph can't change, its
behaviour is fully determined by the definition, which makes it thread-safe: one compiled graph
can be shared across many concurrent requests, and each call to graph.invoke() spins up its own
isolated state. This is the "compile once, invoke many" principle.
Defining and compiling the graph inside your request handler looks harmless but recompiles the entire graph on every single call — roughly 50–200 ms of validation work thrown away each time, for nothing. Define and compile the graph once at module level so it's built at startup and reused. In-function definition is the classic beginner mistake here.
Wrong · inside the function#
Graph is rebuilt and re-validated per request. Expensive, unnecessary, and no state is shared.
Right · at module level#
Compiled once when the app starts; the same immutable object serves every request.
# ✓ compiled ONCE at import — reused across all requests
graph = build_graph().compile()
def handle_request(ticket: str):
# ✗ do NOT put build_graph().compile() in here
return graph.invoke({"raw_input": ticket})Two more production failure modes came up alongside compilation. An infinite loop happens when
a conditional router keeps returning the same node with no exit — fixed by wiring at least one branch to the
terminal END state and adding a retry-count field to state that caps iterations (e.g. at three). A
state-mutation bug happens when a node edits the state dict in place
(state["k"] = v) instead of returning a fresh dictionary.
2.2DAG vs. agent — recipe vs. chef#
A plain pipeline blindly runs fixed steps; an agent inspects what happened and decides what to do next. A directed acyclic graph (a DAG) is a static list of steps that assumes each one succeeds. A LangGraph agent adds decisions and loops on top of that skeleton.
A DAG is a recipe; an agent is a chef. The recipe lists steps in order and assumes nothing goes wrong. The chef reads the state of the dish, tastes it, decides it needs more salt, and loops back if a step failed. In LangGraph the typed state is the chef's working memory, nodes are the actions, and conditional edges are the decisions.
The support-ticket agent from Session 7.1 makes this concrete. A typed SupportState dictionary is
the central contract that flows through every node. A classifier node reads the raw ticket and,
via Gemini 2.5 Flash [!], sorts it into one of four categories; a router then sends it to
the matching handler.
The auto-generated summary at the top of the source names Gemini 1.5 Flash, but the instructor states Gemini 2.5 Flash throughout the live walkthrough (temperature 0). The spoken lesson is the authority here, so these notes use 2.5 Flash.
The classifier is a small defensive pipeline: send the prompt, take the raw model output,
normalize it (strip whitespace/punctuation, l-case), validate it against the
four allowed categories, and fall back to general if it's anything else. The
router then maps the category to a handler node — technical, billing,
fraud, or general — and that routing is a conditional edge.
2.3Why tools exist — the LLM is read-only#
At its core a language model is one function: tokens in, tokens out. Every capability it seems to have — reasoning, summarizing, writing code — is an emergent property of that single token-to-token transform. That architecture has three consequences every engineer must internalize before shipping.
Temporal#
Knowledge is a frozen snapshot at the training cutoff. It cannot know your customer's payment failed this morning.
Environment#
No file system, no network sockets, no DB connections. It runs in a sealed box and cannot reach out to touch anything.
Passivity#
It can describe, recommend, or generate code for an action — but it never performs one. It reacts to input and emits output.
Passivity is not a bug; it's a deliberate design choice that makes the model safe to run in shared, multi-tenant cloud environments. But it's a limitation every agent architect has to work around. The practical upshot is a short debug checklist: when a production AI system misbehaves, run through the four things a tool-free model simply cannot do, and you'll immediately know whether the root cause is a missing tool or something else.
| Tool-free limit | What it can't do |
|---|---|
| No DB access | Can't issue a SQL query, a Mongo find, or read a key from a cache. |
| No API access | Can't make an HTTP request — no Stripe, Salesforce, Slack, or email send. |
| No real-time data | Prices, exchange rates, weather, system health, incident status — all static. |
| No deterministic execution | It's a probabilistic text generator, not a reliable calculator. A stated total can simply be wrong. |
In 2023 a lawyer filed a brief citing six court cases that an LLM had fabricated; none existed, and the lawyer was sanctioned (the Mata v. Avianca [+] case). The lesson: never let the model compute or invent a value that must be exact — money, IDs, lookups. Route it to deterministic tool code.
2.4What a tool is — the three-party contract#
A tool is a plain Python function the model can ask to have run, whose result is fed back into the model's context. Nothing exotic — the function can query a database, call an API, do a calculation, read a file. What makes it a tool is the contract around it: three parties, each with one job.
A brilliant consultant who can't get into your building can only give advice. Tools are the specialists inside the building the consultant can phone. The consultant (the LLM) decides who to call and what to ask; the specialists (your functions) do the actual work.
Without the Python function, no real-world data ever reaches the model and it may hallucinate. Without the model, nothing decides which tool to call. Without the tool node, an error inside a tool crashes the whole graph. The contract only works when all three hold.
2.5Defining a tool — @tool, type hints, docstrings#
The @tool decorator turns an ordinary function into something the model can understand and
the framework can run. Imported from langchain_core.tools, it does three things to the
function it wraps.
Schema extraction#
Inspects the signature — parameter names, type hints, defaults — and builds a JSON schema.
Docstring parsing#
Reads the docstring and uses it as the tool's description inside that schema.
Wrapping#
Returns a StructuredTool (a runnable) that carries its own name, schema, and
description. The original fn is stored as an attribute.
Type hints are the schema contract. Every parameter's annotation becomes the type the model is told to send. Get it right and the model passes a value the function can use; leave it off and things break silently.
Any parameter without a type annotation defaults to string in the schema. So the model is told "send a string," it sends a string, and if your function then hands that string to a query expecting an integer you get a type error at runtime — inside the tool executor, silently — and the call crashes. Annotate every parameter.
The docstring is the intelligence layer — and here's the insight that reframes tool design.
The model never sees your Python source and can't observe what the function does at runtime. Its entire understanding comes from the JSON schema — parameters, types, and the description lifted straight from your docstring. If the consultant and the specialist speak different languages, the docstring is the interpreter between them. A vague docstring means the model doesn't know when to call the tool; the whole thing breaks down. Write the docstring for the model, not for yourself.
A production-grade docstring answers four questions, in the order the model needs them:
| Question | How to answer it |
|---|---|
| What does it do? | Describe the action, not the implementation. "Retrieves a customer's billing info" — not "queries the Postgres customers table." |
| When should the LLM call it? | State the triggering conditions explicitly: "Call this when the query involves billing, invoices, or payment status." |
| What does each parameter mean? | Give the semantic meaning and format, with an example: "customer_id — unique id in the form C-XXXX, e.g. C-1042." |
| What does it return? | Name the fields the model should expect back, so it doesn't have to guess. |
Return a dictionary, not a formatted string: it serializes to JSON, so the model can pull
out individual fields (billing_status, last_payment_date) instead of parsing prose.
And return only the fields the answer needs. A tool that dumps an entire customer row spends
hundreds of tokens to deliver three facts — every wasted token is stolen from the model's reasoning budget.
Filter the noise inside the tool.
from langchain_core.tools import tool
@tool
def get_customer_details(customer_id: str) -> dict:
"""Retrieve a customer's billing info and purchase history.
Call this when the user's query involves billing, invoices,
payment status, or subscription management.
Args:
customer_id: unique customer id, format C-XXXX (e.g. C-1042).
Returns:
dict with billing_status, last_payment_date, purchase_history.
"""
record = crm.lookup(customer_id) # the real work
return { # only what the LLM needs
"billing_status": record.status,
"last_payment_date": record.last_paid,
"purchase_history": record.purchases[:3],
}2.6Binding tools & reading the request#
Defining a tool and binding a tool are two different acts. Defining it with @tool
creates the object. Binding tells a specific model "these tools are available to you."
llm.bind_tools([...]) is a Python-to-JSON handshake: it calls each tool's schema method, collects
the JSON schemas, and returns a new model object. It sends no API request — it just produces a model
that will include those schemas in every future call.
A common mix-up. You can define ten tools and the model still won't use any of them until you
bind_tools them onto the LLM you actually invoke. Binding is what puts the schemas into the
request.
When a bound model decides to use a tool, it doesn't emit normal text. It returns an AIMessage
whose .tool_calls attribute is a list of dictionaries, each with three fields:
Which tool to run.
A dict of arguments matching the schema.
A unique id used to match the tool's response back.
An AIMessage can carry both text and tool calls (the model reasons aloud, then requests a
tool), or only tool calls with empty content (a pure request), or plain text with an empty tool-calls
list (it can answer from context alone). It may also request several tools at once.
| tool_calls | content | meaning |
|---|---|---|
| empty | text | Model answers from context; no tool needed. |
| 1 call | empty | Pure request — no answer possible without data. |
| 1 call | text | "Let me look that up…" then the request. |
| many calls | either | Several tools requested in one turn. |
llm_with_tools = llm.bind_tools([get_customer_details])
resp = llm_with_tools.invoke("check account C-1042")
print(type(resp)) # AIMessage
print(resp.tool_calls)
# [{'name': 'get_customer_details',
# 'args': {'customer_id': 'C-1042'},
# 'id': 'call_abc123'}]For Gemini models the same protocol runs over Google's function-calling API. LangChain's
bind_tools transparently translates between its own tool-schema format and Google's
function-declaration format, so from your side the API is identical regardless of provider — one of LangChain's
core value propositions. (This standardized tool-calling shape was pioneered by OpenAI and later adopted by
Google and Anthropic.)
2.7The tool node — execution & error catching#
The tool node is LangChain's built-in component that actually runs the requested tools and catches
their failures. It sits between the LLM node and the rest of the graph: it intercepts the tool-call
request, runs the matching Python function, and packages the return value as a ToolMessage appended
to the conversation. Its existence is a deliberate principle — separation of reasoning from execution.
The LLM decides what; the tool node does it.
Testability#
Because execution is isolated, you can unit-test the function directly, and mock the tool node in integration tests.
Error containment#
A failing tool raises at the node boundary, not deep in the LLM step — so it can be caught and handled.
Observability#
One central place to log which tool ran, with what args, when, with what result, in how many ms.
The tool node is instantiated with the list of tool objects — the same @tool functions you bound
to the LLM. That shared list is the link between the names the model uses and the functions the node runs.
By default the tool name is the function name. Rename the function but forget to update what's bound to the LLM, and you get a silent failure: the model emits a tool call, but the node finds no tool by that name. Nothing crashes loudly — the call just goes nowhere.
Error handling is not optional. A real CRM lookup can fail many ways: the DB is briefly down, the connection times out, the model passed a malformed id, the id is valid but the record was deleted, or the tool has a bug. In every case an unhandled exception propagates up through the tool node and crashes the graph.
Let an exception escape the tool and it takes the whole graph down; the user sees an HTTP 500. The production
pattern is to catch it at the node and convert it into an error ToolMessage
passed back to the model — which can then retry with different arguments, tell the user the data is
unavailable, or escalate to a human. That containment is what makes tool-using agents resilient.
2.8Interview & practice questions#
Why must the graph be defined at module level rather than inside the request handler?
Compiling validates the entire graph and produces the immutable executable — real work that costs roughly 50–200 ms. Defining it inside the handler repeats that on every request for no benefit. At module level it's compiled once at startup and the same thread-safe object serves every call ("compile once, invoke many").
What are the three "isolations" that make an LLM read-only?
Temporal — its knowledge is frozen at the training cutoff. Environment — no file system, sockets, or DB connections; it runs in a sealed box. Deterministic passivity — it can describe or recommend an action but never performs one. Tools are how you work around all three.
Name the three parties in the tool-calling contract and what each does.
The Python function does the real work and returns a value. The LLM
decides when to call a tool, which one, and with what arguments. The tool node intercepts
the request, validates and executes it, and packages the result as a ToolMessage.
Why is the docstring so important — the model can see the source code anyway, right?
It can't. The model never sees the Python source and can't observe runtime behaviour. Everything it knows comes from the JSON schema, whose description is lifted from the docstring. The docstring is the translation layer between the model and the function; a vague one means the model won't know when or how to call the tool.
What happens if you forget a type annotation on a tool parameter?
It defaults to string in the schema. The model dutifully sends a string; if your function
then feeds it to something expecting another type (say an integer query), you hit a type error at runtime
inside the tool executor — a silent crash rather than a clear validation message.
Why return a dict from a tool instead of a formatted string?
A dict serializes to JSON, so the model can extract specific fields cleanly rather than parsing prose. It also lets you return only the fields the answer needs — keeping the context window lean, since every wasted token is stolen from reasoning.
What does bind_tools actually do, and does it call the API?
It converts each tool's signature, type hints, and docstring into a JSON schema and returns a new model object that will include those schemas in every request. It sends no API call itself — it's a local Python-to-JSON handshake. Defining a tool is separate from binding it.
Why is the tool node a separate component instead of running tools inside the LLM node?
Separating reasoning from execution buys three things: testability (test the function
alone, mock the node), error containment (a failure is caught at the boundary and turned
into an error ToolMessage instead of crashing the graph), and observability
(one central place to log and trace every tool call).
An agent classifies a billing ticket, then produces nothing useful. Missing tool or different bug?
Run the four-limit checklist: it likely needs a tool to look up the account (no DB access) or hit an API. If instead it's returning a confidently wrong number, that's the deterministic-execution limit — move the calculation into tool code. If it invents facts about recent events, that's temporal isolation.
2.9Key takeaways#
- Compile once, invoke many. Building at module level gives a validated, immutable, thread-safe graph reused across requests.
- The LLM is read-only — temporally, environmentally, and by design. Tools are how it acts on the world.
- Three parties, one contract: the function does the work, the model decides, the tool node mediates and contains failures.
- The docstring is the interface. The model reasons entirely from the JSON schema; type hints and a clear docstring are the whole spec.
- Return structured, minimal data and never let a tool exception escape — convert it to an error message the model can handle.
Microexercises#
The reading proves you recognize the ideas; these prove you can use them. Attempt each before opening the walkthrough.
3.1Build it — a production-grade tool#
Write a LangChain tool check_order_status the support agent can call. Requirements:
- Decorate it with
@toolfromlangchain_core.tools. - Take one parameter
order_id, explicitly typed asstr. - Write a docstring that answers all four questions: what it does, when to call it, what the parameter means (with a format example), and what it returns.
- Return a dict with only
status,carrier, andeta_days— not the whole order record, not a formatted string.
Expected result: check_order_status.name == "check_order_status", and invoking
it returns a dict of exactly those three keys.
Show the full walkthrough
Step 1 — decorate and type. The @tool decorator runs its three
operations on the function; the : str annotation is what tells the model the argument is a
string. Skip it and the schema silently defaults to string anyway, but you lose the habit that saves you on
an int parameter later.
Step 2 — write the docstring for the model. This is the tool's entire interface. Describe the action ("look up shipping status"), the trigger ("when the user asks where their order is"), the parameter's meaning and format, and the return shape.
Step 3 — return a minimal dict. Three keys, serializable, no prose. The model
extracts eta_days directly instead of parsing a sentence, and you don't burn tokens on fields
nobody asked for.
from langchain_core.tools import tool
@tool
def check_order_status(order_id: str) -> dict:
"""Look up the shipping status of a customer's order.
Call this when the user asks where their order is, whether it
has shipped, or when it will arrive.
Args:
order_id: unique order id, format ORD-XXXXX (e.g. ORD-90421).
Returns:
dict with status, carrier, and eta_days.
"""
order = orders.lookup(order_id)
return {
"status": order.status,
"carrier": order.carrier,
"eta_days": order.eta_days,
}
# check_order_status.name -> "check_order_status"
# check_order_status.description -> the docstring aboveWhy: type hints plus a four-question docstring are the schema the model reasons from — the tool's usefulness is decided before a single line of its body runs.
3.2Diagnose it — the model ignores your tool#
A teammate defined a tool and wired up the model, but at runtime the model just answers in prose and never calls the tool. Find the root cause, predict the exact behaviour, and fix it.
from langchain_core.tools import tool
from langchain_google_genai import ChatGoogleGenerativeAI
@tool
def get_balance(account):
"""Return the account balance."""
return {"balance": bank.balance(account)}
llm = ChatGoogleGenerativeAI(model="gemini-2.5-flash", temperature=0)
def answer(question: str):
return llm.invoke(question) # "what's the balance on A-77?"Show the full walkthrough
Root cause. The tool is defined but never bound.
answer calls the bare llm, not a tool-bound one, so the model's request never
carries any tool schema — it has no idea get_balance exists.
Predicted behaviour. No crash. resp.tool_calls is an empty list and
the model returns plain text — very likely a hallucinated or hedged balance, since it can't reach the bank.
That silence is the whole lesson: a missing bind fails quietly, not loudly.
Second defect. account has no type annotation, so it defaults to
string in the schema. Harmless while it happens to be a string, but it's the exact setup for a
silent runtime type error the day the function expects something else. Annotate it.
@tool
def get_balance(account: str) -> dict:
"""Return the balance for a bank account.
Call this whenever the user asks about a balance.
Args:
account: account id, format A-XX (e.g. A-77).
"""
return {"balance": bank.balance(account)}
llm = ChatGoogleGenerativeAI(model="gemini-2.5-flash", temperature=0)
llm_with_tools = llm.bind_tools([get_balance]) # ← the missing step
def answer(question: str):
return llm_with_tools.invoke(question) # now emits tool_callsWhy: defining a tool and binding it are two acts — the schema only reaches the model through
bind_tools, and an unbound tool is invisible.
Field notes · LangGraph · Tool Binding & Execution — end of note