← Back to Agentic AI map
Lesson 2.6 · Building Agents with LangChain

Tools in LangChain

Rebuild the weather tool with @tool: LangChain reads the name, docstring and type hints and writes the tool schema for you.

tools

What you will be able to do

  • Explain what a tool is, and who decides versus who executes
  • Turn a Python function into a LangChain tool with @tool
  • Read the tool schema LangChain builds from the name, docstring and type hints
  • Call a tool directly with invoke(), and run a tool call the way a framework does
  • Write tool descriptions a model can choose between
  • Tell a chain, a tool and an agent apart

The idea, in plain English

A tool is a function an agent can use to do something outside the model - look up the weather, do arithmetic, query a database. The model cannot run Python. It can only ask for a tool by name, with arguments. Your code runs it and hands back the result. The model decides; your code executes.

In Lesson 1.5 you did all of it by hand: described get_weather in the system message, told the model to write TOOL: get_weather(city="Mumbai"), matched that line with a regular expression, looked the name up in a TOOLS dict and called the function. Each of those steps was code you wrote and could get wrong.

In LangChain you put @tool above the function. LangChain reads three things - the function name, the docstring and the type hints - and builds a tool with a schema: a JSON description of the name, what it does, and which arguments it takes with which types. That schema is what a tool-calling model receives instead of your hand-written description.

Everything here was run against langchain-core 1.6. Our local llama3 does not support tool calling, so the model’s side - choosing a tool - is shown as the message a tool-calling model produces, and the next lesson runs it.

Worked example: Rebuild the weather tool the LangChain way.

request flowFrom a function to a tool resultstep 1 / 5

1 - An ordinary function

get_weather looks up a city in a dict. Only your Python program knows it exists.

name
get_weather
docstring
Get the current weather for a city.
argument
city: str
model knows it
no

What @tool builds, what the model sees, and who runs what.

The model decides, your code executes

A model produces text - or, with tool calling, a structured request. It never runs get_weather. When a user asks for the weather in Mumbai, the model’s job is to say "call get_weather with city Mumbai"; your program (or the framework) runs the function and returns the real result.

That separation is Lesson 1.5’s, and it does not change in LangChain. What changes is who writes the description, and who reads the request.

Lesson 1.5, by hand

You wrote the tool description into the system message, defined an exact format - TOOL: get_weather(city="CityName") - parsed it with TOOL_PATTERN, checked the name in TOOLS = {"get_weather": get_weather}, and called the function. A model that wrote TOOLS: instead of TOOL:, or answered without the tool, slipped past.

With LangChain tools and a tool-calling model, the description is generated from your function and the request arrives as structured data. The regex disappears. The registry stays - as a dict of tools keyed by name.

@tool and the schema it builds

from langchain_core.tools import tool, then @tool above the function. get_weather is now a StructuredTool with .name ("get_weather"), .description (the docstring) and .args ({"city": {"type": "string"}}).

The full schema - what bind_tools sends to a model - is a small JSON object: name, description, and parameters listing each argument’s type and which are required. Arguments without a default are required.

Where each part of the schema comes from
nameThe function name.
descriptionThe docstring - required.
parametersThe type hints: str becomes string, int becomes integer.
requiredEvery argument without a default.

Type hints become argument types

user_id: int appears in the schema as "type": "integer", and the tool validates input against it: invoke({"user_id": "42"}) converts to 42, invoke({"user_id": "abc"}) raises a ValidationError. search_products(query: str, limit: int) becomes two required parameters, a string and an integer.

Leave the hint off and the argument has no type in the schema at all - {"city": {"title": "City"}}. The model then has to guess, and nothing checks what it sends.

The docstring is the model’s documentation

The docstring becomes the description, and it is not optional: @tool on a function without one raises ValueError: Function must have a docstring if description not provided.

It also has to be useful. """Weather.""" produces a valid schema with description "Weather." - legal, but it tells the model nothing about when to use it. When tools sit side by side, the descriptions are what the model chooses between: "Find a customer by name." versus "Find an order by order ID, e.g. ORD123." Say what the tool does, what the input looks like, and when to use it.

Calling a tool yourself

A decorated tool is no longer called with brackets: get_weather("Mumbai") raises TypeError: ‘StructuredTool’ object is not callable. Use get_weather.invoke({"city": "Mumbai"}) - the same invoke() as every runnable in Lesson 2.4. The original function is still at get_weather.func.

Invoke with a tool call - the dict a tool-calling model puts in AIMessage.tool_calls - and you get a ToolMessage back, with the result and the call’s id. That is what a framework does on each turn of the loop: look the tool up by name, invoke it with the call, send the ToolMessage back to the model.

Watch out: Our llama3 cannot take this step: bind_tools works, but the call fails with "llama3 does not support tools". Tool calling needs a model trained for it, such as llama3.1.

A calculator tool - without eval()

The quick version is return str(eval(expression)). It works - "47 * 89" gives 4183 - and with the same tool, __import__(‘os’).getcwd() returned the project directory. eval runs any Python the model writes.

