Study Notes
Noteapplied

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.

35 min read·verified Content review 2026·reviewed 2026-09-01

After this you should be able to

  • Explain the model-schema-runtime contract behind tool execution
  • Build and diagnose a bounded LangGraph tool loop
bind_toolsToolNodeTool Contracts
AI Engineering · LangGraph

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.

Updated September 2026 · Read ~18 min · Session 7.1 recap + 7.2


// the big idea

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.


SECTION 1

Notebook Summary#

Hand-copy this. Telegraphic recall notes — every point is derivable from the detailed notes below.

Graph compilation & immutability (7.1 recap)
  • 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
DAG = recipe (static steps, assumes success) · Agent = chef (tastes, decides, loops back)
Why tools — LLM is read-only
  • 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
No tools: User→LLM→text (dead end). Tools: User→LLM→[tool_call]→ToolNode→ext sys→result→LLM→answer (loop)
What a tool is — 3-party contract
PartyJob
Python fndoes the real work (DB/API/calc), returns value
LLMdecides when, which, what args
Tool nodeintercepts, validates, executes, packages result
@tool decorator (langchain_core.tools)
  • 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
Docstring answers 4 Qs: what it does · when to call · each param's meaning/format · what it returns
bind_tools & inspecting calls
  • 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)
Tool node — exec & error catching
  • 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

SECTION 2

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.

StateGraph builder .compile() ✓ entry point real ✓ edges → real nodes ✓ router map complete immutable graph object invoke × N
Fig 2.1 — validate once, freeze, then reuse across every request.

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.

⚠ PITFALL · compile-on-every-request

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.

support_agent.py · module scope
# ✓ 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.

Analogy · recall

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.

// note [!] · model version

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.

raw ticket→ LLM classify→ normalize→ validate→ fallback: general

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.

classify→ route → technical billing fraud general

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.

isolation 01

Temporal#

Knowledge is a frozen snapshot at the training cutoff. It cannot know your customer's payment failed this morning.

isolation 02

Environment#

No file system, no network sockets, no DB connections. It runs in a sealed box and cannot reach out to touch anything.

isolation 03

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 limitWhat it can't do
No DB accessCan't issue a SQL query, a Mongo find, or read a key from a cache.
No API accessCan't make an HTTP request — no Stripe, Salesforce, Slack, or email send.
No real-time dataPrices, exchange rates, weather, system health, incident status — all static.
No deterministic executionIt's a probabilistic text generator, not a reliable calculator. A stated total can simply be wrong.
WITHOUT TOOLS user LLM text no external reach WITH TOOLS user LLM tool node ext system result final answer
Fig 2.3 — the LLM never touches the external system directly; it only requests, then reads the result back.
💡 TIP · why determinism matters

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.

Analogy · recall

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.

LLM when · which · args tool node intercept · run · pack Python fn does the real work tool_call ToolMessage execute
Fig 2.4 — the model requests, the tool node mediates, the function executes. No party skips a step.

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.

op 01

Schema extraction#

Inspects the signature — parameter names, type hints, defaults — and builds a JSON schema.

op 02

Docstring parsing#

Reads the docstring and uses it as the tool's description inside that schema.

op 03

Wrapping#

Returns a StructuredTool (a runnable) that carries its own name, schema, and description. The original fn is stored as an attribute.

@tool fn sig + docstring schema extraction docstring parse wrap StructuredTool
Fig 2.5 — one decorator, three jobs: read the shape, read the meaning, hand back a runnable.

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.

⚠ PITFALL · unannotated parameter

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.

⭐ TRICK · the docstring is the translation layer

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:

QuestionHow 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.
⭐ TRICK · return a dict, and only the fields needed

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.

tools.py · production-grade 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.

⚠ PITFALL · defining ≠ binding

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:

field · name

Which tool to run.

field · args

A dict of arguments matching the schema.

field · id

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_callscontentmeaning
emptytextModel answers from context; no tool needed.
1 callemptyPure request — no answer possible without data.
1 calltext"Let me look that up…" then the request.
many callseitherSeveral tools requested in one turn.
inspecting a tool call
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.

consequence 01

Testability#

Because execution is isolated, you can unit-test the function directly, and mock the tool node in integration tests.

consequence 02

Error containment#

A failing tool raises at the node boundary, not deep in the LLM step — so it can be caught and handled.

consequence 03

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.

⚠ PITFALL · name mismatch after a rename

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.

⚠ PITFALL · unhandled exception → 500

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.

LLM1 · request tool node2 · run fn ToolMessage3 · result LLM4 · answer
Fig 2.7 — request → execute → result → answer. The model bookends the trip; the node does the work in the middle.

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#

  1. Compile once, invoke many. Building at module level gives a validated, immutable, thread-safe graph reused across requests.
  2. The LLM is read-only — temporally, environmentally, and by design. Tools are how it acts on the world.
  3. Three parties, one contract: the function does the work, the model decides, the tool node mediates and contains failures.
  4. The docstring is the interface. The model reasons entirely from the JSON schema; type hints and a clear docstring are the whole spec.
  5. Return structured, minimal data and never let a tool exception escape — convert it to an error message the model can handle.
compile-once immutable graph token in → token out 3 isolations 3-party contract docstring = interface annotate params return a dict bind_tools tool_calls: name·args·id ToolMessage contain the error

SECTION 3

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#

// your task

Write a LangChain tool check_order_status the support agent can call. Requirements:

  • Decorate it with @tool from langchain_core.tools.
  • Take one parameter order_id, explicitly typed as str.
  • 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, and eta_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.

solution
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 above

Why: 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#

// your task

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.

broken.py
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.

fixed.py
@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_calls

Why: defining a tool and binding it are two acts — the schema only reaches the model through bind_tools, and an unbound tool is invisible.

@tool + type hint 4-question docstring return minimal dict bind before invoke empty tool_calls = not bound annotate every param

Field notes · LangGraph · Tool Binding & Execution — end of note