Build an Intelligent AI DevOps Copilot

Build an AI copilot that answers questions about your infrastructure health.

Introduction

30 Second Summary

Imagine being able to ask your cloud infrastructure "what happened while I was away?" and getting a plain English answer. That's the difference between a reactive system that fixes problems and an intelligent one that understands them.

In this project, you will build an AI-powered DevOps Copilot that sits on top of your existing self-healing infrastructure pipeline. Your infrastructure will be able to answer questions about its own health, incidents, and remediation history in natural language.

What You'll Build

A complete AI DevOps Copilot system powered by Amazon Bedrock's Nova Micro model, with a REST API, incident memory layer, automated weekly digests, and a dark-themed chat dashboard.

By the end of this project, you'll have:

  • A Copilot Lambda powered by the Bedrock Converse API that collects live infrastructure context and turns raw data into AI-powered, human-readable analysis.
  • A DynamoDB incident memory layer with automatic severity scoring and pattern detection that gives your copilot long-term recall across days and weeks.
  • A weekly digest automation using EventBridge Scheduler and SNS that emails AI-generated operational reports every Monday.
  • Secret Mission: Build an enhanced ops console with a severity-coded incident timeline, analysis mode selector, and on-demand digest generation.

Are there any prerequisites?

Yes. This project builds on top of a self-healing infrastructure pipeline that you should already have deployed and running in us-east-1. You need an EC2 instance with Nginx, a CloudWatch CPU alarm, an SNS notification topic, and Systems Manager auto-remediation already in place.

You also need the AWS CLI configured and Amazon Bedrock model access enabled for Amazon Nova Micro.

Set Up IAM Roles and Enable Bedrock Access

Your self-healing pipeline already detects problems and fixes them automatically. But it has no memory and no voice.

In this step, you will create two IAM execution roles that grant your Lambda functions permission to access the AWS services they need. You will also confirm that Amazon Bedrock model access is enabled for Amazon Nova Micro, the AI model that powers your copilot.

In this step, get ready to:
  • Create a Copilot Lambda execution role with permissions for Bedrock, DynamoDB, CloudWatch, EC2, SSM, Logs, and SNS.
  • Create a Remediation Lambda execution role with permissions for DynamoDB, Logs, and SNS.
  • Verify Amazon Bedrock model access for Amazon Nova Micro in us-east-1.
Create the Copilot Lambda execution role

An execution role is an IAM role that grants a Lambda function permission to access AWS services on your behalf. The Copilot Lambda needs broad read access across your infrastructure.

  • In the AWS Management Console, search for IAM in the top search bar and open it.
  • In the left navigation pane, click Policies.
  • Click Create policy.
  • Select the JSON tab in the policy editor.
  • Delete any existing content in the editor, then paste the following policy:
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "bedrock:InvokeModel"
            ],
            "Resource": "*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "dynamodb:GetItem",
                "dynamodb:PutItem",
                "dynamodb:Query",
                "dynamodb:Scan"
            ],
            "Resource": "*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "cloudwatch:DescribeAlarmHistory",
                "cloudwatch:DescribeAlarms"
            ],
            "Resource": "*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "ec2:DescribeInstanceStatus"
            ],
            "Resource": "*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "ssm:DescribeAutomationExecutions"
            ],
            "Resource": "*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogGroup",
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": "*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "sns:Publish"
            ],
            "Resource": "*"
        }
    ]
}

What does this policy do?

This policy grants seven groups of permissions. Each group maps to a service your copilot calls at runtime:

  • bedrock:InvokeModel lets the Lambda send prompts to Amazon Nova Micro via the Converse API.
  • dynamodb:GetItem/PutItem/Query/Scan lets it read and write incident records.
  • cloudwatch:DescribeAlarmHistory/DescribeAlarms lets it scrape alarm state changes as live context.
  • ec2:DescribeInstanceStatus lets it check instance health.
  • ssm:DescribeAutomationExecutions lets it pull remediation history from Systems Manager.
  • logs:CreateLogGroup/CreateLogStream/PutLogEvents lets it write its own execution logs to CloudWatch Logs.
  • sns:Publish lets it send digest emails via your existing SNS topic.
  • Click Next.
  • For Policy name, enter DevOpsCopilotPolicy.
  • Click Create policy.

Now create the role and attach this policy to it.

  • In the left navigation pane, click Roles.
  • Click Create role.
  • For Trusted entity type, choose AWS service.
  • For the use case, choose Lambda.
  • Click Next.
  • In the policy search box, type DevOpsCopilotPolicy.
  • Select the checkbox next to DevOpsCopilotPolicy in the results.
  • Click Next.
  • For Role name, enter DevOpsCopilotLambdaRole.
  • Click Create role.

You should see a green banner confirming the role was created successfully.

Don't see your policy in the search results?

Make sure you search for exactly DevOpsCopilotPolicy (case-sensitive). If it still does not appear, go back to Policies in the left navigation and confirm the policy exists. Policy creation can take a few seconds to propagate.

help me find my IAM policy when searching during role creation

Create the Remediation Lambda execution role

The Remediation Lambda has a narrower job: it receives SNS notifications, scores severity, and writes incident records to DynamoDB. It needs fewer permissions than the Copilot Lambda.

  • In the left navigation pane, click Policies.
  • Click Create policy.
  • Select the JSON tab.
  • Delete any existing content, then paste the following policy:
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "dynamodb:PutItem"
            ],
            "Resource": "*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogGroup",
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": "*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "sns:Publish"
            ],
            "Resource": "*"
        }
    ]
}

What does this policy do?

This policy is deliberately minimal. It only grants three groups of permissions:

  • dynamodb:PutItem to write incident records (no read access needed).
  • logs:CreateLogGroup/CreateLogStream/PutLogEvents for CloudWatch Logs.
  • sns:Publish to notify when a critical incident is captured.
  • Click Next.
  • For Policy name, enter RemediationPolicy.
  • Click Create policy.

Now create the role.

  • In the left navigation pane, click Roles.
  • Click Create role.
  • For Trusted entity type, choose AWS service.
  • For the use case, choose Lambda.
  • Click Next.
  • In the policy search box, type RemediationPolicy.
  • Select the checkbox next to RemediationPolicy in the results.
  • Click Next.
  • For Role name, enter RemediationLambdaRole.
  • Click Create role.

You should see a green banner confirming the role was created.

Why two separate roles?

Each Lambda function gets only the permissions it needs. This is the principle of least privilege. If the Remediation Lambda were compromised, an attacker could only write to DynamoDB. They could not invoke Bedrock or read CloudWatch alarms.

Verify Bedrock model access for Amazon Nova Micro

Amazon Nova Micro is an AWS first-party model. Access to all Amazon Bedrock foundation models is enabled by default in commercial AWS regions. You need to confirm that this access is active in your account for the us-east-1 region.

  • In the AWS Console search bar, type Amazon Bedrock and open it.
  • Confirm you are in the US East (N. Virginia) region. You can check the region selector in the top right of the console.
  • In the left navigation pane, under Bedrock configurations, click Model access.
  • Look for Amazon Nova Micro in the base models list. Its status should show Access granted.

✔️ I see Access granted for Nova Micro

You are all set. Your Lambda functions will be able to call the Bedrock Converse API with model ID amazon.nova-micro-v1:0.

ⓧ I see a different status or can't find Nova Micro

If Nova Micro does not show Access granted, you need to enable it.

  • On the Model access page, click Enable specific models.
  • Select Amazon Nova Micro from the base models list.
  • Click Next.
  • On the Review and submit page, click Submit.
  • Refresh the page. After a few moments, the status should change to Access granted.

Since Amazon Nova is an AWS first-party model, you do not need to accept any third-party license agreements. Enablement is instant.

Why Amazon Nova Micro?

Amazon Nova Micro is AWS's fastest and cheapest text-only model. It is optimized for speed and low cost in summarization, translation, and classification. For a DevOps copilot that processes short incident summaries and severity classifications, it is an ideal fit.

Because it is an AWS-native model, there is no third-party model access enablement required. The model ID is amazon.nova-micro-v1:0.

Your permissions foundation is in place. Next up, you will create the DynamoDB table that gives your copilot long-term memory for incident history.

Create the DynamoDB Incident Memory Table

Your IAM roles are configured and Bedrock access is confirmed. Now your copilot needs a place to remember what happened.

Without persistent memory, the copilot can only see live data. It has no way to detect patterns like repeat incidents or escalating severity. In this step, you'll create a DynamoDB table that stores structured incident records so the copilot can recall historical context across days and weeks.

In this step, get ready to:
  • Create the DynamoDB table with partition key and on-demand capacity.
  • Add a Global Secondary Index for querying incidents by instance.
Create the DynamoDB table

The DevOpsCopilotIncidents table stores one record per auto-remediation event. Each record gets a unique incident_id as its partition key, which means DynamoDB can look up any single incident instantly.

  • Return to the AWS Management Console in your browser.
  • Confirm your region is set to US East (N. Virginia) us-east-1 in the top-right corner.
  • In the search bar at the top, type DynamoDB and select DynamoDB from the results.
  • In the left navigation pane, choose Tables.
  • Click Create table.
  • For Table name, enter DevOpsCopilotIncidents.
  • For Partition key, enter incident_id and leave the type as String.
  • Leave the Sort key field empty.
  • In Table settings, select Customize settings.
  • Under Read/write capacity settings, select On-demand.

Why on-demand capacity?

On-demand mode means DynamoDB automatically scales to handle whatever traffic you throw at it. You only pay for the reads and writes you actually use.

For a learning project with unpredictable traffic (you might trigger 10 incidents in testing, then nothing for days), on-demand is the most cost-efficient choice.

Add the Global Secondary Index

The partition key incident_id lets you look up a single incident. But the copilot also needs to ask: "What happened to instance i-abc123 this week?" A Global Secondary Index on instance_id makes that query fast.

  • Scroll down to the Secondary indexes section on the same Create table page.
  • Click Create global index.
  • For Partition key, enter instance_id and leave the type as String.
  • For Index name, enter instance_id-index.
  • Leave the Sort key empty.
  • For Attribute projections, keep the default selection of All.
  • Click Create index.

