LangChain Agents & Tool Use
A chain runs a fixed path (L2). An agent lets the model choose the path — which tools to call, in what order, when to stop. This chapter defines tools the LangChain way, runs a tool-calling agent, and shows why the modern answer to "build an agent" points at LangGraph's prebuilt ReAct agent.
- Claude: an Anthropic API key (
ANTHROPIC_API_KEY) from console.anthropic.com +pip install langchain-anthropic - OpenAI: an OpenAI API key (
OPENAI_API_KEY) from platform.openai.com +pip install langchain-openai
bind_tools, create_react_agent, and invoke are identical.Reading and learning works without any of this — run when you're ready. IDs/ARNs in examples are placeholders; swap in your own.Learning objectives
- Define tools with the
@tooldecorator and clear schemas/descriptions. - Bind tools to a model and read back tool-call requests.
- Run a tool-calling agent and trace its reason→act→observe loop.
- Understand why LangGraph's
create_react_agentis the current recommended path. - Add safety: approval gates, error handling, and step limits.
30-second recap
- Agent & tool foundations: The model asks to call a tool, your code runs it and returns the result, and the loop repeats until the model answers — with step caps so it can't run forever.
Defining tools intermediate
Here's the thing nobody tells you when you start building agents: the model can't run your code. It has never seen your functions, it can't import your module, and it certainly can't reach into your process and call anything. All it can do is say a sentence — "I'd like to call get_weather with city='Paris'" — and hope something on your side listens. A tool is how you make that sentence possible: it's the bridge between the model's words and your running code.
Think of it like ordering at a restaurant with a fixed menu. You don't walk into the kitchen and cook — you point at a menu item, and the kitchen does the rest. The menu is what the model sees: a list of names, a one-line description of each dish, and what you have to specify ("how many, what size"). The model picks from that menu; your code is the kitchen. If a dish isn't on the menu, the model literally cannot order it. This is why the quality of your tool descriptions matters so much — they're the menu the model reads, and a badly worded menu gets you the wrong dish.
So a LangChain tool is three things bundled together: a name (what to call), a description (when and why to call it), and an argument schema (what to fill in). The lovely part is you don't hand-write any of that JSON. The @tool decorator reads a plain typed Python function and builds all three from the function itself — the name from the function name, the description from its docstring, the schema from its type hints. You write ordinary code; the decorator packages it into something a model can point at.
The mistake beginners make is treating the docstring as an afterthought — a throwaway "gets weather" — and then wondering why the agent calls the wrong tool or ignores a perfectly good one. Remember the menu analogy: that docstring is the menu entry the model reads to decide. Write it the way you'd write a menu item for a stranger — say plainly when to reach for it, not just what it technically does. A LangChain tool is a function plus a schema and a description. The @tool decorator builds all three from a typed function with a docstring.
@tool reads your ordinary typed function and derives the name, description, and argument schema — the exact interface the model reads to pick and fill a tool.
Read it left→right: your plain function on the left passes through the @tool decorator in the middle and comes out as the three-part interface on the right.
- The left blue box is what you actually write — a normal Python function with a type-hinted
city: str, a docstring saying when to use it, and a body that does the work. - The middle box is the
@tooldecorator. The single arrow in and three arrows out show it doing one job: turning your function into a packaged tool. - The three green boxes are what the model receives — and each is pulled automatically from a different part of your function: the name from the function name, the description from the docstring, and the argument schema from the type hints. You never hand-write the JSON.
In short: Write one typed, documented function; the decorator hands the model the name, description, and schema it needs to call it.
tools.pyfrom langchain_core.tools import tool
@tool
def get_weather(city: str) -> str:
"""Get the current weather for a city. Use when the user asks about weather."""
return f"18°C and clear in {city}"
@tool
def add(a: int, b: int) -> int:
"""Add two integers."""
return a + b
print(get_weather.name) # "get_weather"
print(get_weather.description) # from the docstring — drives tool selection
print(get_weather.args) # {"city": {"type": "string"}} — from the type hints
A tool is just a normal Python function that you let the model call. The @tool decorator sits on top of the function and, from the function itself, builds the three things the model needs to use it: a name, a description, and a list of arguments with their types. You write one plain function; LangChain turns it into something an agent can reach for.
@toolis a decorator — a label placed on the line above a function. It wrapsget_weatherso that instead of a plain function you get a LangChain tool object, without changing the code inside.- The function body is ordinary Python:
get_weathertakes acitystring and returns a weather sentence;addtakes two integers and returns their sum. This is the real work the tool does when called. - The triple-quoted line under each
defis the docstring. LangChain uses it as the tool's description — the text the model reads to decide when to call the tool. Notice it says when to use it ("Use when the user asks about weather"), not just what it does. - The three
printlines read back what the decorator built:.nameis the function name,.descriptioncomes from the docstring, and.argsis inferred from thecity: strtype hint — you didn't write the schema by hand, the type hints did it for you.
What the output means: Three lines print: get_weather (the name), the docstring text (the description), and {"city": {"type": "string"}} (the argument schema). Together these are exactly what the model sees when deciding whether to use this tool.
Try this: Change the docstring to something vague like "Gets weather." and imagine you are the model choosing a tool — the clear "Use when…" version is much easier to pick correctly. A vague description is the #1 reason an agent ignores or misuses a tool.
Binding tools to a model intermediate
You've defined tools. But the model still doesn't know they exist. Handing a model a tool isn't like passing a function to another function — the model runs on a server far away and only ever receives text and a bit of structured metadata. "Binding" is the act of telling the model, on every request, "here are the tools you're allowed to ask for, and here are their schemas." Skip this step and the model will happily answer your weather question by guessing a temperature, because as far as it knows there is no get_weather to call.
The analogy that makes this click: bind_tools is handing someone the menu, and invoke is them placing an order. The waiter (the model) reads the menu, decides what they want, and calls it back to you — "I'll have the get_weather, city of Paris." Crucially, saying the order out loud does not cook the food. The model returns a request — a name, some arguments, and an id — and then stops. Nothing has run. The kitchen (your code) still has to receive that order, actually execute the function, and bring back the result.
This is the single most important mental model on the whole page, so it's worth stating bluntly: the model requests; it never executes. When you call bind_tools and then invoke, what comes back in msg.tool_calls is a to-do list, not a done list. It's the exact same pattern you saw in the raw Anthropic API in C2 — the model emits a tool-use block, you run it, you feed the result back — just one convenient layer up. An agent, which you'll build in the next section, is nothing more than the machine that automates that request→run→feed-back cycle so you stop doing it by hand.
The trap here is assuming that because you got a tool_calls back, the tool actually ran — people print the message, see the tool name and the right arguments, and conclude "great, it worked." It didn't do anything yet; it only decided what to do. Understanding this gap — between deciding and doing — is what stops you from wiring a model straight to a destructive action and being surprised when it fires. Before an agent, understand the primitive under it: bind_tools. It tells the model which tools exist; the model responds with tool-call requests you then execute — exactly the C2 pattern, one layer down.
bind_tools ships the schemas out; the reply is a to-do list (msg.tool_calls), not a done list — running the function is still your job.
Follow the two horizontal arrows between your code (left) and the model (right), then read the red box at the bottom.
- The top arrow is
bind_tools+invoke: you send the model the list of tools and their schemas along with the question. - The bottom arrow is what comes back —
msg.tool_calls, a structured request holding the tool name, the args the model filled in, and an id. - The red box is the payoff: at this point nothing has actually run. The model only decided what to call — executing it and feeding the result back is still your code's job.
In short: Binding communicates the schemas and gets you a request; the gap between "requested" and "executed" is exactly what an agent runtime later closes for you.
Requires: pip install langchain-anthropic (Claude) or pip install langchain-openai (OpenAI)
Binding tools to a model — in Claude or OpenAI
LangChain gives both vendors the same interface, so bind_tools and invoke are identical — only the model object changes. In Claude it's ChatAnthropic(model="claude-opus-4-8") from langchain_anthropic; in OpenAI it's ChatOpenAI(model="gpt-5.5") from langchain_openai. Toggle the tabs — your choice applies across the whole page.
bind.pyfrom langchain_anthropic import ChatAnthropic
model = ChatAnthropic(model="claude-opus-4-8", max_tokens=512)
model_with_tools = model.bind_tools([get_weather, add])
msg = model_with_tools.invoke("What's the weather in Paris?")
print(msg.tool_calls) # [{'name': 'get_weather', 'args': {'city': 'Paris'}, 'id': '...'}]
bind.pyfrom langchain_openai import ChatOpenAI
model = ChatOpenAI(model="gpt-5.5", max_tokens=512)
model_with_tools = model.bind_tools([get_weather, add])
msg = model_with_tools.invoke("What's the weather in Paris?")
print(msg.tool_calls) # [{'name': 'get_weather', 'args': {'city': 'Paris'}, 'id': '...'}]
bind_tools gets you a request — name + args + id. Running the function and returning the result is still your job. An agent is the thing that automates that request→run→feed-back loop for you.This is the primitive underneath an agent: bind_tools. It hands the model the list of tools that exist, then the model replies with a request to call one — it does not run the tool itself. Running it and feeding the result back is still your job. An agent (next lab) is just the thing that automates that request→run→feed-back loop.
ChatAnthropic(model="claude-opus-4-8", max_tokens=512)creates the model client.modelpicks which Claude to use;max_tokenscaps how long the reply can be.model.bind_tools([get_weather, add])makes a new model handle that knows about those two tools. It doesn't call them — it just makes them available for the model to request..invoke("What's the weather in Paris?")sends the question. Because the question is about weather, the model responds by asking to callget_weatherwithcity="Paris".msg.tool_callsis that request: a list with the tool name, the args the model filled in, and an id. This is a plan to call a tool, not the tool's answer.
What the output means: msg.tool_calls prints something like [{'name': 'get_weather', 'args': {'city': 'Paris'}, 'id': '...'}] — the model chose the right tool and extracted Paris from the sentence on its own. There is no weather text yet because nothing has actually run the function.
Try this: Ask "What is 12 plus 30?" instead and look at tool_calls — the model should now request add with {'a': 12, 'b': 30}. Same mechanism, different tool, chosen from the descriptions.
Lab L3.2 · A tool-calling agent intermediate
So far you've been the kitchen — you had to run every tool the model ordered and carry the plate back yourself. That's fine for one call, but real tasks need several rounds: the model asks for the weather, reads it, then asks for the arithmetic, reads that, then finally answers. Doing that dance by hand — invoke, check for tool calls, run them, append the results, invoke again, check again — is tedious and easy to get wrong. An agent is the thing that does the whole dance for you.
Picture hiring an assistant instead of micromanaging one. Before, you gave one instruction and waited for one reply. Now you hand over a goal — "tell me the weather in Paris and what 12 + 30 is" — and a set of tools, and the assistant loops on its own: decides a tool is needed, uses it, looks at the result, decides whether it needs another, and only comes back to you when it has a complete answer. You supply the capabilities (the model and the tools); the agent runtime supplies the loop that keeps calling until the work is done. That loop — think, act, observe, repeat — is exactly the ReAct pattern you met in L1, now running for you automatically.
The recommended way to get that runtime is LangGraph's create_react_agent, and the import surprises people: it comes from langgraph.prebuilt, not from langchain. That's not an accident — it's the whole reason the next section exists. For now, take it as a prebuilt, batteries-included ReAct agent: you pass it a model and a list of tools, it hands you back something you can invoke with a conversation, and it runs the reason→act→observe loop until the model produces a plain answer.
The common wrong turn is following an older tutorial that reaches for initialize_agent or AgentExecutor with cryptic "agent types" like ZERO_SHOT_REACT_DESCRIPTION. Those are the previous generation of the API — they still appear all over the internet, and they still mostly work, but they're not where LangChain is steering you anymore. Learn create_react_agent: it's more controllable, and it's the direct on-ramp to the from-scratch graphs you'll build in L4 and L5. The agent runtime closes the loop: it calls the model, executes any requested tools, feeds results back, and repeats until the model answers. The modern, recommended way to get one is LangGraph's create_react_agent — a prebuilt ReAct agent (L1) that LangChain now points to as the default.
while loop. Model emits tool calls → runtime executes them → results go back as messages → model decides again. It exits when the model returns a plain answer with no tool calls. You supply model + tools; the runtime supplies the loop.
This picture is the agent loop — what create_react_agent does for you in the next lab. It's a cycle between the model and your tools that repeats until the model has a final answer.
- Start at the Model box on the left. The model looks at the conversation and decides: do I need a tool? The label
tool_calls?on the arrow is that decision point. - If it wants a tool (top arrow), control moves right to Run tools — the runtime actually executes the requested function (the step
bind_toolsleft up to you). - The curved arrow back to Model is labelled results fed back as messages: the tool's output is added to the conversation and handed back to the model, which can now decide again. That back-and-forth is the loop.
- If it wants no tool (the
no tool_callsarrow on the far right), the loop exits and the model returns a plain answer. That's how the agent knows it's done.
In short: You supply the model and the tools; the runtime supplies the loop. It is the exact "ask for a tool → run it → feed the result back → ask again" cycle, done automatically until the model stops asking for tools.
shellpip install langgraph langchain-anthropic
Requires: pip install langchain-anthropic langgraph
agent.pyfrom langgraph.prebuilt import create_react_agent
from langchain_anthropic import ChatAnthropic
model = ChatAnthropic(model="claude-opus-4-8", max_tokens=1024)
agent = create_react_agent(model, tools=[get_weather, add]) # prebuilt ReAct loop
result = agent.invoke({"messages": [("user", "Weather in Paris, and what's 12 + 30?")]})
for m in result["messages"]:
m.pretty_print() # see the full reason→act→observe trace
shellpip install langgraph langchain-openai
Requires: pip install langchain-openai langgraph
agent.pyfrom langgraph.prebuilt import create_react_agent
from langchain_openai import ChatOpenAI
model = ChatOpenAI(model="gpt-5.5", max_tokens=1024)
agent = create_react_agent(model, tools=[get_weather, add]) # prebuilt ReAct loop
result = agent.invoke({"messages": [("user", "Weather in Paris, and what's 12 + 30?")]})
for m in result["messages"]:
m.pretty_print() # see the full reason→act→observe trace
Here the loop from the diagram becomes one line of code. create_react_agent gives you a ready-made agent that wraps a model and a set of tools and runs the whole call-model → run-tools → repeat cycle for you until the model produces a final answer.
- The two
importlines bring increate_react_agent(the prebuilt agent from LangGraph) andChatAnthropic(the model, same as the previous lab). create_react_agent(model, tools=[get_weather, add])builds the agent. You pass it the model and the tools; it supplies the loop. Nothing has run yet — this just assembles the agent.agent.invoke({"messages": [("user", "...")]})starts it with a question that needs two tools (weather and arithmetic). The agent will call each tool as needed and keep going until it can answer.- The
for m in result["messages"]loop withm.pretty_print()prints every step of the conversation — the model's tool requests, the tool results, and the final reply — so you can watch the agent reason, act, and observe.
What the output means: You see the full trace: the agent asking to call get_weather('Paris'), the tool result coming back, a call to add(12, 30), its result, and finally a plain-language answer combining both. That sequence of messages is the loop from the diagram, printed out.
Try this: Ask a question that needs only one tool ("What's the weather in Tokyo?") and read the trace — you'll see just one tool call, then the answer. The agent uses only the tools the question actually requires.
AgentExecutor tutorials are legacyYou'll find lots of examples using initialize_agent / AgentExecutor and "agent types" like ZERO_SHOT_REACT_DESCRIPTION. Those are the older LangChain agent API. The current guidance is the LangGraph-based create_react_agent shown here — it's more controllable and it's the bridge into L4/L5. Learn this one.Why the agent lives in LangGraph now advanced
You just imported your agent from langgraph and probably didn't blink. But that one import is a signpost for a genuine shift in how the whole ecosystem thinks about agents, and it's worth stopping on — because it explains why L4 and L5 exist at all. The short version: LangChain's original building block, the chain (L2), turned out to be the wrong shape for an agent, and LangGraph is the right one.
Here's the mismatch in plain terms. A chain is a pipeline — data flows in one direction, step A to step B to step C, and then it's done. That's perfect for "retrieve, then prompt, then parse." But an agent doesn't flow in one direction; it loops. It might call a tool once, or five times, or not at all, and it decides at runtime. A pipeline that can only go forward simply cannot express "go back and try again," and that backward edge — the cycle — is the beating heart of an agent. Ask a chain to loop and you're fighting the abstraction.
The clean way to picture the difference is a subway map versus an assembly line. An assembly line (a chain) has one path from start to finish. A subway map (a graph) has stations connected by edges you can traverse in any order, including loops back to where you were. LangGraph models your agent as exactly that: nodes (the model, the tools) joined by edges, some of them conditional ("if the model asked for a tool, go to the tools node; otherwise, stop"). That structure buys you four things a chain can't give you — cycles, persistent state that survives across steps, conditional branching, and the ability to pause for a human and resume later — and the table below lines each one up against why a chain falls short.
The nuance worth holding onto: create_react_agent hasn't hidden the graph from you, it has merely pre-assembled one. It's a convenient, opaque box that happens to be a compiled LangGraph graph underneath. That's a fine place to start — but the moment your needs stray outside the standard ReAct loop, you don't abandon the framework, you open the box and wire the nodes yourself. That's the entire arc of L4 and L5, and it's why this section is the on-ramp rather than a detour. Notice the import: the recommended agent comes from langgraph, not langchain. That's deliberate. A real agent needs things a plain chain can't express:
Compare the two halves split by the dashed line: the chain on the left, the graph on the right.
- The left side is a chain — boxes A → B → C joined by forward-only arrows. It runs once, front to back, and finishes. The red note underneath is its limit: there is no arrow pointing backward.
- The right side is a LangGraph agent: a model node and a tools node joined by edges. The top arrow is a tool call going out.
- The amber curved arrow is the whole point — control loops back from tools to the model, an unknown number of times. That cycle, plus persistent state, conditional branching, and human pauses (the bottom line), is what a one-way chain simply cannot represent.
In short: Agents loop and chains don't — so the recommended agent lives in langgraph (graphs allow cycles), not langchain (chains are acyclic).
| Need | Why a chain can't & a graph can |
|---|---|
| Cycles | The loop runs an unknown number of times — chains are acyclic; graphs allow cycles (L4/L5) |
| State | Messages, scratchpad, counters persist across steps — graphs carry explicit state (L4) |
| Branching | "If tool call → run tools, else → end" is conditional routing (L4) |
| Human-in-the-loop | Pause for approval before a risky tool, then resume — needs interrupts + persistence (L5) |
create_react_agent is a prebuilt graph — convenient, but a black box. The next two chapters open it up: L4 builds the state + routing from scratch, L5 adds cycles, human approval, and durable persistence. When the prebuilt agent isn't enough, you'll build your own.Lab L3.3 · Making the agent safe advanced
Everything that made the agent powerful just became the reason it's dangerous. A chatbot that only talks can, at worst, say something wrong. An agent that calls tools can do something wrong — delete a record, send an email, run a command, spend money — and it decides to do so based on text from a model that you do not fully control. The instant you gave the model the ability to act, you took on the responsibility of bounding what it's allowed to act on. This section is about the brakes.
The mindset shift is the same one every ops engineer learns the hard way: treat the model's tool arguments as untrusted input, exactly as you'd treat a form field filled in by an anonymous stranger on the internet. That's not paranoia — the arguments really can be steered by content the model read along the way. A poisoned document pulled in by your retriever, or a crafted string returned by another tool, can nudge the model into calling delete_record on the wrong id (this is prompt injection, covered in T1). If you wouldn't run a shell command typed by a stranger without checking it, don't let the model run one either.
So the safety discipline comes down to a few concrete, layered habits, and the good news is they're cheap. Least privilege: only hand the agent the tools it genuinely needs, and prefer read-or-notify over write-or-delete wherever you can — a tool that doesn't exist can't be misused. Approval gates: a destructive tool checks for confirmation before it acts, so the model can request the deletion but a human (or a rule) has to bless it. Return errors, don't raise: when a tool fails, hand the agent a plain error string instead of throwing — an exception kills the whole run, but an error message is just another observation the agent can read and route around. Step limits: cap how many times the loop can go around, so a confused agent burns a few dollars instead of looping until your bill does.
One honest caveat before the lab: the approval gate you're about to see is a tool-level check — the tool inspects a flag and refuses if it isn't set. That's a real and useful guard, but it can only say "no." What it can't do is pause the entire run, surface the pending action to a human, and resume exactly where it left off once they approve. That production-grade version needs LangGraph interrupts plus a checkpointer, which is L5's territory. For now, learn the per-tool pattern; it's the foundation the durable version is built on. An agent that can call tools can cause effects. The same discipline from Chapter 4 and Claude Code (C3) applies here.
Follow the arrow from the left: an untrusted tool call enters, passes through the stack of guardrails in the middle, and lands in one of the two outcome boxes on the right.
- The left box is the tool call the model requested — treated as untrusted input, because its args can be steered by injected content.
- The middle column is the four cheap habits, stacked: only expose needed tools (least privilege), require confirmation for destructive ones (approval gate), hand failures back as text (return errors), and cap the loop (step limit).
- The green box (top-right) is the only path to a real effect — an approved, in-budget call. The red box is where everything else lands: a blocked or error string the agent simply reads and routes around, rather than a crash or an irreversible action.
In short: Layer the brakes so the model can request anything, but only a checked, approved, bounded call ever actually acts.
Requires: pip install langchain-core
safe_tools.pyfrom langchain_core.tools import tool
@tool
def delete_record(record_id: str) -> str:
"""Delete a record by id. Destructive — requires confirmation."""
if not _approved(record_id): # human-in-the-loop gate
return "BLOCKED: awaiting human approval."
try:
_do_delete(record_id)
return f"Deleted {record_id}."
except Exception as e:
return f"ERROR: {e}" # return errors so the agent can adapt
A tool that can change things (delete, send, pay) can cause real damage if the model calls it wrongly. This lab shows the safety pattern for such tools: check for approval before acting, and return errors as text instead of crashing. Both keep the agent controllable.
delete_recordis a normal@tool, but its docstring flags it as destructive — a signal to both the reader and the model that it needs care.if not _approved(record_id):is the approval gate. Before doing anything, the tool checks whether a human has approved this action; if not, it returns"BLOCKED: awaiting human approval."and stops. Nothing is deleted.- Only after approval does the
try:block run the real delete via_do_delete. Wrapping it intrymeans a failure won't crash the whole agent. except Exception as e:catches any failure and returns it as a string ("ERROR: {e}") instead of raising. The agent reads that error like any other tool result and can adapt — an uncaught exception would kill the run instead.
What the output means: If the record isn't approved, the tool returns BLOCKED and nothing happens. If it is approved and the delete works, it returns Deleted {record_id}.. If the delete throws, the agent receives an ERROR: string it can react to — the run keeps going either way.
Try this: Trace what happens when _approved returns False: the very first if returns the BLOCKED message, so _do_delete is never reached. That single guard line is what stands between the model and an irreversible action.
| Guardrail | How |
|---|---|
| Approval gate | Destructive tools check for confirmation before acting (real gate: L5 interrupts) |
| Least privilege | Only give the agent tools it needs; prefer read/notify over write/delete |
| Return errors, don't raise | A tool that returns an error string lets the agent recover; an uncaught exception kills the run |
| Step limit | Cap iterations (a recursion/step limit) so a confused agent can't loop forever |
Common pitfalls expert
| Pitfall | Fix |
|---|---|
Following AgentExecutor/initialize_agent tutorials | Use LangGraph's create_react_agent (current path) |
| Vague tool docstrings | State when to call the tool, not just what it does |
| Tools that raise on error | Return an error string so the agent can adapt |
| No step limit → infinite loops | Set a recursion/step cap |
| Ungated destructive tools | Add an approval gate (real one in L5) |
| Reaching for a prebuilt agent when a chain fits | If the path is fixed, use a chain (L2) |
Exercises expert
Exercise L3.1 — Three-tool agent
Context: Reading a trace to confirm which tools fired is how you verify an agent actually reasoned rather than guessed.
Your task: Give create_react_agent three tools — get_weather, add, and a search_docs tool backed by your L2 retriever — then ask a question that needs two of them.
Requirements:
- Wire three distinct tools, including one backed by your earlier retriever
- Ask a question that requires two of the three
- Read the trace to confirm both were actually called
- Confirm the agent chose them itself
💡 Hint: Craft the question so a single tool can't answer it — that forces the multi-tool path you want to observe.
Exercise L3.2 — Same agent, raw SDK
Context: When the framework is worth it — versus a clearer raw loop — depends on how many tools and how much custom gating you need.
Your task: Rebuild a framework agent using only the raw Chapter-2 manual tool loop, then compare line count and control.
Requirements:
- Reproduce the agent with a hand-written tool loop and the raw SDK
- Compare line count against the framework version
- Compare how much control each gives you
- State when the framework is worth it and when the raw loop is clearer
💡 Hint: The raw loop stays clear for one or two tools with no gating; the framework pulls ahead as tools and custom control multiply.
Exercise L3.3 — Gate a destructive tool
Context: A tool-level flag can refuse an action, but it cannot pause the whole run for a human and resume — which is exactly what L5's interrupts provide.
Your task: Add a send_email tool that returns "BLOCKED" unless an approved=True flag is set, prompt the agent to send an email, and confirm it's blocked.
Requirements:
- The tool refuses unless an approval flag is set
- Prompt the agent to use it and confirm the refusal
- Explain why a real gate must pause the run for a human
- Connect that to LangGraph interrupts + a checkpointer (L5)
💡 Hint: A flag can say no, but only a checkpointed interrupt can stop, surface the pending action, and resume on approval — the production-grade gate.
Show the connection
A tool-level flag can refuse, but it can't pause and wait for a human then resume where it left off. LangGraph interrupts + a checkpointer (L5) stop the graph, surface the pending action, and resume on approval — the production-grade version of this gate.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: LangChain's @tool builds a name, a description, and an argument schema from a typed function with a docstring — because the model never sees your code, only that packaged interface.
Your task: Model @tool: a decorator that captures the name, description, and arg schema from any typed function.
Requirements:
- Capture the function name as the tool name
- Take the description from the docstring
- Derive the arg schema from the type hints (via
inspect) - Attach all three to the function and show them
💡 Hint: inspect.signature gives you the parameters and their annotations; the docstring drives selection, the hints drive the schema.
Show solution
The docstring drives selection; the type hints drive the schema. Model @tool in stdlib:
import inspect
def tool(fn):
fn.tool_name = fn.__name__
fn.description = (fn.__doc__ or "").strip()
sig = inspect.signature(fn)
fn.args = {p.name: (p.annotation.__name__ if p.annotation != inspect._empty else "any")
for p in sig.parameters.values()}
return fn
@tool
def get_weather(city: str) -> str:
"Get the current weather for a city."
return f"18C and clear in {city}"
print(get_weather.tool_name) # get_weather
print(get_weather.description) # from the docstring
print(get_weather.args) # {'city': 'str'}
The model never sees your code — only the name, description, and arg schema. That is what @tool packages.
Context: Binding tools tells the model the schemas so it can emit a structured call. The model requests a call; you still run the tool and feed the result back.
Your task: Model a tool-bound model: given a user message, a stub model returns a tool-call request, which you then dispatch to the real tool.
Requirements:
- The stub model decides whether to request a tool and with what arguments
- Return either a tool-call request or direct text
- A dispatcher runs the requested tool with its args, or returns the text
- Show one message that triggers a tool and one that doesn't
💡 Hint: Separate "the model asks for a call" from "you execute it" — binding only communicates the schema; execution is still your job.
Show solution
The model requests a call; you dispatch it. Runnable:
TOOLS = {"get_weather": lambda city: f"18C in {city}",
"add": lambda a, b: a + b}
def model_with_tools(msg):
# stub: decide which tool the model would ask for
if "weather" in msg.lower():
return {"tool_call": {"name": "get_weather", "args": {"city": "Paris"}}}
return {"text": "I can answer directly."}
def dispatch(resp):
if "tool_call" in resp:
tc = resp["tool_call"]
return TOOLS[tc["name"]](**tc["args"])
return resp["text"]
print(dispatch(model_with_tools("what's the weather?"))) # 18C in Paris
print(dispatch(model_with_tools("hi"))) # I can answer directly.
Binding tools = telling the model the schemas so it can emit a structured call. You still run the tool and feed the result back.
Context: The agent runtime is the reason→act→observe loop: the model requests tools, you execute and append observations, repeat until a final answer — exactly what create_react_agent compiles.
Your task: Model a tool-calling agent loop offline on a multi-step task.
Requirements:
- The model reads the message history to decide the next tool call or a final answer
- Execute each requested tool and append the result as a tool message
- Loop until the model returns a final answer
- Bound the loop with a max-steps cap
- Show it completing a task that needs more than one tool call
💡 Hint: Append tool results as messages the model reads next round; a multi-step task (e.g. add then multiply) exercises the loop properly.
Show solution
The agent runtime is this loop. Runnable stdlib:
TOOLS = {"add": lambda a, b: a + b, "mul": lambda a, b: a * b}
def model(msgs):
# stub reasoner: (2+3)*4 in two tool calls, then answer
done = [m for m in msgs if m["role"] == "tool"]
if len(done) == 0:
return {"call": ("add", {"a": 2, "b": 3})}
if len(done) == 1:
return {"call": ("mul", {"a": done[0]["content"], "b": 4})}
return {"final": done[-1]["content"]}
def run(task, max_steps=6):
msgs = [{"role": "user", "content": task}]
for _ in range(max_steps):
resp = model(msgs)
if "final" in resp:
return resp["final"]
name, args = resp["call"]
result = TOOLS[name](**args)
msgs.append({"role": "tool", "content": result})
return "step limit"
print(run("compute (2+3)*4")) # 20
This reason->act->observe loop is exactly what LangGraph's create_react_agent compiles for you.
Context: Production agents need brakes: a step limit, error isolation around each tool, and an approval gate before a dangerous tool — the L5 human-in-the-loop pattern applied per tool.
Your task: Wrap the tool-call step with a step limit, a try/except around each tool, and an approval gate that blocks dangerous tools.
Requirements:
- A step limit stops runaway loops
- A try/except keeps one failing tool from crashing the run
- An approval gate blocks tools on a dangerous list unless approved
- Return a structured result distinguishing result / blocked / error
- Show an allowed call, a blocked call, and a caught error
💡 Hint: Route every tool call through one guarded function that checks the gate, then runs inside try/except; a denied dangerous tool returns a block, not a result.
Show solution
Three brakes around the loop. Runnable:
DANGEROUS = {"delete_file"}
TOOLS = {"read_file": lambda p: "contents", "delete_file": lambda p: "deleted"}
def guarded_call(name, args, approve):
if name in DANGEROUS and not approve(name, args):
return {"blocked": f"approval denied for {name}"}
try:
return {"result": TOOLS[name](**args)}
except Exception as e:
return {"error": repr(e)} # errors don't crash the loop
def auto_deny(name, args):
return False # a human would decide here
print(guarded_call("read_file", {"p": "a.txt"}, auto_deny)) # result
print(guarded_call("delete_file", {"p": "a.txt"}, auto_deny)) # blocked
print(guarded_call("read_file", {"nope": 1}, auto_deny)) # error, caught
Step limits stop runaways, try/except keeps one bad tool from killing the run, and the approval gate is the L5 human-in-the-loop pattern applied per tool.
Context: The modern answer to "build an agent" points at LangGraph's prebuilt create_react_agent — itself a compiled graph — and you drop to raw LangGraph only for the control it hides.
Your task: Write a decision helper for when to use the prebuilt ReAct agent versus raw LangGraph.
Requirements:
- Default to
create_react_agentfor a standard ReAct loop - Drop to raw LangGraph for custom routing, extra state, human pauses, or multi-agent
- Take those needs as parameters
- Show a case choosing the prebuilt and a case choosing raw LangGraph
💡 Hint: If any of custom-routing / extra-state / HITL / multi-agent is true, you've outgrown the prebuilt; otherwise don't hand-write the loop.
Show solution
Use the prebuilt until you need control it hides. Runnable:
def agent_choice(needs_custom_routing, needs_extra_state,
needs_human_pause, needs_multi_agent):
if any([needs_custom_routing, needs_extra_state,
needs_human_pause, needs_multi_agent]):
return "raw LangGraph -- you need control create_react_agent hides"
return "create_react_agent -- prebuilt ReAct loop, don't hand-write it"
print(agent_choice(False, False, False, False)) # prebuilt
print(agent_choice(True, False, True, False)) # raw LangGraph
create_react_agent is itself a compiled LangGraph graph — start there, and drop to raw nodes/edges only for custom routing, extra state, HITL, or multi-agent (L4/L5).
Context: The production tool-calling agent is the prebuilt create_react_agent driving real @tool functions against a Claude model — the loop you built by hand, now compiled.
Your task: Write the real prebuilt agent with a Claude model and at least one @tool function, using documented APIs. (Needs the libraries installed.)
Requirements:
- Define a tool with the real
@tooldecorator (typed + docstring) - Create a chat model (
ChatAnthropicfor Claude, orChatOpenAIfor OpenAI) - Build the agent with
create_react_agent(model, tools=[...]) - Invoke it with a messages input and read the final answer
- Note that
interrupt_beforeadds the L5 approval gate; label it as needinglanggraph/langchain-anthropic+ a key
💡 Hint: You supply the tools; the prebuilt agent drives the reason→act→observe loop you modeled by hand — verify imports against your installed version.
Show solution
Correct prebuilt agent. Works with either vendor — only the import and model line change (pip install langgraph langchain-anthropic + ANTHROPIC_API_KEY, or langchain-openai + OPENAI_API_KEY):
from langchain_core.tools import tool
from langchain_anthropic import ChatAnthropic # Claude
# from langchain_openai import ChatOpenAI # OpenAI
from langgraph.prebuilt import create_react_agent
@tool
def get_weather(city: str) -> str:
"Get the current weather for a city."
return f"18C and clear in {city}"
model = ChatAnthropic(model="claude-opus-4-8", max_tokens=512) # Claude
# model = ChatOpenAI(model="gpt-5.5", max_tokens=512) # OpenAI
agent = create_react_agent(model, tools=[get_weather])
result = agent.invoke(
{"messages": [{"role": "user", "content": "weather in Paris?"}]}
)
print(result["messages"][-1].content) # model answers using the tool
You write the tools; the prebuilt agent drives the reason->act->observe loop you modeled by hand above. Add interrupt_before for the L5 approval gate. Verify imports against your installed version.
✓ Checkpoint — you can move on when you can…
- Define a tool with
@tooland explain why the docstring matters. - Use
bind_toolsand read backtool_calls. - Run
create_react_agentand read its message trace. - Explain why the recommended agent lives in LangGraph.
- Add approval gates, error handling, and a step limit.
terraform_plan, kubectl_get, describe_pod — are exactly these LangChain tools, each with a description that tells the model when to reach for it and a gate on the destructive ones. Whether you drive them with create_react_agent or a custom LangGraph graph (L4/L5) is the choice the next two chapters equip you to make. See the tools + agent-loop build →Knowledge check check yourself
With bind_tools, what does the model actually return, and whose job is it to run the tool?
Show answer
bind_tools only tells the model which tools exist; the model responds with a tool-call request (name + args + id) in msg.tool_calls — it never executes anything. Running the function and feeding the result back is still your job; an agent runtime is what automates that request→run→feed-back loop.Why does the recommended create_react_agent live in langgraph rather than langchain?