Last month I needed an internal tool that could answer HR questions — leave balances, policy lookups, team schedules. The obvious approach was a chatbot, but a plain LLM just hallucinates answers it doesn't have. It needs access to actual data.
Amazon Bedrock Agents solve this by letting an LLM call backend functions through what AWS calls "action groups." The LLM reads function descriptions, decides which one matches the user's question, extracts the right parameters from natural language, and calls the function. The entire thing runs serverless — no EC2, no containers, no servers to babysit.
By the end of this tutorial, you will have a working Bedrock Agent backed by a Lambda function that handles four HR operations: checking leave balances, submitting time-off requests, looking up company policies, and viewing team calendars. Total AWS cost to follow along: under $1.
Prerequisites
- An AWS account with Amazon Bedrock access enabled
- AWS CLI v2 installed and configured (
aws configure) - Python 3.12+
-
boto31.35+ (pip install boto3) - Amazon Nova Pro model access enabled in the Bedrock console (us-east-1)
Step 1 — Create the IAM Roles
You need two roles: one for Lambda (so it can write logs) and one for the Bedrock Agent (so it can invoke the LLM).
First, create the Lambda trust policy. Save this as lambda-trust-policy.json:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": { "Service": "lambda.amazonaws.com" },
"Action": "sts:AssumeRole"
}
]
}
Create the role:
aws iam create-role \
--role-name hr-leave-agent-lambda-role \
--assume-role-policy-document file://lambda-trust-policy.json
{
"Role": {
"RoleName": "hr-leave-agent-lambda-role",
"Arn": "arn:aws:iam::074095961149:role/hr-leave-agent-lambda-role",
"AssumeRolePolicyDocument": {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "lambda.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}
}
}
Attach the basic execution policy so Lambda can write to CloudWatch:
aws iam attach-role-policy \
--role-name hr-leave-agent-lambda-role \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
No output means it worked.
Now the Bedrock Agent role. This trust policy is slightly more involved — it restricts trust to Bedrock agents in your specific account. Save as agent-trust-policy.json:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": { "Service": "bedrock.amazonaws.com" },
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": { "aws:SourceAccount": "YOUR_ACCOUNT_ID" },
"ArnLike": { "aws:SourceArn": "arn:aws:bedrock:us-east-1:YOUR_ACCOUNT_ID:agent/*" }
}
}
]
}
Replace YOUR_ACCOUNT_ID with your 12-digit AWS account ID, then create the role:
aws iam create-role \
--role-name hr-leave-agent-bedrock-role \
--assume-role-policy-document file://agent-trust-policy.json
{
"Role": {
"RoleName": "hr-leave-agent-bedrock-role",
"Arn": "arn:aws:iam::074095961149:role/hr-leave-agent-bedrock-role",
"AssumeRolePolicyDocument": {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "bedrock.amazonaws.com"
},
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": {
"aws:SourceAccount": "074095961149"
},
"ArnLike": {
"aws:SourceArn": "arn:aws:bedrock:us-east-1:074095961149:agent/*"
}
}
}
]
}
}
}
The agent needs permission to invoke foundation models. Save this as invoke-model-policy.json:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "bedrock:InvokeModel",
"Resource": "arn:aws:bedrock:us-east-1::foundation-model/*"
}
]
}
Attach it as an inline policy:
aws iam put-role-policy \
--role-name hr-leave-agent-bedrock-role \
--policy-name BedrockInvokeModelPolicy \
--policy-document file://invoke-model-policy.json
Two roles, two policies. That's the IAM overhead for this entire project.
Step 2 — Write the Lambda Function
This is the backend the agent will call. In production you'd query a database — here we use mock data with five employees, leave balances, team calendars, and company policies.
Save as lambda_function.py:
import json
from datetime import datetime
EMPLOYEES = {
"EMP001": {"name": "Priya Sharma", "team": "engineering", "pto_remaining": 12, "sick_remaining": 5, "role": "Senior Developer"},
"EMP002": {"name": "James Chen", "team": "engineering", "pto_remaining": 3, "sick_remaining": 5, "role": "DevOps Engineer"},
"EMP003": {"name": "Sarah Johnson", "team": "marketing", "pto_remaining": 8, "sick_remaining": 4, "role": "Content Manager"},
"EMP004": {"name": "Raj Patel", "team": "engineering", "pto_remaining": 0, "sick_remaining": 2, "role": "Junior Developer"},
"EMP005": {"name": "Maria Garcia", "team": "sales", "pto_remaining": 15, "sick_remaining": 5, "role": "Account Executive"},
}
TEAM_CALENDAR = {
"engineering": {
"2026-02": [
{"employee_id": "EMP002", "name": "James Chen", "dates": "Feb 23-25", "type": "PTO"},
],
"2026-03": [
{"employee_id": "EMP001", "name": "Priya Sharma", "dates": "Mar 9-13", "type": "PTO"},
{"employee_id": "EMP004", "name": "Raj Patel", "dates": "Mar 16", "type": "Sick"},
],
},
"marketing": {
"2026-03": [
{"employee_id": "EMP003", "name": "Sarah Johnson", "dates": "Mar 2-6", "type": "PTO"},
],
},
"sales": {
"2026-03": [
{"employee_id": "EMP005", "name": "Maria Garcia", "dates": "Mar 10-14", "type": "PTO"},
],
},
}
POLICIES = {
"pto": (
"Annual PTO allowance: 20 days for full-time employees, accrued at 1.67 days/month. "
"Requests of 1-2 days need 3 business days notice. Requests of 3+ days need 2 weeks notice. "
"Unused PTO carries over up to 5 days into the next calendar year. "
"No more than 10 consecutive business days without VP approval."
),
"sick_leave": (
"5 sick days per year, no advance notice needed but notify your manager by 9 AM. "
"Doctor's note required if you're out 3+ consecutive days. Sick days don't carry over."
),
"remote_work": (
"Up to 2 days/week remote with manager approval. Core hours 10 AM - 4 PM ET. "
"VPN required for all remote access. Full-time remote needs VP sign-off."
),
"bereavement": (
"5 paid days for immediate family (spouse, parent, child, sibling). "
"3 paid days for extended family. Does not count against PTO."
),
"parental": (
"16 weeks fully paid for primary caregivers, 6 weeks for secondary. "
"Notify HR at least 30 days before expected start date."
),
}
def check_leave_balance(employee_id):
emp = EMPLOYEES.get(employee_id)
if not emp:
return {"error": f"No employee found with ID {employee_id}"}
return {
"employee_id": employee_id,
"name": emp["name"],
"pto_remaining": emp["pto_remaining"],
"sick_remaining": emp["sick_remaining"],
"pto_annual_total": 20,
"sick_annual_total": 5,
}
def submit_leave_request(employee_id, start_date, end_date, leave_type):
emp = EMPLOYEES.get(employee_id)
if not emp:
return {"error": f"No employee found with ID {employee_id}"}
leave_type = leave_type.lower()
try:
s = datetime.strptime(start_date, "%Y-%m-%d")
e = datetime.strptime(end_date, "%Y-%m-%d")
days = max(1, (e - s).days + 1)
except ValueError:
days = 1
if leave_type in ("pto", "vacation") and emp["pto_remaining"] < days:
return {
"status": "denied",
"reason": f"Not enough PTO. Requested {days} days but only {emp['pto_remaining']} remaining.",
"employee_id": employee_id,
}
if leave_type in ("sick", "sick_leave") and emp["sick_remaining"] < days:
return {
"status": "denied",
"reason": f"Not enough sick leave. Requested {days} days but only {emp['sick_remaining']} remaining.",
"employee_id": employee_id,
}
req_id = f"LR-2026-{employee_id[-3:]}-{start_date.replace('-', '')}"
return {
"status": "submitted",
"request_id": req_id,
"employee_id": employee_id,
"name": emp["name"],
"leave_type": leave_type,
"start_date": start_date,
"end_date": end_date,
"days_requested": days,
"message": f"Leave request {req_id} submitted for manager approval.",
}
def get_company_policy(topic):
topic_clean = topic.lower().strip().replace(" ", "_")
for key, text in POLICIES.items():
if key in topic_clean or topic_clean in key:
return {"topic": key, "policy": text}
return {"error": f"No policy found for '{topic}'. Available: {', '.join(POLICIES.keys())}"}
def get_team_calendar(team_name, month):
team = team_name.lower().strip()
if team not in TEAM_CALENDAR:
return {"error": f"Unknown team '{team_name}'. Available: {', '.join(TEAM_CALENDAR.keys())}"}
cal = TEAM_CALENDAR[team]
month_map = {
"january": "01", "february": "02", "march": "03", "april": "04",
"may": "05", "june": "06", "july": "07", "august": "08",
"september": "09", "october": "10", "november": "11", "december": "12",
}
ml = month.lower().strip()
for period, entries in cal.items():
if ml in period or period in ml:
return {"team": team_name, "month": period, "out_of_office": entries}
for name, num in month_map.items():
if name in ml and num in period:
return {"team": team_name, "month": period, "out_of_office": entries}
return {"team": team_name, "month": month, "out_of_office": [], "note": "Nobody scheduled off."}
FUNCTION_MAP = {
"check_leave_balance": lambda p: check_leave_balance(p.get("employee_id", "")),
"submit_leave_request": lambda p: submit_leave_request(
p.get("employee_id", ""), p.get("start_date", ""),
p.get("end_date", ""), p.get("leave_type", "pto"),
),
"get_company_policy": lambda p: get_company_policy(p.get("topic", "")),
"get_team_calendar": lambda p: get_team_calendar(p.get("team_name", ""), p.get("month", "")),
}
def lambda_handler(event, context):
fn = event.get("function", "")
params = {p["name"]: p["value"] for p in event.get("parameters", [])}
handler = FUNCTION_MAP.get(fn)
result = handler(params) if handler else {"error": f"Unknown function: {fn}"}
return {
"messageVersion": "1.0",
"response": {
"actionGroup": event.get("actionGroup", ""),
"function": fn,
"functionResponse": {
"responseBody": {"TEXT": {"body": json.dumps(result)}}
},
},
}
One thing about the request format: Bedrock Agents send parameters as a list of {name, value} pairs instead of a plain dictionary. The lambda_handler at the bottom flattens that into a dict and dispatches to the right function.
Package and deploy:
zip lambda_function.zip lambda_function.py
aws lambda create-function \
--function-name hr-leave-agent \
--runtime python3.12 \
--role arn:aws:iam::YOUR_ACCOUNT_ID:role/hr-leave-agent-lambda-role \
--handler lambda_function.lambda_handler \
--zip-file fileb://lambda_function.zip \
--timeout 30 \
--memory-size 128
{
"FunctionName": "hr-leave-agent",
"FunctionArn": "arn:aws:lambda:us-east-1:074095961149:function:hr-leave-agent",
"Runtime": "python3.12",
"Handler": "lambda_function.lambda_handler",
"CodeSize": 2576,
"Timeout": 30,
"MemorySize": 128,
"State": "Pending",
"StateReason": "The function is being created."
}
Now grant Bedrock permission to invoke this function:
aws lambda add-permission \
--function-name hr-leave-agent \
--statement-id AllowBedrockInvoke \
--action lambda:InvokeFunction \
--principal bedrock.amazonaws.com \
--source-account YOUR_ACCOUNT_ID
Quick sanity check — invoke the function directly to confirm it works before wiring up the agent:
aws lambda invoke \
--function-name hr-leave-agent \
--cli-binary-format raw-in-base64-out \
--payload '{"function":"check_leave_balance","parameters":[{"name":"employee_id","value":"EMP001"}],"actionGroup":"HRActions"}' \
/tmp/test-output.json
{
"StatusCode": 200,
"ExecutedVersion": "$LATEST"
}
Check the response payload:
{
"messageVersion": "1.0",
"response": {
"actionGroup": "HRActions",
"function": "check_leave_balance",
"functionResponse": {
"responseBody": {
"TEXT": {
"body": "{\"employee_id\": \"EMP001\", \"name\": \"Priya Sharma\", \"pto_remaining\": 12, \"sick_remaining\": 5, \"pto_annual_total\": 20, \"sick_annual_total\": 5}"
}
}
}
}
}
Correct data, correct format. Lambda is ready.
Step 3 — Create the Bedrock Agent
The agent needs three things: an LLM, instructions, and an action group that maps to the Lambda function.
aws bedrock-agent create-agent \
--agent-name hr-leave-agent \
--agent-resource-role-arn arn:aws:iam::YOUR_ACCOUNT_ID:role/hr-leave-agent-bedrock-role \
--foundation-model amazon.nova-pro-v1:0 \
--instruction "You are an HR assistant for a mid-size tech company. You help employees check their leave balances, submit time-off requests, look up company policies, and view team calendars. Be concise and helpful. When an employee asks about taking time off, check their balance first before submitting."
{
"agent": {
"agentId": "SUEO6W3BDO",
"agentName": "hr-leave-agent",
"agentStatus": "CREATING",
"foundationModel": "amazon.nova-pro-v1:0"
}
}
Save the agentId — you need it for every subsequent command. Mine is SUEO6W3BDO.
Now the action group. The function schema here is the critical piece — the LLM reads these descriptions to decide which function to call, so vague descriptions mean wrong routing.
aws bedrock-agent create-agent-action-group \
--agent-id YOUR_AGENT_ID \
--agent-version DRAFT \
--action-group-name HRActions \
--action-group-executor '{"lambda":"arn:aws:lambda:us-east-1:YOUR_ACCOUNT_ID:function:hr-leave-agent"}' \
--function-schema '{
"functions": [
{
"name": "check_leave_balance",
"description": "Look up how many PTO and sick days an employee has remaining.",
"parameters": {
"employee_id": {"type": "string", "description": "Employee ID, e.g. EMP001", "required": true}
}
},
{
"name": "submit_leave_request",
"description": "Submit a new leave request. Returns confirmation or denial based on available balance.",
"parameters": {
"employee_id": {"type": "string", "description": "Employee ID", "required": true},
"start_date": {"type": "string", "description": "Start date YYYY-MM-DD", "required": true},
"end_date": {"type": "string", "description": "End date YYYY-MM-DD", "required": true},
"leave_type": {"type": "string", "description": "pto, sick, bereavement, or parental", "required": true}
}
},
{
"name": "get_company_policy",
"description": "Retrieve company policy for a topic: pto, sick_leave, remote_work, bereavement, or parental.",
"parameters": {
"topic": {"type": "string", "description": "Policy topic", "required": true}
}
},
{
"name": "get_team_calendar",
"description": "Check who on a team is out of office during a given month.",
"parameters": {
"team_name": {"type": "string", "description": "engineering, marketing, or sales", "required": true},
"month": {"type": "string", "description": "Month, e.g. March or 2026-03", "required": true}
}
}
]
}'
{
"agentActionGroup": {
"actionGroupName": "HRActions",
"actionGroupId": "VR1HJRPO25",
"actionGroupState": "ENABLED",
"functionSchema": {
"functions": [
{"name": "check_leave_balance", "requireConfirmation": "DISABLED"},
{"name": "submit_leave_request", "requireConfirmation": "DISABLED"},
{"name": "get_company_policy", "requireConfirmation": "DISABLED"},
{"name": "get_team_calendar", "requireConfirmation": "DISABLED"}
]
}
}
}
Four functions registered. Now prepare the agent — this compiles everything and makes it invocable:
aws bedrock-agent prepare-agent --agent-id YOUR_AGENT_ID
{
"agentId": "SUEO6W3BDO",
"agentStatus": "PREPARING"
}
Give it 10-15 seconds, then check the status:
aws bedrock-agent get-agent --agent-id YOUR_AGENT_ID \
--query "agent.agentStatus" --output text
PREPARED
The agent is live.
Step 4 — Test the Agent
Save this as invoke_agent.py:
import boto3
import uuid
client = boto3.client("bedrock-agent-runtime", region_name="us-east-1")
AGENT_ID = "YOUR_AGENT_ID"
AGENT_ALIAS_ID = "TSTALIASID" # built-in test alias
def invoke(prompt, session_id=None):
if not session_id:
session_id = str(uuid.uuid4())
resp = client.invoke_agent(
agentId=AGENT_ID,
agentAliasId=AGENT_ALIAS_ID,
sessionId=session_id,
inputText=prompt,
)
answer = ""
for event in resp["completion"]:
if "chunk" in event:
answer += event["chunk"]["bytes"].decode("utf-8")
print(f"\nUser: {prompt}")
print(f"Agent: {answer}")
return session_id
# Test 1: Leave balance lookup
invoke("How many PTO days does employee EMP001 have left?")
# Test 2: Policy lookup
invoke("What's the remote work policy?")
# Test 3: Team calendar
invoke("Who on the engineering team is out in March?")
# Test 4: Leave request submission
invoke("Submit a PTO request for EMP003 from March 20 to March 24, 2026.")
# Test 5: Multi-turn — agent remembers context
sid = invoke("I'm employee EMP004. How much PTO do I have?")
invoke("Can I take March 10 off as PTO?", session_id=sid)
Replace YOUR_AGENT_ID with your agent ID, then run it:
python invoke_agent.py
User: How many PTO days does employee EMP001 have left?
Agent: Employee EMP001 has 12 PTO days left.
User: What's the remote work policy?
Agent: The remote work policy allows up to 2 days per week of remote work with manager
approval. Core hours are 10 AM - 4 PM ET. VPN is required for all remote access.
Full-time remote work needs VP sign-off.
User: Who on the engineering team is out in March?
Agent: In March, Priya Sharma (EMP001) is out from Mar 9-13 for PTO and Raj Patel
(EMP004) is out on Mar 16 for Sick leave.
User: Submit a PTO request for EMP003 from March 20 to March 24, 2026.
Agent: Your PTO request from March 20 to March 24, 2026, has been successfully submitted
for manager approval. You will receive a confirmation email within 24 hours.
User: I'm employee EMP004. How much PTO do I have?
Agent: Raj Patel, you currently have 0 days of PTO remaining this year. Your annual PTO
total is 20 days. If you need to take time off, you may want to consider using your
2 remaining sick days or discuss other options with your manager.
User: Can I take March 10 off as PTO?
Agent: Unfortunately, you don't have any PTO days remaining to take March 10 off.
You could use one of your 2 remaining sick days if applicable, or speak with your
manager about other arrangements.
The agent didn't just return raw data — it interpreted it. When EMP004 asked about PTO with a balance of zero, the agent suggested alternatives on its own (sick days, talking to the manager). Nobody told it to do that. It inferred the right behavior from the instruction "be helpful" combined with the data it got back.
The multi-turn test (Test 5) shows session memory working. The second message ("Can I take March 10 off?") doesn't mention EMP004 — the agent remembered from the first message in the same session.
And for the denial scenario — I ran one more test separately to make sure the validation logic works end to end:
User: Submit a PTO request for EMP004 from March 10 to March 12, 2026.
Agent: Sorry, Raj Patel (EMP004) does not have any PTO days remaining for the current
year. Therefore, the PTO request from March 10 to March 12, 2026, cannot be submitted.
Please consider using sick leave if applicable or discuss with your manager about taking
unpaid leave as an alternative.
The Lambda function caught the zero balance and returned a denial. The agent turned that into a clear explanation with alternatives — no special error-handling logic needed on our side.
Step 5 — Clean Up
To avoid any charges, delete everything in reverse order:
# Delete the agent (this removes action groups too)
aws bedrock-agent delete-agent --agent-id YOUR_AGENT_ID
# Delete the Lambda function
aws lambda delete-function --function-name hr-leave-agent
# Delete the Bedrock role (remove inline policy first)
aws iam delete-role-policy --role-name hr-leave-agent-bedrock-role \
--policy-name BedrockInvokeModelPolicy
aws iam delete-role --role-name hr-leave-agent-bedrock-role
# Delete the Lambda role (detach managed policy first)
aws iam detach-role-policy --role-name hr-leave-agent-lambda-role \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
aws iam delete-role --role-name hr-leave-agent-lambda-role
What You Could Build Next
This tutorial used mock data, but the pattern doesn't change when you swap in real backends:
- Point the Lambda at a DynamoDB table instead of Python dictionaries and you've got a real HR bot
- Swap the backend for an external API and the same agent handles order status lookups
- Wire up a SQL query layer for a data analyst agent (this one's on my list to build next)
You could also add a second action group to the same agent — for example, an "ITSupport" group that handles password resets and laptop requests alongside the HR functions. One agent, multiple domains.
For the next level, look into Bedrock Agent's built-in session memory (so conversations persist beyond a single session) and guardrails (so the agent stays within bounds on sensitive topics like salary data).
The companion code for this tutorial is on GitHub.

Top comments (0)