What is a Global Secondary Index?

A GSI is like building a second lookup table on top of your data. It copies all your items into a new arrangement organized by a different key. In this case, the GSI groups all incidents by which EC2 instance they affected.

The "All" projection means every attribute from the main table gets copied into the index. This lets the copilot pull full incident details when querying by instance, without needing a second lookup.

  • Scroll to the bottom of the page and click Create table.
  • Wait for the table status to change to Active. This usually takes 10-20 seconds.

Table stuck in Creating status?

DynamoDB tables with indexes can take slightly longer to create. If the status hasn't changed after a minute, click the refresh icon in the table list. If you see an error, confirm you're in the us-east-1 region and that the table name DevOpsCopilotIncidents doesn't already exist.

Help me troubleshoot my DynamoDB table creation

  • Click the table name DevOpsCopilotIncidents to open its details.
  • Select the Indexes tab and confirm you see instance_id-index with status Active.

Your incident memory layer is ready. Next up, you'll build the Remediation Lambda that captures incidents from your self-healing pipeline and writes them into this table with automatic severity scoring.

Build the Remediation Lambda

Your DynamoDB table is ready to receive incident records. Now you need a Lambda function that listens for auto-remediation events and captures them into that table.

The Remediation Lambda sits between your self-healing pipeline and DynamoDB. When your CloudWatch alarm fires and triggers auto-remediation, this function captures the event, scores its severity based on how often that instance has had problems recently, and writes a structured record to the DevOpsCopilotIncidents table.

In this step, get ready to:
  • Create the Remediation Lambda function with Python 3.13 runtime.
  • Implement severity scoring logic based on incident frequency and time patterns.
  • Store structured incident records in DynamoDB.
Create the Remediation Lambda function

You need a Lambda function that receives SNS notifications from your self-healing pipeline. The function will use the Python 3.13 runtime, which includes Boto3 (the AWS SDK for Python) out of the box.

  • Open the Functions page of the Lambda console.
  • Click Create function.
  • Select Author from scratch.
  • For Function name, enter RemediationCaptureFunction.
  • For Runtime, choose Python 3.13.
  • Expand Change default execution role.
  • Select Use an existing role.
  • From the Existing role dropdown, choose RemediationLambdaRole.
  • Click Create function.

You should see a success banner and the function's code editor with a default lambda_function.py file.

Why Python 3.13?

The python3.13 runtime runs on Amazon Linux 2023 and includes Boto3 in the runtime. This means you can call DynamoDB and other AWS services directly without packaging any external dependencies.

  • Before writing code, increase the function timeout. Click the Configuration tab.
  • Click General configuration, then click Edit.
  • Set Timeout to 30 seconds.
  • Click Save.

Why increase the timeout?

The default Lambda timeout is 3 seconds. This function queries DynamoDB for historical incidents before writing a new record, which can take a few seconds depending on network latency. Setting it to 30 seconds provides comfortable headroom.

Don't see RemediationLambdaRole in the dropdown?

Make sure you are in the us-east-1 region. IAM roles are global, but if your console switched regions, the role list may not load correctly. Also confirm the role was created in Step 1 with a trust policy for lambda.amazonaws.com.

Help me find my RemediationLambdaRole in the Lambda console.

Implement the severity scoring logic

The severity scoring function queries DynamoDB for previous incidents on the same instance, then assigns a severity level based on how frequently that instance has had problems.

  • Return to the Code tab in your function.
  • Select all the default code in lambda_function.py and delete it.
  • Paste the following imports, initialization, and severity scoring function:
import json
import uuid
import boto3
from datetime import datetime, timedelta, timezone

# Initialize DynamoDB resource
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('DevOpsCopilotIncidents')

def calculate_severity(instance_id):
    """Score severity based on incident frequency and time patterns."""
    now = datetime.now(timezone.utc)
    one_hour_ago = (now - timedelta(hours=1)).isoformat()
    four_hours_ago = (now - timedelta(hours=4)).isoformat()
    twenty_four_hours_ago = (now - timedelta(hours=24)).isoformat()

    # Query all incidents for this instance using the GSI
    response = table.query(
        IndexName='instance_id-index',
        KeyConditionExpression='instance_id = :iid',
        ExpressionAttributeValues={
            ':iid': instance_id
        }
    )

    incidents = response.get('Items', [])

    # Filter to incidents within each time window
    recent = [i for i in incidents if i.get('timestamp', '') > twenty_four_hours_ago]
    last_hour = [i for i in recent if i.get('timestamp', '') > one_hour_ago]
    last_four_hours = [i for i in recent if i.get('timestamp', '') > four_hours_ago]

    # Apply severity scoring rules
    if len(last_hour) >= 3:
        return 'critical'
    elif len(last_four_hours) >= 2:
        return 'high'
    elif len(recent) > 0:
        return 'medium'
    else:
        return 'low'

What does this code do?

The imports bring in Boto3 for AWS calls, uuid for generating unique incident IDs, and datetime for time-based severity calculations.

The calculate_severity function queries the instance_id-index GSI to find all past incidents for a given instance. It then counts how many fall within 1 hour, 4 hours, and 24 hours to determine severity:

  • Critical: 3+ incidents on the same instance in the last hour.
  • High: 2+ incidents in the last 4 hours.
  • Medium: any repeat incident in the last 24 hours.
  • Low: first incident on this instance in 24 hours.

You will not see a change yet. The next chunk adds the handler that calls this function and stores incident records.

Add the event handler and deploy

The handler function is the entry point that Lambda calls when an SNS notification arrives. It parses the alarm event, calls the severity scorer, and writes the structured incident to DynamoDB.

  • Below the calculate_severity function, paste the following handler code:
def lambda_handler(event, context):
    """Process SNS notifications from the self-healing pipeline."""
    for record in event['Records']:
        # Parse the SNS message
        sns_message = json.loads(record['Sns']['Message'])

        # Extract incident details from the CloudWatch alarm notification
        alarm_name = sns_message.get('AlarmName', 'Unknown')
        instance_id = sns_message.get('Trigger', {}).get('Dimensions', [{}])[0].get('value', 'Unknown')

        # Calculate severity based on incident history
        severity = calculate_severity(instance_id)

        # Create structured incident record
        incident = {
            'incident_id': str(uuid.uuid4()),
            'timestamp': datetime.now(timezone.utc).isoformat(),
            'instance_id': instance_id,
            'alarm_name': alarm_name,
            'action_taken': 'Auto-remediation triggered',
            'severity_score': severity
        }

        # Store the incident in DynamoDB
        table.put_item(Item=incident)

        print(f"Captured incident: {incident['incident_id']} | Severity: {severity}")

    return {
        'statusCode': 200,
        'body': json.dumps({'message': 'Incidents captured successfully'})
    }

What does this handler do?

The lambda_handler function loops through each SNS record in the incoming event. For each record it:

  • Parses the JSON message body from the SNS notification.
  • Extracts the AlarmName and the instance ID from the alarm's dimensions.
  • Calls calculate_severity to score the incident based on historical frequency.
  • Writes a structured record with a UUID, ISO 8601 timestamp, instance ID, alarm name, action taken, and severity score.
  • Click Deploy in the code editor toolbar to save and deploy your function.

You should see a green banner confirming the function was updated successfully.

Seeing a deployment error?

Check for indentation issues. Python is whitespace-sensitive, so make sure the lambda_handler function is at the top level (not nested inside calculate_severity). Also confirm that you deleted all of the default boilerplate before pasting.

Help me fix my Lambda deployment error.

✔️ Awesome, I've got everything!

Great. Double check you have clicked Deploy and seen the green success banner.

ⓧ I'd like to double check the full code

Here is the complete lambda_function.py for comparison:

import json
import uuid
import boto3
from datetime import datetime, timedelta, timezone

# Initialize DynamoDB resource
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('DevOpsCopilotIncidents')

def calculate_severity(instance_id):
    """Score severity based on incident frequency and time patterns."""
    now = datetime.now(timezone.utc)
    one_hour_ago = (now - timedelta(hours=1)).isoformat()
    four_hours_ago = (now - timedelta(hours=4)).isoformat()
    twenty_four_hours_ago = (now - timedelta(hours=24)).isoformat()

    # Query all incidents for this instance using the GSI
    response = table.query(
        IndexName='instance_id-index',
        KeyConditionExpression='instance_id = :iid',
        ExpressionAttributeValues={
            ':iid': instance_id
        }
    )

    incidents = response.get('Items', [])

    # Filter to incidents within each time window
    recent = [i for i in incidents if i.get('timestamp', '') > twenty_four_hours_ago]
    last_hour = [i for i in recent if i.get('timestamp', '') > one_hour_ago]
    last_four_hours = [i for i in recent if i.get('timestamp', '') > four_hours_ago]

    # Apply severity scoring rules
    if len(last_hour) >= 3:
        return 'critical'
    elif len(last_four_hours) >= 2:
        return 'high'
    elif len(recent) > 0:
        return 'medium'
    else:
        return 'low'

def lambda_handler(event, context):
    """Process SNS notifications from the self-healing pipeline."""
    for record in event['Records']:
        # Parse the SNS message
        sns_message = json.loads(record['Sns']['Message'])

        # Extract incident details from the CloudWatch alarm notification
        alarm_name = sns_message.get('AlarmName', 'Unknown')
        instance_id = sns_message.get('Trigger', {}).get('Dimensions', [{}])[0].get('value', 'Unknown')

        # Calculate severity based on incident history
        severity = calculate_severity(instance_id)

        # Create structured incident record
        incident = {
            'incident_id': str(uuid.uuid4()),
            'timestamp': datetime.now(timezone.utc).isoformat(),
            'instance_id': instance_id,
            'alarm_name': alarm_name,
            'action_taken': 'Auto-remediation triggered',
            'severity_score': severity
        }

        # Store the incident in DynamoDB
        table.put_item(Item=incident)

        print(f"Captured incident: {incident['incident_id']} | Severity: {severity}")

    return {
        'statusCode': 200,
        'body': json.dumps({'message': 'Incidents captured successfully'})
    }

Your Remediation Lambda is deployed and ready to capture incidents. Next up, you will build the Copilot Lambda that uses AI to turn raw infrastructure data into human-readable analysis.

