Giving agents
tools
Section 2. Your first agent could only talk. Here it gets hands: Python functions it can call, a ready-made toolbox, plain-English access to S3, DynamoDB and Lambda, and patterns for tools that stay fast in production.
From talking to doing
In Section 1 you built an agent that understands requests and writes clever answers. But it couldn't check the weather, read a file, query a database, or change anything in the real world.
That limit hurts the moment you try to build something practical. Picture an AI assistant for your team that discusses project requirements brilliantly, yet can't check whether the deployment pipeline is running, read the latest error logs, or update a ticket. Or a customer-service agent that understands tricky questions perfectly, but can't look up an order, process a refund or book an appointment.
✦Think of it like this
A brilliant new hire on day one, before IT has given them any logins. They can discuss your product for hours, but they can't open Jira, can't read the dashboards, can't send a single email. Tools are those logins.
In this section we fix that by giving the agent : the ability to interact with real systems, fetch live data and do meaningful work beyond writing text. We start by turning plain Python functions into tools, then use ready-made tools that save days of work, then build custom tools for problems only your business has. By the end, your agents won't just talk about solutions; they'll carry them out.
What you'll learn here
- Why agents need tools
- Building your first tool-enabled agent
- Using prebuilt tools from the Strands ecosystem, including AWS access
- Building custom tools, and the class-based and async patterns for when simple tools aren't enough
- A quick start with Strands Agent Builder
Before you start
- 1
A working Strands setup from Section 1
Your virtual environment, AWS credentials and
python setup/check_setup.pyshowing ✅ for both models. - 2
Basic Python
Functions, return values, dictionaries and simple classes. We explain the new bits (decorators, type hints, async) as we go.
- 3
AWS permissions for notebook 2
Besides Bedrock, notebook 2 creates and deletes a small bucket, a table, a function and its role. An admin user in a sandbox account, or
setup/iam-policy-course.json, covers all of it. - 4
A little and JSON (helpful, not required)
Knowing that an is "send a request to a URL, get back" makes the web and AWS examples easier to follow.
| Notebook | What it covers | Where on this page |
|---|---|---|
01_building_tools.ipynb | Function to tool with @tool, a tip calculator, a multi-tool sales assistant, a custom inventory checker | 2.1, 2.2, 2.4 |
02_prebuilt_and_aws_tools.ipynb | Prebuilt tools (calculator, http_request, file_write) and use_aws against S3, DynamoDB and Lambda | 2.3 |
03_advanced_tool_patterns.ipynb | The database-connection problem and class-based tools; async tools that run in parallel | 2.5 |
cd section-02-agent-tools
pip install -r requirements.txt
jupyter labEvery notebook opens with the same two cells: install the libraries, then choose the model once and keep it in a variable called model.
import os
from strands.models.bedrock import BedrockModel
MODEL_ID = os.getenv("BEDROCK_MODEL_ID", "global.anthropic.claude-sonnet-4-6")
REGION = os.getenv("AWS_REGION", os.getenv("AWS_DEFAULT_REGION", "us-east-1"))
os.environ.setdefault("AWS_REGION", REGION)
model = BedrockModel(model_id=MODEL_ID, region_name=REGION)
print(f"Using {MODEL_ID} in {REGION}")iAlways pass the model
Every agent in this section is created with Agent(model=model, ...). If you leave model out, Strands falls back to its own built-in default, which can change between versions or may not be enabled in your account. Passing it explicitly keeps every lab on the same, known model.
!Two things you'll see while running
Clean-up: notebook 2 creates real AWS resources. Its last cell deletes them; always run it.
Deprecation notices: strands-agents-tools 0.8.x prints a warning for a few prebuilt tools (calculator, http_request, tavily, current_time). They still work; the course pins strands-agents-tools<0.9 so they keep working.
Why agents need tools
Suppose you've built an agent to help with your development workflow. It understands git, explains CI/CD and answers project questions. Then you actually use it.
You ask: "How many open issues do I have in my project repo, and which one has been sitting there the longest?" It replies: "I can't access your GitHub account, but you can check by going to your repository's Issues tab and sorting by creation date."
You already know how to check GitHub. That's why you asked. What you've built is a chatbot that can only answer generically from what it knows. Now give the same agent a GitHub tool and ask again:
- 1
The agent works out that it needs GitHub's API
- 2
It calls the GitHub tool
list_issues(repo="myproject", state="open") - 3
Data comes back
23 open issues; issue #47 "Memory leak in data processor" was created 127 days ago.
- 4
The LLM answers with real facts
"You've got 23 open issues. The oldest is #47 about a memory leak in the data processor; it's been open for 127 days. Probably worth tackling that one."
Same question, with and without the GitHub tool
- YouHow many open issues do I have in my project repo, and which one has been sitting there the longest?
- ModelNo tool can reach GitHub, so I can only explain how to look it up.
- Agent“I can't access your GitHub account, but you can check by going to your repository's Issues tab and sorting by creation date.”
What LLMs can't do on their own
LLMs are remarkable at understanding and writing text: essays, explanations, code suggestions. But they have built-in limits, and each one maps to a kind of tool that fixes it.
| Limit | What it means | The kind of tool that fixes it |
|---|---|---|
| Knowledge cutoff | Can't know events or data after its last training update | Search, news, weather and other live-data APIs |
| No real-world interaction | Can't read files, call APIs or execute tasks | File, HTTP, email, database and AWS tools |
| No persistent memory | Forgets past conversations once the session ends | Memory tools and stores (Section 3) |
| No built-in verification | Can't fact-check or confirm its own output is right | Calculators, code execution, lookups against a source of truth |
With tools, agents can query GitHub, read files and databases, check server status and more. A weather API tool fetches your city's weather, a news API tool gets the latest headlines, an email tool sends a message, a file-system tool reads and writes files, and a calculator tool does exact maths. You'll build or use several of these in this section.
Brain, coordinator, hands
Here is the relationship between an agent and its tools, in one picture.
✦Think of it like this
A film shoot. The director (the LLM) decides each shot. The crew (the tools) work the cameras, lights and sound. The assistant director (the agent) runs the set: takes the director's call, gets the crew moving, and reports back what happened.
Kinds of tools to think about
When you design an agent, ask what it needs to find, do, compute, connect to, and any special media it handles. These five families cover almost everything.
From function to tool
Before building agents, let's turn one ordinary Python function into something an agent can use. Say you already have a function that checks whether your staging server is up:
import requests
def check_server_status_plain(server_url):
"""Check if a server is responding."""
try:
response = requests.get(server_url, timeout=5)
return f"Server is up. Status code: {response.status_code}"
except requests.exceptions.RequestException:
return "Server is down or unreachable"
print(check_server_status_plain("https://httpbin.org/get"))It works when you call it. But ask an agent "Is the staging server running?" and it replies something like: "I don't have the ability to check server status. You can use curl or ping." The agent can't magically discover a function sitting in some Python file.
The fix is called , also known as tool use. To make the function available, add three things: the @tool (tells Strands to offer it to agents), (tell the agent what data to send) and a clear (tells it what the function does, so it knows when to use it).
from strands import Agent, tool
@tool # Decorator
def check_server_status(server_url: str) -> str: # Type hints
"""Check if a server is responding by making an HTTP request.
Args:
server_url: The URL of the server to check
Returns:
A message indicating whether the server is up or down
"""
try:
response = requests.get(server_url, timeout=5)
return f"Server is up. Status code: {response.status_code}"
except requests.exceptions.RequestException:
return "Server is down or unreachable"Inside, the function makes an HTTP GET request to the URL with a 5-second . If the server answers, it returns the (200 means success). If anything fails (server down, network error, timeout), it catches the and returns a friendly message instead of crashing. Now hand it to an agent:
agent = Agent(model=model, tools=[check_server_status])
response = agent("Is the staging server running? Check https://httpbin.org/get")The agent now calls check_server_status with the right URL and answers from the real result. This pattern works for any function: add @tool, give it to the agent, and the agent can run it.
Remove an ingredient and see what the model loses
@tool
def check_server_status(server_url: str) -> str:
"""Check if a server is responding by making an HTTP request.
Args:
server_url: The URL of the server to check
"""
try:
response = requests.get(server_url, timeout=5)
return f"Server is up. Status code: {response.status_code}"
except requests.exceptions.RequestException:
return "Server is down or unreachable"Roughly what the model is shown
What that means
Ready. The model knows what the tool does, when to use it and exactly what to send.
When one tool isn't enough
Real requests usually need several steps. Imagine a sales assistant and the request: "Pull last quarter's sales data and email a summary to the team." That's not one task but three: query the database, analyse the numbers, send an email. So you build three small tools:
@tool
def get_sales_data(quarter: str) -> dict:
"""Retrieve sales data for a specific quarter."""
return {"revenue": 1250000, "deals": 47, "quarter": quarter}
@tool
def analyze_sales(revenue: int, deals: int, quarter: str) -> str:
"""Calculate key metrics from sales data."""
avg_deal = revenue / deals
return f"{quarter}: ${revenue:,} revenue, {deals} deals, ${avg_deal:,.0f} avg deal size"
@tool
def send_email(to: str, subject: str, body: str) -> str:
"""Send an email message."""
print(f"\n[EMAIL] to={to} | subject={subject}\n{body}\n")
return f"Email sent to {to}"
agent = Agent(model=model, tools=[get_sales_data, analyze_sales, send_email])
response = agent("Pull last quarter's sales data and email a summary to team@company.com")Each tool does one job. The data tool only fetches data, the analysis tool only crunches numbers, the email tool only sends messages. That makes them reusable: analysing data from another source? Same analysis tool. Emailing something else? Same email tool. And you never wrote "if the user asks for a sales summary, do steps 1, 2, 3". The agent works out which tools to use, in what order, and how to pass data from one to the next.
Watch the sales request unfold
✓Tip: build focused, single-purpose tools
Resist one mega-tool that does everything. It's harder to maintain, harder for the agent to reason about, and impossible to reuse elsewhere. Think of a TV remote with 80 unlabelled buttons versus three clearly named ones: the model, like you, picks correctly when each button does one obvious thing.
Two more multi-tool chains
This is what makes agents useful. You build focused tools, each doing one thing well. The agent figures out which to use, in what order, and how to pass data between them. You don't write workflows or state machines; you describe what each tool does and the agent handles the rest. Now let's build one end to end.
Your first tool-enabled agent
Let's build something you'd actually use: an agent that works out the tip and splits the bill when you're out for dinner with friends.
@tool
def calculate_tip(bill_amount: float, tip_percentage: float, num_people: int = 1) -> dict:
"""Calculate tip and split the bill among people.
Args:
bill_amount: Total bill amount in dollars
tip_percentage: Tip percentage (e.g., 15, 18, 20)
num_people: Number of people splitting the bill (default: 1)
"""
tip = bill_amount * (tip_percentage / 100)
total = bill_amount + tip
per_person = total / num_people
return {
"bill": bill_amount,
"tip": round(tip, 2),
"total": round(total, 2),
"per_person": round(per_person, 2),
}
agent = Agent(model=model, tools=[calculate_tip])
response = agent(
"The bill is $85. What's a 20% tip, and how much does each person pay if we're splitting it 4 ways?"
)
print("\n\nFinal answer:\n", response.message["content"][0]["text"])- Step 1: @tool def calculate_tip(…) Create the tool: a normal Python function plus the three ingredients.
num_people: int = 1has a default, so it's optional. - Args: section One line per parameter. This is how the model learns that "20%" means
tip_percentage=20, not 0.2. - return {…} A dictionary is fine. The agent hands the whole thing back to the model, which turns it into a sentence.
- Step 2: Agent(model=model, tools=[calculate_tip]) Give the tool to the agent.
- Step 3: agent("…") Ask in plain English.
response.message["content"][0]["text"]is the text of the agent's final reply.
I'll calculate the 20% tip and split the bill among 4 people for you. Tool #1: calculate_tip Here's the breakdown: - **Bill amount**: $85.00 - **20% tip**: $17.00 - **Total with tip**: $102.00 - **Amount per person** (splitting 4 ways): $25.50 So each person should pay $25.50. Final answer: Here's the breakdown: … So each person should pay $25.50.
Tool #1: calculate_tip line the moment it runs your tool. The last block is our own print of the final answer.So what just happened?
- 1
Understood the intent
You were asking about a tip calculation.
- 2
Extracted the numbers
$85, 20%, 4 people.
- 3
Called
calculate_tipwith them - 4
Got the result back
- 5
Wrote a natural, helpful reply
Here's what you didn't write:
- Logic to parse "The bill is $85" and pull out the number
- Code to recognise that "20%" means
tip_percentage=20 - Rules to work out that "splitting it 4 ways" means
num_people=4 - Any routing logic or if/else statements
The agent figured all of that out from what you described: the function name, its parameters and its docstring.
Try it yourself
Ask the same agent differently phrased questions. It understands intent, not just keywords:
agent("What's a 15% tip on $42?")
agent("Bill is $120, we want to tip 18%, split between 3 people")
agent("Calculate tip for $67.50 at 20%")Tip agent playground
Tool call the model would make
Tool result (computed for real)
In the real agent the LLM does the extraction. Here a small pattern matcher stands in for it, so very unusual phrasings may trip it up; the model would cope.
The whole recipe, again
- 1
Create a tool
A Python function with
@tool. - 2
Give it to an agent
Add it to the
toolslist. - 3
Let the model decide when to use it
Whether the tool calculates tips or checks servers, the approach is identical; only the inside of the function changes. The agent needs to understand just three things about any tool, and it learns all three from your function's name, parameters and docstring:
✦Think of it like this
Splitting the bill at a dhaba: one friend says "₹1,700, add 10% for the waiter, five of us", and another opens the calculator app. The friend talking is the model (understands loose words), the calculator app is your tool (exact, boring, reliable), and whoever reads the result aloud is the agent.
The Strands tool ecosystem
You now know how to turn a Python function into a tool. The good news: you don't have to build every tool yourself.
When you start a software project, you don't write your own HTTP library or JSON parser; you install packages others built. Agent tools work the same way. Why spend three days building a web scraper when someone has already solved it? The agents in later sections reuse these same tools, just combined in more interesting ways.
✦Think of it like this
Your phone's app store. You don't build a maps app or a UPI app before you can get anything done; you install them and get on with your day. The Strands toolbox is an app store for agent abilities: install once, then just import the tool you need.
Strands gives you tools from four places, and the agent treats them all the same way:
| Pillar | What it gives you | Where you use it |
|---|---|---|
| Prebuilt community tools | The strands-agents-tools package: dozens of ready tools for files, shell commands, web search, HTTP requests, maths, AWS and more. Think of it as a package manager for agent abilities. | This section |
| Custom Python tools | When nothing prebuilt fits, such as your company's internal API or domain-specific logic, @tool turns any function into a tool. | 2.1, 2.2, 2.4, 2.5 |
| tools | A whole ecosystem of standardised tools that work across agent frameworks. If someone has built an MCP server for a service (a database connector, an API wrapper), you plug it in without writing integration code. Public catalogues of MCP servers make them easy to find. | Section 5 |
| AWS service integrations | AWS-specific tools in the package (like use_aws), plus custom tools you write for S3, DynamoDB, Lambda, SageMaker, Bedrock and other services. Your agent gets direct access to your cloud infrastructure and data. | This section |
Prebuilt community tools
These tools handle common jobs out of the box: file operations, web searches, HTTP calls, AWS services, maths, even multi-agent orchestration. They're already installed with this section's requirements.txt; on their own you'd install them with:
pip install strands-agents-toolsHere's the prebuilt calculator in action. One import gives the agent expressions, equation solving and calculus:
from strands import Agent
from strands_tools import calculator
agent = Agent(model=model, tools=[calculator], system_prompt="You are a helpful math assistant.")
agent("What's 42 raised to the power of 9?")
agent("Solve the equation x^2 + 5x + 6 = 0")
agent("What's the derivative of sin(x) * cos(x)?")| Question | What the calculator returns |
|---|---|
| 42 to the power of 9 | 406,671,383,849,472 (exact; an LLM guessing "in its head" often gets digits wrong) |
| x² + 5x + 6 = 0 | x = −2 or x = −3 |
| d/dx of sin(x)·cos(x) | cos²(x) − sin²(x), which simplifies to cos(2x) |
Under the hood the calculator uses , a symbolic maths library, so it handles complex expressions and calculus that would take real effort to implement yourself.
What's in the toolbox?
Think of these as categories of abilities you can add to an agent instantly:
| Category | Tools | What they do |
|---|---|---|
| File operations | file_read, file_write, editor | Read and write files; advanced editing with syntax highlighting |
| Maths | calculator | Calculations, symbolic maths, equation solving |
| Web and search | http_request, tavily_search, tavily_extract, exa_search, bright_data | API calls, live web search, extracting page content, web scraping |
| Code execution | python_repl, shell, code_interpreter | Run Python, run shell commands, isolated sandbox execution |
| AWS services | use_aws, retrieve, nova_reels, agent_core_memory | Work with S3, DynamoDB, Lambda; query Bedrock Knowledge Bases; create videos; store memories |
| Memory and storage | memory, mem0_memory, mongodb_memory, elasticsearch_memory | Store and retrieve documents and memories across runs |
| Communication | slack, speak | Post to Slack workspaces; text-to-speech output |
| Image and video | generate_image, image_reader, search_video, chat_video | Create and analyse images; semantic video search |
| Automation and utilities | browser, use_computer, cron, current_time, sleep, diagram, rss | Web automation, desktop control, scheduling, time utilities, diagramming |
| Advanced | swarm, workflow, use_llm, batch, mcp_client | Multi-agent coordination, automated workflows, nested AI loops, parallel execution |
iAbout those deprecation warnings
In strands-agents-tools 0.8.x, calculator, http_request, tavily and current_time print a deprecation notice. They still work, and the course pins the version below 0.9 so they keep working. When you move on, their replacements are http_request/web_fetch from strands.vended_tools, the official Tavily MCP server, and ContextInjector for the current time.
Pick the right tools for the job
Toolbox: tap the tools this agent needs
Many tools, one request
Give the agent several prebuilt tools and it works out how to use them together. Here it fetches a live weather forecast for Bangalore, crunches the numbers and saves a summary:
from strands_tools import file_write, http_request
os.environ["BYPASS_TOOL_CONSENT"] = "true"
agent = Agent(
model=model,
tools=[http_request, calculator, file_write],
system_prompt="You help with data analysis tasks.",
)
agent("""
Fetch the 7-day forecast from
https://api.open-meteo.com/v1/forecast?latitude=12.97&longitude=77.59&daily=temperature_2m_max,temperature_2m_min&timezone=Asia/Kolkata
extract the daily maximum temperatures for Bangalore,
calculate the average maximum temperature over the period,
and save a short summary to weather_summary.txt
""")if os.path.exists("weather_summary.txt"):
print(open("weather_summary.txt").read())
else:
print("No weather_summary.txt yet — scroll up to see which tools the agent called.")AaNew word: tool consent
Tools that change things on your machine, like file_write, normally stop and ask you to confirm first. Setting the BYPASS_TOOL_CONSENT=true skips that prompt so the demo runs end to end. It's like turning off the OTP step for payments: fine in a sandbox, never in a real application.
AWS integration with use_aws
Most real applications run in the cloud. Your data sits in S3 buckets, your records in DynamoDB or RDS, your business logic in Lambda functions. An agent that can't touch these is disconnected from your actual systems. The prebuilt use_aws tool fixes that: describe what you want in English, and the agent turns it into AWS API calls, made with .
use_aws makes the boto3 call. Two safety gates stay in place: a confirmation for changes, and your IAM permissions.from strands_tools import use_aws
aws_agent = Agent(
model=model,
tools=[use_aws, calculator],
system_prompt=f"You are an AWS assistant that helps manage cloud resources. Use region {REGION}.",
)
aws_agent("List all S3 buckets in my account")✦Think of it like this
use_aws is a helper at a government office who knows every form by heart. You say "I want to renew my licence", and they fill in the right form for the right counter. But your ID card still decides which counters will serve you. That ID card is IAM: use_aws can only do what your credentials are allowed to do.
Three real scenarios, on resources the notebook creates for you
The scenarios need real resources to act on, so notebook 2 first creates small demo ones, each with a random suffix so nothing you already own is touched, and deletes them all at the end.
Show the setup cell (3.0) that creates the demo resources
import csv
import io
import json
import time
import zipfile
import boto3
session = boto3.Session(region_name=REGION)
account_id = session.client("sts").get_caller_identity()["Account"]
s3 = session.client("s3")
dynamodb = session.client("dynamodb")
iam = session.client("iam")
lambda_client = session.client("lambda")
import uuid
suffix = uuid.uuid4().hex[:6] # unique names, so we never touch resources you already have
BUCKET = f"agent-tools-demo-{account_id}-{REGION}-{suffix}"
TABLE = f"customers-{suffix}"
FUNCTION = f"order-processor-{suffix}"
ROLE_NAME = f"order-processor-demo-role-{suffix}"
# --- S3 bucket + CSV -------------------------------------------------------
if REGION == "us-east-1":
s3.create_bucket(Bucket=BUCKET)
else:
s3.create_bucket(Bucket=BUCKET, CreateBucketConfiguration={"LocationConstraint": REGION})
rows = [
("month", "region", "revenue"),
("2024-01", "North", 120000), ("2024-02", "North", 135000), ("2024-03", "North", 142000),
("2024-01", "South", 98000), ("2024-02", "South", 104000), ("2024-03", "South", 111000),
]
buf = io.StringIO()
csv.writer(buf).writerows(rows)
s3.put_object(Bucket=BUCKET, Key="sales-2024.csv", Body=buf.getvalue().encode())
print(f"S3 bucket ready: s3://{BUCKET}/sales-2024.csv")
# --- DynamoDB table + one customer -----------------------------------------
dynamodb.create_table(
TableName=TABLE,
KeySchema=[{"AttributeName": "customer_id", "KeyType": "HASH"}],
AttributeDefinitions=[{"AttributeName": "customer_id", "AttributeType": "S"}],
BillingMode="PAY_PER_REQUEST",
)
dynamodb.get_waiter("table_exists").wait(TableName=TABLE)
dynamodb.put_item(
TableName=TABLE,
Item={"customer_id": {"S": "12345"}, "name": {"S": "Asha Rao"}, "email": {"S": "asha@example.com"}},
)
print(f"DynamoDB table ready: {TABLE}")
# --- Lambda function ---------------------------------------------------------
trust = {
"Version": "2012-10-17",
"Statement": [{"Effect": "Allow", "Principal": {"Service": "lambda.amazonaws.com"}, "Action": "sts:AssumeRole"}],
}
role_arn = iam.create_role(RoleName=ROLE_NAME, AssumeRolePolicyDocument=json.dumps(trust))["Role"]["Arn"]
iam.attach_role_policy(RoleName=ROLE_NAME, PolicyArn="arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole")
time.sleep(10) # let IAM propagate
handler_code = '''
import json
def handler(event, context):
order_id = event.get("order_id", "unknown")
return {"order_id": order_id, "status": "PROCESSED", "message": f"Order {order_id} processed successfully"}
'''
zip_buf = io.BytesIO()
with zipfile.ZipFile(zip_buf, "w") as z:
z.writestr("lambda_function.py", handler_code)
lambda_client.create_function(
FunctionName=FUNCTION,
Runtime="python3.12",
Role=role_arn,
Handler="lambda_function.handler",
Code={"ZipFile": zip_buf.getvalue()},
Timeout=10,
)
lambda_client.get_waiter("function_active_v2").wait(FunctionName=FUNCTION)
print(f"Lambda function ready: {FUNCTION}")Data analysis on S3
Read a from S3, total the revenue, and write a summary back.
aws_agent(
f"Read the sales data from S3 bucket '{BUCKET}' key 'sales-2024.csv', "
"calculate the total revenue, and save a short summary back to the same bucket as 'sales-summary.txt'"
)The six rows add up to 710,000 (North 397,000 + South 313,000). Expect the agent to read the file with S3 get_object, total it with the calculator, then write sales-summary.txt with put_object.
Customer service on DynamoDB
aws_agent(
f"Look up customer ID 12345 in the DynamoDB table '{TABLE}' and update their email to newemail@example.com. "
"Then read the item back to confirm."
)Workflow orchestration with Lambda
aws_agent(f"Invoke the Lambda function '{FUNCTION}' with the payload {{\"order_id\": \"67890\"}} and tell me the result")See the AWS calls behind each English request
- YouList all S3 buckets in my account
- use_awss3.list_buckets()
- Result3 buckets, including agent-tools-demo-…-a1b2c3
- Agent“You have 3 S3 buckets: … and agent-tools-demo-…-a1b2c3.”
Look at those requests again. You described what you wanted (read this file, update this record, invoke this function) and the agent worked out the AWS API calls. You didn't write boto3 code, handle AWS responses, or even name the operation. The pattern is: one tool (use_aws), many services, plain-English requests.
!The resources must exist, and must be cleaned up
These requests only work because cell 3.0 created the bucket, CSV, table and function first. Run the clean-up cell when you're done:
# S3: delete all objects, then the bucket
for obj in s3.list_objects_v2(Bucket=BUCKET).get("Contents", []):
s3.delete_object(Bucket=BUCKET, Key=obj["Key"])
s3.delete_bucket(Bucket=BUCKET)
print("Deleted bucket", BUCKET)
dynamodb.delete_table(TableName=TABLE)
print("Deleted table", TABLE)
lambda_client.delete_function(FunctionName=FUNCTION)
iam.detach_role_policy(RoleName=ROLE_NAME, PolicyArn="arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole")
iam.delete_role(RoleName=ROLE_NAME)
print("Deleted Lambda function and role")You've now used community tools and AWS integrations. Next, the tools only you can build.
Building custom tools
When no community tool fits, you build your own: an internal API only your company uses, a proprietary database with its own business rules, or something nobody has built yet.
Say you run an online store, and customers ask "Is product X in stock?" all day. You need a tool that checks your inventory:
@tool
def check_inventory(product_id: str) -> str:
"""Check if a product is in stock.
Args:
product_id: The product ID to check (e.g., "PROD-123")
"""
inventory = {"PROD-123": 15, "PROD-456": 0, "PROD-789": 8}
quantity = inventory.get(product_id, 0)
if quantity > 0:
return f"Product {product_id} is in stock. We have {quantity} units available."
return f"Product {product_id} is currently out of stock."
agent = Agent(model=model, tools=[check_inventory])
# All of these work — the agent understands intent, not just keywords
agent("Is PROD-123 in stock?")
agent("Do we have PROD-456 available?")
agent("Check inventory for PROD-789")
agent("Can I order PROD-123 right now?")- @tool Without it, this is just regular Python the agent never sees.
- The docstring When someone asks "Is PROD-123 in stock?", the model reads "Check if a product is in stock" and recognises this tool fits.
- Args: product_id … (e.g., "PROD-123") Tells the model which piece of the question to extract, and what it looks like.
- inventory = {…} A dictionary standing in for a database. In production you'd replace it with a real query or an API call to your inventory system; nothing else changes.
- return … A plain sentence the model can use directly in its reply.
Four differently worded questions, and the agent recognises all of them as inventory checks. In traditional chatbot development you would write patterns or train an to handle those variations, map "in stock", "available" and "can I order" to the same intent, and extract the product ID from different sentence shapes. Here you skipped all of that. You wrote one function with a clear description, and the language model handles intent recognition and parameter extraction.
Regex chatbot vs agent: who understands the question?
Regex chatbot (matches only "in stock" or "available")
Agent with check_inventory
Agent: “Product PROD-123 is in stock. We have 15 units available.”
The agent column runs the real tool logic (15, 0 and 8 units). A small matcher stands in for the model's understanding, so treat it as a demo of the idea.
Your turn: build one tool you care about
The best way to make this stick is to build a tool for a problem you actually have. Pick one, write it, give it to an agent, and ask questions in your own words. Rough logic is fine; what matters is seeing your tool fit into the agent loop.
| Idea | A signature to start from | Ask it |
|---|---|---|
| Weather in your city | get_weather(city: str) -> str (call a weather API) | "Do I need an umbrella in Pune today?" |
| Validate an email | is_valid_email(email: str) -> bool | "Is asha@example a valid address?" |
| Age from birthdate | age_from_birthdate(birthdate: str) -> int | "How old is someone born on 1990-08-15?" |
| BMI | calculate_bmi(height_cm: float, weight_kg: float) -> dict | "I'm 172 cm and 70 kg, what's my BMI?" |
| Shipping cost | shipping_cost(weight_kg: float, distance_km: float) -> float | "Cost to ship 3 kg from Bangalore to Delhi?" |
iYou already know enough for most tools
Plain @tool functions cover the majority of what you'll build. As your agents grow, though, you'll hit situations where they aren't enough: several tools sharing an expensive resource, slow APIs, and inputs that need strict validation. You don't need to memorise these now; just know they exist so you recognise them when they show up.
When simple tools aren't enough
Your inventory agent now has five tools: check stock, update stock, get supplier info, track shipments and generate reports. You deploy it. Within an hour your database administrator messages you: "Why is your agent opening 50 database connections per minute?"
You check the logs: every tool opens its own database connection. One user request may trigger three or four tool calls, so three or four connections, and dozens of users at once means the database is drowning. The @tool decorator is perfect for simple cases, but as agents grow you need more control. Notebook 3 walks through two such situations with real, countable numbers, using a small local SQLite database.
The database connection problem, and class-based tools
First the notebook creates a tiny inventory database and a connect_to_database() helper that counts every connection it opens:
import sqlite3
DB_PATH = "inventory_demo.db"
# Create and seed a small inventory database
with sqlite3.connect(DB_PATH) as conn:
conn.execute("DROP TABLE IF EXISTS inventory")
conn.execute("CREATE TABLE inventory (id TEXT PRIMARY KEY, name TEXT, quantity INTEGER, price REAL)")
conn.executemany(
"INSERT INTO inventory VALUES (?, ?, ?, ?)",
[
("PROD-123", "Wireless Mouse", 15, 29.99),
("PROD-456", "USB-C Hub", 0, 49.99),
("PROD-789", "Mechanical Keyboard", 8, 89.99),
],
)
print("Database ready:", DB_PATH)
CONNECTIONS_OPENED = 0
def connect_to_database():
"""Open a new database connection and count it."""
global CONNECTIONS_OPENED
CONNECTIONS_OPENED += 1
# check_same_thread=False: the agent may run tools on worker threads
return sqlite3.connect(DB_PATH, check_same_thread=False)Now the naive version, where each tool opens a connection, uses it once and closes it:
from strands import Agent, tool
@tool
def check_stock(product_id: str) -> str:
"""Check product stock."""
db = connect_to_database()
row = db.execute("SELECT quantity FROM inventory WHERE id = ?", (product_id,)).fetchone()
db.close()
return f"{product_id}: {row[0]} units" if row else f"{product_id} not found"
@tool
def update_stock(product_id: str, quantity: int) -> str:
"""Update product stock."""
db = connect_to_database()
db.execute("UPDATE inventory SET quantity = ? WHERE id = ?", (quantity, product_id))
db.commit()
db.close()
return f"Updated {product_id} to {quantity} units"
CONNECTIONS_OPENED = 0
agent = Agent(model=model, tools=[check_stock, update_stock])
agent("Check stock for PROD-123 and PROD-789, set PROD-456 to 25 units, then confirm PROD-456's new level")
print(f"\n\nConnections opened for one request: {CONNECTIONS_OPENED}")That one request needs four tool calls (two checks, an update, a confirm), so it prints about 4 connections. Harmless here, painful in production, because opening a connection is expensive:
- It takes time. Each new connection needs a handshake, typically 50 to 100 milliseconds over a network, before any query runs.
- It uses server memory for every open connection.
- Databases cap them. By default MySQL allows 151 simultaneous connections and PostgreSQL 100. Hit the cap and your agent starts failing with "too many connections".
What you want is to connect once when the agent starts and share that connection across all the tools. Separate decorated functions have no place to keep shared state, so the fix is to group related tools inside a class:
import threading
class InventoryTools:
def __init__(self):
# One shared connection for every tool on this object.
# The agent can run tools in parallel threads, so guard the shared
# connection with a lock.
self.db = connect_to_database()
self.lock = threading.Lock()
@tool
def check_stock(self, product_id: str) -> str:
"""Check product stock level.
Args:
product_id: The product ID to check
"""
with self.lock:
row = self.db.execute("SELECT name, quantity FROM inventory WHERE id = ?", (product_id,)).fetchone()
return f"{row[0]} ({product_id}): {row[1]} units in stock" if row else f"Product {product_id} not found"
@tool
def update_stock(self, product_id: str, quantity: int) -> str:
"""Update product stock quantity.
Args:
product_id: The product ID to update
quantity: New quantity to set
"""
with self.lock:
cur = self.db.execute("UPDATE inventory SET quantity = ? WHERE id = ?", (quantity, product_id))
self.db.commit()
return f"Updated {product_id} to {quantity} units" if cur.rowcount else f"Product {product_id} not found"
CONNECTIONS_OPENED = 0
inventory = InventoryTools()
agent = Agent(model=model, tools=[inventory.check_stock, inventory.update_stock])
agent("Check stock for PROD-123 and PROD-789, set PROD-456 to 40 units, then confirm PROD-456's new level")
print(f"\n\nConnections opened for one request: {CONNECTIONS_OPENED}")- __init__ Runs once when you create
InventoryTools(). That's where the single connection is opened. - self.lock Strands may run several tool calls at the same time on different . The makes them take turns on the shared connection.
- @tool on methods Tool methods work exactly like tool functions;
selfis hidden from the model. - "… WHERE id = ?", (product_id,) A . Never build SQL with f-strings: the values come from the model, so treat them like user input (see ).
- tools=[inventory.check_stock, …] Pass the methods of one object. Now the same request prints 1 connection.
How many connections does your agent open?
Function tools (connection per call)
120 connections opened per minute, about 6.0 s of every minute spent just opening them.
Class tools (one shared connection)
1 connection per running copy of your app, opened once and reused for all 120 tool calls.
Assumes about 50 ms to open each connection. Connection limits (100 for PostgreSQL, 151 for MySQL by default) count connections open at the same moment, so bursts of overlapping requests hit them long before the per-minute total looks scary.
✦Think of it like this
Calling your bank's customer care with four questions. Function tools hang up after each answer and dial again, waiting through the IVR every time. Class tools stay on the same call and ask all four. Same answers, a fraction of the waiting, and the call centre's phone lines stay free for others.
This pattern fits any shared resource: API clients that keep a connection pool, file handles for reading large datasets, connections to a cache like Redis or Memcached, or login tokens that must be refreshed now and then.
Slow warehouse checks, and async tools
Two days later your product manager writes: "Users say the agent is too slow. It takes ages to tell them whether an order can ship." To answer "Can we ship 100 units of PROD-123?", the agent checks three warehouses, each with its own API that takes about 2 seconds. Called one after another:
warehouse_a = check_warehouse_inventory("PROD-123", "warehouse_a") # 2 seconds
warehouse_b = check_warehouse_inventory("PROD-123", "warehouse_b") # 2 seconds
warehouse_c = check_warehouse_inventory("PROD-123", "warehouse_c") # 2 seconds
# Total: 6 seconds of waiting(The notebook's real tool names the warehouses east, west and central.) Users stare at a spinner and abandon their carts. But look closely: the three checks don't depend on each other. Warehouse B doesn't need A's answer. They could all happen at the same time.
That's what is for. When a function is marked async, Python's can pause it at each while it waits for slow input/output (like an API response) and switch to other async work on the same thread. Several async functions make progress together, taking turns whenever one is waiting.
import asyncio
import time
@tool
async def check_warehouse_inventory(product_id: str, warehouse: str) -> dict:
"""Check inventory at a specific warehouse.
Args:
product_id: Product ID to check
warehouse: Warehouse identifier (e.g., "east", "west", "central")
"""
await asyncio.sleep(2) # simulate a slow API call
data = {
"east": {"PROD-123": 45, "PROD-456": 12},
"west": {"PROD-123": 30, "PROD-456": 0},
"central": {"PROD-123": 60, "PROD-456": 25},
}
quantity = data.get(warehouse, {}).get(product_id, 0)
return {"warehouse": warehouse, "product_id": product_id, "quantity": quantity}
async def main():
agent = Agent(model=model, tools=[check_warehouse_inventory])
start = time.time()
response = await agent.invoke_async(
"Can we ship 100 units of PROD-123? Check all warehouses: east, west, and central."
)
elapsed = time.time() - start
print("\n\n" + response.message["content"][0]["text"])
print(f"\nTotal time: {elapsed:.1f}s (three sequential checks alone would take ~6s)")
await main()- async def Marks the tool as pausable.
- await asyncio.sleep(2) Stands in for a 2-second API call. While one call waits here, the others run.
- agent.invoke_async(…) The async way to call an agent. When the model asks for all three checks in one turn, Strands runs them .
- await main() Works in Jupyter, which already has an event loop. In a plain
.pyscript useasyncio.run(main())instead. - Result 45 + 30 + 60 = 135 units, so yes, 100 can ship. The tool waiting takes about 2 seconds instead of 6; total time also includes the model's thinking.
In a real tool you'd use an async HTTP client instead of asyncio.sleep:
import httpx
@tool
async def check_warehouse_inventory(product_id: str, warehouse: str) -> int:
"""Check inventory at a specific warehouse."""
async with httpx.AsyncClient() as client:
response = await client.get(f"https://api.warehouse-{warehouse}.example.com/inventory/{product_id}")
return response.json()["quantity"]Race: sequential vs async warehouse checks
Sequential
0.0 s
Async
0.0 s
The race plays at 4× speed so you don't wait. The times shown are real-scale.
✦Think of it like this
A waiter with three tables. The slow way: take table 1's order, wait at the kitchen until it's cooked, serve it, then go to table 2. The async way: hand all three orders to the kitchen, and serve each as it's ready. The kitchen still takes the same time per dish; you just stop standing around.
iNot every tool needs async
If a tool finishes almost instantly (say under 100 ms) or only does calculations with no external calls, async adds complexity and no measurable speed. Start with regular tools; add async when slow I/O actually hurts.
Choosing your approach
Start with the decorator: it's simple and covers most cases. Build, test, deploy, and watch what happens. Connection trouble? Group the tools in a class. Slow external calls? Make them async so they run concurrently.
One more option: if a tool needs strict validation of its inputs (a specific format, or only certain allowed values), Strands lets you write an explicit input schema yourself, called a TOOL_SPEC, instead of deriving it from type hints. It's an advanced pattern we won't need in this course. In fact you've already met one: use_aws is defined with a TOOL_SPEC.
Quick start with Strands Agent Builder
You can now write tools by hand, use prebuilt ones and reach AWS. But when you just want to test an idea quickly, writing the imports and setup every time slows you down. Agent Builder removes that friction.
Agent Builder is a : you describe what you want in plain English, and it builds and tests it for you. No imports, no setup. It's made for rapid prototyping and learning.
✦Think of it like this
Training wheels on a bicycle. You get to ride and explore right away without worrying about balance. Once you're confident, the training wheels come off and you write your own agents with full control.
Install it with (or pip), then start it:
pipx install strands-agents-builder # or: pip install strands-agents-builder
strands # interactive mode
strands "Build a tool that converts Celsius to Fahrenheit"An Agent Builder session (illustrative)
A replay of what a session looks like. The exact wording and generated code will differ each time you run it.
Ask it to "Build a tool that converts Celsius to Fahrenheit", and Agent Builder will:
- 1
Create the conversion tool
- 2
Test it with an example
For instance, "What's 100°C in Fahrenheit?"
- 3
Show you the result
100°C is 212°F.
- 4
Let you keep trying more conversions
Two modes
⇄Interactive mode
Run strands and have a conversation with the builder. Ask it to create tools, test agents or explain concepts.
→Command mode
Give one instruction directly:strands "Build a tool that monitors API rate limits across all services and alerts at 80% capacity"
What it comes with
| Kind of agent | Built-in tools it uses |
|---|---|
| Data processing | Calculator, Python REPL, file editor |
| Web-connected | HTTP requests, Slack |
| AWS-integrated | use_aws to manage cloud resources |
| Multi-agent | swarm and workflow to coordinate several agents |
| Creative | Image generation and video creation powered by Amazon Bedrock |
Under the hood
⌘Point it at the course model
Agent Builder uses Amazon Bedrock by default and reads the model ID from the STRANDS_MODEL_ID environment variable. Its built-in default is an older Claude Sonnet model, so set it to the one the course uses:
export STRANDS_MODEL_ID=global.anthropic.claude-sonnet-4-6
strandsKeeping what you build
Agent Builder runs interactively and does not save the generated tool code to files on its own. Two ways to keep your work:
- Start it with
--kb <knowledge-base-id>(or setSTRANDS_KNOWLEDGE_BASE_ID) to store and retrieve tools across sessions using an Amazon Bedrock . - Copy the generated code from the session into your own Python files.
Agent Builder is great for learning and experiments. For production applications you'll want the full control of writing agents yourself, with the patterns from this section.
Tools in LangChain
If you've searched for how to build agents, you've met LangChain. It's been around longer and has a big community, so it's worth knowing how it compares.
Both frameworks do the same core thing: turn Python functions into tools and let agents use them. The difference is approach. Strands is model-driven: you write minimal code and let the LLM work out the workflow. LangChain gives you more explicit control over how the agent executes, which means more setup code but more ways to customise. You already ran a LangChain agent in Section 1; here is its tool next to one from this section:
from strands import Agent, tool
@tool
def check_inventory(product_id: str) -> str:
"""Check if a product is in stock.
Args:
product_id: The product ID to check (e.g., "PROD-123")
"""
...
agent = Agent(model=model, tools=[check_inventory])
agent("Is PROD-123 in stock?")from langchain.tools import tool
# A simple tool the agent can call
@tool
def greet(name: str) -> str:
"""Greet someone by name."""
return f"Hello, {name}! Welcome to the world of AI agents."
agent = create_agent(model=llm, tools=[greet], system_prompt="You are a friendly assistant.")
response = agent.invoke({"messages": [{"role": "user", "content": "Please greet Alice and Bob."}]})| Strands Agents | LangChain | |
|---|---|---|
| Making a tool | @tool + type hints + docstring | @tool + type hints + docstring (same idea) |
| Approach | Model-driven: minimal code, the LLM plans the workflow | Explicit control over execution; more setup, more customisation |
| AWS fit | Native: Bedrock, use_aws, Lambda, S3 out of the box | Via langchain-aws integrations |
| Ecosystem | Growing, AWS-backed | Larger and older community |
We use Strands in this course because it's simpler to learn and fits naturally with AWS services like Bedrock, Lambda and S3. If you work in another environment or need LangChain's larger ecosystem, the tool ideas you learned here transfer directly.
Recap and self-check
Your agents no longer just talk about solutions. They carry them out.
We started with the problem: LLMs are great at generating text but can't check databases, call APIs or touch the real world. We solved it with tools. You turned Python functions into tools with @tool, gave one agent several tools and watched it choose the order, used the prebuilt Strands toolbox, and reached S3, DynamoDB and Lambda in plain English with use_aws. You built custom tools for your own logic, learned when to group tools in a class or make them async, and saw Agent Builder do the setup for you.
You should now be able to explain
| Idea | In one line |
|---|---|
| Why tools | Without them an agent tells you how; with them it does it |
| Brain, coordinator, hands | The LLM decides, the agent coordinates, tools act |
| Function to tool | @tool + type hints + docstring; the model sees name, description and input schema, never the body |
| Many tools | Small single-purpose tools; the agent picks the order and passes data between them |
| Tool ecosystem | Community package, custom tools, MCP tools, AWS integrations, all in one tools=[…] list |
use_aws | English to boto3 calls; confirmation for changes; IAM still decides what's allowed |
| Class tools | Share an expensive resource (like one DB connection) across tool methods |
| Async tools | Independent slow calls run concurrently: total time ≈ slowest call, not the sum |
| Agent Builder | A ready-made Strands agent for quick experiments; write your own for production |
When something goes wrong
| What you see | What it usually means and how to fix it |
|---|---|
"…is deprecated" warnings from calculator, http_request or current_time | Expected with strands-agents-tools 0.8.x. The tools still work; carry on. |
| The notebook pauses asking you to confirm a file write or AWS change | That's tool consent. Answer in the prompt, or set os.environ["BYPASS_TOOL_CONSENT"] = "true" as notebook 2 does (sandbox only). |
AccessDenied from S3, DynamoDB, Lambda or IAM | Your user or role lacks that permission. Use an admin sandbox user or attach setup/iam-policy-course.json. |
| Lambda creation fails with "role … cannot be assumed" | The new IAM role hadn't propagated yet. Wait a few seconds and re-run the setup cell (it already sleeps 10 s). |
| The agent says it can't do something you gave it a tool for | Check the tool is in tools=[…] and the docstring clearly says what it does and when to use it. |
ModuleNotFoundError | Run the %pip install -q -r requirements.txt cell, then restart the kernel. |
!Did you clean up?
If you ran notebook 2, make sure its last cell (3.5) ran: it deletes the demo S3 bucket, DynamoDB table, Lambda function and IAM role. Notebook 3's SQLite file (inventory_demo.db) is local and costs nothing.
Self-check
Ten quick questions. Pick an answer to see why it's right or wrong.
1. You ask an agent with no tools how many open issues your GitHub repo has. What do you get?
2. Which three things turn a plain function into a Strands tool?
3. What does the model actually see of your tool?
4. In the sales assistant, who decided to call get_sales_data, then analyze_sales, then send_email?
5. Why prefer several focused tools over one mega-tool?
6. use_aws is about to call dynamodb.update_item. What happens by default?
7. You're signed in as a read-only user and ask the agent to update a DynamoDB item. What happens?
8. Your agent opens a new database connection on every tool call. The fix?
9. Three independent warehouse APIs each take 2 seconds. As async tools called in one turn, roughly how long do the checks take?
10. Should a tool that adds two numbers locally be async?
Score: 0 of 10 answered
→Up next: Section 3, agent memory
Your agents can act, but they forget everything between conversations. Next you'll fix that with short-term and long-term memory and context-management strategies, using Mem0 and Amazon Bedrock AgentCore Memory, and build a personalised agent that remembers a user's preferences across sessions. Folder: section-03-agent-memory.
Glossary
Every underlined word on this page, in one place.
Software that uses an LLM to plan, call tools, look at results and keep working towards a goal.
A fully managed AWS NoSQL database. Items are looked up by key, like customer_id = 12345.
AWS object storage: files (objects) kept in buckets, read and written over an API.
Application programming interface: a defined way for one program to ask another to do something, usually over the network.
Marks a Python function that can pause while waiting (for a network reply, say) so other work runs meanwhile.
Inside an async function: "wait here for this result, and let other tasks run in the meantime".
Run a function in the cloud without managing servers. You pay only while it runs.
The official AWS SDK for Python. Every AWS call in this course, including the ones use_aws makes, goes through it.
Command-line interface: a program you use by typing commands in a terminal, like aws, pip or strands.
Several tasks making progress in overlapping time, for example three API calls all waiting at once.
Comma-separated values: a plain-text table, one row per line, columns separated by commas.
A line starting with @ placed above a Python function to add behaviour. @tool registers a function as an agent tool.
A warning that a feature still works but will be removed or replaced in a future version.
The text in triple quotes at the top of a function. For tools, it becomes the description the model reads to decide when to call it.
A named setting that lives outside your code, like BEDROCK_MODEL_ID or BYPASS_TOOL_CONSENT. Programs read it at run time.
The scheduler that runs async functions, switching to whichever one is ready whenever another is waiting.
Python's way of signalling an error. try/except catches it so your code can respond instead of crashing.
The model's ability to say "run this function with these arguments" instead of only writing text. The agent runs it and returns the result.
A number a server returns with every response: 200 means OK, 404 not found, 500 server error.
AWS Identity and Access Management: decides who can do what in your AWS account. An agent can never do more than the credentials it runs with.
A model trained to map a sentence to a fixed label like CHECK_INVENTORY. Traditional chatbots need one; tool-using agents don't.
A simple text format for structured data: {curly braces} for objects, [square brackets] for lists. Most APIs speak it.
A managed Amazon Bedrock store that indexes your documents for retrieval. Agent Builder can use one to keep tools between sessions.
A model trained on huge amounts of text that understands and writes language. The agent's brain.
A guard that lets only one thread use a shared resource at a time, so two tool calls don't trample the same database connection.
An open standard for exposing tools and data to AI apps. Any MCP server can be plugged into a Strands agent. Covered in Section 5.
SQL with placeholders (?) whose values are passed separately. The database treats them as data, never as SQL code.
Installs a Python command-line app into its own isolated environment, so its libraries don't clash with your projects.
A pattern language for matching text, like PROD-\d+ to find product IDs. Precise but brittle: it only matches what you anticipated.
A common style of web API: send an HTTP request (GET, POST…) to a URL and get JSON back.
An attack where text meant as data is run as SQL. Building queries with f-strings invites it; parameterised queries prevent it.
A Python library for symbolic maths: exact arithmetic, solving equations, derivatives and integrals. The calculator tool is built on it.
A separate line of execution inside one program. Strands can run several tool calls on different threads at once.
The longest you're willing to wait for a response. requests.get(…, timeout=5) gives up after 5 seconds instead of hanging forever.
A function or service an agent can call to do something it can't do with words alone: fetch data, compute exactly, change a system.
Annotations like server_url: str or -> dict that say what type a value should be. Strands turns them into the tool's input schema.