Lesson 1.6 replaced eval with a small evaluator that only allows numbers and + - * /. Wrap that in @tool instead. The model sees the same schema; your machine is no longer at its mercy.

Chain, tool, agent

A chain is a fixed path: prompt | llm | parser runs the same steps every time. A tool is one action the program can take. An agent is a model in a loop that decides, each turn, whether to call a tool, which one, or to answer.

This lesson builds the tools. Lesson 2.7 gives them to an agent and lets LangChain run the loop you wrote by hand in Lesson 1.6.

Three different things
ChainConnects steps in a fixed order.
ToolOne action, with a schema the model can read.
AgentChooses which tool to call - or none - in a loop.

Step-by-step code

The weather tool
from langchain_core.tools import tool @tool def get_weather(city: str) -> str: """Get the current weather for a city.""" weather = { "mumbai": "Rainy, 27°C", "delhi": "Sunny, 34°C", "hyderabad": "Cloudy, 29°C", } return weather.get(city.lower(), "Unknown city") print(get_weather.invoke({"city": "Mumbai"})) # Rainy, 27°C print(get_weather.invoke({"city": "Paris"})) # Unknown city print(get_weather.func("Hyderabad")) # Cloudy, 29°C - the original function get_weather("Mumbai") # TypeError: 'StructuredTool' object is not callable
What @tool built
from langchain_core.utils.function_calling import convert_to_openai_tool print(type(get_weather).__name__) # StructuredTool print(get_weather.name) # get_weather print(get_weather.description) # Get the current weather for a city. print(get_weather.args) # {'city': {'title': 'City', 'type': 'string'}} print(convert_to_openai_tool(get_weather)) # the schema a model receives
Output - the tool schema
{ "type": "function", "function": { "name": "get_weather", "description": "Get the current weather for a city.", "parameters": { "properties": { "city": { "type": "string" } }, "required": ["city"], "type": "object" } } }
Type hints and docstrings - checked
get_user(user_id: int) schema {'user_id': {'type': 'integer'}} invoke "42" user 42 - converted invoke "abc" ValidationError - unable to parse string as an integer get_weather.invoke({"town": "Mumbai"}) ValidationError - city: Field required search_products(query: str, limit: int) parameters query: string, limit: integer - both required def get_weather(city) - no type hint schema {'city': {'title': 'City'}} - no type at all no docstring ValueError: Function must have a docstring if description not provided. """Weather.""" accepted - description "Weather."
A safe calculator tool
import ast import operator from langchain_core.tools import tool OPERATORS = { ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv, ast.USub: operator.neg, } def evaluate(node): if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)): return node.value if isinstance(node, ast.BinOp) and type(node.op) in OPERATORS: return OPERATORS[type(node.op)](evaluate(node.left), evaluate(node.right)) if isinstance(node, ast.UnaryOp) and type(node.op) in OPERATORS: return OPERATORS[type(node.op)](evaluate(node.operand)) raise ValueError("only numbers and + - * / are allowed") @tool def calculate(expression: str) -> str: """Calculate an arithmetic expression using numbers, + - * / and brackets, e.g. '47 * 89'.""" try: return str(evaluate(ast.parse(expression, mode="eval").body)) except (SyntaxError, ValueError, ZeroDivisionError) as error: return f"Calculation error: {error}" print(calculate.invoke({"expression": "47 * 89"})) # 4183 print(calculate.invoke({"expression": "12 * (3 + 4)"})) # 84 print(calculate.invoke({"expression": "__import__('os').getcwd()"})) # Calculation error: only numbers and + - * / are allowed print(calculate.invoke({"expression": "10 / 0"})) # Calculation error: division by zero
Running tool calls - the framework’s job
from langchain_core.messages import AIMessage tools = {t.name: t for t in [get_weather, calculate]} # What a tool-calling model replies with (llama3 cannot - see below) reply = AIMessage(content="", tool_calls=[ {"name": "get_weather", "args": {"city": "Hyderabad"}, "id": "call_1"}, {"name": "calculate", "args": {"expression": "47 * 89"}, "id": "call_2"}, ]) print(reply.tool_calls[0]) # {'name': 'get_weather', 'args': {'city': 'Hyderabad'}, 'id': 'call_1', 'type': 'tool_call'} for call in reply.tool_calls: result = tools[call["name"]].invoke(call) print(type(result).__name__, result.name, repr(result.content), result.tool_call_id) # ToolMessage get_weather 'Cloudy, 29°C' call_1 # ToolMessage calculate '4183' call_2
Giving the tools to llama3
from langchain_ollama import ChatOllama llm = ChatOllama(model="llama3").bind_tools([get_weather, calculate]) llm.invoke("What's the weather in Hyderabad?") # ResponseError: registry.ollama.ai/library/llama3:latest does not support tools (status code: 400) # Tool calling needs a model trained for it - for example llama3.1.
Lesson 1.5 vs Lesson 2.6
# Lesson 1.5 - describe, parse, register, execute: all by hand SYSTEM = 'You have access to one tool: get_weather(city) ... TOOL: get_weather(city="CityName")' TOOL_PATTERN = re.compile(r'TOOL:\s*(\w+)\(\s*city\s*=\s*"([^"]*)"\s*\)') TOOLS = {"get_weather": get_weather} match = TOOL_PATTERN.search(output) result = TOOLS[match.group(1)](match.group(2)) # Lesson 2.6 - the description comes from the function, the request is structured @tool def get_weather(city: str) -> str: """Get the current weather for a city.""" ... tools = {t.name: t for t in [get_weather]} result = tools[call["name"]].invoke(call)