Build the Copilot Lambda with Log Collector and Bedrock

Your Remediation Lambda is now capturing incidents and scoring their severity. The next piece is the AI brain itself.

In this step, you will build the Copilot Lambda. This function collects live infrastructure data from CloudWatch, EC2, and Systems Manager, then feeds that context into the Bedrock Converse API with Amazon Nova Micro to produce human-readable analysis. It also exposes multiple modes so you can ask questions, retrieve incidents, or generate a weekly digest.

In this step, get ready to:
  • Create the Copilot Lambda function with a built-in log collector that scrapes CloudWatch, EC2, and SSM data.
  • Integrate the Bedrock Converse API with Amazon Nova Micro for AI-powered analysis.
  • Add multi-mode routing for /ask, /incidents, and /digest endpoints.
Create the Lambda function and build the log collector

The log collector is what gives your copilot real context about your infrastructure. Without it, the AI would just be guessing.

  • Return to the Lambda console in AWS.
  • Click Create function.
  • Select Author from scratch.
  • For Function name, enter DevOpsCopilotFunction.
  • For Runtime, select Python 3.13.
  • Expand Change default execution role, select Use an existing role, and choose DevOpsCopilotLambdaRole.
  • Click Create function.

Why Python 3.13?

The python3.13 runtime includes Boto3 (the AWS SDK for Python) out of the box. This means you can call Bedrock, CloudWatch, EC2, SSM, and DynamoDB without packaging any extra dependencies.

The Copilot Lambda will call Bedrock, which can take several seconds to respond. The default Lambda timeout of 3 seconds is too short.

  • Click the Configuration tab.
  • Click General configuration in the left sidebar.
  • Click Edit.
  • Change Timeout to 30 seconds.
  • Click Save.

Now switch back to the Code tab. You will replace the default code with your log collector and AI integration.

  • Select all the existing code in lambda_function.py and delete it.
  • Paste in the following imports, client setup, and log collector functions:
import json
import boto3
from datetime import datetime, timedelta, timezone

# Initialize AWS service clients
bedrock = boto3.client('bedrock-runtime', region_name='us-east-1')
cloudwatch = boto3.client('cloudwatch', region_name='us-east-1')
ec2 = boto3.client('ec2', region_name='us-east-1')
ssm = boto3.client('ssm', region_name='us-east-1')
dynamodb = boto3.resource('dynamodb', region_name='us-east-1')
sns = boto3.client('sns', region_name='us-east-1')

# DynamoDB table reference for incident memory
incidents_table = dynamodb.Table('DevOpsCopilotIncidents')

# Bedrock model configuration
MODEL_ID = 'amazon.nova-micro-v1:0'


def collect_infrastructure_logs():
    """Scrape live infrastructure context from AWS services."""
    now = datetime.now(timezone.utc)
    start_time = now - timedelta(hours=24)

    # Collect CloudWatch alarm history (state changes in last 24h)
    alarm_history = cloudwatch.describe_alarm_history(
        AlarmTypes=['MetricAlarm'],
        HistoryItemType='StateUpdate',
        StartDate=start_time,
        EndDate=now,
        MaxRecords=50
    )

    # Collect EC2 instance status (current health of all instances)
    instance_status = ec2.describe_instance_status(
        IncludeAllInstances=True
    )

    # Collect SSM automation executions (recent remediation actions)
    automation_executions = ssm.describe_automation_executions(
        Filters=[
            {
                'Key': 'StartTimeBefore',
                'Values': [now.isoformat()]
            },
            {
                'Key': 'StartTimeAfter',
                'Values': [start_time.isoformat()]
            }
        ]
    )

    return {
        'alarm_history': alarm_history.get('AlarmHistoryItems', []),
        'instance_status': instance_status.get('InstanceStatuses', []),
        'automation_executions': automation_executions.get('AutomationExecutionMetadataList', [])
    }


def format_infrastructure_context(logs):
    """Format raw logs into a structured prompt for the AI."""
    context_parts = []

    context_parts.append("=== CLOUDWATCH ALARM HISTORY (Last 24h) ===")
    for item in logs['alarm_history'][:10]:
        context_parts.append(
            f"- Alarm: {item.get('AlarmName', 'Unknown')} | "
            f"Time: {item.get('Timestamp', 'N/A')} | "
            f"Summary: {item.get('HistorySummary', 'N/A')}"
        )

    context_parts.append("\n=== EC2 INSTANCE STATUS ===")
    for status in logs['instance_status'][:10]:
        instance_id = status.get('InstanceId', 'Unknown')
        state = status.get('InstanceState', {}).get('Name', 'Unknown')
        system_status = status.get('SystemStatus', {}).get('Status', 'Unknown')
        context_parts.append(
            f"- Instance: {instance_id} | State: {state} | System: {system_status}"
        )

    context_parts.append("\n=== SSM AUTOMATION EXECUTIONS (Last 24h) ===")
    for exe in logs['automation_executions'][:10]:
        context_parts.append(
            f"- Document: {exe.get('DocumentName', 'Unknown')} | "
            f"Status: {exe.get('AutomationExecutionStatus', 'Unknown')} | "
            f"Time: {exe.get('ExecutionStartTime', 'N/A')}"
        )

    return "\n".join(context_parts)

What does this code do?

This section sets up the foundation of your copilot with two key parts:

  • collect_infrastructure_logs() calls three AWS APIs: describe_alarm_history for CloudWatch alarm state changes, describe_instance_status for EC2 health, and describe_automation_executions for SSM remediation logs.
  • format_infrastructure_context() takes the raw API responses and formats them into a human-readable text block. This formatted text becomes the context that Nova Micro reads to understand your infrastructure.
Integrate the Bedrock Converse API with Nova Micro

AWS recommends the Converse API over the older invoke_model approach because it unifies the inference request across all Bedrock models. Your copilot uses it to send infrastructure context plus the user's question to Nova Micro and get back plain-English analysis.

  • Below the format_infrastructure_context function, paste the following Bedrock integration code:

def get_system_prompt(mode):
    """Return the system prompt for a given analysis mode."""
    prompts = {
        'summary': (
            "You are a DevOps copilot. Summarize the infrastructure events "
            "concisely. Focus on what happened, when, and what was done about it. "
            "Use plain English."
        ),
        'root_cause': (
            "You are a DevOps copilot specializing in root cause analysis. "
            "Analyze the infrastructure data and identify probable root causes "
            "for any incidents. Explain your reasoning step by step."
        ),
        'prevention': (
            "You are a DevOps copilot focused on preventive recommendations. "
            "Based on the infrastructure data, suggest specific actions to prevent "
            "future incidents. Be actionable and specific."
        ),
        'repeat_offenders': (
            "You are a DevOps copilot specializing in pattern detection. "
            "Identify any repeated incidents, recurring failures, or problem "
            "instances. Highlight patterns and trends."
        )
    }
    return prompts.get(mode, prompts['summary'])


def ask_bedrock(question, infrastructure_context, mode='summary'):
    """Send a question to Bedrock Nova Micro with infrastructure context."""
    system_prompt = get_system_prompt(mode)

    # Combine infrastructure data with the user's question
    user_message = f"""Infrastructure Context:
{infrastructure_context}

Question: {question}"""

    # Call Bedrock Converse API
    response = bedrock.converse(
        modelId=MODEL_ID,
        messages=[
            {
                'role': 'user',
                'content': [{'text': user_message}]
            }
        ],
        system=[{'text': system_prompt}],
        inferenceConfig={'maxTokens': 1024, 'temperature': 0.5, 'topP': 0.9}
    )

    # Parse the AI response
    response_text = response['output']['message']['content'][0]['text']
    token_usage = response.get('usage', {})

    return {
        'answer': response_text,
        'tokens_used': {
            'input': token_usage.get('inputTokens', 0),
            'output': token_usage.get('outputTokens', 0)
        }
    }

What does this code do?

get_system_prompt() defines four specialized AI personalities. Each mode gives Nova Micro a different lens: summarizing events, analyzing root causes, recommending preventive actions, or detecting repeat offenders.

ask_bedrock() uses the Converse API with amazon.nova-micro-v1:0. The system parameter sets the AI's role as a first-class parameter. The response parsing at response['output']['message']['content'][0]['text'] extracts the AI's plain-text answer. Token usage is also tracked for cost awareness.

Why Converse API instead of invoke_model?

AWS recommends the Converse operation over InvokeModel because it unifies the inference request across Amazon Bedrock models. The same code structure works if you later swap Nova Micro for Nova Pro or any other supported model by changing one string.

Converse also provides cleaner response parsing (no raw JSON byte deserialization) and built-in token tracking via response['usage'].

Add endpoint routing for /ask, /incidents, and /digest

The copilot needs to handle three different request types. The /ask endpoint accepts natural language questions. The /incidents endpoint reads stored incidents from DynamoDB. The /digest endpoint generates a full weekly operational report.

  • Below the ask_bedrock function, paste the DynamoDB query, digest generation, and handler routing code:

def get_incidents():
    """Read incident records from DynamoDB."""
    response = incidents_table.scan(Limit=50)
    items = response.get('Items', [])
    # Sort by timestamp descending (most recent first)
    items.sort(key=lambda x: x.get('timestamp', ''), reverse=True)
    return items


def generate_digest():
    """Generate a weekly operational report using Bedrock."""
    # Collect live infrastructure state
    logs = collect_infrastructure_logs()
    infrastructure_context = format_infrastructure_context(logs)

    # Pull incident history from DynamoDB
    incidents = get_incidents()
    incident_summary = (
        f"\n\n=== INCIDENT HISTORY FROM MEMORY ===\n"
        f"Total incidents recorded: {len(incidents)}\n"
    )
    for inc in incidents[:20]:
        incident_summary += (
            f"- {inc.get('timestamp', 'N/A')} | "
            f"Instance: {inc.get('instance_id', 'N/A')} | "
            f"Severity: {inc.get('severity_score', 'N/A')} | "
            f"Action: {inc.get('action_taken', 'N/A')}\n"
        )

    full_context = infrastructure_context + incident_summary

    # Ask Bedrock to generate the digest report
    result = ask_bedrock(
        "Generate a weekly operational digest report. Include: overall health "
        "summary, incident count and severity breakdown, top recurring issues, "
        "and recommended actions for the coming week.",
        full_context,
        mode='summary'
    )

    return result['answer']


