Bedrock Agents
A Bedrock Agent runs the W3 tool loop for you: declare action groups (Lambda), attach a Knowledge Base, and AWS orchestrates the calls and keeps session state.
- AWS credentials (
aws configure) + Bedrock model access enabled in your region +pip install boto3 - AWS credentials (
aws configure) +pip install boto3
Learning objectives
- Explain how a Bedrock Agent relates to the manual tool loop from W3.
- Define action groups backed by Lambda functions.
- Attach a Knowledge Base so the agent can retrieve and act.
- Invoke the agent and trace its orchestration + session state.
code/aws5-bedrock-agents/ in the course, with a README on how to run it. Read here, run there — no need to copy-paste.An Agent is the W3 loop, managed essential
In W3 you ran the tool loop by hand: call model → get tool_use → run tool → return result → repeat. A Bedrock Agent runs that loop for you. You declare action groups (tools, implemented as Lambda) and optionally attach a Knowledge Base; AWS orchestrates, maintains session memory, and calls your Lambdas.
issue_refund needs a confirmation path. W6 (Guardrails) is one layer; your Lambda authorization is another.Define an action group (Lambda-backed tool) essential
An action group maps an OpenAPI-ish schema to a Lambda. When the agent decides to act, Bedrock invokes the Lambda with the parameters and feeds the return value back into the loop.
action_lambda.py# The Lambda that backs an action group. Bedrock calls it with the
# agent's chosen action + parameters, and expects a structured response.
def lambda_handler(event, context):
action = event["actionGroup"]
api_path = event["apiPath"] # e.g. "/pods/{name}/status"
params = {p["name"]: p["value"] for p in event.get("parameters", [])}
if api_path == "/pods/status":
result = {"phase": "CrashLoopBackOff", "restarts": 7}
else:
result = {"error": "unknown action"}
return {
"messageVersion": "1.0",
"response": {
"actionGroup": action,
"apiPath": api_path,
"httpStatusCode": 200,
"responseBody": {"application/json": {"body": str(result)}},
},
}
This is the Lambda function that does the real work behind one of the agent's tools (an action group). You never call it yourself — Bedrock calls it for you whenever the agent decides to take an action. Bedrock passes in a big dictionary called event describing which action and what parameters, and your job is to do the work and hand back a result in the exact shape Bedrock expects.
def lambda_handler(event, context):is the fixed entry point AWS Lambda always calls.eventholds the request Bedrock sent;contextis runtime info you can usually ignore here.action = event["actionGroup"]andapi_path = event["apiPath"]read which tool and which operation the agent chose (e.g. the path/pods/{name}/status).params = {p["name"]: p["value"] for p in event.get("parameters", [])}is a dictionary comprehension: Bedrock sends parameters as a list of{name, value}pairs, and this line flips them into an easy-to-use{name: value}dictionary..get(..., [])means "use an empty list if there are no parameters" so it never crashes.- The
if api_path == "/pods/status":block is where your logic lives. Here it just returns a fake pod status; anything Bedrock didn't recognise falls through to theelseand returns an"unknown action"error. - The big
return {...}is the contract Bedrock requires. It must includemessageVersionand aresponseobject echoing backactionGroup/apiPath, an HTTP-stylehttpStatusCode, and the answer insideresponseBody. Get this shape wrong and the agent can't read your result.
What the output means: Nothing prints to a screen — a Lambda hands its return value straight back to Bedrock. For a status request the agent receives {"phase": "CrashLoopBackOff", "restarts": 7} wrapped in that response envelope, and folds it into its reasoning.
Try this: Trace what happens if the agent asks for /pods/logs instead: api_path won't match "/pods/status", so result becomes the error dict. Add a second elif api_path == "/pods/logs": branch to handle it.
Create and wire the agent intermediate
create_agent.pyimport boto3
agent = boto3.client("bedrock-agent", region_name="us-east-1")
a = agent.create_agent(
agentName="sre-assistant",
foundationModel="anthropic.claude-3-5-sonnet-20241022-v2:0",
instruction="You are an SRE assistant. Diagnose with read-only tools first; "
"never take a destructive action without explicit confirmation.",
agentResourceRoleArn="arn:aws:iam::123456789012:role/bedrock-agent-role",
)
agent_id = a["agent"]["agentId"]
# attach the Lambda action group (schema omitted for brevity)
agent.create_agent_action_group(
agentId=agent_id, agentVersion="DRAFT", actionGroupName="k8s-tools",
actionGroupExecutor={"lambda": "arn:aws:lambda:us-east-1:123456789012:function:k8s-tools"},
apiSchema={"s3": {"s3BucketName": "my-schemas", "s3ObjectKey": "k8s.json"}},
)
agent.prepare_agent(agentId=agent_id) # compile the DRAFT so it is invocable
This script creates the agent itself from your laptop using boto3 (the AWS SDK for Python). Three things happen in order: make the agent, give it a tool (action group), then prepare it so it's ready to answer. This is set-up you run once, not every time a user chats.
agent = boto3.client("bedrock-agent", region_name="us-east-1")opens a connection to the Bedrock control plane — the API for building agents (a different client than the one that runs them, which you'll see in the next lab).agent.create_agent(...)makes the agent. The key inputs:foundationModelpicks the brain (a Claude model),instructionis the system prompt telling it how to behave ("diagnose read-only first, confirm before destructive actions"), andagentResourceRoleArnis the IAM role granting it permission to call your Lambdas.agent_id = a["agent"]["agentId"]pulls the new agent's ID out of the response so the next call can attach a tool to it.agent.create_agent_action_group(...)gives the agent a tool.actionGroupExecutor={"lambda": "..."}says "run this Lambda when the tool fires" (the Lambda from W5.1), andapiSchemapoints to an OpenAPI schema in S3 that describes the tool's inputs so the model knows how to call it. It attaches toagentVersion="DRAFT"— the editable working copy.agent.prepare_agent(agentId=agent_id)compiles that DRAFT into a runnable version. Until you prepare it, the agent exists but can't be invoked.
What the output means: No visible output, but afterwards AWS holds a prepared agent with one Lambda-backed tool, ready to receive questions. The ARNs and account number 123456789012 here are placeholders — swap in your own.
Try this: Read the instruction string closely: that plain-English sentence is the single biggest lever on agent behaviour. Rewrite it to also say "always cite the runbook you used" and notice you've changed the agent without touching any tool code.
Invoke and trace advanced
invoke_agent.pyimport boto3
rt = boto3.client("bedrock-agent-runtime", region_name="us-east-1")
resp = rt.invoke_agent(
agentId="AGENT123", agentAliasId="TSTALIASID",
sessionId="session-1", # session state persists across turns
inputText="Is the checkout-api pod healthy? If not, explain why.",
)
# the response is an event stream
for event in resp["completion"]:
if "chunk" in event:
print(event["chunk"]["bytes"].decode(), end="")
Now the agent is built, this is how you actually talk to it. You send one question and read the answer back. Crucially, the answer doesn't arrive all at once — it streams in pieces, so the loop at the bottom stitches those pieces together as they come.
rt = boto3.client("bedrock-agent-runtime", region_name="us-east-1")opens the runtime client — the one for using a live agent (note it'sbedrock-agent-runtime, not thebedrock-agentbuild client from the last lab).resp = rt.invoke_agent(...)sends the turn.agentId+agentAliasIdpick which agent/version to talk to, andinputTextis the user's actual question.sessionId="session-1"is the memory key. Reuse the samesessionIdon the next call and the agent remembers the earlier turns — that's the "session state" the agent keeps for you, so you don't have to resend the whole history.for event in resp["completion"]:loops over the stream of events coming back. The reply is delivered as a sequence of chunks, not one string.if "chunk" in event:keeps only the text chunks, andprint(event["chunk"]["bytes"].decode(), end="")turns each chunk's raw bytes into text and prints it with no line break, so the sentence builds up smoothly on one line.
What the output means: You'll see the agent's answer appear piece by piece, e.g. a diagnosis of the checkout-api pod. Behind the scenes the agent may have called your Lambda and read the Knowledge Base first — but you only see the final streamed words.
Try this: Call invoke_agent a second time with the same sessionId and a follow-up like "and what caused it?" — the agent uses the earlier turn as context. Change the sessionId to a new value and it starts fresh with no memory.
Exercise W5.1 — Diagnose-then-fix agent
Context: The payoff of an Agent is watching it orchestrate real tools against real knowledge. An SRE-style diagnose-then-fix bot exercises every piece at once: action-group tools, a Knowledge Base of runbooks, and a human-confirmation gate before a destructive action.
Your task: Give your agent two action-group tools (get_pod_status, restart_pod) and a Knowledge Base of runbooks, ask it to fix a crash-looping pod, and trace the orchestration end to end.
Requirements:
- Two tools attached: a read-only
get_pod_statusand a mutatingrestart_pod - A Knowledge Base of runbooks the agent can retrieve from
- Trace which tool ran, in what order, and what the KB returned
- Show where the agent paused for confirmation before the destructive restart
- Needs AWS creds; capture the streamed trace so the flow is auditable
💡 Hint: Read-before-write: the diagnostic tool should run first and feed the decision, with confirmation gating the restart_pod call.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: A Bedrock Agent is the managed version of the tool loop you hand-rolled in W3: AWS owns the reason-act cycle, and you supply only the model, the instruction, and a role it can assume. Standing one up is the first step of every agent build.
Your task: Use bedrock-agent.create_agent to define an agent with a foundation model, an instruction (its system prompt), and an agentResourceRoleArn, then print the returned agentId.
Requirements:
- Create a
boto3client for thebedrock-agentcontrol plane in an explicit region - Pass
agentName,foundationModel,instruction, andagentResourceRoleArn - The instruction reads as a system prompt (what the agent does, when to use tools)
- Print
resp["agent"]["agentId"]so later steps can reference it - Note this needs real AWS credentials to run
💡 Hint: The control-plane client (bedrock-agent) creates the agent; a different runtime client will later invoke it. Keep the returned id.
Show solution
The agent is the managed W3 loop; you give it a model, an instruction (its system prompt), and an execution role.
import boto3
agent = boto3.client("bedrock-agent", region_name="us-east-1")
resp = agent.create_agent(
agentName="support-agent",
foundationModel="anthropic.claude-3-5-sonnet-20241022-v2:0",
instruction="You help customers with orders. Use tools for lookups.",
agentResourceRoleArn="arn:aws:iam::123:role/agent-role",
)
print(resp["agent"]["agentId"])
Context: An action group is how an Agent gets tools: the Lambda is the code that runs, and an OpenAPI schema is the contract the model reads to know what arguments a tool takes. New tools are always attached to the mutable DRAFT version.
Your task: Call create_agent_action_group to attach an orders tool whose actionGroupExecutor is a Lambda ARN and whose apiSchema is an S3-hosted OpenAPI file, on agentVersion=DRAFT.
Requirements:
- Target the
DRAFTversion, not a numbered/published one actionGroupExecutorpoints at a Lambda function ARNapiSchemareferences an S3 bucket + object key holding the API contract- Give the action group a clear
actionGroupName(e.g.orders) - Confirm the call returns without error before moving on
💡 Hint: Think executor = the code, schema = the contract. Both hang off the same DRAFT agent until you prepare it.
Show solution
An action group is a tool: actionGroupExecutor.lambda is the code, apiSchema.s3 is the contract, all on the DRAFT version.
import boto3
agent = boto3.client("bedrock-agent", region_name="us-east-1")
agent.create_agent_action_group(
agentId="AGENT123",
agentVersion="DRAFT",
actionGroupName="orders",
actionGroupExecutor={"lambda": "arn:aws:lambda:us-east-1:123:function:orders"},
apiSchema={"s3": {"s3BucketName": "my-schemas",
"s3ObjectKey": "orders-openapi.json"}},
)
print("action group attached")
Context: Editing an agent changes only DRAFT; nothing is runnable until you compile it. prepare_agent turns DRAFT into a runnable version, after which the runtime streams the completion back event by event.
Your task: Call prepare_agent on the agent, then use a bedrock-agent-runtime client to invoke_agent with a sessionId and inputText, reading the streamed completion.
Requirements:
prepare_agentis called before any invoke- Use the
bedrock-agent-runtimeclient (not the control plane) to invoke - Pass
agentId,agentAliasId,sessionId, andinputText - Iterate
resp["completion"]and decode eachchunk["bytes"]as it arrives - Print the assembled text of the streamed answer
💡 Hint: The completion is an event stream, not a single string — loop over it and only act on events that carry a chunk.
Show solution
prepare_agent turns DRAFT into a runnable version; invoke_agent returns an event stream in resp["completion"].
import boto3
agent = boto3.client("bedrock-agent", region_name="us-east-1")
agent.prepare_agent(agentId="AGENT123")
rt = boto3.client("bedrock-agent-runtime", region_name="us-east-1")
resp = rt.invoke_agent(
agentId="AGENT123", agentAliasId="ALIAS1",
sessionId="sess-1", inputText="Where is order A100?",
)
for event in resp["completion"]:
if "chunk" in event:
print(event["chunk"]["bytes"].decode(), end="")
Context: When the Agent decides to call a tool, it invokes your Lambda with a fixed event shape and expects an equally fixed response envelope back. Getting that envelope exactly right is what makes the orchestration work — and it is pure logic you can test offline.
Your task: Write the lambda_handler for the orders action group: read apiPath and the parameters list, and return the required messageVersion/response envelope.
Requirements:
- Flatten the
parameterslist into a name→value dict - Branch on
event["apiPath"](e.g./orders/status) - Echo back
actionGroupandapiPathfrom the event - Return
messageVersion, anhttpStatusCode, and aresponseBodyunderapplication/json - Prove it offline: feed a hand-made event and assert the status code is 200
💡 Hint: The response must mirror the request's actionGroup and apiPath; the actual data goes inside responseBody["application/json"]["body"].
Show solution
The Lambda receives actionGroup, apiPath, and a parameters list, and must echo them in a specific envelope.
def lambda_handler(event, context):
params = {p["name"]: p["value"] for p in event.get("parameters", [])}
if event["apiPath"] == "/orders/status":
body = {"status": "shipped", "order_id": params.get("order_id")}
else:
body = {"error": "unknown path"}
return {
"messageVersion": "1.0",
"response": {
"actionGroup": event["actionGroup"],
"apiPath": event["apiPath"],
"httpStatusCode": 200,
"responseBody": {"application/json": {"body": str(body)}},
},
}
evt = {"actionGroup":"orders","apiPath":"/orders/status",
"parameters":[{"name":"order_id","value":"A100"}]}
print(lambda_handler(evt, None)["response"]["httpStatusCode"]) # 200
Context: A coherent multi-turn conversation depends on the Agent remembering earlier turns, and AWS keys that memory off the sessionId you pass. Get the id policy wrong and either users leak into each other's sessions or every turn starts cold.
Your task: Show that reusing one sessionId across two invoke_agent calls preserves context, and model a session-id policy with a session_for(user_id, sessions) helper that hands each user a stable id.
Requirements:
session_forreturns the same id for repeat calls with the sameuser_id- Different users get different session ids
- Assert same-user calls compare equal (a durable per-user session)
- Explain that reusing the id across turns is what makes the agent remember
- The id logic runs offline; the actual invokes need AWS creds
💡 Hint: A dict.setdefault keyed by user gives you one durable id per user in a couple of lines — no need to regenerate on every turn.
Show solution
AWS manages session memory keyed by sessionId; a stable per-user id keeps a conversation coherent.
import uuid
def session_for(user_id, sessions):
# one durable session per user; reuse across turns
return sessions.setdefault(user_id, str(uuid.uuid4()))
sessions = {}
s1 = session_for("u-42", sessions)
s2 = session_for("u-42", sessions) # same user -> same session
print(s1 == s2) # True -> agent remembers prior turns
# rt.invoke_agent(agentId=.., agentAliasId=.., sessionId=s1, inputText="...")
# rt.invoke_agent(agentId=.., agentAliasId=.., sessionId=s1, inputText="and then?")
Context: Not every assistant should be a managed Agent. A managed Agent removes the loop/memory boilerplate, but a hand-rolled Converse loop is the right call when you need bespoke orchestration the Agent hides. Teams get this trade-off wrong in both directions.
Your task: Encode agent_vs_loop(want_managed_memory, need_custom_orchestration, few_tools) that returns which approach to use, and justify the pick for a support bot with 3 tools and standard memory.
Requirements:
- Custom orchestration need forces the hand-rolled loop, whatever else is true
- Managed memory + few tools + no exotic control flow → a Bedrock Agent
- Return a clear string label (e.g.
bedrock-agentvshand-rolled-loop) - Demonstrate the support-bot case resolves to the managed Agent
- State in one line why the managed loop + session state fits that case
💡 Hint: Check the disqualifier first: if custom orchestration is required, decide immediately; only then weigh memory and tool count.
Show solution
A managed Agent removes loop/memory boilerplate; a hand-rolled loop wins only when you need bespoke orchestration.
def agent_vs_loop(want_managed_memory, need_custom_orchestration, few_tools):
if need_custom_orchestration:
return "hand-rolled-loop" # you need control the Agent hides
if want_managed_memory and few_tools:
return "bedrock-agent" # AWS owns loop + session state
return "bedrock-agent"
print(agent_vs_loop(True, False, True)) # bedrock-agent
# Support bot: standard memory + 3 tools + no exotic control flow
# -> Bedrock Agent; the managed W3 loop + session state is exactly the fit.
✓ Checkpoint — you can move on when you can…
- Explain how an Agent maps onto the W3 manual tool loop.
- Define an action group and the Lambda contract it expects.
- Attach a Knowledge Base and explain what the agent gains.
- Invoke an agent with a session and read the streamed completion.
Knowledge check check yourself
How does a Bedrock Agent relate to the manual W3 tool loop, and what does it manage for you?
Show answer
What is the role of prepare_agent, and how does sessionId affect an invocation?