Build a Community Christmas Tree with AI-Generated Ornaments
Create a shared Christmas tree where visitors add AI-generated ornaments using Amazon Nova Canvas, with defense-in-depth content moderation using Bedrock Guardrails and Claude.
The holidays are the perfect time for a community project. In this tutorial, we’ll build a shared Christmas tree where anyone can describe an ornament and watch AI bring it to life. Every ornament appears on the same tree, creating a collaborative holiday decoration.
But here’s the challenge: when you open AI image generation to the public, you need robust content moderation. We’ll implement a defense-in-depth security pattern using AWS Bedrock Guardrails and Claude Haiku to ensure all ornaments stay festive and family-friendly.
See it live at www.oldbluechair.com/christmas-tree. Get the complete code at github.com/StoliRocks/christmas-tree-api.
What We’re Building
Our community Christmas tree will:
- Moderate user input using Bedrock Guardrails (blocks harmful content)
- Enhance prompts with Claude Haiku 4.5 (ensures quality ornament images)
- Generate ornament images using Amazon Nova Canvas
- Rate limit to 3 ornaments per IP per day
- Display all ornaments on a shared tree in real-time
Security Architecture: Defense in Depth
The key insight for public AI applications: never trust user input. We implement three layers of protection:
User Prompt
│
▼
┌─────────────────────────────────────┐
│ Layer 1: Bedrock Guardrails │ ← Fast, built-in content filtering
│ (Hate, Violence, Sexual, Insults) │ Blocks obvious violations
└─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ Layer 2: Claude Haiku 4.5 │ ← Context-aware enhancement
│ (Prompt enhancement + filtering) │ Transforms for quality output
└─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ Layer 3: Nova Canvas │ ← Image generation
│ (Generate ornament image) │ With pre-sanitized prompt
└─────────────────────────────────────┘
Project Structure
christmas-tree-api/
├── template.yaml # SAM template
├── src/
│ ├── generate.py # POST /ornaments - Create new ornament
│ ├── list_ornaments.py # GET /ornaments - List all ornaments
│ └── shared/
│ ├── __init__.py
│ ├── moderation.py # Guardrails + Claude moderation
│ ├── nova.py # Nova Canvas image generation
│ ├── storage.py # S3 + DynamoDB operations
│ └── rate_limit.py # IP-based rate limiting
├── frontend/ # Example frontend (not covered in tutorial)
│ └── christmas-tree.astro
└── requirements.txt
Setting Up Bedrock Guardrails
First, create a guardrail in the AWS Console:
- Navigate to Amazon Bedrock → Guardrails → Create guardrail
- Name it
prompt_moderation - Configure content filters with HIGH strength for:
- Hate
- Insults
- Sexual
- Violence
- Misconduct
- Set blocked message: “Please keep your ornament description family-friendly”
- Create the guardrail and note the Guardrail ID
Guardrail created successfully Name: prompt_moderation ID: ahcksb2cjljz ARN: arn:aws:bedrock:us-east-1:123456789:guardrail/ahcksb2cjljz
The Moderation Layer
This is the core security component. It applies Guardrails first, then uses Claude to enhance the prompt:
# src/shared/moderation.py
"""Content moderation and prompt sanitization.
Security Design Pattern: Defense in Depth
1. AWS Bedrock Guardrails - Built-in content filtering
2. Claude Haiku 4.5 - Prompt enhancement and context-aware filtering
"""
import boto3
import json
import logging
import os
from typing import Tuple
logger = logging.getLogger()
logger.setLevel(logging.INFO)
bedrock = boto3.client("bedrock-runtime", region_name="us-east-1")
# Guardrail configuration from environment
GUARDRAIL_ID = os.environ.get("GUARDRAIL_ID", "")
GUARDRAIL_VERSION = os.environ.get("GUARDRAIL_VERSION", "DRAFT")
# Prompt for Claude to enhance user input
ENHANCEMENT_PROMPT = """You are a prompt engineer for a Christmas ornament generator.
Transform the user's idea into a better image generation prompt. Keep their concept but optimize it.
Rules:
1. Output ONLY the enhanced prompt, nothing else
2. Always include: "circular glass Christmas tree ornament ball"
3. Always include: "transparent background, PNG style, no rectangular edges"
4. Always include: "hanging from silver hook, festive and sparkly"
5. Add relevant colors and textures
6. Keep it under 200 characters total
User's idea: """
def apply_guardrails(text: str) -> Tuple[bool, str]:
"""
Apply Bedrock Guardrails to check for inappropriate content.
Returns:
Tuple of (passed: bool, message: str)
"""
if not GUARDRAIL_ID:
logger.warning("No guardrail ID configured, skipping")
return True, ""
try:
response = bedrock.apply_guardrail(
guardrailIdentifier=GUARDRAIL_ID,
guardrailVersion=GUARDRAIL_VERSION,
source="INPUT",
content=[{"text": {"text": text}}]
)
action = response.get("action", "NONE")
if action == "GUARDRAIL_INTERVENED":
outputs = response.get("outputs", [])
message = outputs[0].get("text", "Content not allowed") if outputs else "Content not allowed"
logger.info(f"Guardrail blocked: {text[:50]}...")
return False, message
return True, ""
except Exception as e:
logger.error(f"Guardrail error: {e}")
return True, "" # Fail open, but log the error
def enhance_prompt(user_prompt: str) -> str:
"""
Use Claude Haiku 4.5 to enhance the prompt for better ornament generation.
"""
try:
response = bedrock.invoke_model(
modelId="global.anthropic.claude-haiku-4-5-20251001-v1:0",
contentType="application/json",
accept="application/json",
body=json.dumps({
"anthropic_version": "bedrock-2023-05-31",
"max_tokens": 300,
"messages": [
{
"role": "user",
"content": ENHANCEMENT_PROMPT + user_prompt
}
]
})
)
result = json.loads(response["body"].read())
enhanced = result.get("content", [{}])[0].get("text", "").strip()
if enhanced:
logger.info(f"Enhanced: {enhanced[:100]}...")
return enhanced
except Exception as e:
logger.error(f"Enhancement error: {e}")
# Fallback if Claude fails
return f"circular glass Christmas ornament ball depicting {user_prompt}, transparent background, silver hook, festive sparkly"
def moderate_and_enhance(user_prompt: str) -> Tuple[bool, str, str]:
"""
Full moderation pipeline: Guardrails + enhancement.
Returns:
Tuple of (approved: bool, message: str, enhanced_prompt: str)
"""
# Layer 1: Bedrock Guardrails
passed, block_message = apply_guardrails(user_prompt)
if not passed:
return False, block_message, ""
# Layer 2: Context-specific blocklist
blocked_themes = [
"scary", "horror", "creepy", "demon", "devil",
"political", "trump", "biden", "election",
"mickey", "disney", "marvel", "pokemon" # Copyright
]
prompt_lower = user_prompt.lower()
for theme in blocked_themes:
if theme in prompt_lower:
return False, "Please keep your ornament festive and family-friendly!", ""
# Layer 3: Enhance with Claude
enhanced = enhance_prompt(user_prompt)
return True, "", enhanced
Nova Canvas Integration
The image generation is now simple—it receives a pre-sanitized, enhanced prompt:
# src/shared/nova.py
"""Nova Canvas image generation for ornaments."""
import boto3
import base64
import json
import logging
from typing import Optional
logger = logging.getLogger()
bedrock = boto3.client("bedrock-runtime", region_name="us-east-1")
def generate_ornament(enhanced_prompt: str) -> Optional[bytes]:
"""
Generate an ornament image using Nova Canvas.
Args:
enhanced_prompt: Pre-processed prompt from moderation layer
Returns:
PNG image bytes or None if generation failed
"""
logger.info(f"Generating: {enhanced_prompt[:100]}...")
body = json.dumps({
"taskType": "TEXT_IMAGE",
"textToImageParams": {
"text": enhanced_prompt,
},
"imageGenerationConfig": {
"numberOfImages": 1,
"height": 512,
"width": 512,
"quality": "standard",
}
})
try:
response = bedrock.invoke_model(
modelId="amazon.nova-canvas-v1:0",
contentType="application/json",
accept="application/json",
body=body
)
result = json.loads(response["body"].read())
if result.get("images"):
logger.info("Image generated successfully")
return base64.b64decode(result["images"][0])
except Exception as e:
logger.error(f"Nova Canvas error: {e}")
return None
The Lambda Handler
The handler orchestrates the full flow:
# src/generate.py
"""Lambda handler for POST /ornaments"""
import json
import logging
from shared.nova import generate_ornament
from shared.storage import save_ornament
from shared.rate_limit import check_rate_limit, increment_count
from shared.moderation import moderate_and_enhance
logger = logging.getLogger()
logger.setLevel(logging.INFO)
def handler(event, context):
"""Handle POST /ornaments - Create new ornament."""
# Get client IP
ip_address = (
event.get("requestContext", {}).get("http", {}).get("sourceIp") or
"unknown"
)
# Check rate limit
allowed, remaining = check_rate_limit(ip_address)
if not allowed:
return error_response(429, "You've created 3 ornaments today. Come back tomorrow!")
# Parse request
try:
body = json.loads(event.get("body", "{}"))
prompt = body.get("prompt", "").strip()
except json.JSONDecodeError:
return error_response(400, "Invalid JSON body")
if not prompt:
return error_response(400, "Prompt is required")
if len(prompt) > 200:
return error_response(400, "Prompt must be 200 characters or less")
logger.info(f"User prompt: {prompt}")
# === SECURITY: Moderate and enhance ===
approved, rejection_message, enhanced_prompt = moderate_and_enhance(prompt)
if not approved:
logger.info(f"Rejected: {rejection_message}")
return error_response(400, rejection_message)
logger.info(f"Enhanced: {enhanced_prompt[:100]}...")
# Generate image with enhanced prompt
image_bytes = generate_ornament(enhanced_prompt)
if not image_bytes:
return error_response(500, "Failed to generate ornament. Please try again.")
# Save (store original prompt for display)
ornament = save_ornament(image_bytes, prompt, ip_address)
increment_count(ip_address)
return {
"statusCode": 201,
"headers": cors_headers(),
"body": json.dumps({
"ornament": ornament,
"remaining": remaining - 1
})
}
def cors_headers():
return {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type"
}
def error_response(status_code: int, message: str):
return {
"statusCode": status_code,
"headers": cors_headers(),
"body": json.dumps({"error": message})
}
Rate Limiting
Protect against abuse with IP-based rate limiting using DynamoDB TTL:
# src/shared/rate_limit.py
import boto3
import os
import logging
from datetime import datetime, timedelta
logger = logging.getLogger()
dynamodb = boto3.resource("dynamodb")
TABLE_NAME = os.environ.get("RATE_LIMITS_TABLE")
table = dynamodb.Table(TABLE_NAME)
DAILY_LIMIT = 3
TTL_HOURS = 24
def check_rate_limit(ip_address: str) -> tuple[bool, int]:
"""Check if IP is within rate limits."""
response = table.get_item(Key={"ip_address": ip_address})
item = response.get("Item", {})
count = int(item.get("count", 0))
remaining = max(0, DAILY_LIMIT - count)
allowed = count < DAILY_LIMIT
return allowed, remaining
def increment_count(ip_address: str) -> None:
"""Increment count with 24-hour TTL."""
ttl = int((datetime.now() + timedelta(hours=TTL_HOURS)).timestamp())
table.update_item(
Key={"ip_address": ip_address},
UpdateExpression="SET #count = if_not_exists(#count, :zero) + :one, #ttl = :ttl",
ExpressionAttributeNames={"#count": "count", "#ttl": "ttl"},
ExpressionAttributeValues={":zero": 0, ":one": 1, ":ttl": ttl}
)
SAM Template
The complete infrastructure-as-code:
# template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Christmas Tree Ornament Generator API
Globals:
Function:
Timeout: 60
Runtime: python3.12
MemorySize: 512
Resources:
OrnamentsBucket:
Type: AWS::S3::Bucket
Properties:
PublicAccessBlockConfiguration:
BlockPublicAcls: false
BlockPublicPolicy: false
CorsConfiguration:
CorsRules:
- AllowedHeaders: ['*']
AllowedMethods: [GET]
AllowedOrigins: ['*']
OrnamentsBucketPolicy:
Type: AWS::S3::BucketPolicy
Properties:
Bucket: !Ref OrnamentsBucket
PolicyDocument:
Statement:
- Effect: Allow
Principal: '*'
Action: 's3:GetObject'
Resource: !Sub '${OrnamentsBucket.Arn}/ornaments/*'
OrnamentsTable:
Type: AWS::DynamoDB::Table
Properties:
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- AttributeName: ornament_id
AttributeType: S
KeySchema:
- AttributeName: ornament_id
KeyType: HASH
RateLimitsTable:
Type: AWS::DynamoDB::Table
Properties:
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- AttributeName: ip_address
AttributeType: S
KeySchema:
- AttributeName: ip_address
KeyType: HASH
TimeToLiveSpecification:
AttributeName: ttl
Enabled: true
HttpApi:
Type: AWS::Serverless::HttpApi
Properties:
CorsConfiguration:
AllowOrigins: ['*']
AllowMethods: [GET, POST, OPTIONS]
AllowHeaders: [Content-Type]
GenerateOrnamentFunction:
Type: AWS::Serverless::Function
Properties:
Handler: generate.handler
CodeUri: src/
Timeout: 90
MemorySize: 1024
Environment:
Variables:
BUCKET_NAME: !Ref OrnamentsBucket
ORNAMENTS_TABLE: !Ref OrnamentsTable
RATE_LIMITS_TABLE: !Ref RateLimitsTable
GUARDRAIL_ID: "YOUR_GUARDRAIL_ID" # Update this!
GUARDRAIL_VERSION: "DRAFT"
Policies:
- DynamoDBCrudPolicy:
TableName: !Ref OrnamentsTable
- DynamoDBCrudPolicy:
TableName: !Ref RateLimitsTable
- S3CrudPolicy:
BucketName: !Ref OrnamentsBucket
- Statement:
- Effect: Allow
Action: bedrock:InvokeModel
Resource:
- 'arn:aws:bedrock:*::foundation-model/amazon.nova-canvas-v1:0'
- 'arn:aws:bedrock:*:*:inference-profile/global.anthropic.claude-haiku-4-5-*'
- Effect: Allow
Action: bedrock:ApplyGuardrail
Resource: 'arn:aws:bedrock:*:*:guardrail/*'
Events:
Api:
Type: HttpApi
Properties:
ApiId: !Ref HttpApi
Path: /ornaments
Method: POST
ListOrnamentsFunction:
Type: AWS::Serverless::Function
Properties:
Handler: list_ornaments.handler
CodeUri: src/
Environment:
Variables:
ORNAMENTS_TABLE: !Ref OrnamentsTable
Policies:
- DynamoDBReadPolicy:
TableName: !Ref OrnamentsTable
Events:
Api:
Type: HttpApi
Properties:
ApiId: !Ref HttpApi
Path: /ornaments
Method: GET
Outputs:
ApiEndpoint:
Value: !Sub 'https://${HttpApi}.execute-api.${AWS::Region}.amazonaws.com'
Deploying
# Build and deploy
sam build
sam deploy --guided --capabilities CAPABILITY_IAM --region us-east-1
During --guided deployment, accept the defaults. After deployment completes, note your API endpoint from the outputs—you’ll need it for your frontend.
Update GUARDRAIL_ID in template.yaml with your guardrail ID from the earlier step, then redeploy:
sam deploy
Cost Considerations
| Service | Cost | Notes |
|---|---|---|
| Nova Canvas | ~$0.04/image | 512x512 standard |
| Claude Haiku 4.5 | ~$0.001/request | Prompt enhancement |
| Bedrock Guardrails | ~$0.001/request | Content filtering |
| Lambda | ~$0.20/1M requests | Minimal |
| DynamoDB | ~$1.25/1M writes | On-demand |
For ~10 ornaments/day: under $15/month. At 100/day, expect ~$130/month (dominated by Nova Canvas at $0.04/image).
Testing the Security
Try these prompts to verify the moderation works:
| Prompt | Expected Result |
|---|---|
| ”A snowman with a red scarf” | ✅ Approved, enhanced, generated |
| ”Golden star with silver glitter” | ✅ Approved |
| ”Something violent and scary” | ❌ Blocked by Guardrails |
| ”A creepy demon ornament” | ❌ Blocked by context filter |
| ”Mickey Mouse ornament” | ❌ Blocked (copyright) |
What’s Next
You’ve built a production-ready AI image generation API with:
- Bedrock Guardrails for fast content filtering
- Claude Haiku for intelligent prompt enhancement
- Defense in depth security architecture
- Rate limiting to prevent abuse
Ideas to extend it:
- Add image moderation on the output (Rekognition)
- Implement voting/likes for ornaments
- Add user authentication for persistent profiles
- Create shareable tree snapshots
More agent tutorials:
- Fishing Report Agent — NOAA tides, weather, and marine conditions
- Holiday Cocktail Agent — Weather-aware drink suggestions with TheCocktailDB
See the live demo at www.oldbluechair.com/christmas-tree and add your own ornament!
Happy holidays! 🎄
Comments
to join the discussion.