def lambda_handler(event, context):
    """Main handler routing requests to the appropriate mode."""
    # Determine the request path and method from API Gateway event
    path = event.get('path', '/ask')
    http_method = event.get('httpMethod', 'POST')

    try:
        # Route: /ask (POST) - Natural language question
        if path == '/ask' and http_method == 'POST':
            body = json.loads(event.get('body', '{}'))
            question = body.get('question', 'What is the current health of my infrastructure?')
            mode = body.get('mode', 'summary')

            # Collect live data and ask the AI
            logs = collect_infrastructure_logs()
            infrastructure_context = format_infrastructure_context(logs)
            result = ask_bedrock(question, infrastructure_context, mode)

            return {
                'statusCode': 200,
                'headers': {
                    'Content-Type': 'application/json',
                    'Access-Control-Allow-Origin': '*'
                },
                'body': json.dumps({
                    'answer': result['answer'],
                    'mode': mode,
                    'tokens_used': result['tokens_used']
                })
            }

        # Route: /incidents (GET) - Read from DynamoDB
        elif path == '/incidents' and http_method == 'GET':
            incidents = get_incidents()
            # Convert Decimal types for JSON serialization
            for inc in incidents:
                for key, value in inc.items():
                    if hasattr(value, '__int__'):
                        inc[key] = str(value)

            return {
                'statusCode': 200,
                'headers': {
                    'Content-Type': 'application/json',
                    'Access-Control-Allow-Origin': '*'
                },
                'body': json.dumps({
                    'incidents': incidents,
                    'count': len(incidents)
                })
            }

        # Route: /digest (GET) - Generate weekly report
        elif path == '/digest' and http_method == 'GET':
            digest = generate_digest()

            return {
                'statusCode': 200,
                'headers': {
                    'Content-Type': 'application/json',
                    'Access-Control-Allow-Origin': '*'
                },
                'body': json.dumps({
                    'digest': digest
                })
            }

        else:
            return {
                'statusCode': 404,
                'headers': {
                    'Content-Type': 'application/json',
                    'Access-Control-Allow-Origin': '*'
                },
                'body': json.dumps({'error': f'Route not found: {http_method} {path}'})
            }

    except Exception as e:
        return {
            'statusCode': 500,
            'headers': {
                'Content-Type': 'application/json',
                'Access-Control-Allow-Origin': '*'
            },
            'body': json.dumps({'error': str(e)})
        }

What does this code do?

get_incidents() reads from the DevOpsCopilotIncidents DynamoDB table and returns incidents sorted by timestamp.

generate_digest() combines live infrastructure data with historical incidents from DynamoDB, then asks Bedrock to generate a comprehensive weekly report.

lambda_handler() routes incoming API Gateway requests based on the path and HTTP method. Each route returns a properly formatted response with CORS headers so the ops console dashboard can call these endpoints later.

  • Click Deploy in the Code source pane to save and deploy your function.

You should see a green banner confirming the function was updated successfully.

Seeing a deployment error?

  • Check for syntax errors. A missing colon or mismatched parenthesis will prevent deployment.
  • Make sure all three code chunks are pasted in order in the same file. The functions reference each other.
  • Verify lambda_function.py is selected in the file explorer on the left of the code editor.

help me debug my Lambda deployment error

Now test the function to confirm it can reach Bedrock and your infrastructure services.

  • Click the Test tab.
  • Click Create new event.
  • For Event name, enter TestAsk.
  • Replace the default JSON with the following test event:
{
  "path": "/ask",
  "httpMethod": "POST",
  "body": "{\"question\": \"What is the current health of my infrastructure?\", \"mode\": \"summary\"}"
}
  • Click Save, then click Test.
  • Expand Details under Execution result. You should see a statusCode: 200 response containing an AI-generated summary of your infrastructure health.

What should I see?

The response body contains an answer field with Nova Micro's plain-English analysis of your infrastructure. If no alarms have fired recently, the AI will report that everything appears healthy. The tokens_used field shows how many input and output tokens the call consumed.

Getting a timeout or access denied error?

  • If you see a timeout: confirm you set the timeout to 30 seconds under Configuration > General configuration. The default 3 seconds is not enough for Bedrock calls.
  • If you see an access denied error for Bedrock: confirm you enabled model access for Amazon Nova Micro in the Bedrock console (Step 1).
  • If you see an access denied error for other services: verify that DevOpsCopilotLambdaRole has the correct permissions for bedrock:InvokeModel, cloudwatch:DescribeAlarmHistory, ec2:DescribeInstanceStatus, and ssm:DescribeAutomationExecutions.

help me fix my DevOpsCopilotFunction Lambda test failure

✔️ Awesome, I've got everything!

Great. Double check you have saved and deployed your function before moving on.

ⓧ I'd like to double check the full code

Here is the complete lambda_function.py for DevOpsCopilotFunction:

import json
import boto3
from datetime import datetime, timedelta, timezone

# Initialize AWS service clients
bedrock = boto3.client('bedrock-runtime', region_name='us-east-1')
cloudwatch = boto3.client('cloudwatch', region_name='us-east-1')
ec2 = boto3.client('ec2', region_name='us-east-1')
ssm = boto3.client('ssm', region_name='us-east-1')
dynamodb = boto3.resource('dynamodb', region_name='us-east-1')
sns = boto3.client('sns', region_name='us-east-1')

# DynamoDB table reference for incident memory
incidents_table = dynamodb.Table('DevOpsCopilotIncidents')

# Bedrock model configuration
MODEL_ID = 'amazon.nova-micro-v1:0'


def collect_infrastructure_logs():
    """Scrape live infrastructure context from AWS services."""
    now = datetime.now(timezone.utc)
    start_time = now - timedelta(hours=24)

    # Collect CloudWatch alarm history (state changes in last 24h)
    alarm_history = cloudwatch.describe_alarm_history(
        AlarmTypes=['MetricAlarm'],
        HistoryItemType='StateUpdate',
        StartDate=start_time,
        EndDate=now,
        MaxRecords=50
    )

    # Collect EC2 instance status (current health of all instances)
    instance_status = ec2.describe_instance_status(
        IncludeAllInstances=True
    )

    # Collect SSM automation executions (recent remediation actions)
    automation_executions = ssm.describe_automation_executions(
        Filters=[
            {
                'Key': 'StartTimeBefore',
                'Values': [now.isoformat()]
            },
            {
                'Key': 'StartTimeAfter',
                'Values': [start_time.isoformat()]
            }
        ]
    )

    return {
        'alarm_history': alarm_history.get('AlarmHistoryItems', []),
        'instance_status': instance_status.get('InstanceStatuses', []),
        'automation_executions': automation_executions.get('AutomationExecutionMetadataList', [])
    }


def format_infrastructure_context(logs):
    """Format raw logs into a structured prompt for the AI."""
    context_parts = []

    context_parts.append("=== CLOUDWATCH ALARM HISTORY (Last 24h) ===")
    for item in logs['alarm_history'][:10]:
        context_parts.append(
            f"- Alarm: {item.get('AlarmName', 'Unknown')} | "
            f"Time: {item.get('Timestamp', 'N/A')} | "
            f"Summary: {item.get('HistorySummary', 'N/A')}"
        )

    context_parts.append("\n=== EC2 INSTANCE STATUS ===")
    for status in logs['instance_status'][:10]:
        instance_id = status.get('InstanceId', 'Unknown')
        state = status.get('InstanceState', {}).get('Name', 'Unknown')
        system_status = status.get('SystemStatus', {}).get('Status', 'Unknown')
        context_parts.append(
            f"- Instance: {instance_id} | State: {state} | System: {system_status}"
        )

    context_parts.append("\n=== SSM AUTOMATION EXECUTIONS (Last 24h) ===")
    for exe in logs['automation_executions'][:10]:
        context_parts.append(
            f"- Document: {exe.get('DocumentName', 'Unknown')} | "
            f"Status: {exe.get('AutomationExecutionStatus', 'Unknown')} | "
            f"Time: {exe.get('ExecutionStartTime', 'N/A')}"
        )

    return "\n".join(context_parts)


def get_system_prompt(mode):
    """Return the system prompt for a given analysis mode."""
    prompts = {
        'summary': (
            "You are a DevOps copilot. Summarize the infrastructure events "
            "concisely. Focus on what happened, when, and what was done about it. "
            "Use plain English."
        ),
        'root_cause': (
            "You are a DevOps copilot specializing in root cause analysis. "
            "Analyze the infrastructure data and identify probable root causes "
            "for any incidents. Explain your reasoning step by step."
        ),
        'prevention': (
            "You are a DevOps copilot focused on preventive recommendations. "
            "Based on the infrastructure data, suggest specific actions to prevent "
            "future incidents. Be actionable and specific."
        ),
        'repeat_offenders': (
            "You are a DevOps copilot specializing in pattern detection. "
            "Identify any repeated incidents, recurring failures, or problem "
            "instances. Highlight patterns and trends."
        )
    }
    return prompts.get(mode, prompts['summary'])


def ask_bedrock(question, infrastructure_context, mode='summary'):
    """Send a question to Bedrock Nova Micro with infrastructure context."""
    system_prompt = get_system_prompt(mode)

    # Combine infrastructure data with the user's question
    user_message = f"""Infrastructure Context:
{infrastructure_context}

Question: {question}"""

    # Call Bedrock Converse API
    response = bedrock.converse(
        modelId=MODEL_ID,
        messages=[
            {
                'role': 'user',
                'content': [{'text': user_message}]
            }
        ],
        system=[{'text': system_prompt}],
        inferenceConfig={'maxTokens': 1024, 'temperature': 0.5, 'topP': 0.9}
    )

    # Parse the AI response
    response_text = response['output']['message']['content'][0]['text']
    token_usage = response.get('usage', {})

    return {
        'answer': response_text,
        'tokens_used': {
            'input': token_usage.get('inputTokens', 0),
            'output': token_usage.get('outputTokens', 0)
        }
    }


