In Part 2, we built chains - fixed pipelines where the steps are always the same: prompt, then model, then parser. Chains are predictable, but they can't do anything a human didn't explicitly script in advance.
Agents flip that around. Instead of a fixed path, you hand the model a set of tools and let it decide, at run time, which ones to call, in what order, and when it's done. That's what we're building today.
Recap: The Series So Far
- Part 1 - What LangChain is & your first script
- Part 2 - Prompts, models, and LCEL chains
- Part 3 (this article) - Tools and your first agent
- Part 4 - RAG: teaching your agent to read documents
- Part 5 - Memory and middleware
- Part 6 - Debugging and observing agents with LangSmith
What Is a Tool?
A tool is just a normal Python function, with two things added so the model can understand it:
- A clear docstring explaining what it does and when to use it
- Type hints on its parameters, so LangChain can build a schema the model understands
LangChain turns that function into something the model can "see" and choose to call, using the @tool decorator.
from langchain.tools import tool
@tool
def get_weather(city: str) -> str:
"""Get the current weather for a given city."""
# In a real app, you'd call a weather API here
fake_data = {"chennai": "32°C, humid", "london": "16°C, cloudy"}
return fake_data.get(city.lower(), "No data for that city.")
That's it - get_weather is now a tool. Two things matter enormously here:
- The docstring is not a comment - the model reads it to decide whether this tool is relevant to the user's question. A vague docstring means the model may never call your tool, or call it at the wrong time.
-
Type hints define the tool's schema.
city: strtells LangChain (and the model) that this tool expects a single string argument namedcity.
Building Your First Agent
Now let's give an agent a small toolbox and watch it reason about which tool to use.
from dotenv import load_dotenv
from langchain.agents import create_agent
from langchain.tools import tool
load_dotenv()
@tool
def get_weather(city: str) -> str:
"""Get the current weather for a given city."""
fake_data = {"chennai": "32°C, humid", "london": "16°C, cloudy"}
return fake_data.get(city.lower(), "No data for that city.")
@tool
def add_numbers(a: float, b: float) -> float:
"""Add two numbers together and return the result."""
return a + b
agent = create_agent(
"gpt-5",
tools=[get_weather, add_numbers],
system_prompt="You are a helpful assistant. Use tools when they help answer the question.",
)
response = agent.invoke(
{"messages": [{"role": "user", "content": "What's the weather in Chennai, and what's 45 + 78?"}]}
)
print(response["messages"][-1].content)
Run this, and the agent will:
- Read the question and notice it actually contains two sub-tasks
- Call
get_weather("Chennai") - Call
add_numbers(45, 78) - Combine both tool results into one coherent final answer
You never wrote an if/else to decide "call the weather tool" vs "call the math tool" - the model figured that out from the docstrings and the user's question.
Peeking Inside the Agent's Reasoning
It's worth seeing the intermediate steps at least once, so the "loop" from Part 1 stops feeling like magic:
response = agent.invoke(
{"messages": [{"role": "user", "content": "What's the weather in London?"}]}
)
for msg in response["messages"]:
print(f"{msg.__class__.__name__}: {msg.content}")
You'll see something like:
-
HumanMessage: the original question -
AIMessage: the model deciding to callget_weather, with no content yet -
ToolMessage: the string your function returned -
AIMessage: the final, natural-language answer
This is the agent loop: model → tool call → tool result → back to the model → repeat until the model has enough information to answer without calling another tool.
Giving Tools Better Structure with Pydantic
For tools with several parameters, docstrings alone get messy. You can define an explicit input schema with Pydantic for more reliable parsing:
from pydantic import BaseModel, Field
from langchain.tools import tool
class SearchInput(BaseModel):
query: str = Field(description="The search term to look up")
max_results: int = Field(default=5, description="Maximum number of results to return")
@tool(args_schema=SearchInput)
def search_docs(query: str, max_results: int = 5) -> str:
"""Search internal documentation for a given query."""
return f"Found {max_results} results for '{query}' (placeholder)."
This isn't necessary for simple tools, but it becomes valuable once your tools have optional parameters, defaults, or validation rules you want enforced before the function ever runs.
A Word on Safety
Agents that call tools autonomously are powerful - and that's exactly why you should think about what each tool is allowed to do before wiring it up. A tool that deletes files, sends emails, or hits a paid API should never be handed to an agent without limits. In Part 5, we'll cover middleware, LangChain's mechanism for adding guardrails like human approval steps and rate limits around tool calls.
What's Next
You've now built a real agent that reasons about which tool to use - the core skill behind everything from customer support bots to coding assistants. In Part 4, we'll connect an agent to your own documents using RAG (Retrieval-Augmented Generation), so it can answer questions grounded in content it was never trained on.
This is Part 3 of a 6-part LangChain beginner series. Catch up on Part 1 and Part 2 if you missed them.

Top comments (0)