Tip: Test every tool with invoke() before giving it to a model. If the tool is wrong on its own, no model will make it right.

Tools at a glance

@tool

Turn a function into a LangChain tool.

from langchain_core.tools import tool
.name / .description / .args

What the schema is built from.

get_weather.args
invoke(dict)

Run the tool yourself; returns the result.

get_weather.invoke({"city": "Mumbai"})
invoke(tool_call)

Run a model’s tool call; returns a ToolMessage.

tools[call["name"]].invoke(call)
.func

The original Python function.

get_weather.func("Mumbai")
convert_to_openai_tool

Print the schema a model receives.

convert_to_openai_tool(get_weather)
bind_tools

Give tools to a tool-calling model.

llm.bind_tools([get_weather, calculate])

Try it yourself

The code does not change. Swap the content string and the program does something else entirely.

Add a city

“Add Chennai to get_weather and invoke it with "CHENNAI".”

Read the schema

“Print convert_to_openai_tool() for get_weather, then add a units: str = "celsius" argument and compare.”

Remove a hint

“Delete the str type hint from city and look at get_weather.args.”

Run a tool call

“Write an AIMessage with a calculate tool call and run it through the tools dict.”

What usually goes wrong

Calling the tool like a function

@tool replaces the function with a tool object. Use invoke(), or .func for the original.

✗ get_weather("Mumbai")
✓ get_weather.invoke({"city": "Mumbai"})
A missing or vague docstring

No docstring is an error. A one-word docstring is worse: valid, and useless to the model choosing between tools.

✗ """Weather."""
✓ """Get the current weather for a city."""
Leaving out type hints

Without a hint the argument has no type in the schema, and nothing validates what the model sends.

✗ def get_user(user_id):
✓ def get_user(user_id: int) -> str:
eval() in a calculator tool

The model’s argument becomes Python code on your machine. Use an arithmetic-only evaluator, as in Lesson 1.6.

✗ return str(eval(expression))
✓ return str(evaluate(ast.parse(expression, mode="eval").body))
Thinking the model runs the tool

The model only asks. If your code does not invoke the tool and send back the result, nothing happened - whatever the model says next.

Key points

  • A tool is a function an agent can use to act outside the model.
  • The model chooses the tool; your application executes it.
  • @tool turns a function into a StructuredTool with a schema.
  • Name from the function, description from the docstring, argument types from the type hints.
  • The docstring is required, and it is what the model reads when choosing.
  • Call tools with invoke(); a tool call returns a ToolMessage.
  • Tool calling needs a tool-calling model - llama3 is not one.

Quick check before you move on

What does @tool do?
Turns a Python function into a LangChain tool with a name, a description and an argument schema.
Why are type hints useful when defining a tool?
They become the argument types in the schema, and the tool validates input against them.
Why is the docstring important?
It becomes the tool description the model reads to decide when to use the tool. Without one, @tool raises an error.
Does the LLM execute your Python function?
No. It asks for a tool call; your application or the framework runs the function.
What is a tool schema?
A JSON description of the tool: its name, its description, and its arguments with their types and which are required.
How is prompt | llm | parser different from a @tool function?
The chain is a fixed sequence of steps. The tool is one action that a model may choose to request.
What did Module 1 do by hand that @tool helps with?
Writing the tool description and the exact output format, and parsing the model’s text with a regex to get the tool name and arguments.

Quiz

  1. 1.

    You write get_weather("Mumbai") after decorating it with @tool. What happens?

  2. 2.

    get_user(user_id: int) is invoked with "abc". What happens?

  3. 3.

    You invoke a tool with a tool call from AIMessage.tool_calls. What comes back?

  4. 4.

    What is still needed from Lesson 1.5 when you use LangChain tools?

Interview questions

What is a tool in an agent system?

A function with a schema - name, description, typed arguments - that a model can request. The model decides when to call it; the application executes it and returns the result.

How do you design a good tool?

One clear job, a descriptive name, a docstring that says what it does and when to use it, typed arguments, and safe execution - validate input, return errors as results, never run model output as code.

Why is native tool calling more reliable than a text format like TOOL: ...?

The request arrives as structured data - name, arguments, id - instead of text a regex has to find. It still depends on a model trained for tool calling, and you still validate the arguments.

Comments

Sign in to leave a comment. Your name and photo come from Google; nothing else is shared.

Loading comments...