Your first AI agent on AWS: two tools and a loop you wrote yourself.
By the end of this walkthrough you'll have a Python script that answers a question about your own AWS account by deciding, on its own, which of two functions to call. That's an AI agent: a model that can ask your code to do something, and a loop that keeps handing it the results until it has an answer.
Two things we're deliberately not using. A Bedrock knowledge base, which is the piece that lets a model search your own documents. And Agents for Amazon Bedrock, the managed service that builds an agent for you. Both are useful later. Neither shows you the loop, and the loop is the part worth understanding.
Amazon Bedrock is the AWS service that gives you one way to call several foundation models, which are the large pretrained models you send text to and get text back from.
What this needs, and what it costs you
- An AWS account you can sign in to.
- Python 3, and
boto3, the AWS library for Python. - AWS credentials set up on your machine, allowed to call Bedrock and to read Amazon S3.
- Access to one model, which you request in the Bedrock console, the web interface for AWS.
Amazon Bedrock is pay per use, so this walkthrough spends money every time it calls a model. What it costs depends on the model and on how much text goes in and comes back. Prices change, so we won't put a number in this article: read the Amazon Bedrock pricing page before you run anything.
The good news is what this doesn't create. No vector store, no index, no endpoint, no server. Nothing is left running when the script exits, and there's nothing to remember to delete. That's the main reason we're writing the loop by hand instead of standing up a knowledge base.
And if you'd rather spend nothing: the two tool functions in step 3 are ordinary Python and run fine with no model anywhere near them. Read the loop, run the tools by hand, and you'll still see what an agent does.
The loop, in four lines
- You send the model a question, plus a description of the tools it may use.
- The model replies with an answer, or with a request: call this tool, with these arguments.
- Your code runs the tool and sends the result back.
- Repeat until the model answers instead of asking.
That's it. The model never runs anything itself. It asks, and your code decides whether to oblige.
Step 1: turn on a model and copy its ID
Open Amazon Bedrock in the AWS console and pick your Region first, top right. A Region is the part of the world where AWS runs the service, and which models you can use depends on it.
Now find model access and request access to one model. Access is per model and per Region, and you ask for it before your first call.
Then open the model list and copy the model ID of the model you got access to. A model ID is the string you name in your code to say which model you want. We're not printing one here, because your console is the truth about what you have.
What you should see: one model with access granted, and a model ID on your clipboard.
Step 2: get Python talking to AWS
pip install boto3 boto3 is the AWS SDK for Python: the library that turns a Python function call into an AWS request. It needs credentials, and if you've never set those up, the boto3 credentials guide lists every place it looks. No separate check needed, because the first tool you write does that job for free.
Step 3: write the two tools
A tool is just a function. Nothing about it is special, and the model can't see inside it.
Make a file called agent.py:
import boto3 REGION = "paste-your-region-here" MODEL_ID = "paste-your-model-id-here" s3 = boto3.client("s3", region_name=REGION) bedrock = boto3.client("bedrock-runtime", region_name=REGION) def list_buckets(): """Names of the S3 buckets in this account.""" response = s3.list_buckets() return {"buckets": [bucket["Name"] for bucket in response["Buckets"]]} def count_objects(bucket_name): """How many objects one listing call finds in a bucket.""" response = s3.list_objects_v2(Bucket=bucket_name) return {"bucket": bucket_name, "objects": len(response.get("Contents", []))} A bucket is the container your files live in on Amazon S3, and each file you put in one is called an object.
Try the first tool now, before any model is involved. Add print(list_buckets()) at the bottom and run python agent.py. Names coming back means your credentials work, and it cost nothing, because listing S3 is not a Bedrock call. Take the print line out again.
Two honest notes on count_objects. It counts what one listing call returns, and a bucket with a lot of objects comes back in pages, which the boto3 reference for list_objects_v2 shows how to walk through. One call is enough today. And if you have no buckets, make one empty bucket in the S3 console so the second tool has something to count, then delete it after.
Step 4: describe the tools to the model
The model can't read your Python. It only knows what you tell it, so every tool gets a name, a description, and a schema: a small description of the arguments it takes, written in JSON, a plain text format for structured data.
Here's the catch. That description is the only thing the model has to go on when it decides whether this is the right tool, so a vague one gets ignored.
TOOL_CONFIG = { "tools": [ { "toolSpec": { "name": "list_buckets", "description": "List the names of the S3 buckets in this AWS account.", "inputSchema": { "json": {"type": "object", "properties": {}, "required": []} }, } }, { "toolSpec": { "name": "count_objects", "description": "Count the objects in one S3 bucket. Needs the bucket name.", "inputSchema": { "json": { "type": "object", "properties": { "bucket_name": { "type": "string", "description": "The name of the bucket to count.", } }, "required": ["bucket_name"], } }, } }, ] } TOOLS = {"list_buckets": list_buckets, "count_objects": count_objects} def run_tool(name, arguments): if name not in TOOLS: return {"error": f"There is no tool called {name}."} try: return TOOLS[name](**arguments) except Exception as problem: return {"error": str(problem)} run_tool hands errors back to the model instead of crashing, and that's deliberate. An agent told "that bucket doesn't exist" can try another. An agent whose script has died can't.
Step 5: write the loop
This is the whole agent, and it's shorter than the tool descriptions.
MAX_TURNS = 5 def ask(question): messages = [{"role": "user", "content": [{"text": question}]}] for turn in range(MAX_TURNS): response = bedrock.converse( modelId=MODEL_ID, messages=messages, toolConfig=TOOL_CONFIG, ) reply = response["output"]["message"] messages.append(reply) if response["stopReason"] != "tool_use": return "\n".join(b["text"] for b in reply["content"] if "text" in b) results = [] for block in reply["content"]: if "toolUse" not in block: continue use = block["toolUse"] print(f" calling {use['name']} with {use['input']}") results.append( { "toolResult": { "toolUseId": use["toolUseId"], "content": [{"json": run_tool(use["name"], use["input"])}], } } ) messages.append({"role": "user", "content": results}) return "Hit the turn limit without finishing." if __name__ == "__main__": print(ask("What S3 buckets do I have, and how many objects are in each one?")) Three things in there are worth reading twice. converse is the Bedrock call for a back-and-forth conversation with a model, and toolConfig is how the tools come along for the ride.
messages is the whole conversation, and you keep appending to it. The model has no memory between calls. That list is the memory.
stopReason is the model telling you why it stopped talking. tool_use means it wants something run. Anything else means it's answering you. The toolUseId matches a result to the request it answers, so send it back exactly as it arrived. The boto3 reference for converse has the full shape.
MAX_TURNS is a rail, not a detail. A loop that can call a paid service forever is worth a hard stop.
Step 6: run it
python agent.py What you should see: a calling list_buckets with {} line, then a calling count_objects line for each bucket you own, then a sentence listing your buckets with their counts.
Read that order again, because it's the point of the whole article. The model asked for the names first, because it couldn't count a bucket without knowing what it was called. You didn't write that order anywhere. It came out of the loop.
If you own a lot of buckets, the script may run out of turns and say so. That's the rail doing its job. Raise MAX_TURNS once you've read the pricing page and decided you're happy.
Why this works, and where a Bedrock knowledge base fits
It works because of a split worth keeping for life: the model decides, and your code acts. The model never touched your AWS account. It asked for count_objects with a bucket name, and your Python chose to run it. Every permission that mattered is still an IAM permission, short for Identity and Access Management, the AWS service that decides who can do what. Your agent can only do what the credentials running the script may do. So a tool that reads is a small decision, and a tool that deletes is a much bigger one.
So where does a Bedrock knowledge base come in? Different job. A knowledge base looks answers up in your own documents and hands the text to the model as context. That's retrieval. What you built is action. Real systems use both, and the loop is the better one to learn first, because a knowledge base is mostly something you configure.
And Agents for Amazon Bedrock is the managed version of the loop you just wrote by hand. Now it reads like a form instead of a mystery.
If it doesn't work
- Access denied from Bedrock. Usually step 1. Access is per model and per Region, so check that
REGIONmatches the Region where you asked for it, then check your credentials are allowed to call Bedrock. - A model ID that isn't recognised. Same story: copy it from the console exactly, and use one that's available in your
REGION. - A validation error on the second call. Something in
messagesis the wrong shape. The usual cause is appending the tool results without the model's own reply in front of them. The conversation has to alternate, soreplygoes in before the results do. - The model answers without calling anything. Your tool descriptions aren't earning their place. Say plainly what each returns.
Your turn
Add a third tool tonight. Make it read-only, in any AWS service you're curious about, with a description that says plainly what it returns. Then ask a question that needs two of your three tools together, and watch which ones it reaches for.
Which tool did you add, and did it pick the one you expected? Tell us in the comments.
Where this comes from
Every statement about how AWS behaves is linked to the AWS or boto3 documentation page it comes from, right where we make it.
AWS changes things. If a step doesn't match your screen, the documentation is right and we're out of date. Tell us and we'll fix the article.