def get_incidents():
    """Read incident records from DynamoDB."""
    response = incidents_table.scan(Limit=50)
    items = response.get('Items', [])
    # Sort by timestamp descending (most recent first)
    items.sort(key=lambda x: x.get('timestamp', ''), reverse=True)
    return items


def generate_digest():
    """Generate a weekly operational report using Bedrock."""
    # Collect live infrastructure state
    logs = collect_infrastructure_logs()
    infrastructure_context = format_infrastructure_context(logs)

    # Pull incident history from DynamoDB
    incidents = get_incidents()
    incident_summary = (
        f"\n\n=== INCIDENT HISTORY FROM MEMORY ===\n"
        f"Total incidents recorded: {len(incidents)}\n"
    )
    for inc in incidents[:20]:
        incident_summary += (
            f"- {inc.get('timestamp', 'N/A')} | "
            f"Instance: {inc.get('instance_id', 'N/A')} | "
            f"Severity: {inc.get('severity_score', 'N/A')} | "
            f"Action: {inc.get('action_taken', 'N/A')}\n"
        )

    full_context = infrastructure_context + incident_summary

    # Ask Bedrock to generate the digest report
    result = ask_bedrock(
        "Generate a weekly operational digest report. Include: overall health "
        "summary, incident count and severity breakdown, top recurring issues, "
        "and recommended actions for the coming week.",
        full_context,
        mode='summary'
    )

    return result['answer']


def lambda_handler(event, context):
    """Main handler routing requests to the appropriate mode."""
    # Determine the request path and method from API Gateway event
    path = event.get('path', '/ask')
    http_method = event.get('httpMethod', 'POST')

    try:
        # Route: /ask (POST) - Natural language question
        if path == '/ask' and http_method == 'POST':
            body = json.loads(event.get('body', '{}'))
            question = body.get('question', 'What is the current health of my infrastructure?')
            mode = body.get('mode', 'summary')

            # Collect live data and ask the AI
            logs = collect_infrastructure_logs()
            infrastructure_context = format_infrastructure_context(logs)
            result = ask_bedrock(question, infrastructure_context, mode)

            return {
                'statusCode': 200,
                'headers': {
                    'Content-Type': 'application/json',
                    'Access-Control-Allow-Origin': '*'
                },
                'body': json.dumps({
                    'answer': result['answer'],
                    'mode': mode,
                    'tokens_used': result['tokens_used']
                })
            }

        # Route: /incidents (GET) - Read from DynamoDB
        elif path == '/incidents' and http_method == 'GET':
            incidents = get_incidents()
            # Convert Decimal types for JSON serialization
            for inc in incidents:
                for key, value in inc.items():
                    if hasattr(value, '__int__'):
                        inc[key] = str(value)

            return {
                'statusCode': 200,
                'headers': {
                    'Content-Type': 'application/json',
                    'Access-Control-Allow-Origin': '*'
                },
                'body': json.dumps({
                    'incidents': incidents,
                    'count': len(incidents)
                })
            }

        # Route: /digest (GET) - Generate weekly report
        elif path == '/digest' and http_method == 'GET':
            digest = generate_digest()

            return {
                'statusCode': 200,
                'headers': {
                    'Content-Type': 'application/json',
                    'Access-Control-Allow-Origin': '*'
                },
                'body': json.dumps({
                    'digest': digest
                })
            }

        else:
            return {
                'statusCode': 404,
                'headers': {
                    'Content-Type': 'application/json',
                    'Access-Control-Allow-Origin': '*'
                },
                'body': json.dumps({'error': f'Route not found: {http_method} {path}'})
            }

    except Exception as e:
        return {
            'statusCode': 500,
            'headers': {
                'Content-Type': 'application/json',
                'Access-Control-Allow-Origin': '*'
            },
            'body': json.dumps({'error': str(e)})
        }

Your AI copilot is alive. It can scrape live infrastructure data, feed it to Nova Micro, and return human-readable analysis across four specialized modes. Next up, you will expose this copilot as a REST API so it can be queried from anywhere.

Create the REST API with API Gateway

Your Copilot Lambda is ready to answer questions, fetch incidents, and generate digests. But right now, nothing can reach it from the outside world.

In this step, you'll create a REST API in API Gateway that exposes three endpoints. Each endpoint maps to a different mode in your Copilot Lambda, giving external clients (including the chat dashboard you'll build next) a clean way to communicate with your copilot over HTTP.

In this step, get ready to:
  • Create a REST API with three resources: /ask, /incidents, and /digest.
  • Configure Lambda proxy integration on each endpoint.
  • Enable CORS and deploy the API to a production stage.
Create the REST API and add resources

A REST API in API Gateway acts as a front door for your Lambda function. You define resources (URL paths) and methods (HTTP verbs) that map to your backend.

  • Return to the AWS Management Console and search for API Gateway in the top search bar.
  • Select **API Gateway** from the results.
  • Choose Create API.
  • Find the REST API card (not the one labeled "Private") and choose Build.
  • For API name, enter DevOpsCopilotAPI.
  • Keep API endpoint type set to Regional.
  • Choose Create API.

You should see the API's Resources pane with a root / resource listed.

Now create the three resources that map to your copilot's modes.

  • Choose Create resource.
  • Keep Proxy resource turned off.
  • Keep Resource path as /.
  • For Resource name, enter ask.
  • Choose Create resource.

Repeat the same process to create two more resources.

  • Select the root / resource again, then choose Create resource.
  • For Resource name, enter incidents.
  • Choose Create resource.
  • Select the root / resource once more, then choose Create resource.
  • For Resource name, enter digest.
  • Choose Create resource.

Your resource tree should now show /ask, /incidents, and /digest under the root.

Why individual resources instead of a proxy?

You could use a single catch-all proxy resource to route everything to your Lambda. Individual named resources make the API self-documenting. Anyone looking at the API can immediately see what endpoints exist without reading the Lambda code.

Configure Lambda proxy integration on each endpoint

Each resource needs a method (the HTTP verb) connected to your Copilot Lambda via Lambda proxy integration. With proxy integration, API Gateway passes the full request straight to the Lambda and returns whatever the Lambda sends back.

Start with the /ask resource.

  • Select the /ask resource, then choose Create method.
  • For Method type, select POST.
  • For Integration type, choose Lambda function.
  • Turn on Lambda proxy integration.
  • For Lambda function, enter DevOpsCopilotFunction and select it from the dropdown.
  • Choose Create method.

A dialog may appear asking to grant API Gateway permission to invoke your Lambda. Choose OK to allow it.

Now repeat for /incidents.

  • Select the /incidents resource, then choose Create method.
  • For Method type, select GET.
  • For Integration type, choose Lambda function.
  • Turn on Lambda proxy integration.
  • For Lambda function, enter DevOpsCopilotFunction and select it.
  • Choose Create method.

Finally, repeat for /digest.

  • Select the /digest resource, then choose Create method.
  • For Method type, select GET.
  • For Integration type, choose Lambda function.
  • Turn on Lambda proxy integration.
  • For Lambda function, enter DevOpsCopilotFunction and select it.
  • Choose Create method.

What is Lambda proxy integration?

Lambda proxy integration means API Gateway forwards the entire HTTP request (headers, body, path, query parameters) directly to your Lambda function as an event. Your Lambda is responsible for formatting the response, including setting headers and status codes.

This is why your Copilot Lambda already returns responses in the format API Gateway expects, with statusCode, headers, and body fields.

Enable CORS and deploy to a prod stage

