Build a Community Christmas Tree with AI-Generated Ornaments

Open Seas 35 min read December 22, 2025 |
0

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:

  1. Moderate user input using Bedrock Guardrails (blocks harmful content)
  2. Enhance prompts with Claude Haiku 4.5 (ensures quality ornament images)
  3. Generate ornament images using Amazon Nova Canvas
  4. Rate limit to 3 ornaments per IP per day
  5. 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:

  1. Navigate to Amazon BedrockGuardrailsCreate guardrail
  2. Name it prompt_moderation
  3. Configure content filters with HIGH strength for:
    • Hate
    • Insults
    • Sexual
    • Violence
    • Misconduct
  4. Set blocked message: “Please keep your ornament description family-friendly”
  5. Create the guardrail and note the Guardrail ID
Output

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

ServiceCostNotes
Nova Canvas~$0.04/image512x512 standard
Claude Haiku 4.5~$0.001/requestPrompt enhancement
Bedrock Guardrails~$0.001/requestContent filtering
Lambda~$0.20/1M requestsMinimal
DynamoDB~$1.25/1M writesOn-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:

PromptExpected 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:

See the live demo at www.oldbluechair.com/christmas-tree and add your own ornament!

Happy holidays! 🎄

Found this helpful?
0

Comments

Loading comments...