Building a Multi-Stage Approval Workflow with AWS Step Functions and Bedrock
- Erhan Vikyol
- AWS , Serverless , Architecture , Automation
- 02 Nov, 2025
Approval workflows are everywhere in enterprise environments. Whether it’s approving expense reports, access requests, or infrastructure changes, manual approvals create bottlenecks that slow everyone down.
We’ve all experienced the pain of waiting for approvals:
- Delays: Reviewers aren’t always available when you need them
- Bottlenecks: A single approver becomes a single point of failure
- Burnout: Reviewers drown in trivial requests
- Poor UX: Simple requests take hours or days
What if we could automatically handle the routine requests and only escalate the complex ones? That’s exactly what we’ll build.
In this post, I’ll show you how to build a 3-stage approval workflow that combines simple rules, AI evaluation, and human judgment. We’ll use a fun example: approving food orders for employees working overtime.
The Use Case: Overtime Food Order Approvals
Here’s the scenario: your company provides food for employees working overtime. But approving every food order manually? That’s a bottleneck. Let’s automate it with three stages:
- Stage 1 (Auto-Approval): Pizza under $20? Approved instantly!
- Stage 2 (AI Evaluation): AI checks if the order is reasonable (simple food, sensible cost per person)
- Stage 3 (Human Approval): Something fancy? Your manager decides.
This way, most requests get handled automatically, and managers only review the complex cases.
Architecture Overview
Here’s how the pieces fit together:
Approval Workflow Architecture
Components
AWS Step Functions: Orchestrates the entire workflow, managing state transitions and error handling.
Lambda Functions:
- Auto-Approval Lambda: Stage 1 - applies simple business rules
- Bedrock Lambda: Stage 2 - invokes an AI model to evaluate requests
- Notification Lambda: Stage 3 - sends SNS notifications to managers
- Callback Lambda: Stage 3 - notifies the state machine with the approve/deny decision.
Amazon Bedrock (Nova Lite): AI-powered evaluation for Stage 2, determining if requests are reasonable.
API Gateway + SNS: Handles human approval callback mechanism for Stage 3.
Implementation Deep Dive
Execution is orchestrated by an AWS Step Functions state machine, which invokes the Lambda functions and Amazon Bedrock; SNS sends approval notifications, and Amazon API Gateway processes human approval results.
3-stage Approval Workflow
Stage 1: Lambda Auto-Approval
Stage 1 is simple: apply deterministic rules to catch the easy wins. For this simple demo application, let’s keep our approval logic embedded in the handler function. As an example, pizza under $20 is a common order type, so this can be approved automatically.
Auto-approve food orders for overtime employees, if:
- Food type is pizza
- Cost is less than $20
def lambda_handler(event, context):
employee_name = event.get('employee_name', 'Anonymous')
food_type = event.get('food_type', '').lower()
cost = float(event.get('cost', 0))
request_description = event.get('request_description', '')
number_of_employees = int(event.get('number_of_employees', 1))
# Check if it's pizza and cost < $20
is_pizza = 'pizza' in food_type
is_under_budget = cost < 20.0
# Auto-approve if pizza AND cost < $20
is_auto_approved = is_pizza and is_under_budget
approval_reason = ""
if is_auto_approved:
approval_reason = f"Auto-approved: Pizza order under $20 (${cost:.2f})"
else:
if not is_pizza:
approval_reason = f"Cannot auto-approve: Not pizza (food type: {food_type})"
elif not is_under_budget:
approval_reason = f"Cannot auto-approve: Cost ${cost:.2f} exceeds $20 limit"
else:
approval_reason = "Cannot auto-approve. Requires further review."
return {
"isAutoApproved": is_auto_approved,
"employeeName": employee_name,
"requestDescription": request_description,
"foodType": food_type,
"cost": cost,
"numberOfEmployees": number_of_employees,
"approvalReason": approval_reason
}
Stage 2: Bedrock AI Evaluation
When requests exceed our simple threshold rules, we escalate them to AI for evaluation. Unlike static rules, AI can consider context. Through prompt engineering, we teach the model what “reasonable” means. For example, $60 of Thai food for 5 people ($12/person) is sensible, but $100 Sushi order for 2 people raises a red flag. The AI evaluates:
- Is this restaurant type appropriate for the spending level?
- Is the cost per person reasonable for this cuisine?
- Does the quantity match the total cost?
We use Amazon Nova Lite, as it’s fast and cost-effective, and perfect for our demo application.
def lambda_handler(event, context):
employee_name = event.get('employeeName', 'Unknown')
request_description = event.get('requestDescription', '')
food_type = event.get('foodType', '')
cost = float(event.get('cost', 0))
number_of_employees = int(event.get('numberOfEmployees', 1))
# Calculate cost per employee
cost_per_employee = cost / number_of_employees if number_of_employees > 0 else cost
# Prepare prompt for Bedrock
prompt = f"""You are a helpful AI assistant evaluating food orders for employees working overtime.
Employee: {employee_name}
Food Type: {food_type}
Request Description: {request_description}
Total Cost: ${cost:.2f}
Number of Employees: {number_of_employees}
Cost per Employee: ${cost_per_employee:.2f}
Determine if this order is REASONABLE and can be auto-approved.
APPROVE if:
- Nothing fancy (pizza, sandwiches, burgers, pasta, Chinese/Thai food, etc.)
- Cost per employee is reasonable (typically $10-15 per person, max $20 per person)
- Common delivery food items
- Reasonable for the number of people
REJECT (require manager approval) if:
- Fancy/expensive restaurants (fine dining, sushi, steakhouse)
- Cost per employee exceeds $20
- Alcoholic beverages
- Unusually expensive items
- Unreasonable quantities for the number of people
Respond ONLY with JSON in this exact format:
{{
"canAutoApprove": true/false,
"reasoning": "brief explanation of your decision"
}}"""
bedrock = boto3.client('bedrock-runtime', region_name=os.environ.get('AWS_REGION', 'us-east-1'))
# Call Amazon Nova via Bedrock
response = bedrock.invoke_model(
modelId='amazon.nova-lite-v1:0',
contentType='application/json',
accept='application/json',
body=json.dumps({
"messages": [
{
"role": "user",
"content": [
{
"text": prompt
}
]
}
],
"inferenceConfig": {
"maxTokens": 200,
"temperature": 0.2
}
})
)
response_body = json.loads(response['body'].read())
ai_response = response_body['output']['message']['content'][0]['text']
# Parse JSON response
ai_result = json.loads(ai_response)
return {
"canAutoApprove": ai_result.get("canAutoApprove", False),
"aiReasoning": ai_result.get("reasoning", "No reasoning provided"),
"employeeName": employee_name,
"requestDescription": request_description,
"foodType": food_type,
"cost": cost,
"numberOfEmployees": number_of_employees,
"costPerEmployee": cost_per_employee
}
Stage 3: Human Manager Approval
When neither rules nor AI can decide, we bring in a human. The manager gets an email with clickable approve/deny links: When the manager clicks a link, API Gateway invokes a callback Lambda that resumes the Step Functions workflow using the task token.
def lambda_handler(event, context):
task_token = event['TaskToken']
employee_name = event['EmployeeName']
request_description = event['RequestDescription']
food_type = event.get('FoodType', 'N/A')
cost = event.get('Cost', 0)
number_of_employees = event.get('NumberOfEmployees', 1)
encoded_token = urllib.parse.quote(task_token)
api_gw_url = os.environ['API_GATEWAY_URL']
sns_topic_arn = os.environ['SNS_TOPIC_ARN']
stage_name = os.environ['STAGE_NAME']
# Construct approval and denial URLs
approval_url = f"{api_gw_url}/{stage_name}/approve?token={encoded_token}&status=approved"
denial_url = f"{api_gw_url}/{stage_name}/approve?token={encoded_token}&status=denied"
# Create the message content
message = (
f"🍕 Overtime Food Order Approval Request 🍕\n\n"
f"Employee: {employee_name}\n"
f"Food Type: {food_type}\n"
f"Request: {request_description}\n"
f"Total Cost: ${cost:.2f}\n"
f"Number of Employees: {number_of_employees}\n"
f"Cost per Employee: ${float(cost) / int(number_of_employees):.2f}\n\n"
f"This order requires manager approval. Please review:\n\n"
f"✅ Approve: {approval_url}\n\n"
f"❌ Deny: {denial_url}\n"
)
# Send the message using SNS
sns = boto3.client('sns')
sns.publish(
TopicArn=sns_topic_arn,
Message=message,
Subject=f"🍕 Food Order Approval: {employee_name} - ${cost:.2f}"
)
Step Functions State Machine
Step Functions orchestrates the workflow logic, with JSONata used to handle data transformations between states.
JSONata is a declarative query and transformation language that enables inline data mapping, filtering, and restructuring within Step Functions. It reduces reliance on Lambda functions for payload manipulation, improves readability, and keeps state transitions self-contained and maintainable.
Here’s a simplified view of the state machine:
StateMachine:
Type: AWS::StepFunctions::StateMachine
Properties:
StateMachineName: FoodOrderApprovalStateMachine
DefinitionString: |
{
"Comment": "3-Stage Food Order Approval",
"StartAt": "Start",
"States": {
"Start": {
"Type": "Pass",
"Next": "Stage1_AutoApprovalCheck"
},
"Stage1_AutoApprovalCheck": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Arguments": {
"FunctionName": "${AutoApprovalFunction}",
"Payload": "{% $states.input %}"
},
"Next": "IsStage1Approved"
},
"IsStage1Approved": {
"Type": "Choice",
"Choices": [
{
"Next": "Approved",
"Condition": "{% $isAutoApproved = true %}"
}
],
"Default": "Stage2_BedrockAI"
},
"Stage2_BedrockAI": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Next": "IsStage2Approved"
},
"IsStage2Approved": {
"Type": "Choice",
"Choices": [
{
"Next": "Approved",
"Condition": "{% $canAutoApprove = true %}"
}
],
"Default": "Stage3_ManagerApproval"
},
"Stage3_ManagerApproval": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke.waitForTaskToken",
"TimeoutSeconds": 3600,
"Next": "IsStage3Approved"
},
"Approved": {
"Type": "Pass",
"Output": {
"status": "approved",
"message": "Food order approved! 🍕 Enjoy your meal!"
},
"End": true
}
}
}
Testing the Workflow
Let’s test all three stages with AI-generated examples:
Test Case 1: Stage 1 Auto-Approval
{
"employee_name": "John Doe",
"food_type": "pizza",
"cost": 18.99,
"number_of_employees": 3,
"request_description": "3 large pepperoni pizzas"
}
✅ Result: Approved instantly at Stage 1 (pizza < $20)
Test Case 2: Stage 2 AI Approval
{
"employee_name": "Jane Smith",
"food_type": "burgers",
"cost": 45.00,
"number_of_employees": 4,
"request_description": "Burgers and fries from the local diner"
}
✅ Result: Approved by AI at Stage 2 ($11.25/person is reasonable)
Test Case 3: Stage 3 Human Approval
{
"employee_name": "Bob Johnson",
"food_type": "fine dining",
"cost": 120.00,
"number_of_employees": 2,
"request_description": "Dinner at the fancy steakhouse"
}
⏳ Result: Escalated to manager ($60/person, fancy restaurant)
Design Decisions
When to use each stage:
- Rules (Stage 1): Simple rules that evaluate the most common food types under a specific budget.
- AI (Stage 2): Nuanced cases that need context understanding
- Human (Stage 3): Edge cases requiring judgment calls
Error handling: We fail safely. If a stage fails or can’t decide, escalate to the next stage. Better to over-escalate than auto-approve incorrectly.
Wrapping Up
By combining simple rules, AI evaluation, and human judgment, we’ve built an approval system that’s both fast and smart. Most requests get approved instantly, AI handles the nuanced middle ground, and humans only review the truly complex cases.
This pattern works for way more than just food orders:
- Expense reports
- Infrastructure changes
- Access requests
- Policy exceptions
- Purchase orders
- Leave requests
The key is balance: automate what you can confidently automate, and escalate what needs human judgment.
Want to try it? The complete CloudFormation template, Lambda code, and test cases are in the repository. Deploy it, test it, and adapt it to your use case!
Comments
Leave a Comment