Your chat dashboard (which you'll build in the next step) will call this API from a browser. Browsers block cross-origin requests unless the API explicitly allows them through CORS headers.

Enable CORS on each resource so the browser's preflight requests succeed.

  • Select the /ask resource.
  • In the Resource details section, choose Enable CORS.
  • Select the POST method checkbox.
  • Confirm Access-Control-Allow-Origin is set to '*'.
  • Choose Save.
  • Repeat the same CORS steps for /incidents (selecting the GET method).
  • Repeat for /digest (selecting the GET method).

How does CORS work with Lambda proxy integration?

Enabling CORS in the console creates an OPTIONS method on each resource to handle preflight requests. However, for Lambda proxy integrations, your Lambda function must also return CORS headers in its actual responses.

Your Copilot Lambda already includes Access-Control-Allow-Origin in its response headers, so both the preflight and the actual request will work correctly.

Now deploy the API so it's accessible over the internet.

  • Choose Deploy API (in the top-right of the Resources page).
  • For Stage, select New stage.
  • For Stage name, enter prod.
  • Choose Deploy.

After deployment, you'll see the Stage details page. Copy the Invoke URL displayed at the top. It looks something like this:

https://{api-id}.execute-api.us-east-1.amazonaws.com/prod

Save this URL somewhere handy. You'll use it in the next step to connect your chat dashboard and in the weekly digest automation.

Test your API quickly

You can test the /incidents endpoint right now by pasting the invoke URL with /incidents appended into your browser. You should see a JSON response from your Copilot Lambda (likely an empty incidents list if no incidents have been captured yet).

Getting a 500 Internal Server Error?

Make sure your Lambda function name is exactly DevOpsCopilotFunction when configuring the integration. A typo will cause API Gateway to fail when invoking the function.

Also check that you deployed the API after enabling CORS. Changes in API Gateway are not live until you deploy to a stage.

help me troubleshoot my API Gateway 500 error with Lambda proxy integration

Your copilot now has a public API. Anyone (or anything) with the invoke URL can ask questions, fetch incidents, and request digests. Next, you'll build a visual chat dashboard that puts a friendly face on these endpoints.

Connect the Remediation Lambda to Your Pipeline

Your Remediation Lambda and Copilot Lambda are both deployed, but right now the Remediation Lambda is sitting idle. It has no connection to the events flowing through your self-healing pipeline.

In this step, you'll wire the RemediationCaptureFunction into your existing pipeline by subscribing it to your SNS topic. Once connected, every alarm notification that triggers auto-remediation will also be captured, scored, and stored for your copilot to analyze.

In this step, get ready to:
  • Subscribe the Remediation Lambda to your self-healing pipeline's SNS topic.
  • Test the full event flow by triggering a CPU spike and verifying the incident appears in DynamoDB.
Subscribe the Remediation Lambda to your SNS topic

Your self-healing pipeline uses an SNS topic to fan out alarm notifications. By adding your Lambda as a subscriber, it receives a copy of every notification the pipeline sends.

There are two parts to this connection: granting SNS permission to invoke the Lambda, and then creating the subscription itself.

  • First, find the ARN of your existing self-healing pipeline's SNS topic. You can find this in the SNS console under Topics.
  • Copy the topic ARN. It looks like arn:aws:sns:us-east-1:123456789012:YourTopicName.
  • Grant SNS permission to invoke your Remediation Lambda by running this command in your terminal:
aws lambda add-permission \
    --function-name RemediationCaptureFunction \
    --source-arn [[SNS_TOPIC_ARN="arn:aws:sns:us-east-1:YOUR_ACCOUNT_ID:YourTopicName"]] \
    --statement-id sns-invoke \
    --action "lambda:InvokeFunction" \
    --principal sns.amazonaws.com

What does this command do?

This adds a resource-based policy to your Lambda function that explicitly allows the SNS service to invoke it. Without this permission, SNS would be blocked from triggering the function even if a subscription exists.

The --source-arn restricts the permission so only your specific SNS topic can invoke the function. This prevents other topics from triggering it.

  • You should see a JSON response containing a Statement object with the permission details.
  • Now subscribe the Lambda to the SNS topic by running:
aws sns subscribe \
    --topic-arn [[SNS_TOPIC_ARN="arn:aws:sns:us-east-1:YOUR_ACCOUNT_ID:YourTopicName"]] \
    --protocol lambda \
    --notification-endpoint [[REMEDIATION_LAMBDA_ARN="arn:aws:lambda:us-east-1:YOUR_ACCOUNT_ID:function:RemediationCaptureFunction"]]

What does this command do?

The --protocol lambda tells SNS to deliver messages by invoking a Lambda function. The --notification-endpoint is the ARN of the function that should receive those messages.

Unlike email or HTTP subscriptions, Lambda subscriptions are confirmed immediately. No manual confirmation step is required.

  • You should see a response containing a SubscriptionArn value. This confirms the subscription is active.

Getting an authorization error?

Make sure you ran the add-permission command first. The subscription itself will succeed even without it, but SNS won't be able to actually invoke the Lambda when an event arrives.

Double-check that your SNS topic ARN is correct. You can verify it in the SNS console under Topics.

Help me troubleshoot my SNS to Lambda subscription. I'm getting an authorization error when trying to subscribe RemediationCaptureFunction to my SNS topic.

Test the connection with a CPU spike

The best way to confirm everything works end-to-end is to trigger a real incident through your self-healing pipeline. You'll stress the EC2 instance's CPU to fire the CloudWatch alarm, which sends a notification to SNS, which now invokes your Remediation Lambda.

  • Connect to your EC2 instance using Session Manager or SSH.
  • Run this command to spike the CPU:
stress-ng --cpu 1 --timeout 300s

What does this command do?

This runs a CPU stress test for 5 minutes. It generates enough CPU load to trigger your CloudWatch alarm, which kicks off the entire self-healing chain. If stress-ng is not installed, you can use dd if=/dev/urandom | bzip2 -9 > /dev/null as an alternative.

  • Wait 3-5 minutes for the CloudWatch alarm to transition to the ALARM state. You can monitor this in the CloudWatch console under Alarms.
  • Once the alarm fires, verify the incident was captured by scanning your DynamoDB table:
aws dynamodb scan --table-name DevOpsCopilotIncidents
  • You should see at least one item in the Items array with fields like incident_id, instance_id, severity, and timestamp.

What should I see?

The scan result should show a Count of 1 or more. Each item represents an incident your Remediation Lambda captured and scored. The severity field shows the score (low, medium, high, or critical) based on the incident frequency patterns.

If this is the first incident in the last 24 hours, the severity will be low. Repeated incidents on the same instance within a shorter window produce higher severity scores.

No items in DynamoDB?

Check the CloudWatch alarm state. If it hasn't transitioned to ALARM, the CPU spike may not have been intense enough or the evaluation period hasn't elapsed yet. Wait a few more minutes.

Check the Lambda's CloudWatch Logs for errors. In the Lambda console, open RemediationCaptureFunction and click the Monitor tab, then View CloudWatch Logs. Look for invocation errors or permission issues.

Verify the SNS subscription is active. In the SNS console, open your topic and check the Subscriptions tab. The Lambda subscription status should show Confirmed.

Help me debug why my RemediationCaptureFunction isn't writing incidents to DynamoDB after an alarm fires.

Your Remediation Lambda is now wired into the pipeline. Every time your infrastructure auto-heals, the event is captured, scored, and stored. Next up, you'll build a visual dashboard to query the copilot and explore these incidents in real time.

Build the Ops Console Chat Dashboard

Your copilot can answer questions about infrastructure health, but right now the only way to talk to it is through raw API calls.

In this step, you will build a dark-themed chat dashboard hosted on S3 that gives you a visual interface to ask your infrastructure questions in plain English. The dashboard connects directly to your API Gateway /ask endpoint.

In this step, get ready to:
  • Create an S3 bucket configured for static website hosting.
  • Build and upload a dark-themed HTML chat dashboard.
  • Configure the bucket policy for public read access.
Create the S3 bucket with static website hosting

S3 can serve static files (HTML, CSS, JavaScript) as a website without needing a traditional web server. You will create a bucket and enable its static website hosting feature.

  • In the AWS Console, search for S3 and open the S3 service.
  • Click Create bucket.
  • Enter a globally unique bucket name. Use the pattern devops-copilot-console- followed by your AWS account ID or your name to make it unique.
  • For AWS Region, select US East (N. Virginia) us-east-1.
  • Leave all other settings as defaults.
  • Click Create bucket.

Now enable static website hosting on the bucket.

  • Click on your new bucket name to open it.
  • Click the Properties tab.
  • Scroll down to Static website hosting and click Edit.
  • Select Enable.
  • In the Index document field, enter index.html.
  • Click Save changes.

What is static website hosting?

When you enable static website hosting on an S3 bucket, AWS gives it a public HTTP endpoint that serves your files directly to browsers. Instead of needing a server running 24/7, S3 handles all the web serving for you.

You should see a green success banner confirming static website hosting is enabled. Note the bucket website endpoint URL displayed in the Static website hosting section. It follows the format http://your-bucket-name.s3-website-us-east-1.amazonaws.com.

Build and upload the HTML chat dashboard

The dashboard is a single HTML file with embedded CSS and JavaScript. It provides a chat-style interface where you type questions and see AI-generated responses from your copilot.

  • Open any text editor on your computer (Notepad, TextEdit, VS Code, or similar).
  • Create a new file and paste the following code into it:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>DevOps Copilot - Ops Console</title>
    <style>
        /* Reset and dark theme base */
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            background: #0d1117;
            color: #c9d1d9;
            font-family: 'Courier New', monospace;
            height: 100vh;
            display: flex;
            flex-direction: column;
        }

        /* Header bar */
        .header {
            background: #161b22;
            padding: 16px 24px;
            border-bottom: 1px solid #30363d;
        }
        .header h1 { font-size: 18px; color: #58a6ff; }
        .header p { font-size: 12px; color: #8b949e; margin-top: 4px; }

        /* Chat message area */
        .chat-container {
            flex: 1;
            overflow-y: auto;
            padding: 24px;
        }
        .message {
            margin-bottom: 16px;
            padding: 12px 16px;
            border-radius: 8px;
            max-width: 80%;
        }
        .message.user {
            background: #1f6feb;
            margin-left: auto;
            color: #ffffff;
        }
        .message.assistant {
            background: #161b22;
            border: 1px solid #30363d;
        }
        .message .label {
            font-size: 11px;
            color: #8b949e;
            margin-bottom: 4px;
            text-transform: uppercase;
        }
        .message .text {
            white-space: pre-wrap;
            line-height: 1.5;
        }

        /* Input area at the bottom */
        .input-area {
            background: #161b22;
            padding: 16px 24px;
            border-top: 1px solid #30363d;
            display: flex;
            gap: 12px;
        }
        .input-area input {
            flex: 1;
            background: #0d1117;
            border: 1px solid #30363d;
            color: #c9d1d9;
            padding: 12px 16px;
            border-radius: 6px;
            font-size: 14px;
            font-family: inherit;
        }
        .input-area input:focus {
            outline: none;
            border-color: #58a6ff;
        }
        .input-area button {
            background: #238636;
            color: #ffffff;
            border: none;
            padding: 12px 24px;
            border-radius: 6px;
            cursor: pointer;
            font-size: 14px;
            font-weight: bold;
        }
        .input-area button:hover { background: #2ea043; }
        .input-area button:disabled {
            background: #21262d;
            color: #484f58;
            cursor: not-allowed;
        }
        .loading { color: #8b949e; font-style: italic; }
    </style>
</head>
<body>
    <div class="header">
        <h1>DevOps Copilot</h1>
        <p>Ask your infrastructure anything</p>
    </div>

    <div class="chat-container" id="chat-container">
        <div class="message assistant">
            <div class="label">Copilot</div>
            <div class="text">DevOps Copilot online. Ask me about your infrastructure health, incidents, or remediation history.</div>
        </div>
    </div>

    <div class="input-area">
        <input type="text" id="user-input" placeholder="Ask about your infrastructure..." onkeypress="if(event.key==='Enter')sendMessage()">
        <button id="send-btn" onclick="sendMessage()">Send</button>
    </div>

    <script>
        // Your API Gateway prod stage URL
        const API_URL = '[[API_GATEWAY_URL="https://xxxxxxxxxx.execute-api.us-east-1.amazonaws.com/prod"]]';

        // Send the user's question to the copilot
        async function sendMessage() {
            const input = document.getElementById('user-input');
            const message = input.value.trim();
            if (!message) return;

            // Display the user's message in the chat
            addMessage(message, 'user');
            input.value = '';
            document.getElementById('send-btn').disabled = true;

            // Show a loading indicator while waiting for AI response
            const loadingId = addMessage('Analyzing infrastructure...', 'assistant', true);

            try {
                // POST the question to the /ask endpoint
                const response = await fetch(API_URL + '/ask', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({ question: message })
                });

                const data = await response.json();

                // Replace loading indicator with the AI response
                removeMessage(loadingId);
                addMessage(data.response || data.error || 'No response received.', 'assistant');
            } catch (error) {
                // Display connection errors in the chat
                removeMessage(loadingId);
                addMessage('Error connecting to copilot: ' + error.message, 'assistant');
            }

            document.getElementById('send-btn').disabled = false;
        }

        // Add a message bubble to the chat container
        function addMessage(text, role, isLoading = false) {
            const container = document.getElementById('chat-container');
            const div = document.createElement('div');
            const id = 'msg-' + Date.now();
            div.id = id;
            div.className = 'message ' + role;
            div.innerHTML = '<div class="label">' + (role === 'user' ? 'You' : 'Copilot') + '</div>' +
                '<div class="text' + (isLoading ? ' loading' : '') + '">' + text + '</div>';
            container.appendChild(div);
            container.scrollTop = container.scrollHeight;
            return id;
        }

        // Remove a message by its ID (used for loading indicators)
        function removeMessage(id) {
            const el = document.getElementById(id);
            if (el) el.remove();
        }
    </script>
</body>
</html>

What does this code do?

  • The <style> section creates the dark ops-console theme with a monospace font, dark backgrounds, and color-coded chat bubbles (blue for you, dark gray for the copilot).
  • The chat-container div displays the conversation history with scrolling.
  • The input-area provides a text field and Send button for typing questions.
  • The sendMessage() function sends a POST request to your API Gateway /ask endpoint with the user's question, then displays the AI response in the chat.
  • The API_URL constant must point to your API Gateway prod stage URL so the dashboard can reach your copilot Lambda.
  • Replace the API_URL placeholder value with your actual API Gateway invoke URL. You can find this in the API Gateway console under Stages > prod > Invoke URL.
  • Save the file as index.html on your computer.

Finding your API Gateway URL

Your invoke URL looks like https://xxxxxxxxxx.execute-api.us-east-1.amazonaws.com/prod. Do not include a trailing slash after prod because the JavaScript already appends /ask to the URL.

Now upload the file to your S3 bucket.

  • Return to your S3 bucket in the AWS Console.
  • Click the Objects tab.
  • Click Upload.
  • Click Add files and select your index.html file.
  • Click Upload.

You should see a success message confirming the file was uploaded.

Upload not working?

  • Make sure the file is named exactly index.html (lowercase, no spaces).
  • Check that you are uploading to the correct bucket.

Help me troubleshoot my S3 file upload.

Configure the bucket policy for public read access

By default, S3 blocks all public access to your bucket. For the website to be viewable in a browser, you need to disable block public access and add a bucket policy that grants read permissions to everyone.

  • In your bucket, click the Permissions tab.
  • In the Block public access (bucket settings) section, click Edit.
  • Uncheck Block all public access.
  • A confirmation field appears. Type confirm in the text field.
  • Click Save changes.

Now add the bucket policy that grants public read access.

  • Still on the Permissions tab, scroll down to Bucket policy and click Edit.
  • Paste the following policy into the editor, replacing the bucket name with your actual bucket name:
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "PublicReadGetObject",
            "Effect": "Allow",
            "Principal": "*",
            "Action": ["s3:GetObject"],
            "Resource": ["arn:aws:s3:::[[S3_BUCKET_NAME="your-bucket-name"]]/*"]
        }
    ]
}

