When I started working with agents, tools were the concept that made the rest of the architecture fall into place. A language model can reason over the information in its context, but it cannot independently read a local file, query a private database, call a current weather service, or run a command. The surrounding application has to provide those capabilities.
In an agent, these capabilities are called tools. A tool is a function that the model can request when it needs information or wants an operation to be performed. The agent framework runs the function and returns its result to the model.
This distinction is important for anyone new to agents. The model does the reasoning, but ordinary application code does the work. Once I understood that division of responsibility, tools stopped looking like a special AI feature and started looking like a familiar software interface.
In this post, I will explain how tools work in the Strands Agents SDK. I will begin with the tool-calling loop, then build several examples using prebuilt tools, custom Python functions, private data, tool chaining, and Model Context Protocol (MCP).
How tool calling works
The language model does not execute Python code directly. When I create a Strands agent, the SDK gives the model a description of each available tool. This description contains the tool name, its purpose, and the parameters it accepts.
When the model decides that a tool is required, it produces a structured tool request. For example, it may request get_weather with city set to Las Vegas. The Strands SDK receives that request, calls the corresponding Python function, and sends the function result back to the model. The model then uses the result to produce an answer or request another tool.
The sequence can be summarized as follows:
- The user sends a request to the agent.
- The model decides whether it needs a tool.
- The model requests a tool with specific arguments.
- Strands runs the tool.
- The tool result is returned to the model.
- The model responds or requests another tool.
This repeated process is the agent loop. The model is responsible for reasoning about which tool to use, while the application is responsible for executing the tool.
I find it useful to compare this with a conventional application. In a traditional program, a developer writes the control flow that decides exactly which function runs next. In an agent, the developer supplies the functions and the operating instructions, while the model participates in choosing the next function. The execution still happens in normal code. What changes is how the next operation is selected.
Set up a Strands project
The examples in this tutorial require Python 3.10 or newer. I recommend using a virtual environment so the tutorial dependencies remain separate from other Python projects. Install the Strands SDK, the community tools package, and requests.
python -m venv .venv
source .venv/bin/activate
pip install strands-agents strands-agents-tools requests
Strands uses Amazon Bedrock as its default model provider. To use the default configuration, configure AWS credentials with permission to invoke a supported model in Amazon Bedrock. Strands also supports other model providers.
Start with prebuilt tools
The first question I ask before writing a tool is whether an appropriate tool already exists. The strands-agents-tools package provides implementations for common operations. The following agent can inspect the current directory and read files.
from strands import Agent
from strands_tools import file_read, shell
agent = Agent(tools=[file_read, shell])
agent(
"List the files in the current directory. "
"If a README file exists, read it and summarize the project."
)
The application does not hardcode that sequence. It provides the capabilities, and the model selects them based on the request and previous results.
A tool is also a permission. I only give an agent the capabilities it needs. File-writing access, a shell, or a production API should be treated like access granted to any other application.
The community package contains additional tools for editing files, running Python, making HTTP requests, checking the current time, and interacting with AWS services, among other functionalities.
Creating a custom tool
Prebuilt tools are useful, but most real applications eventually need access to a domain-specific API or internal operation. Strands uses the @tool decorator to expose a Python function to an agent. The following tool gets the current temperature for a city from the Open-Meteo API.
from strands import Agent, tool
import requests
@tool
def get_weather(city: str) -> str:
"""Get the current temperature for a city.
Args:
city: Name of the city
"""
geo_response = requests.get(
"https://geocoding-api.open-meteo.com/v1/search",
params={"name": city, "count": 1},
timeout=10,
)
geo_response.raise_for_status()
geo_data = geo_response.json()
if not geo_data.get("results"):
return f"No location was found for {city}."
latitude = geo_data["results"][0]["latitude"]
longitude = geo_data["results"][0]["longitude"]
weather_response = requests.get(
"https://api.open-meteo.com/v1/forecast",
params={
"latitude": latitude,
"longitude": longitude,
"current": "temperature_2m",
},
timeout=10,
)
weather_response.raise_for_status()
weather_data = weather_response.json()
temperature_c = weather_data["current"]["temperature_2m"]
temperature_f = round(temperature_c * 9 / 5 + 32)
return f"The current temperature in {city} is {temperature_f}°F."
agent = Agent(tools=[get_weather])
agent("What is the current temperature in Las Vegas?")
The decorator function @tool contains the main parts of a tool definition. The function name becomes the tool name. The type annotation on city defines the expected input type. The docstring tells the model what the tool does and explains the argument. The returned string becomes context that the model can use in its response.
Clear tool definitions improve tool selection. A tool should have a specific name, a focused responsibility, typed parameters, and a docstring that explains when it is useful. The result should contain the information needed for the model's next decision without including unnecessary API data.
The example also handles two common failures. It checks for an unknown city and calls raise_for_status() so HTTP errors are not silently treated as valid responses. I consider this part of the tool contract. A model cannot reason sensibly about a failure if the tool hides the failure or returns malformed data. Production tools should provide useful error information because the result informs the model's next decision.
Chain tools with a system prompt
A tool description explains one operation. A system prompt explains how the agent should use several operations together. I think of the description as the documentation for one operation and the system prompt as the operating policy for the agent.
The following example adds a second tool that recommends clothing. The system prompt tells the agent to check the weather before requesting a recommendation.
from strands import Agent, tool
import requests
@tool
def get_weather(city: str) -> dict:
"""Get current weather conditions for a city.
Args:
city: Name of the city
"""
geo_response = requests.get(
"https://geocoding-api.open-meteo.com/v1/search",
params={"name": city, "count": 1},
timeout=10,
)
geo_response.raise_for_status()
geo_data = geo_response.json()
if not geo_data.get("results"):
return {"error": f"No location was found for {city}."}
latitude = geo_data["results"][0]["latitude"]
longitude = geo_data["results"][0]["longitude"]
weather_response = requests.get(
"https://api.open-meteo.com/v1/forecast",
params={
"latitude": latitude,
"longitude": longitude,
"current": "temperature_2m,wind_speed_10m,precipitation",
},
timeout=10,
)
weather_response.raise_for_status()
current = weather_response.json()["current"]
return {
"city": city,
"temperature_f": round(current["temperature_2m"] * 9 / 5 + 32),
"wind_mph": round(current["wind_speed_10m"] * 0.621),
"precipitation_mm": current["precipitation"],
}
@tool
def clothing_recommendation(
temperature_f: int,
precipitation_mm: float,
) -> str:
"""Recommend clothing for the supplied weather conditions.
Args:
temperature_f: Temperature in degrees Fahrenheit
precipitation_mm: Current precipitation in millimeters
"""
if temperature_f < 40:
recommendation = "Wear a heavy coat, gloves, and a warm hat."
elif temperature_f < 60:
recommendation = "Wear a sweater or light jacket."
elif temperature_f < 80:
recommendation = "Wear light, breathable clothing."
else:
recommendation = "Wear shorts, a T-shirt, and sunscreen."
if precipitation_mm > 0:
recommendation += " Bring an umbrella."
return recommendation
agent = Agent(
tools=[get_weather, clothing_recommendation],
system_prompt=(
"You are a travel assistant. When a user asks what to wear, "
"first call get_weather for the requested city. If the weather "
"tool succeeds, pass its temperature and precipitation values "
"to clothing_recommendation. Include the weather conditions and "
"the clothing recommendation in the final answer."
),
)
agent("I am going to Las Vegas today. What should I wear?")
Because get_weather returns structured fields, the agent can pass its temperature and precipitation values directly to the second tool. I learned quickly that prose is convenient for a final answer but fragile when another tool needs to consume the result.
Note that the system prompt improves the reliability of the sequence, but it should not be used as the only safety control. If an operation must follow a strict rule, I enforce that rule in application code or inside the tool itself. A prompt can guide model behavior, but it is not a replacement for validation, authorization, or deterministic control flow.
Give an agent access to private data
Tools can provide controlled access to data that was not included in the model's training data. The data can remain in its existing system and be retrieved only when the agent needs it. This is often more useful than attempting to place an entire dataset in the prompt.
Consider the following local JSON file:
{
"las_vegas": [
"Cirque du Soleil - May 23",
"Adele - May 24",
"UFC 315 - May 25"
],
"new_york": [
"Hamilton - May 22",
"Yankees vs Red Sox - May 24"
]
}
These entries are sample data rather than a current event listing. A class-based tool can load the file and expose a method for searching it.
import json
from strands import Agent, tool
class EventLookup:
def __init__(self, file_path: str):
with open(file_path, encoding="utf-8") as file:
self.events = json.load(file)
@tool
def find_events(self, city: str) -> str:
"""Find events in the local schedule for a city.
Args:
city: Name of the city
"""
city_key = city.lower().replace(" ", "_")
matches = self.events.get(city_key, [])
if not matches:
return f"No events were found for {city}."
return "\n".join(matches)
event_lookup = EventLookup("events.json")
agent = Agent(
tools=[event_lookup.find_events],
system_prompt=(
"You answer questions about the local event schedule. "
"Use find_events when a user asks which events are listed for a city."
),
)
agent("Which events are listed for Las Vegas?")
The EventLookup object keeps the loaded JSON data as state, while the decorated find_events method provides a limited interface to that data. The agent can search the schedule but cannot modify the file because no write tool has been provided. I like this example because it makes the permission boundary visible in the code. The object may have access to the complete file, but the agent only receives the operation I intentionally expose.
The same approach can be used with a database connection, an authenticated API client, or an internal service. The model does not need to be retrained when the underlying data changes. The tool retrieves the latest available data when it is called.
Connect external tools with MCP
Custom Python functions work well for integrations maintained inside the same application. They become less convenient when every external system requires a new wrapper maintained by the agent application. Model Context Protocol provides a standard way to connect tools supplied by another process or service.
The following example uses the AWS Documentation MCP server. It requires uv because uvx starts the server.
from mcp import stdio_client, StdioServerParameters
from strands import Agent
from strands.tools.mcp import MCPClient
aws_documentation = MCPClient(
lambda: stdio_client(
StdioServerParameters(
command="uvx",
args=["awslabs.aws-documentation-mcp-server@latest"],
)
)
)
agent = Agent(
tools=[aws_documentation],
system_prompt=(
"You are an AWS development assistant. Search the AWS "
"documentation before answering questions about AWS services. "
"Base the answer on the retrieved documentation."
),
)
agent("How does response streaming work with AWS Lambda?")
The MCPClient starts the server through standard input and output, discovers its tools, and exposes them to the agent. The server provides operations for searching and reading AWS documentation. Strands manages the client lifecycle when the client is passed directly in the agent's tools list.
From the model's perspective, an MCP tool has the same basic elements as a local tool: a name, a description, an input schema, and a result. MCP allows the implementation and transport to be managed separately from the agent application.
The important lesson I took from this example is that MCP changes how tools are distributed, not the fundamental tool-calling model. The agent still selects a described operation, the application executes it through a client, and the result returns to the model.
MCP does not remove the need for access control. I review the tools exposed by a server, configure authentication correctly, and restrict the agent to the operations it requires. Strands also supports filtering which MCP tools are made available to an agent.
What I learned about tool design
The most reliable tools I have worked with perform one clear operation. Small tools are easier for the model to select and easier for developers to test. A name such as find_events communicates more than a general name such as process_data. If a function performs several unrelated operations, I usually split it before exposing it to an agent.
I write tool descriptions as API documentation. The description should explain the operation, define every argument, and distinguish the tool from similar capabilities. The model uses this information when choosing a tool, so an imprecise description can cause an otherwise correct implementation to be selected at the wrong time.
I also treat input validation and error handling as part of tool design. Network calls need timeouts and should handle unsuccessful responses. Tools that modify data need authorization checks and validation of the requested change. Important constraints should be enforced by code rather than depending only on the model following a prompt.
The shape of the result matters as much as the shape of the input. I return the fields required for the next step rather than a complete raw response from an external service. When another tool will consume the result, a structured dictionary is generally more dependable than prose.
Finally, I provide the minimum necessary permissions. A read-only file lookup is safer than unrestricted file access. A specific API operation is safer than a general shell command. A smaller tool set also gives the model fewer overlapping choices, which can improve tool selection.
Takeaways
Tools allow a Strands agent to use information and capabilities outside the model. The model decides when a tool is needed, Strands executes the tool, and the result is returned to the model through the agent loop.
The strands-agents-tools package provides common capabilities that can be added directly to an agent. The @tool decorator exposes application-specific Python functions. Class-based tools can provide controlled access to stateful resources such as local data or database clients. MCP connects an agent to tool collections implemented and maintained outside the application.
My main conclusion is that building an agent is not primarily about giving a model as many capabilities as possible. It is about designing a small, understandable interface between model reasoning and application code. The better that interface is defined, the easier the agent is to understand, test, and control.
For someone learning Strands, I recommend starting with a small read-only tool for information you already use regularly. Define one focused function, document its inputs, return a concise result, and add it to Agent(tools=[...]). Once that works, add another tool and observe how the agent uses the first result to choose its next action. That progression provides a practical way to understand the agent loop without hiding it behind a large application.
Top comments (0)