What does this policy do?

This policy grants everyone (Principal: *) permission to read objects (s3:GetObject) from your bucket. This is what makes your dashboard publicly accessible as a website.

  • Click Save changes.

You should see a warning banner on the bucket indicating it now has public access. This is expected for a static website.

Getting an access denied error on save?

  • Make sure you completed the Block Public Access step first. The bucket policy will be rejected if Block Public Access is still enabled.
  • Double check that the bucket name in the Resource field matches your actual bucket name exactly.

Help me fix my S3 bucket policy error.

Test your dashboard by navigating to your bucket's website endpoint URL.

  • Go back to the Properties tab.
  • Scroll to Static website hosting and click the Bucket website endpoint URL.
  • Your dark-themed ops console should load with the welcome message from the copilot.

What should I see?

A dark-themed page with a header reading DevOps Copilot and a subtitle Ask your infrastructure anything. Below that, a chat bubble from the copilot with a welcome message. At the bottom, a text input field and green Send button.

Seeing a 403 Forbidden error?

  • Confirm you are using the website endpoint URL (format: http://bucket-name.s3-website-us-east-1.amazonaws.com), not the regular S3 object URL.
  • Verify the bucket policy was saved successfully and references the correct bucket name.
  • Make sure Block Public Access is fully disabled on the bucket.

Help me fix my S3 static website 403 error.

Your ops console is live. Next up, you will set up a weekly digest automation that emails AI-generated operational reports every Monday.

Set Up the Weekly Digest Automation

Your copilot can already generate operational digests on demand through the /digest endpoint. The next step is making this proactive.

Instead of waiting for someone to ask, your copilot will automatically generate and email a weekly operational report every Monday morning. You will use EventBridge Scheduler to trigger the digest on a cron schedule and deliver it through your existing SNS topic.

In this step, get ready to:
  • Create an EventBridge Scheduler schedule with a cron expression for every Monday at 9:00 AM UTC.
  • Configure the schedule to invoke the Copilot Lambda with a digest payload.
  • Verify the Lambda publishes the AI-generated digest to your existing SNS topic for email delivery.
Create an EventBridge Scheduler schedule

EventBridge Scheduler is a serverless service that lets you create recurring tasks using cron expressions. You will create a schedule that fires every Monday at 9:00 AM UTC and invokes your Copilot Lambda in digest mode.

  • In the AWS Console search bar, type EventBridge Scheduler and select Amazon EventBridge Scheduler.
  • On the Schedules page, click Create schedule.

What is EventBridge Scheduler?

EventBridge Scheduler is AWS's recommended way to run tasks on a recurring schedule. It replaces the older EventBridge scheduled rules with better scalability, flexible time windows, and built-in retry support.

It supports cron expressions (specific times like "every Monday at 9 AM") and rate expressions (intervals like "every 5 minutes"). For a weekly digest, a cron expression gives you precise control over the day and time.

  • For Schedule name, enter WeeklyDigestSchedule.
  • For Description, enter Weekly AI-generated operational digest every Monday at 9 AM UTC.
  • For Schedule group, leave it set to default.
  • Under Schedule pattern, select Recurring schedule.
  • Select Cron-based schedule.
  • Enter the following cron expression:
cron(0 9 ? * MON *)

What does this cron expression mean?

The cron format for EventBridge Scheduler is cron(minutes hours day-of-month month day-of-week year). Here is what each field does:

  • 0 = minute 0 (top of the hour).
  • 9 = 9:00 AM UTC.
  • ? = no specific day-of-month (required when you specify day-of-week).
  • * = every month.
  • MON = Monday only.
  • * = every year.
  • For Flexible time window, select Off.
  • Click Next.

Why turn the flexible time window off?

A flexible time window lets EventBridge run your schedule within a range (e.g. 15 minutes after the target time) to spread load. For a weekly operational digest, you want it delivered at a predictable time so your team knows when to expect it.

Configure the Lambda target with a digest payload

The schedule needs to know which Lambda function to invoke and what data to send. You will point it at your Copilot Lambda and pass a JSON payload that mimics the API Gateway digest request format.

  • On the Select target page, choose Templated targets.
  • Select AWS Lambda Invoke.
  • In the Invoke section, select your DevOpsCopilotFunction from the dropdown.
  • In the JSON payload field, enter the following:
{"path": "/digest", "httpMethod": "GET"}

Why this specific payload?

Your Copilot Lambda routes requests based on the path and httpMethod fields in the event. This is the same structure that API Gateway sends when someone hits the /digest endpoint.

By reusing the same format, EventBridge Scheduler triggers the exact same digest logic that the REST API uses. The Lambda does not need to know whether the request came from a human or a schedule.

  • Click Next.
Set up the execution role and create the schedule

EventBridge Scheduler needs an IAM execution role with permission to invoke your Lambda function. The console can create this automatically.

  • On the Settings page, confirm that Enable schedule is toggled on.
  • Under Permissions, select Create new role for this schedule.
  • For Role name, enter WeeklyDigestSchedulerRole.
  • Click Next.

What does the execution role do?

EventBridge Scheduler assumes this role to call lambda:InvokeFunction on your DevOpsCopilotFunction. The console automatically attaches the required permission policy when you select the Lambda target.

  • On the Review and create schedule page, verify the following details:
  • Schedule name: WeeklyDigestSchedule.
  • Schedule expression: cron(0 9 ? * MON *).
  • Target: DevOpsCopilotFunction.
  • Click Create schedule.

You should see your new schedule listed on the Schedules page with a status of Enabled.

Don't see your schedule listed?

  • Make sure you are in the us-east-1 region. Schedules are region-specific.
  • Check that the status column shows Enabled. If it shows Disabled, click on the schedule name and toggle it on.

Still stuck? Why isn't my EventBridge Scheduler schedule showing up or enabled?

Your AI DevOps Copilot now has proactive intelligence. Every Monday at 9:00 AM UTC, EventBridge Scheduler will invoke your Lambda in digest mode. The Lambda collects the past week's infrastructure data, sends it through Amazon Nova Micro for analysis, and publishes the resulting operational report to your SNS topic for email delivery.

Next up, you will put everything together with an end-to-end test that triggers a real incident and watches the entire system respond.

Run an End-to-End Test

Your weekly digest automation is configured and ready for next Monday. Every piece of the DevOps Copilot system is now deployed and wired together.

Time to prove it all works. You will trigger a real CPU spike on your EC2 instance and watch the full pipeline react: alarm fires, auto-remediation executes, the Remediation Lambda captures and scores the incident, and the copilot answers questions about it in plain English.

In this step, get ready to:
  • Trigger a CPU spike on your EC2 instance to generate a real incident.
  • Verify the incident capture pipeline stored and scored the event in DynamoDB.
  • Query the copilot through your ops console and test follow-up questions.
Trigger a CPU spike on your EC2 instance

To test the end-to-end flow, you need a real incident. The fastest way to generate one is to SSH into your EC2 instance and spike the CPU so your CloudWatch alarm fires.

  • Connect to your EC2 instance via SSH by running this command:
ssh -i [[PEMPATH="path to your .pem file"]] ec2-user@[[EC2_PUBLIC_IP="your EC2 public IPv4 DNS"]]
  • Once connected, install the stress tool. On Amazon Linux 2, enable the EPEL repository first by running:
sudo amazon-linux-extras install epel -y
sudo yum install -y stress

What if I'm on Amazon Linux 2023?

If your instance runs Amazon Linux 2023, use sudo dnf install -y stress-ng instead. Then replace stress with stress-ng in the command below.

  • Spike the CPU for 5 minutes by running:
stress --cpu 4 --timeout 300

What does this command do?

The --cpu 4 flag spawns 4 CPU-intensive worker threads. The --timeout 300 flag keeps them running for 300 seconds (5 minutes).

This pushes CPU utilization high enough to trip your CloudWatch alarm, which triggers the entire self-healing pipeline.

  • Wait 3-5 minutes for the CloudWatch alarm to transition into the ALARM state.
  • You should receive an email notification from your SNS topic confirming the alarm fired.

Check the alarm state in the console

In the AWS Console, navigate to CloudWatch and select Alarms in the left sidebar. Your CPU alarm should show In alarm in red.

Alarm not firing?

Make sure your EC2 instance is the same one monitored by your CloudWatch alarm from the self-healing pipeline project. You can check the alarm's dimensions in the CloudWatch console to confirm the instance ID matches.

If the alarm evaluation period is longer than 5 minutes, increase the stress timeout to match. For example, stress --cpu 4 --timeout 600 runs for 10 minutes.

Why isn't my CloudWatch alarm firing after the stress test?

Verify the incident capture pipeline

Once the alarm fires, your self-healing pipeline auto-remediates the issue. The RemediationCaptureFunction should have received the SNS notification, scored the incident's severity, and stored a record in DynamoDB.

  • Open a new terminal on your local machine (not the EC2 SSH session).
  • Scan the DynamoDB table to check for your incident record by running:
aws dynamodb scan --table-name DevOpsCopilotIncidents

What should I see?

The output should contain one or more items with fields like incident_id, instance_id, timestamp, severity, and action_taken. The severity field should show a value like low, medium, high, or critical depending on how frequently your instance has been stressed.

  • Confirm the scan output contains at least one incident record with a severity score assigned by your Remediation Lambda.

No items in DynamoDB?

Check that the RemediationCaptureFunction is subscribed to the correct SNS topic. In the Lambda console, open your function's Configuration tab, then Triggers. You should see the SNS topic listed.

Also check the function's CloudWatch Logs for errors. Navigate to CloudWatch then Log groups and search for /aws/lambda/RemediationCaptureFunction.

Help me debug why my RemediationCaptureFunction isn't writing to DynamoDB.

Query the copilot and test follow-up questions

The moment of truth. Your AI copilot should now be able to answer questions about what just happened to your infrastructure.

  • Open your ops console in a browser. Navigate to your S3 static website URL:
http://[[S3_BUCKET_NAME="your-bucket-name"]].s3-website-us-east-1.amazonaws.com
  • In the chat input, type the following question and press Enter:
What happened to my infrastructure in the last hour?

The copilot scrapes live data from CloudWatch alarm history, EC2 instance status, and SSM remediation logs using its built-in log collector. It then sends this context to Amazon Nova Micro via the Bedrock Converse API and returns a plain English summary.

  • You should see an AI-generated response describing the CPU spike, the alarm state change, and the remediation action that was taken.
  • Try these follow-up questions one at a time to test different analysis modes:
What was the severity of the most recent incident?
Are there any repeat offenders in my infrastructure?
What preventive actions do you recommend?
  • Verify each question returns a coherent, context-aware response from the copilot.

What makes this different from a basic chatbot?

Your copilot is not just passing your question to an LLM. Before every response, the log collector gathers live infrastructure data from CloudWatch, EC2, and SSM. It also queries the DevOpsCopilotIncidents table for historical patterns.

This combination of real-time data and incident memory is what gives the copilot the ability to detect patterns, score severity, and make recommendations.

  • Verify the /incidents endpoint by opening this URL in a new browser tab:
https://[[API_ID="your-api-id"]].execute-api.us-east-1.amazonaws.com/prod/incidents
  • You should see a JSON response listing your incident records from DynamoDB.
  • Verify the /digest endpoint by opening this URL:
https://[[API_ID="your-api-id"]].execute-api.us-east-1.amazonaws.com/prod/digest
  • The /digest endpoint should return an AI-generated operational report summarizing your infrastructure's recent health. It also publishes this report to your SNS topic.

Getting an error from the API?

If you see a 500 error, check the DevOpsCopilotFunction logs in CloudWatch. Navigate to Log groups and open /aws/lambda/DevOpsCopilotFunction. Look for Python tracebacks that indicate the issue.

Common causes include: the Lambda execution role is missing a permission, the Bedrock model access was not enabled, or the DynamoDB table name has a typo in the function code.

Help me debug a 500 error from my DevOps Copilot API.

Secret mission

Enhanced Ops Console Dashboard

Your ops console works, but it could do more. Build an upgraded dashboard with a severity-coded incident timeline, analysis mode selector, on-demand digest generation, and auto-refresh polling to create a real-time operational command center.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. The primary ongoing cost is the EC2 instance from your prerequisite project (approximately $0.01/hr for a t2.micro).

Cost warning

If you leave the EC2 instance from your prerequisite project running, it costs approximately $0.25/day. The DynamoDB table, Lambda functions, API Gateway, S3 bucket, and EventBridge Scheduler all fall within Free Tier limits for this project's usage level.

Bedrock usage is pay-per-token. Nova Micro is the cheapest text model in Bedrock, so testing typically costs pennies. However, the weekly digest schedule invokes the Copilot Lambda every Monday, which calls Bedrock. If you do not plan to return, delete the schedule to avoid recurring token charges.

Resources you used:

  • EC2 instance (from prerequisite self-healing pipeline).
  • DynamoDB table: DevOpsCopilotIncidents.
  • Lambda functions: DevOpsCopilotFunction and RemediationCaptureFunction.
  • API Gateway REST API: DevOpsCopilotAPI.
  • S3 bucket: devops-copilot-console.
  • EventBridge Scheduler: WeeklyDigestSchedule.
  • IAM roles: DevOpsCopilotLambdaRole and RemediationLambdaRole.
  • SNS subscription (RemediationCaptureFunction on your self-healing pipeline topic).

Keep everything running

No action needed. Choose this if you are still actively building or experimenting with your AI DevOps Copilot.

  • Your total cost is approximately $0.25/day, almost entirely from the running EC2 instance.
  • The weekly digest schedule invokes Bedrock every Monday, costing a fraction of a cent per invocation.
  • All other services (DynamoDB, Lambda, API Gateway, S3, EventBridge Scheduler) remain within Free Tier limits at this usage level.

Pause - I'll come back to this later

Shut down running compute to reduce costs while keeping all configuration intact. You can resume later without rebuilding.

  • Stop (do not terminate) your EC2 instance from the EC2 console. This reduces costs to approximately $0.01/day for EBS storage only.
  • Delete the EventBridge schedule to prevent recurring Bedrock charges while you are away. Run this in your terminal:
aws scheduler delete-schedule --name WeeklyDigestSchedule
  • Leave Lambda functions, API Gateway, DynamoDB, and S3 in place. They cost nothing when idle.
  • When you return, start the EC2 instance and recreate the EventBridge schedule.

Delete - I don't want to use this again

Remove all project resources completely. Follow each step below to avoid orphaned resources.

  • Delete the EventBridge schedule:
aws scheduler delete-schedule --name WeeklyDigestSchedule
  • Delete both Lambda functions:
aws lambda delete-function --function-name DevOpsCopilotFunction
aws lambda delete-function --function-name RemediationCaptureFunction
  • Delete the API Gateway REST API. First, find your API ID:
aws apigateway get-rest-apis --query "items[?name=='DevOpsCopilotAPI'].id" --output text
  • Then delete the API using the ID from the output:
aws apigateway delete-rest-api --rest-api-id [[API_ID="your API ID from the previous command"]]
  • Delete the DynamoDB table:
aws dynamodb delete-table --table-name DevOpsCopilotIncidents
  • Delete the S3 bucket and its contents:
aws s3 rb s3://devops-copilot-console --force
  • Remove the SNS subscription for the Remediation Lambda. First, find the subscription ARN:
aws sns list-subscriptions --query "Subscriptions[?Protocol=='lambda' && contains(Endpoint, 'RemediationCaptureFunction')].SubscriptionArn" --output text
  • Then unsubscribe using the ARN from the output:
aws sns unsubscribe --subscription-arn [[SUBSCRIPTION_ARN="your subscription ARN from the previous command"]]
  • Delete the IAM roles. In the IAM console, navigate to Roles, search for DevOpsCopilotLambdaRole, select it, detach all attached policies under the Permissions tab, then click Delete. Repeat for RemediationLambdaRole.
  • Optionally, stop or terminate the EC2 instance from the prerequisite project. In the EC2 console, select your instance, click Instance state, then choose Terminate instance.

That's a Wrap!

That's a Wrap!

Nice work! Your infrastructure can now answer questions about its own health, incidents, and remediation history in plain English.

You've learned how to:

  • Build an AI-powered DevOps Copilot using the Bedrock Converse API with Amazon Nova Micro that turns raw CloudWatch alarm history, EC2 instance status, and SSM remediation logs into human-readable analysis.
  • Create a serverless incident memory layer with DynamoDB, automatic severity scoring, and pattern detection that gives the copilot long-term recall across days and weeks.
  • Deploy a complete REST API with API Gateway and Lambda proxy integration, a dark-themed ops console on S3, and automated weekly digest reports via EventBridge Scheduler and SNS.
  • 💎 Enhanced the ops console with a severity-coded incident timeline, analysis mode selector, and on-demand digest generation.

Ready to quiz yourself?