Build an AI DevOps Agent on AWS
Build an AI agent that executes real AWS operations from natural language commands.
Introduction
30 Second Summary
Managing cloud infrastructure usually means clicking through endless console pages or memorizing terminal commands. What if you could just tell an AI what you want in plain English and watch it do the work for you?
In this project, you will build and deploy an AI DevOps agent that takes natural language commands, decides which AWS operations to run, asks for your permission, then executes them on your real AWS account.
What You'll Build
A Python-based AI agent powered by LangGraph and AWS Bedrock, deployed as a serverless web application with a real-time streaming chat interface.
By the end of this project, you'll have:
- A LangGraph state machine that orchestrates AI tool-calling and human approval flows for real AWS operations.
- A streaming chat interface that shows the agent's reasoning, tool selection, and execution in real-time using Server-Sent Events.
- A serverless deployment on AWS Lambda and S3, accessible via a public URL you can share in interviews.
- Secret Mission: Add a live agent trace panel that visualizes each step of the state machine as it executes.
Are there any prerequisites?
You need basic Python syntax familiarity, a GitHub account, and an AWS account with free tier credits. No prior experience with AI agents or LangGraph is required.
Before We Start
Before We Start...
Set Up Your Development Environment
Your AI DevOps agent needs three foundational pieces to work. It needs Python to run the agent logic, the AWS CLI to authenticate with your cloud account, and access to a language model on AWS Bedrock that can understand natural language and call tools.
In this step, you will install and verify each of these pieces so the rest of the project builds on a solid foundation.
In this step, get ready to:
- Install Python 3.11 and verify it works.
- Install and configure the AWS CLI so your machine can talk to AWS.
- Enable model access for Mistral Ministral 3B in the Bedrock console.
Install Python 3.11
Python is the language your AI agent is written in. LangGraph and the AWS SDK (boto3) both require Python 3.11 or later.
- Install Python 3.11 by running these commands:
sudo apt update
sudo apt install python3.11 python3.11-venv python3.11-dev -y
What do these packages do?
- python3.11 installs the Python interpreter itself.
- python3.11-venv lets you create isolated virtual environments (you will use one later).
- python3.11-dev includes headers needed to compile some pip packages.
- Verify the installation by running:
python3.11 --version
You should see output like Python 3.11.x (the exact patch number may vary).
Command not found?
If your system says python3.11: command not found, the deadsnakes PPA may be needed. Run sudo add-apt-repository ppa:deadsnakes/ppa then retry the install command above.
help me install Python 3.11 on my Linux machine
Install and configure the AWS CLI
The AWS CLI is a command-line tool that lets your machine authenticate with AWS services. Your agent uses these credentials to call Bedrock and manage S3 buckets.
- Download and install AWS CLI v2 by running these commands:
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
unzip awscliv2.zip
sudo ./aws/install
- Verify the installation by running:
aws --version
You should see output starting with aws-cli/2.x.x.
Seeing 'unzip: command not found'?
Install unzip first by running sudo apt install unzip -y, then retry the commands above.
help me install the AWS CLI on Linux
Now you need to connect the CLI to your AWS account using your credentials.
- Run the configure command:
aws configure
What will this command ask?
The CLI will prompt you for four values:
- AWS Access Key ID and Secret Access Key from your IAM credentials (found in the AWS console under IAM > Users > Security credentials).
- Default region name: enter us-east-1.
- Default output format: enter json.
- Enter your AWS Access Key ID when prompted.
- Enter your Secret Access Key when prompted.
- Enter us-east-1 for the default region.
- Enter json for the output format.
- Verify your credentials are working by running:
aws sts get-caller-identity
You should see a JSON response containing your Account, UserId, and Arn. This confirms your CLI can authenticate with AWS.
Seeing 'Unable to locate credentials'?
This means aws configure did not save correctly. Run it again and double-check you are pasting your Access Key ID and Secret Access Key without extra spaces.
help me fix my AWS CLI credentials
Enable Mistral Ministral 3B in the Bedrock console
AWS Bedrock is a managed service that gives you access to foundation models through an API. Before you can call a model, you need to make sure your account has access to it.
What is AWS Bedrock?
Bedrock provides a unified API for calling language models from multiple providers (Mistral, Anthropic, Meta, and more). You pay per token with no upfront commitment. Since you already have AWS credits, there is no additional signup needed.
Mistral Ministral 3B costs just $0.10 per million input tokens and $0.10 per million output tokens. Your total spend for this project will be well under $1.
- Open your web browser and go to the AWS Management Console.
- Make sure your region is set to US East (N. Virginia) us-east-1 in the top-right corner of the console.
- Search for Bedrock in the top search bar and select Amazon Bedrock.
- In the left sidebar, click Model access.
Model access is now automatic
AWS simplified Bedrock model access in late 2025. Serverless models (including Mistral Ministral 3B) are now available automatically in supported regions without a manual enablement step. If you see Ministral 3B listed as available, you are good to go.
If your account is new or you see a first-time subscription prompt for Mistral models, approve it. This may take up to 15 minutes to activate.
- Confirm that Mistral Ministral 3B (or Ministral 3B) shows as available in the model access list.
- If it shows as requiring a subscription, click through to enable it and wait for the status to change to Available.
- Verify Bedrock access works from your terminal by running:
aws bedrock list-foundation-models --region us-east-1 --query "modelSummaries[?contains(modelId, 'ministral')]"
You should see JSON output listing the Ministral model. This confirms your credentials have Bedrock access.
Empty list or access denied?
If you see an empty array [], confirm you are in the us-east-1 region. Ministral 3B is available in US East (N. Virginia).
If you see an AccessDeniedException, your IAM user may need the bedrock:ListFoundationModels permission. Attach the AmazonBedrockFullAccess managed policy to your IAM user.
help me fix Bedrock access permissions
Your development environment is ready. Next up, you will create the project structure, install the Python packages your agent needs, and push everything to GitHub.
Initialize the Project and Install Dependencies
Your development environment is ready with Python, AWS CLI, and Bedrock access all configured.
Now you need a proper project structure to build your agent in. In this step, you will create the project folder, install the Python libraries your agent depends on, and push everything to GitHub so you have a clean starting point.
In this step, get ready to:
- Create the project directory structure and initialize a Git repository.
- Set up a Python virtual environment and install all dependencies.
- Create a GitHub repository and push your initial commit.
Create the project directory structure
Your agent project needs two main directories: one for the Python backend and one for the HTML frontend.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder with its subdirectories by running these commands:
mkdir ai-devops-agent
cd ai-devops-agent
mkdir backend frontend
- Confirm the directories exist by running:
ls
You should see backend and frontend listed in the output.
Don't see both folders?
Make sure you ran all three commands in sequence. The cd ai-devops-agent command moves you into the folder before creating subdirectories.
help me troubleshoot my project directory structure
- Initialize a Git repository in your project folder by running:
git init
You should see a message like "Initialized empty Git repository in .../ai-devops-agent/.git/".
- Open the project in VS Code by running:
code .
What does this set up?
The backend/ folder will hold your Python agent code and FastAPI server.
The frontend/ folder will hold the HTML/CSS/JS chat interface that talks to your agent.
Create a Python virtual environment and install dependencies
A Python virtual environment keeps your project's packages isolated from other Python projects on your machine. This prevents version conflicts between projects.
- Create a virtual environment using Python 3.11 by running:
python3.11 -m venv venv
- Activate the virtual environment by running:
source venv/bin/activate
Your terminal prompt should now show (venv) at the beginning, confirming the virtual environment is active.
Don't see (venv) in your prompt?
Make sure you are inside the ai-devops-agent directory when you run the activate command. Run pwd to confirm your location.
help me activate my Python virtual environment
Next, you will create a requirements.txt file that lists all the Python packages your agent needs.
- In VS Code, create a new file called requirements.txt in the root of your ai-devops-agent folder.
- Paste the following content into the file:
langgraph
boto3
fastapi
mangum
uvicorn
langchain-core
What are these packages?
- langgraph is the agent orchestration framework. It lets you build AI agents as state machines with nodes, edges, and conditional routing.
- boto3 is the AWS SDK for Python. Your agent uses it to call Bedrock and perform S3 operations.
- fastapi is the web framework that creates your streaming API endpoints.
- mangum wraps your FastAPI app so it can run inside AWS Lambda.
- uvicorn is a local development server for testing your API before deploying.
- langchain-core provides the message types (HumanMessage, AIMessage) that LangGraph uses internally.
- Save requirements.txt.
- Install all the dependencies by running this command in your terminal:
pip install -r requirements.txt
This will download and install all six packages along with their dependencies. You should see "Successfully installed" at the end of the output.
Seeing installation errors?
Make sure your virtual environment is active (you should see (venv) in your prompt). If not, run source venv/bin/activate again.
If you see permission errors, do NOT use sudo. Instead, confirm the venv is active and try again.
help me fix my pip install errors
✔️ Awesome, I've got everything!
Great. Double check you have saved requirements.txt and that your pip install completed successfully.
ⓧ I'd like to double check the full code
Here is the complete requirements.txt file:
langgraph
boto3
fastapi
mangum
uvicorn
langchain-core
Create the GitHub repository and push your initial commit
Pushing your project to GitHub now gives you a clean baseline to return to if anything goes wrong in later steps.
First, you need a .gitignore file to prevent your virtual environment folder from being tracked by Git.
- Create a .gitignore file by running this command:
echo -e "venv/\n__pycache__/\n*.pyc\n.env" > .gitignore
Why a .gitignore?
The venv/ folder contains thousands of files from your installed packages. You do not want to upload those to GitHub. Anyone who clones your repo can recreate the virtual environment using requirements.txt instead.
- Stage all your files and create your first commit by running these commands:
git add .
git commit -m "Initial project structure with requirements"
You should see a message confirming the commit with a list of files that were added.
Now create a repository on GitHub to push your code to.
- Open github.com in your browser and sign in.
- Click the + icon in the top right corner.
- Select New repository.
- Enter ai-devops-agent as the repository name.
- Leave all other settings as their defaults (Public, no README, no .gitignore).
- Click Create repository.
GitHub shows a quick setup page with commands for pushing an existing repository.
- Connect your local repo to GitHub and push by running these commands:
git branch -M main
git remote add origin [[YOUR_REPO_URL="https://github.com/YOUR_USERNAME/ai-devops-agent.git"]]
git push -u origin main
Where do I find my repository URL?
On the GitHub quick setup page, copy the HTTPS URL shown at the top. It will look like https://github.com/yourusername/ai-devops-agent.git.
- Refresh your GitHub repository page in the browser.
You should see your requirements.txt, .gitignore, and the backend/ and frontend/ folders listed on the repository page.
Push failed or permission denied?
If you see a "permission denied" error, GitHub may need you to authenticate. Try using a personal access token instead of your password, or set up SSH keys for your account.
If you see "remote origin already exists", run git remote remove origin and then try the remote add command again with the correct URL.
help me push my code to GitHub
Your project structure is ready and backed up on GitHub. Next up, you will build the LangGraph agent that calls AWS Bedrock and uses tools to manage your infrastructure.
Build the LangGraph Agent with Bedrock
Your project skeleton is ready and all dependencies are installed. Now it is time to build the brain of your AI agent.
An AI agent is not just a chatbot. It is a program that can reason about what tools to use, decide when to call them, and respond based on the results. You will build this reasoning loop as a state machine using LangGraph, connected to AWS Bedrock for LLM inference.
In this step, get ready to:
- Configure the Bedrock client and define tool schemas the LLM can call.
- Build the LLM node that sends messages to Bedrock and parses tool-call responses.
- Assemble a StateGraph with conditional routing that decides whether to invoke tools or respond to the user.
Define the agent state schema and configure the Bedrock client
Every LangGraph agent starts with a state definition and an LLM client. The state holds the conversation history, and the client sends that conversation to the model.
- Create a new file called agent.py inside the backend/ folder of your ai-devops-agent project.
- Add the imports and Bedrock client configuration by pasting this code:
import boto3
import json
from typing import Annotated, Literal
from langgraph.graph import StateGraph, START, END, MessagesState
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import interrupt, Command
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage, SystemMessage
# AWS Bedrock client
bedrock_client = boto3.client("bedrock-runtime", region_name="us-east-1")
MODEL_ID = "mistral.ministral-3b-2410-v1:0"
What does this code do?
The imports bring in LangGraph's graph-building tools (StateGraph, START, END) and message types that LangGraph uses to track conversations.
MessagesState is a built-in state schema that stores a list of messages. This is all LangGraph needs to track the full conversation between the user, the LLM, and any tools.
bedrock_client connects to the Bedrock runtime API in us-east-1. MODEL_ID points to Mistral Ministral 3B, the cheapest model on Bedrock at $0.10 per million tokens.
- Save backend/agent.py.
You will not see any output yet. The next chunks build on this foundation.
Next, you need to tell the LLM what tools it can use. The Bedrock Converse API expects tool definitions in a specific JSON schema format that describes each tool's name, purpose, and parameters.
- In backend/agent.py, add the tool configuration below your MODEL_ID line:
# Tool definitions for Bedrock Converse API
TOOL_CONFIG = {
"tools": [
{
"toolSpec": {
"name": "list_s3_buckets",
"description": "List all S3 buckets in the AWS account",
"inputSchema": {
"json": {
"type": "object",
"properties": {},
"required": []
}
}
}
},
{
"toolSpec": {
"name": "create_s3_bucket",
"description": "Create a new S3 bucket with optional versioning",
"inputSchema": {
"json": {
"type": "object",
"properties": {
"bucket_name": {
"type": "string",
"description": "The name for the new S3 bucket (globally unique, lowercase, 3-63 chars)"
},
"enable_versioning": {
"type": "boolean",
"description": "Whether to enable versioning on the bucket"
}
},
"required": ["bucket_name"]
}
}
}
}
]
}
What does this code do?
TOOL_CONFIG is the menu of tools you hand to the LLM. When the model receives a user message, it reads these definitions and decides whether to call a tool or just respond with text.
Each tool spec includes a name, a natural-language description (so the LLM knows when to use it), and an input schema describing what parameters the tool accepts. The create_s3_bucket tool requires a bucket_name and optionally accepts enable_versioning.
- Save backend/agent.py.
Seeing a JSON syntax error?
The nested dictionaries have many levels of braces and brackets. Double-check that every opening { has a matching } and every [ has a matching ].
Help me fix a syntax error in my TOOL_CONFIG dictionary
Now you need placeholder functions that the agent will eventually call. You will add the real implementations in the next step. For now, stubs let you build and test the graph structure.
- Below TOOL_CONFIG, add the tool stubs and the tool registry:
# Tool implementations (stubs - full implementations added in next step)
def list_s3_buckets(**kwargs):
pass
def create_s3_bucket(bucket_name: str, enable_versioning: bool = False):
pass
TOOLS = {
"list_s3_buckets": list_s3_buckets,
"create_s3_bucket": create_s3_bucket,
}
What does this code do?
The two functions are empty placeholders that return None. The TOOLS dictionary maps tool names (matching the names in TOOL_CONFIG) to the actual Python functions. When the LLM requests a tool by name, your agent looks up the function here.
- Save backend/agent.py.
Create the LLM node that talks to Bedrock
The LLM node is the core of your agent. It takes the current conversation, converts it into the format Bedrock expects, calls the model, and parses the response back into LangGraph's message types.
- Below the TOOLS dictionary, add the llm_node function:
# LangGraph nodes
def llm_node(state: MessagesState):
"""Call Bedrock with the conversation and tool definitions."""
bedrock_messages = []
for msg in state["messages"]:
if isinstance(msg, HumanMessage):
bedrock_messages.append({"role": "user", "content": [{"text": msg.content}]})
elif isinstance(msg, AIMessage):
if msg.tool_calls:
content = []
if msg.content:
content.append({"text": msg.content})
for tc in msg.tool_calls:
content.append({
"toolUse": {
"toolUseId": tc["id"],
"name": tc["name"],
"input": tc["args"]
}
})
bedrock_messages.append({"role": "assistant", "content": content})
elif msg.content:
bedrock_messages.append({"role": "assistant", "content": [{"text": msg.content}]})
elif isinstance(msg, ToolMessage):
bedrock_messages.append({
"role": "user",
"content": [{"toolResult": {"toolUseId": msg.tool_call_id, "content": [{"text": msg.content}]}}]
})
response = bedrock_client.converse(
modelId=MODEL_ID,
messages=bedrock_messages,
system=[{"text": "You are an AI DevOps assistant. You help users manage their AWS infrastructure using natural language. Always explain what you are about to do before doing it."}],
toolConfig=TOOL_CONFIG
)
output = response["output"]["message"]
stop_reason = response["stopReason"]
if stop_reason == "tool_use":
tool_calls = []
text_content = ""
for block in output["content"]:
if "toolUse" in block:
tc = block["toolUse"]
tool_calls.append({
"id": tc["toolUseId"],
"name": tc["name"],
"args": tc["input"]
})
elif "text" in block:
text_content = block["text"]
ai_msg = AIMessage(content=text_content, tool_calls=tool_calls)
else:
text_content = ""
for block in output["content"]:
if "text" in block:
text_content += block["text"]
ai_msg = AIMessage(content=text_content)
return {"messages": [ai_msg]}
What does this code do?
This function does three things. First, it converts LangGraph's message objects into the format the Bedrock Converse API expects (each message type maps to a different role and content structure).
Second, it calls bedrock_client.converse() with the formatted messages, a system prompt that tells the model it is a DevOps assistant, and the tool definitions.
Third, it checks the stopReason. If the model decided to use a tool, it extracts the tool call details. If the model just wants to talk, it extracts the text. Either way, it returns an AIMessage back into the graph's state.
- Save backend/agent.py.
Getting an indentation error?
Python is strict about consistent indentation. Make sure you are using 4 spaces (not tabs) throughout the function. In VS Code, check the bottom-right status bar to confirm it says "Spaces: 4".
Help me fix an indentation error in my llm_node function
Wire up the StateGraph with conditional edges
A StateGraph connects your nodes with edges that define the flow of execution. Your graph needs a tool execution node, a routing function that decides the next step, and the wiring that ties everything together.
- Below llm_node, add the tool_node function that executes whichever tool the LLM requested:
def tool_node(state: MessagesState):
"""Execute the tools requested by the LLM."""
results = []
last_message = state["messages"][-1]
for tool_call in last_message.tool_calls:
tool_fn = TOOLS[tool_call["name"]]
result = tool_fn(**tool_call["args"])
results.append(ToolMessage(content=str(result), tool_call_id=tool_call["id"]))
return {"messages": results}
What does this code do?
tool_node reads the last AI message, looks up each requested tool by name in the TOOLS dictionary, calls it with the arguments the LLM provided, and wraps the result in a ToolMessage. This result flows back to the LLM so it can incorporate the tool output into its next response.
- Save backend/agent.py.
- Below tool_node, add the routing function that decides whether the LLM wants to use a tool or is done:
def should_continue(state: MessagesState) -> Literal["tool_node", END]:
"""Route to tools or end based on whether the LLM made a tool call."""
last_message = state["messages"][-1]
if last_message.tool_calls:
return "tool_node"
return END
What does this code do?
This is the decision point in your graph. After the LLM responds, should_continue checks whether the response contains tool calls. If yes, execution flows to tool_node. If no, the conversation ends and the final response is returned to the user.
- Save backend/agent.py.
Now you have all the pieces. The final step is assembling them into a compiled graph that you can invoke.
- At the bottom of backend/agent.py, add the graph assembly function:
# Build the graph
def create_agent():
builder = StateGraph(MessagesState)
builder.add_node("llm_node", llm_node)
builder.add_node("tool_node", tool_node)
builder.add_edge(START, "llm_node")
builder.add_conditional_edges("llm_node", should_continue, ["tool_node", END])
builder.add_edge("tool_node", "llm_node")
checkpointer = InMemorySaver()
return builder.compile(checkpointer=checkpointer)
agent = create_agent()
What does this code do?
create_agent() wires up the full execution flow. Messages always start at llm_node. From there, should_continue decides the next step. If the LLM calls a tool, execution goes to tool_node, then loops back to llm_node so the model can see the tool result.
The InMemorySaver checkpointer saves graph state between interactions. This is essential for the human-in-the-loop pattern you will add later, because it lets the graph pause and resume across separate requests.
- Save backend/agent.py.
- Verify the file has no syntax errors by running this command from your ai-devops-agent directory:
python3.11 -c "import backend.agent; print('Agent graph compiled successfully')"
You should see Agent graph compiled successfully printed in your terminal.
Seeing a ModuleNotFoundError?
Make sure your virtual environment is activated. You should see (venv) at the start of your terminal prompt. If not, activate it by running source venv/bin/activate from inside the ai-devops-agent folder.
Also confirm you are running the command from the ai-devops-agent root folder (not from inside backend/), since the import uses backend.agent as a package path.
Help me fix a ModuleNotFoundError when importing backend.agent
Why LangGraph for an AI agent?
LangGraph models your agent as a state machine. Each node is a function, and edges define the flow between them. This is more powerful than a simple loop because you get built-in checkpointing, the ability to pause for human input, and clear visibility into what the agent is doing at each step.
The graph pattern also makes it easy to add nodes later (like a human approval step) without rewriting existing logic.
✔️ Awesome, I've got everything!
Great. Double check you have saved backend/agent.py and that the import test printed successfully.
ⓧ I'd like to double check the full code
Here is the complete backend/agent.py file at the end of this step:
import boto3
import json
from typing import Annotated, Literal
from langgraph.graph import StateGraph, START, END, MessagesState
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import interrupt, Command
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage, SystemMessage
# AWS Bedrock client
bedrock_client = boto3.client("bedrock-runtime", region_name="us-east-1")
MODEL_ID = "mistral.ministral-3b-2410-v1:0"
# Tool definitions for Bedrock Converse API
TOOL_CONFIG = {
"tools": [
{
"toolSpec": {
"name": "list_s3_buckets",
"description": "List all S3 buckets in the AWS account",
"inputSchema": {
"json": {
"type": "object",
"properties": {},
"required": []
}
}
}
},
{
"toolSpec": {
"name": "create_s3_bucket",
"description": "Create a new S3 bucket with optional versioning",
"inputSchema": {
"json": {
"type": "object",
"properties": {
"bucket_name": {
"type": "string",
"description": "The name for the new S3 bucket (globally unique, lowercase, 3-63 chars)"
},
"enable_versioning": {
"type": "boolean",
"description": "Whether to enable versioning on the bucket"
}
},
"required": ["bucket_name"]
}
}
}
}
]
}
# Tool implementations (stubs - full implementations added in next step)
def list_s3_buckets(**kwargs):
pass
def create_s3_bucket(bucket_name: str, enable_versioning: bool = False):
pass
TOOLS = {
"list_s3_buckets": list_s3_buckets,
"create_s3_bucket": create_s3_bucket,
}
# LangGraph nodes
def llm_node(state: MessagesState):
"""Call Bedrock with the conversation and tool definitions."""
bedrock_messages = []
for msg in state["messages"]:
if isinstance(msg, HumanMessage):
bedrock_messages.append({"role": "user", "content": [{"text": msg.content}]})
elif isinstance(msg, AIMessage):
if msg.tool_calls:
content = []
if msg.content:
content.append({"text": msg.content})
for tc in msg.tool_calls:
content.append({
"toolUse": {
"toolUseId": tc["id"],
"name": tc["name"],
"input": tc["args"]
}
})
bedrock_messages.append({"role": "assistant", "content": content})
elif msg.content:
bedrock_messages.append({"role": "assistant", "content": [{"text": msg.content}]})
elif isinstance(msg, ToolMessage):
bedrock_messages.append({
"role": "user",
"content": [{"toolResult": {"toolUseId": msg.tool_call_id, "content": [{"text": msg.content}]}}]
})
response = bedrock_client.converse(
modelId=MODEL_ID,
messages=bedrock_messages,
system=[{"text": "You are an AI DevOps assistant. You help users manage their AWS infrastructure using natural language. Always explain what you are about to do before doing it."}],
toolConfig=TOOL_CONFIG
)
output = response["output"]["message"]
stop_reason = response["stopReason"]
if stop_reason == "tool_use":
tool_calls = []
text_content = ""
for block in output["content"]:
if "toolUse" in block:
tc = block["toolUse"]
tool_calls.append({
"id": tc["toolUseId"],
"name": tc["name"],
"args": tc["input"]
})
elif "text" in block:
text_content = block["text"]
ai_msg = AIMessage(content=text_content, tool_calls=tool_calls)
else:
text_content = ""
for block in output["content"]:
if "text" in block:
text_content += block["text"]
ai_msg = AIMessage(content=text_content)
return {"messages": [ai_msg]}
def tool_node(state: MessagesState):
"""Execute the tools requested by the LLM."""
results = []
last_message = state["messages"][-1]
for tool_call in last_message.tool_calls:
tool_fn = TOOLS[tool_call["name"]]
result = tool_fn(**tool_call["args"])
results.append(ToolMessage(content=str(result), tool_call_id=tool_call["id"]))
return {"messages": results}
def should_continue(state: MessagesState) -> Literal["tool_node", END]:
"""Route to tools or end based on whether the LLM made a tool call."""
last_message = state["messages"][-1]
if last_message.tool_calls:
return "tool_node"
return END
# Build the graph
def create_agent():
builder = StateGraph(MessagesState)
builder.add_node("llm_node", llm_node)
builder.add_node("tool_node", tool_node)
builder.add_edge(START, "llm_node")
builder.add_conditional_edges("llm_node", should_continue, ["tool_node", END])
builder.add_edge("tool_node", "llm_node")
checkpointer = InMemorySaver()
return builder.compile(checkpointer=checkpointer)
agent = create_agent()
Your agent's brain is now wired up. It can receive messages, talk to Bedrock, and decide when to call tools. Next, you will give those tools real power by implementing actual AWS operations and adding a human approval step before anything destructive happens.
Define AWS Tools and Add Human-in-the-Loop
Your agent has a brain (the LLM node) and a skeleton (the state graph), but its tools are empty stubs. Right now, when the LLM decides to call list_s3_buckets, the function does nothing and returns None.
In this step, you will give your tools real power by connecting them to boto3 (the AWS SDK for Python). You will also add a critical safety mechanism: human-in-the-loop confirmation that pauses the agent before it executes any destructive action on your real AWS account.
In this step, get ready to:
- Implement the S3 tool functions with real AWS API calls.
- Add LangGraph's interrupt() pattern to pause for human approval before creating buckets.
- Test the full agent loop locally to verify everything works end-to-end.
Implement the list_s3_buckets tool
The simplest tool is the read-only one. Listing buckets does not change anything on your account, so it does not need human approval.
- In backend/agent.py, find the stub implementation of list_s3_buckets:
# Tool implementations (stubs - full implementations added in next step)
def list_s3_buckets(**kwargs):
pass
- Replace the entire list_s3_buckets function (including the comment above it) with this implementation:
# Tool implementations
def list_s3_buckets(**kwargs):
s3 = boto3.client("s3")
response = s3.list_buckets()
buckets = [b["Name"] for b in response["Buckets"]]
if not buckets:
return "No S3 buckets found in this account."
return f"Found {len(buckets)} buckets: {', '.join(buckets)}"
What does this code do?
This function creates a fresh S3 client, calls the ListBuckets API, and formats the response into a human-readable string. The agent will see this string as the tool's result and use it to compose its reply to you.
- Save backend/agent.py.
Add create_s3_bucket with human-in-the-loop approval
Creating a bucket is a write operation that changes your real AWS account. You do not want the agent doing this without your explicit approval. LangGraph's interrupt() function pauses the entire graph execution, saves state to the checkpointer, and waits for you to resume it with an approval or rejection.
- In the same file, find the stub create_s3_bucket function:
def create_s3_bucket(bucket_name: str, enable_versioning: bool = False):
pass
- Replace the entire create_s3_bucket function with this implementation:
def create_s3_bucket(bucket_name: str, enable_versioning: bool = False):
# Human-in-the-loop: ask for approval before creating
approval = interrupt({
"action": "create_s3_bucket",
"bucket_name": bucket_name,
"enable_versioning": enable_versioning,
"message": f"I want to create S3 bucket '{bucket_name}' with versioning={'enabled' if enable_versioning else 'disabled'}. Approve?"
})
if approval.get("approved") is not True:
return f"Action cancelled by user. Bucket '{bucket_name}' was NOT created."
s3 = boto3.client("s3")
s3.create_bucket(Bucket=bucket_name)
if enable_versioning:
s3.put_bucket_versioning(
Bucket=bucket_name,
VersioningConfiguration={"Status": "Enabled"}
)
return f"Successfully created S3 bucket '{bucket_name}' with versioning={'enabled' if enable_versioning else 'disabled'}."
What does this code do?
- interrupt() pauses the graph and sends a payload (the action details and a confirmation message) back to whatever called the agent. The graph state is saved to the checkpointer automatically.
- When the graph resumes, the return value of interrupt() is whatever the caller passed in via Command(resume=...). You check if approved is True before proceeding.
- If approved, the function creates the bucket and optionally enables versioning. If rejected, it returns a cancellation message without touching AWS.
Why interrupt() instead of a simple confirmation prompt?
A simple input() call would only work in the terminal. The interrupt() pattern saves the full graph state and works across any interface: a web UI, an API, a Slack bot, or a CLI. When your agent is deployed as an API later, the frontend will send the approval as an HTTP request that resumes the paused graph.
- Save backend/agent.py.
Seeing a red squiggle under interrupt?
Make sure your import at the top of the file includes interrupt from langgraph.types. The import was added in the previous step: from langgraph.types import interrupt, Command.
Help me fix my interrupt import.
Test the agent locally
Before building the web interface, you should confirm the agent works end-to-end in the terminal. You will test both the read-only tool (list buckets) and the interrupt flow (create bucket).
- Make sure you are in the backend/ directory and your virtual environment is activated.
- Start a Python interactive session by running:
python3.11 -i agent.py
What does the -i flag do?
The -i flag runs the script and then drops you into an interactive Python prompt. This means all your functions, the agent object, and imports are already loaded and ready to use.
- Test the list buckets tool by sending a natural language message to the agent. Paste this into the interactive prompt:
# Test listing S3 buckets
result = agent.invoke(
{"messages": [HumanMessage(content="List my S3 buckets")]},
config={"configurable": {"thread_id": "test-1"}}
)
print(result["messages"][-1].content)
You should see the agent respond with a list of your existing S3 buckets, or a message saying no buckets were found. This confirms the LLM parsed your intent, selected the correct tool, and executed it.
- Now test the human-in-the-loop flow. Paste this into the interactive prompt:
# Test the create bucket interrupt flow
from langgraph.types import Command
try:
result = agent.invoke(
{"messages": [HumanMessage(content="Create an S3 bucket called test-agent-bucket-12345")]},
config={"configurable": {"thread_id": "test-2"}}
)
except Exception as e:
print("Graph paused for approval (expected)")
# Check the interrupted state
state = agent.get_state({"configurable": {"thread_id": "test-2"}})
print("Next nodes:", state.next)
print("Interrupt payload:", state.tasks[0].interrupts[0].value)
What should I see?
The graph pauses when it hits interrupt() inside create_s3_bucket. You should see Next nodes: ('tool_node',) and the interrupt payload showing the bucket name and confirmation message.
- Resume the graph with a rejection to confirm the cancellation path works (this avoids accidentally creating a real bucket). Paste this:
# Resume with rejection - no bucket gets created
result = agent.invoke(
Command(resume={"approved": False}),
config={"configurable": {"thread_id": "test-2"}}
)
print(result["messages"][-1].content)
You should see a message confirming the action was cancelled and no bucket was created.
- Exit the interactive session by typing:
exit()
Getting an AccessDeniedException?
This means your AWS credentials do not have permission to call the Bedrock or S3 APIs. Verify your credentials are configured by running aws sts get-caller-identity in a separate terminal. Also confirm you enabled the Mistral Ministral 3B model in the Bedrock console (Step 1).
Help me debug my AWS credentials for the Bedrock agent.
✔️ Awesome, I've got everything!
Great. Double check you have saved backend/agent.py with both full tool implementations.
ⓧ I'd like to double check the full code
Here is the complete backend/agent.py file as it should look at the end of this step:
import boto3
import json
from typing import Annotated, Literal
from langgraph.graph import StateGraph, START, END, MessagesState
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import interrupt, Command
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage, SystemMessage
# AWS Bedrock client
bedrock_client = boto3.client("bedrock-runtime", region_name="us-east-1")
MODEL_ID = "mistral.ministral-3b-2410-v1:0"
# Tool definitions for Bedrock Converse API
TOOL_CONFIG = {
"tools": [
{
"toolSpec": {
"name": "list_s3_buckets",
"description": "List all S3 buckets in the AWS account",
"inputSchema": {
"json": {
"type": "object",
"properties": {},
"required": []
}
}
}
},
{
"toolSpec": {
"name": "create_s3_bucket",
"description": "Create a new S3 bucket with optional versioning",
"inputSchema": {
"json": {
"type": "object",
"properties": {
"bucket_name": {
"type": "string",
"description": "The name for the new S3 bucket (globally unique, lowercase, 3-63 chars)"
},
"enable_versioning": {
"type": "boolean",
"description": "Whether to enable versioning on the bucket"
}
},
"required": ["bucket_name"]
}
}
}
}
]
}
# Tool implementations
def list_s3_buckets(**kwargs):
s3 = boto3.client("s3")
response = s3.list_buckets()
buckets = [b["Name"] for b in response["Buckets"]]
if not buckets:
return "No S3 buckets found in this account."
return f"Found {len(buckets)} buckets: {', '.join(buckets)}"
def create_s3_bucket(bucket_name: str, enable_versioning: bool = False):
# Human-in-the-loop: ask for approval before creating
approval = interrupt({
"action": "create_s3_bucket",
"bucket_name": bucket_name,
"enable_versioning": enable_versioning,
"message": f"I want to create S3 bucket '{bucket_name}' with versioning={'enabled' if enable_versioning else 'disabled'}. Approve?"
})
if approval.get("approved") is not True:
return f"Action cancelled by user. Bucket '{bucket_name}' was NOT created."
s3 = boto3.client("s3")
s3.create_bucket(Bucket=bucket_name)
if enable_versioning:
s3.put_bucket_versioning(
Bucket=bucket_name,
VersioningConfiguration={"Status": "Enabled"}
)
return f"Successfully created S3 bucket '{bucket_name}' with versioning={'enabled' if enable_versioning else 'disabled'}."
TOOLS = {
"list_s3_buckets": list_s3_buckets,
"create_s3_bucket": create_s3_bucket,
}
# LangGraph nodes
def llm_node(state: MessagesState):
"""Call Bedrock with the conversation and tool definitions."""
bedrock_messages = []
for msg in state["messages"]:
if isinstance(msg, HumanMessage):
bedrock_messages.append({"role": "user", "content": [{"text": msg.content}]})
elif isinstance(msg, AIMessage):
if msg.tool_calls:
content = []
if msg.content:
content.append({"text": msg.content})
for tc in msg.tool_calls:
content.append({
"toolUse": {
"toolUseId": tc["id"],
"name": tc["name"],
"input": tc["args"]
}
})
bedrock_messages.append({"role": "assistant", "content": content})
elif msg.content:
bedrock_messages.append({"role": "assistant", "content": [{"text": msg.content}]})
elif isinstance(msg, ToolMessage):
bedrock_messages.append({
"role": "user",
"content": [{"toolResult": {"toolUseId": msg.tool_call_id, "content": [{"text": msg.content}]}}]
})
response = bedrock_client.converse(
modelId=MODEL_ID,
messages=bedrock_messages,
system=[{"text": "You are an AI DevOps assistant. You help users manage their AWS infrastructure using natural language. Always explain what you are about to do before doing it."}],
toolConfig=TOOL_CONFIG
)
output = response["output"]["message"]
stop_reason = response["stopReason"]
if stop_reason == "tool_use":
tool_calls = []
text_content = ""
for block in output["content"]:
if "toolUse" in block:
tc = block["toolUse"]
tool_calls.append({
"id": tc["toolUseId"],
"name": tc["name"],
"args": tc["input"]
})
elif "text" in block:
text_content = block["text"]
ai_msg = AIMessage(content=text_content, tool_calls=tool_calls)
else:
text_content = ""
for block in output["content"]:
if "text" in block:
text_content += block["text"]
ai_msg = AIMessage(content=text_content)
return {"messages": [ai_msg]}
def tool_node(state: MessagesState):
"""Execute the tools requested by the LLM."""
results = []
last_message = state["messages"][-1]
for tool_call in last_message.tool_calls:
tool_fn = TOOLS[tool_call["name"]]
result = tool_fn(**tool_call["args"])
results.append(ToolMessage(content=str(result), tool_call_id=tool_call["id"]))
return {"messages": results}
def should_continue(state: MessagesState) -> Literal["tool_node", END]:
"""Route to tools or end based on whether the LLM made a tool call."""
last_message = state["messages"][-1]
if last_message.tool_calls:
return "tool_node"
return END
# Build the graph
def create_agent():
builder = StateGraph(MessagesState)
builder.add_node("llm_node", llm_node)
builder.add_node("tool_node", tool_node)
builder.add_edge(START, "llm_node")
builder.add_conditional_edges("llm_node", should_continue, ["tool_node", END])
builder.add_edge("tool_node", "llm_node")
checkpointer = InMemorySaver()
return builder.compile(checkpointer=checkpointer)
agent = create_agent()
Your agent now has real teeth. It can read from and write to your AWS account, with a safety gate that prevents accidental changes. Next up, you will build the streaming API that exposes this agent over HTTP so a web frontend can talk to it.
Create the Streaming FastAPI Backend
Your agent can think, select tools, and execute AWS actions. Right now it only runs in memory with no way for a web browser to talk to it.
In this step, you will build a FastAPI web server that exposes the agent over HTTP. The server streams every event (thinking, tool calls, interrupts, results) to the browser in real-time using Server-Sent Events.
In this step, get ready to:
- Build a streaming /chat endpoint that pipes agent events to the browser as SSE.
- Add a /resume endpoint that continues execution after human approval.
- Wrap the app with Mangum for AWS Lambda and test locally with uvicorn.
Build the FastAPI app with a /chat endpoint
The frontend will send a chat message and expect a stream of events back. You need an HTTP endpoint that accepts a message, runs the agent, and yields each event as it happens.
- Create a new file called app.py inside your backend/ folder.
- Add the following imports, app setup, and CORS middleware:
import json
import uuid
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
from fastapi.middleware.cors import CORSMiddleware
from langchain_core.messages import HumanMessage
from langgraph.types import Command
from agent import agent
app = FastAPI()
# Allow the frontend (any origin) to call this API
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
# In-memory thread storage
threads = {}
What does this code do?
- Imports FastAPI, its StreamingResponse (for SSE), and the LangGraph Command class (for resuming after an interrupt).
- Imports the compiled agent from agent.py so the API can run the graph.
- Adds CORS middleware with wildcard origins so the frontend can call the API from any domain.
- Creates an in-memory threads dictionary to track conversation sessions by ID.
You won't see a change yet. The next chunk adds the main endpoint.
- Below the threads = {} line, add the /chat endpoint:
@app.post("/chat")
async def chat(request: Request):
body = await request.json()
message = body.get("message", "")
thread_id = body.get("thread_id", str(uuid.uuid4()))
if thread_id not in threads:
threads[thread_id] = thread_id
config = {"configurable": {"thread_id": thread_id}}
async def event_stream():
try:
# Stream agent execution using updates mode
for event in agent.stream(
{"messages": [HumanMessage(content=message)]},
config=config,
stream_mode="updates"
):
for node_name, node_output in event.items():
sse_data = json.dumps({"node": node_name, "data": _serialize_output(node_output)})
yield f"data: {sse_data}\n\n"
except Exception as e:
# Check if the exception is actually an interrupt
state = agent.get_state(config)
if state.next:
interrupt_data = None
if hasattr(state, 'tasks') and state.tasks:
for task in state.tasks:
if hasattr(task, 'interrupts') and task.interrupts:
interrupt_data = task.interrupts[0].value
sse_data = json.dumps({
"node": "interrupt",
"data": {"thread_id": thread_id, "interrupt": interrupt_data}
})
yield f"data: {sse_data}\n\n"
else:
sse_data = json.dumps({"node": "error", "data": {"message": str(e)}})
yield f"data: {sse_data}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(event_stream(), media_type="text/event-stream")
What does this endpoint do?
This is the core of your streaming API. Here is how it works:
- Accepts a JSON body with a message string and an optional thread_id.
- Calls agent.stream() with stream_mode="updates" so each node emits its state change as a separate event.
- Each event is formatted as an SSE line (data: {...}\n\n) and yielded to the browser.
- When an interrupt() fires inside a tool, LangGraph raises an exception. The except block catches it, reads the paused graph state, and sends the interrupt payload to the frontend.
- A final [DONE] sentinel tells the browser the stream is finished.
Why SSE instead of WebSockets?
SSE runs over plain HTTP and works through load balancers, CDNs, and Lambda Function URLs without extra configuration. WebSockets require persistent connections that are harder to manage in serverless environments.
For a one-directional stream (server pushes events to the client), SSE is simpler and cheaper.
- Save backend/app.py.
Seeing an import error?
- Make sure your virtual environment is activated. Run source venv/bin/activate if you are not sure.
- Check that fastapi is installed by running pip show fastapi. If not, re-run your pip install command from Step 2.
Help me debug this import error
Add the /resume endpoint for human approval
When the agent hits an interrupt (asking the user to approve a bucket creation), it pauses. The frontend needs a way to send the approval back and resume execution.
- In backend/app.py, add the /resume endpoint below the /chat function:
@app.post("/resume")
async def resume(request: Request):
body = await request.json()
thread_id = body.get("thread_id")
approved = body.get("approved", False)
config = {"configurable": {"thread_id": thread_id}}
async def event_stream():
# Resume the graph with the user's approval decision
for event in agent.stream(
Command(resume={"approved": approved}),
config=config,
stream_mode="updates"
):
for node_name, node_output in event.items():
sse_data = json.dumps({"node": node_name, "data": _serialize_output(node_output)})
yield f"data: {sse_data}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(event_stream(), media_type="text/event-stream")
What does this endpoint do?
- Accepts a thread_id (identifying the paused conversation) and an approved boolean.
- Passes Command(resume={"approved": approved}) to agent.stream(). LangGraph uses this value as the return value of the interrupt() call that paused the graph.
- Streams the remaining execution (tool result, final LLM response) back to the frontend in the same SSE format.
- Save backend/app.py.
Add Mangum and test locally
Your FastAPI app needs two more things: a serialization helper so LangGraph messages can travel over HTTP, and a Lambda handler for deployment.
- Below the /resume endpoint, add the _serialize_output helper function:
def _serialize_output(output):
"""Convert LangGraph output to JSON-serializable format."""
if "messages" in output:
messages = []
for msg in output["messages"]:
msg_dict = {"type": type(msg).__name__, "content": msg.content}
if hasattr(msg, "tool_calls") and msg.tool_calls:
msg_dict["tool_calls"] = msg.tool_calls
if hasattr(msg, "tool_call_id") and msg.tool_call_id:
msg_dict["tool_call_id"] = msg.tool_call_id
messages.append(msg_dict)
return {"messages": messages}
return str(output)
What does this helper do?
LangGraph messages are Python objects that cannot be directly serialized to JSON. This function extracts the important fields (message type, content, tool calls) into plain dictionaries the frontend can parse.
- At the very bottom of backend/app.py, add the Mangum handler:
# Lambda handler via Mangum
from mangum import Mangum
handler = Mangum(app)
What is Mangum?
Mangum is an adapter that translates AWS Lambda events into ASGI requests that FastAPI can process. When Lambda invokes your function, Mangum converts the event payload, routes it through FastAPI, and returns the response in the format Lambda expects.
Locally you use uvicorn to serve the app. In production, Lambda calls the handler function instead.
- Save backend/app.py.
- Start the local dev server by running this command from your project root (ai-devops-agent/):
uvicorn backend.app:app --reload
What does this command do?
- uvicorn is an ASGI web server that runs FastAPI apps locally.
- backend.app:app tells uvicorn to find the app object inside backend/app.py.
- --reload restarts the server automatically when you save file changes.
- You should see output like Uvicorn running on http://127.0.0.1:8000 in your terminal.
- Open a second terminal and test the /chat endpoint by running:
curl -N -X POST http://127.0.0.1:8000/chat \
-H "Content-Type: application/json" \
-d '{"message": "List my S3 buckets"}'
- You should see SSE events streaming in your terminal, formatted as data: {"node": "llm_node", ...} lines followed by data: [DONE].
Not seeing any SSE events?
- Check that the first terminal still shows uvicorn running without errors. If you see an import error, make sure your virtual environment is activated in that terminal.
- Confirm that your AWS credentials are configured by running aws sts get-caller-identity in the second terminal. The agent needs valid credentials to call Bedrock.
- If you see Connection refused, make sure you are hitting port 8000 and that uvicorn started successfully.
Help me debug my streaming endpoint
- Press Ctrl+C in the first terminal to stop uvicorn when you are done testing.
✔️ Awesome, I've got everything!
Great. Double check you have saved backend/app.py and that the curl test returned SSE events.
ⓧ I'd like to double check the full code
Here is the complete backend/app.py for comparison:
import json
import uuid
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
from fastapi.middleware.cors import CORSMiddleware
from langchain_core.messages import HumanMessage
from langgraph.types import Command
from agent import agent
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
# In-memory thread storage
threads = {}
@app.post("/chat")
async def chat(request: Request):
body = await request.json()
message = body.get("message", "")
thread_id = body.get("thread_id", str(uuid.uuid4()))
if thread_id not in threads:
threads[thread_id] = thread_id
config = {"configurable": {"thread_id": thread_id}}
async def event_stream():
try:
for event in agent.stream(
{"messages": [HumanMessage(content=message)]},
config=config,
stream_mode="updates"
):
for node_name, node_output in event.items():
sse_data = json.dumps({"node": node_name, "data": _serialize_output(node_output)})
yield f"data: {sse_data}\n\n"
except Exception as e:
state = agent.get_state(config)
if state.next:
interrupt_data = None
if hasattr(state, 'tasks') and state.tasks:
for task in state.tasks:
if hasattr(task, 'interrupts') and task.interrupts:
interrupt_data = task.interrupts[0].value
sse_data = json.dumps({
"node": "interrupt",
"data": {"thread_id": thread_id, "interrupt": interrupt_data}
})
yield f"data: {sse_data}\n\n"
else:
sse_data = json.dumps({"node": "error", "data": {"message": str(e)}})
yield f"data: {sse_data}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(event_stream(), media_type="text/event-stream")
@app.post("/resume")
async def resume(request: Request):
body = await request.json()
thread_id = body.get("thread_id")
approved = body.get("approved", False)
config = {"configurable": {"thread_id": thread_id}}
async def event_stream():
for event in agent.stream(
Command(resume={"approved": approved}),
config=config,
stream_mode="updates"
):
for node_name, node_output in event.items():
sse_data = json.dumps({"node": node_name, "data": _serialize_output(node_output)})
yield f"data: {sse_data}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(event_stream(), media_type="text/event-stream")
def _serialize_output(output):
"""Convert LangGraph output to JSON-serializable format."""
if "messages" in output:
messages = []
for msg in output["messages"]:
msg_dict = {"type": type(msg).__name__, "content": msg.content}
if hasattr(msg, "tool_calls") and msg.tool_calls:
msg_dict["tool_calls"] = msg.tool_calls
if hasattr(msg, "tool_call_id") and msg.tool_call_id:
msg_dict["tool_call_id"] = msg.tool_call_id
messages.append(msg_dict)
return {"messages": messages}
return str(output)
# Lambda handler via Mangum
from mangum import Mangum
handler = Mangum(app)
Your backend is streaming agent events in real-time. Next up, you will build the chat frontend that connects to this API and renders the agent's thinking, tool calls, and confirmation prompts in a live UI.
Build the Real-Time Chat Frontend
Your backend is streaming agent events over Server-Sent Events. Now you need a frontend that can consume that stream and render the agent's thinking, tool calls, and confirmation prompts in real time.
In this step, you will build a vanilla HTML/CSS/JS chat interface inside your frontend/ directory. The UI uses the Fetch API's ReadableStream to process SSE data as it arrives, displaying each event as a distinct message bubble.
In this step, get ready to:
- Create the HTML chat interface structure.
- Style the UI with a dark theme and distinct message types.
- Add JavaScript for streaming events and human approval buttons.
Create the HTML chat interface
The chat page needs a header, a scrollable message container, and a text input with a send button. The HTML links to a stylesheet and a script file that you will create next.
- Create a file called index.html inside the frontend/ folder by running:
touch frontend/index.html
- Open frontend/index.html in VS Code and paste the following:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI DevOps Agent</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="app">
<header>
<h1>AI DevOps Agent</h1>
<p>Manage your AWS infrastructure with natural language</p>
</header>
<!-- Scrollable message area -->
<div class="chat-container" id="chat-container">
<!-- Default greeting from the agent -->
<div class="message agent">
<div class="message-content">
Hello! I can help you manage your AWS infrastructure. Try asking me to list your S3 buckets or create a new one.
</div>
</div>
</div>
<!-- User input area -->
<div class="input-container">
<input type="text" id="message-input" placeholder="e.g., Create an S3 bucket called my-demo-app with versioning" autocomplete="off">
<button id="send-btn" onclick="sendMessage()">Send</button>
</div>
</div>
<script src="app.js"></script>
</body>
</html>
What does this code do?
The #chat-container div holds all message bubbles. New messages get appended here dynamically by JavaScript.
The #send-btn button calls sendMessage() which you will define in app.js. The initial .message.agent div gives the user a greeting when the page first loads.
- Save frontend/index.html.
- Open frontend/index.html in your browser (right-click the file in VS Code's Explorer sidebar and select Open in Browser, or drag it into a browser window).
- You should see the heading "AI DevOps Agent", a greeting message, and an input field with a Send button. It looks unstyled for now.
Seeing a blank page?
Make sure you saved the file before opening it. Also confirm the file is named exactly index.html (not index.html.txt) and lives inside the frontend/ folder.
Help me debug my blank HTML page
Style the chat UI with a dark theme
Each message type in the agent's response stream needs a distinct visual treatment. User messages appear on the right in blue. Agent replies sit on the left. Tool calls get an orange border. Confirmation prompts get a red border with action buttons.
- Create frontend/style.css by running:
touch frontend/style.css
- Open frontend/style.css and paste the base layout styles:
/* Reset defaults and set dark background */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f1117;
color: #e1e4e8;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
/* Main chat card container */
.app {
width: 100%;
max-width: 800px;
height: 90vh;
display: flex;
flex-direction: column;
background: #161b22;
border-radius: 12px;
overflow: hidden;
border: 1px solid #30363d;
}
header {
padding: 20px;
border-bottom: 1px solid #30363d;
text-align: center;
}
header h1 {
font-size: 1.5rem;
color: #58a6ff;
}
header p {
font-size: 0.85rem;
color: #8b949e;
margin-top: 4px;
}
/* Scrollable message area */
.chat-container {
flex: 1;
overflow-y: auto;
padding: 20px;
display: flex;
flex-direction: column;
gap: 12px;
}
What does this code do?
The .app container is a centered card using flexbox column layout. It takes 90% of the viewport height so the chat fills most of the screen.
The .chat-container uses flex: 1 to grow into all available space between the header and input bar. overflow-y: auto makes it scrollable when messages overflow.
- You won't see a change yet. The next chunk adds the message and input styles that complete the look.
- Below the .chat-container styles, paste the message type and input styles:
/* Base message bubble */
.message {
max-width: 80%;
padding: 12px 16px;
border-radius: 12px;
font-size: 0.9rem;
line-height: 1.5;
}
/* User messages: blue, aligned right */
.message.user {
align-self: flex-end;
background: #1f6feb;
color: #fff;
}
/* Agent messages: dark with border, aligned left */
.message.agent {
align-self: flex-start;
background: #21262d;
border: 1px solid #30363d;
}
/* Tool call messages: orange border, monospace */
.message.tool-call {
align-self: flex-start;
background: #1c2128;
border: 1px solid #f0883e;
font-family: monospace;
font-size: 0.8rem;
}
/* Interrupt messages: red border for confirmation prompts */
.message.interrupt {
align-self: flex-start;
background: #1c2128;
border: 1px solid #f85149;
padding: 16px;
}
.message.interrupt .actions {
margin-top: 12px;
display: flex;
gap: 8px;
}
.message.interrupt .actions button {
padding: 8px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
font-size: 0.85rem;
}
.btn-approve {
background: #238636;
color: #fff;
}
.btn-reject {
background: #da3633;
color: #fff;
}
/* Input bar at the bottom */
.input-container {
padding: 16px 20px;
border-top: 1px solid #30363d;
display: flex;
gap: 8px;
}
#message-input {
flex: 1;
padding: 12px 16px;
border-radius: 8px;
border: 1px solid #30363d;
background: #0d1117;
color: #e1e4e8;
font-size: 0.9rem;
outline: none;
}
#message-input:focus {
border-color: #58a6ff;
}
#send-btn {
padding: 12px 24px;
background: #238636;
color: #fff;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
font-size: 0.9rem;
}
#send-btn:hover {
background: #2ea043;
}
.message-content {
white-space: pre-wrap;
}
What does this code do?
Each .message variant uses a different border color so you can visually distinguish what the agent is doing at each stage. Orange means a tool is being called. Red means the agent needs your approval.
The .btn-approve and .btn-reject classes style the human-in-the-loop buttons that appear inside interrupt messages.
- Save frontend/style.css and refresh your browser.
- You should see a dark-themed chat card centered on the page, with a blue "AI DevOps Agent" header, the greeting message in a dark bubble on the left, and a styled input bar with a green Send button at the bottom.
Styles not showing up?
Confirm the file is named exactly style.css (not styles.css) and is in the same frontend/ folder as index.html. The HTML links to style.css using a relative path.
Try a hard refresh with Ctrl+Shift+R to bypass browser cache.
Help me debug why my CSS is not loading
✔️ Awesome, I've got everything!
Great. Double check you have saved both files before moving on.
ⓧ I'd like to double check the full code
Here is the complete frontend/style.css:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f1117;
color: #e1e4e8;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
.app {
width: 100%;
max-width: 800px;
height: 90vh;
display: flex;
flex-direction: column;
background: #161b22;
border-radius: 12px;
overflow: hidden;
border: 1px solid #30363d;
}
header {
padding: 20px;
border-bottom: 1px solid #30363d;
text-align: center;
}
header h1 {
font-size: 1.5rem;
color: #58a6ff;
}
header p {
font-size: 0.85rem;
color: #8b949e;
margin-top: 4px;
}
.chat-container {
flex: 1;
overflow-y: auto;
padding: 20px;
display: flex;
flex-direction: column;
gap: 12px;
}
.message {
max-width: 80%;
padding: 12px 16px;
border-radius: 12px;
font-size: 0.9rem;
line-height: 1.5;
}
.message.user {
align-self: flex-end;
background: #1f6feb;
color: #fff;
}
.message.agent {
align-self: flex-start;
background: #21262d;
border: 1px solid #30363d;
}
.message.tool-call {
align-self: flex-start;
background: #1c2128;
border: 1px solid #f0883e;
font-family: monospace;
font-size: 0.8rem;
}
.message.interrupt {
align-self: flex-start;
background: #1c2128;
border: 1px solid #f85149;
padding: 16px;
}
.message.interrupt .actions {
margin-top: 12px;
display: flex;
gap: 8px;
}
.message.interrupt .actions button {
padding: 8px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
font-size: 0.85rem;
}
.btn-approve {
background: #238636;
color: #fff;
}
.btn-reject {
background: #da3633;
color: #fff;
}
.input-container {
padding: 16px 20px;
border-top: 1px solid #30363d;
display: flex;
gap: 8px;
}
#message-input {
flex: 1;
padding: 12px 16px;
border-radius: 8px;
border: 1px solid #30363d;
background: #0d1117;
color: #e1e4e8;
font-size: 0.9rem;
outline: none;
}
#message-input:focus {
border-color: #58a6ff;
}
#send-btn {
padding: 12px 24px;
background: #238636;
color: #fff;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
font-size: 0.9rem;
}
#send-btn:hover {
background: #2ea043;
}
.message-content {
white-space: pre-wrap;
}
Add JavaScript for streaming and approval buttons
The JavaScript connects your chat UI to the backend's SSE stream. When the user types a message, the script sends it to the /chat endpoint and reads the streamed events one by one, rendering each as it arrives.
- Create frontend/app.js by running:
touch frontend/app.js
- Open frontend/app.js and paste the core message-sending and streaming logic:
// Backend API URL - you'll replace this after deploying to Lambda
const API_URL = "your-lambda-function-url-here";
let currentThreadId = null;
// Send the user's message and start streaming the response
function sendMessage() {
const input = document.getElementById("message-input");
const message = input.value.trim();
if (!message) return;
addMessage(message, "user");
input.value = "";
// Generate a thread ID to track this conversation
if (!currentThreadId) {
currentThreadId = crypto.randomUUID();
}
fetchStream("/chat", { message: message, thread_id: currentThreadId });
}
// Connect to the SSE stream and read events as they arrive
function fetchStream(endpoint, body) {
fetch(API_URL + endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
}).then(response => {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
function read() {
reader.read().then(({ done, value }) => {
if (done) return;
buffer += decoder.decode(value, { stream: true });
// Split on newlines to find complete SSE messages
const lines = buffer.split("\n");
buffer = lines.pop();
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6);
if (data === "[DONE]") return;
try {
handleEvent(JSON.parse(data));
} catch (e) {
console.error("Parse error:", e);
}
}
}
read();
});
}
read();
});
}
What does this code do?
sendMessage() grabs the input text, immediately renders it as a user bubble, then kicks off the stream. The currentThreadId links all messages in one conversation so the agent's checkpointer can track state.
fetchStream() uses the Fetch API's ReadableStream interface to process Server-Sent Events incrementally. Each data: line is parsed as JSON and passed to handleEvent() which you will add next.
- You won't see a change yet. The next chunk adds the event handling and display functions.
- Below the fetchStream function, paste the event handling and display logic:
// Route each streamed event to the correct display function
function handleEvent(event) {
const { node, data } = event;
if (node === "llm_node" && data.messages) {
for (const msg of data.messages) {
if (msg.content) {
addMessage(msg.content, "agent");
}
if (msg.tool_calls) {
for (const tc of msg.tool_calls) {
addMessage(
"Tool: " + tc.name + "\nArgs: " + JSON.stringify(tc.args, null, 2),
"tool-call"
);
}
}
}
} else if (node === "tool_node" && data.messages) {
for (const msg of data.messages) {
addMessage(msg.content, "agent");
}
} else if (node === "interrupt" && data.interrupt) {
addInterruptMessage(data.interrupt, data.thread_id);
}
}
// Append a message bubble to the chat container
function addMessage(content, type) {
const container = document.getElementById("chat-container");
const div = document.createElement("div");
div.className = "message " + type;
div.innerHTML = '<div class="message-content">' + escapeHtml(content) + '</div>';
container.appendChild(div);
container.scrollTop = container.scrollHeight;
}
// Render a confirmation prompt with Approve/Reject buttons
function addInterruptMessage(interrupt, threadId) {
const container = document.getElementById("chat-container");
const div = document.createElement("div");
div.className = "message interrupt";
div.innerHTML =
'<div class="message-content">' + escapeHtml(interrupt.message) + '</div>' +
'<div class="actions">' +
'<button class="btn-approve" onclick="handleApproval(\'' + threadId + '\', true)">Approve</button>' +
'<button class="btn-reject" onclick="handleApproval(\'' + threadId + '\', false)">Reject</button>' +
'</div>';
container.appendChild(div);
container.scrollTop = container.scrollHeight;
}
// Send the user's approval or rejection to the /resume endpoint
function handleApproval(threadId, approved) {
const buttons = document.querySelectorAll(".message.interrupt .actions button");
buttons.forEach(btn => btn.disabled = true);
addMessage(approved ? "Approved" : "Rejected", "user");
fetchStream("/resume", { thread_id: threadId, approved: approved });
}
// Safely escape HTML to prevent XSS in rendered messages
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
// Allow sending with Enter key
document.getElementById("message-input").addEventListener("keydown", function(e) {
if (e.key === "Enter") sendMessage();
});
What does this code do?
handleEvent() inspects the node field of each SSE event. If the LLM node sent a response, it renders the text and any tool calls. If the tool node returned a result, it shows that. If an interrupt arrived, it renders the confirmation prompt with buttons.
addInterruptMessage() creates the human-in-the-loop UI. When the user clicks Approve or Reject, handleApproval() disables the buttons and streams the /resume endpoint to continue the graph execution.
- Save frontend/app.js and refresh your browser.
- Type any message in the input field and press Enter.
- You should see your message appear as a blue bubble on the right side of the chat. The agent will not respond because the backend is not deployed yet, but this confirms the JavaScript is loaded and working.
Message not appearing when you press Enter?
Open DevTools with F12 and check the Console tab for errors. A common issue is the file being named app.js but placed outside the frontend/ folder.
Also check that you do not have a typo in the getElementById calls. The IDs must match exactly: message-input and chat-container.
Help me debug why my chat message is not appearing
✔️ Awesome, I've got everything!
Great. Make sure all three frontend files are saved before moving on.
ⓧ I'd like to double check the full code
Here is the complete frontend/app.js:
const API_URL = "your-lambda-function-url-here";
let currentThreadId = null;
function sendMessage() {
const input = document.getElementById("message-input");
const message = input.value.trim();
if (!message) return;
addMessage(message, "user");
input.value = "";
if (!currentThreadId) {
currentThreadId = crypto.randomUUID();
}
fetchStream("/chat", { message: message, thread_id: currentThreadId });
}
function fetchStream(endpoint, body) {
fetch(API_URL + endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
}).then(response => {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
function read() {
reader.read().then(({ done, value }) => {
if (done) return;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop();
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6);
if (data === "[DONE]") return;
try {
handleEvent(JSON.parse(data));
} catch (e) {
console.error("Parse error:", e);
}
}
}
read();
});
}
read();
});
}
function handleEvent(event) {
const { node, data } = event;
if (node === "llm_node" && data.messages) {
for (const msg of data.messages) {
if (msg.content) {
addMessage(msg.content, "agent");
}
if (msg.tool_calls) {
for (const tc of msg.tool_calls) {
addMessage("Tool: " + tc.name + "\nArgs: " + JSON.stringify(tc.args, null, 2), "tool-call");
}
}
}
} else if (node === "tool_node" && data.messages) {
for (const msg of data.messages) {
addMessage(msg.content, "agent");
}
} else if (node === "interrupt" && data.interrupt) {
addInterruptMessage(data.interrupt, data.thread_id);
}
}
function addMessage(content, type) {
const container = document.getElementById("chat-container");
const div = document.createElement("div");
div.className = "message " + type;
div.innerHTML = '<div class="message-content">' + escapeHtml(content) + '</div>';
container.appendChild(div);
container.scrollTop = container.scrollHeight;
}
function addInterruptMessage(interrupt, threadId) {
const container = document.getElementById("chat-container");
const div = document.createElement("div");
div.className = "message interrupt";
div.innerHTML =
'<div class="message-content">' + escapeHtml(interrupt.message) + '</div>' +
'<div class="actions">' +
'<button class="btn-approve" onclick="handleApproval(\'' + threadId + '\', true)">Approve</button>' +
'<button class="btn-reject" onclick="handleApproval(\'' + threadId + '\', false)">Reject</button>' +
'</div>';
container.appendChild(div);
container.scrollTop = container.scrollHeight;
}
function handleApproval(threadId, approved) {
const buttons = document.querySelectorAll(".message.interrupt .actions button");
buttons.forEach(btn => btn.disabled = true);
addMessage(approved ? "Approved" : "Rejected", "user");
fetchStream("/resume", { thread_id: threadId, approved: approved });
}
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
document.getElementById("message-input").addEventListener("keydown", function(e) {
if (e.key === "Enter") sendMessage();
});
Why vanilla JavaScript instead of React?
This project uses plain HTML, CSS, and JavaScript for the frontend so you can deploy it as static files to S3 with zero build step. There is no need for a Node.js toolchain, no bundler, and no framework dependency. The total frontend is three small files.
For a portfolio project that demonstrates AI agent orchestration, keeping the frontend minimal puts the focus squarely on the backend architecture.
Your chat frontend is ready. Next up, you will deploy the Python backend to AWS Lambda with a Function URL so the frontend can talk to the agent over the internet.
Deploy Backend to AWS Lambda
Your agent is running locally, but the frontend needs to reach it over the internet. AWS Lambda lets you run code without managing servers.
In this step, you will package your Python application into a deployment zip, create the Lambda function with the right permissions, and expose it as a public HTTPS endpoint using a Function URL.
In this step, get ready to:
- Package the application with dependencies into a deployment zip.
- Create an IAM role and Lambda function via the AWS CLI.
- Create a Function URL and test streaming with curl.
Package the application into a deployment zip
Lambda runs your code from a zip file that contains both your application code and all its Python dependencies. You will install the dependencies into a folder and bundle everything together.
- Make sure you are inside the ai-devops-agent project folder on your Desktop.
- Install all dependencies into a package/ directory by running this command:
pip install langgraph langchain-core boto3 fastapi mangum -t package/
What does the -t flag do?
The -t package/ flag tells pip to install packages into the package/ directory instead of your virtual environment. This lets you bundle them alongside your code in the zip file.
- Copy your application code into the package directory by running these commands:
# Create the backend package directory
mkdir -p package/backend
# Copy application files into the package
cp backend/agent.py package/backend/
cp backend/app.py package/backend/
# Create __init__.py to make backend a proper Python package
touch package/backend/__init__.py
# Copy agent.py to root level so the import resolves correctly
cp backend/agent.py package/agent.py
Why copy agent.py to two places?
The Lambda handler is set to backend.app.handler, which loads backend/app.py. Inside app.py, the import from agent import agent looks for agent.py on the Python path root. Placing a copy at the root of the zip ensures this import resolves correctly in the Lambda environment.
- Create the deployment zip by running:
cd package && zip -r ../deployment.zip . && cd ..
- Confirm the zip was created by running:
ls -lh deployment.zip
You should see deployment.zip listed with a file size of roughly 30-60 MB.
Zip file is missing or 0 bytes?
Make sure you ran the pip install -t package/ command first. The package/ directory needs to contain all the installed dependencies before you zip it.
Also check that you are running commands from the ai-devops-agent root folder, not from inside backend/ or package/.
help me figure out why my deployment.zip is empty or not being created
Create the IAM role and Lambda function
Your Lambda function needs an IAM role that grants it permission to call Bedrock for LLM inference and S3 for bucket operations. You will create the role, attach the policies, then create the function itself.
- Create a trust policy file that allows Lambda to assume this role by running:
cat > trust-policy.json << 'EOF'
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {"Service": "lambda.amazonaws.com"},
"Action": "sts:AssumeRole"
}
]
}
EOF
What is a trust policy?
A trust policy defines which AWS service is allowed to "assume" (use) this role. Here you are telling AWS that the Lambda service can take on this role's permissions when running your function.
- Create the IAM role by running:
aws iam create-role \
--role-name ai-devops-agent-role \
--assume-role-policy-document file://trust-policy.json
- Attach an inline policy for Bedrock access by running:
aws iam put-role-policy \
--role-name ai-devops-agent-role \
--policy-name bedrock-access \
--policy-document '{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": "bedrock:InvokeModel",
"Resource": "*"
}]
}'
- Attach an inline policy for S3 access by running:
aws iam put-role-policy \
--role-name ai-devops-agent-role \
--policy-name s3-access \
--policy-document '{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": "s3:*",
"Resource": "*"
}]
}'
What do these policies grant?
The bedrock-access policy allows your Lambda to call the Bedrock Converse API for LLM inference. The s3-access policy allows the agent's tools to list and create S3 buckets on your behalf.
- Wait 10 seconds for the role to propagate, then look up your AWS account ID by running:
sleep 10
aws sts get-caller-identity --query Account --output text
Copy the 12-digit account ID that prints out. You will need it in the next command.
- Create the Lambda function by running this command, replacing the account ID placeholder with your own:
aws lambda create-function \
--function-name ai-devops-agent \
--runtime python3.11 \
--handler backend.app.handler \
--role arn:aws:iam::[[AWS_ACCOUNT_ID="your 12-digit AWS account ID"]]::role/ai-devops-agent-role \
--zip-file fileb://deployment.zip \
--memory-size 512 \
--timeout 30 \
--region us-east-1
What do these settings mean?
- handler backend.app.handler tells Lambda to load the backend.app module and call the handler function (created by Mangum).
- memory-size 512 gives the function 512 MB of RAM, enough for the LangGraph agent.
- timeout 30 allows up to 30 seconds per request, accounting for Bedrock inference time.
You should see a JSON response with "State": "Pending" or "State": "Active".
Seeing an InvalidParameterValueException?
If you see an error about the role ARN, double check your 12-digit account ID. Make sure there is no extra space or typo in the ARN string.
If you see "The role defined for the function cannot be assumed by Lambda", the role has not finished propagating yet. Wait 15 more seconds and run the command again.
help me fix my Lambda create-function error
Create a Function URL and test with curl
A Function URL gives your Lambda a dedicated HTTPS endpoint that your frontend can call directly. You will enable RESPONSE_STREAM mode so the agent's responses stream to the browser in real time.
- Create the Function URL with CORS and streaming enabled by running:
aws lambda create-function-url-config \
--function-name ai-devops-agent \
--auth-type NONE \
--invoke-mode RESPONSE_STREAM \
--cors '{"AllowOrigins":["*"],"AllowMethods":["*"],"AllowHeaders":["*"]}' \
--region us-east-1
What do these flags configure?
- auth-type NONE makes the endpoint publicly accessible without IAM signing.
- invoke-mode RESPONSE_STREAM enables streaming responses up to 200 MB, essential for Server-Sent Events.
- cors allows your S3-hosted frontend (on a different origin) to call this endpoint.
The command output includes a FunctionUrl field. Copy this URL and save it somewhere. You will need it in the next step when connecting the frontend.
- When using the AWS CLI with auth type NONE, you must manually add resource-based policies for public access. Run these two commands:
aws lambda add-permission \
--function-name ai-devops-agent \
--statement-id FunctionURLAllowPublicInvokeUrl \
--action lambda:InvokeFunctionUrl \
--principal "*" \
--function-url-auth-type NONE \
--region us-east-1
aws lambda add-permission \
--function-name ai-devops-agent \
--statement-id FunctionURLAllowPublicInvoke \
--action lambda:InvokeFunction \
--principal "*" \
--function-url-auth-type NONE \
--region us-east-1
Why two permission statements?
Lambda requires both lambda:InvokeFunctionUrl and lambda:InvokeFunction permissions for public Function URLs. Without both, callers get a 403 Forbidden error even though the auth type is NONE.
- Test that the endpoint is working by sending a request with curl:
curl -X POST [[LAMBDA_FUNCTION_URL="your Function URL from the output above"]]/chat \
-H "Content-Type: application/json" \
-d '{"message": "list my s3 buckets", "thread_id": "test-1"}' \
--no-buffer
You should see Server-Sent Events streaming in your terminal, with lines starting with data: followed by JSON payloads. The final line will be data: [DONE].
Getting a 403 Forbidden or no response?
If you see a 403 error, the resource-based policies may not have propagated yet. Wait 30 seconds and try the curl command again.
If you get a timeout or no data, check that the Function URL ends with a trailing slash or has the /chat path appended correctly. The URL format is https://xxxx.lambda-url.us-east-1.on.aws/chat.
If you see an Internal Server Error, check the CloudWatch logs for your function by running:
aws logs tail /aws/lambda/ai-devops-agent --region us-east-1
help me debug my Lambda Function URL returning errors
Your backend is now live on the internet. Next up, you will deploy the frontend to S3 and point it at this Function URL to complete the full stack.
Deploy Frontend to S3 and Connect Everything
Your backend is live and ready to accept requests. Now you need a place for users to actually interact with the agent through a browser.
In this step, you will deploy the chat frontend to an S3 bucket configured for static website hosting. Then you will wire it up to the Lambda Function URL so the full application works end-to-end.
In this step, get ready to:
- Create an S3 bucket configured for static website hosting.
- Upload the frontend files with the correct API endpoint.
- Test the full deployed application end-to-end in a browser.
Create an S3 bucket for static website hosting
S3 can serve static files (HTML, CSS, JS) directly as a website. You need a bucket with public read access and static website hosting enabled.
- First, store your AWS account ID in a variable so you can use it in the bucket name. Run this command:
# Get your account ID and store it for reuse
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
echo $ACCOUNT_ID
What does this command do?
This queries your AWS credentials and extracts just the 12-digit account ID. Storing it in a shell variable lets you reuse it in subsequent commands without copy-pasting.
- You should see your 12-digit AWS account ID printed in the terminal.
- Create the S3 bucket by running this command:
# Create the frontend hosting bucket
aws s3api create-bucket \
--bucket ai-devops-agent-frontend-$ACCOUNT_ID \
--region us-east-1
Why include the account ID in the bucket name?
S3 bucket names must be globally unique across all AWS accounts. Appending your account ID guarantees no collision with another user following the same project.
- You should see a JSON response containing the bucket's location.
- Enable static website hosting on the bucket by running:
# Enable static website hosting with index.html as the default page
aws s3 website s3://ai-devops-agent-frontend-$ACCOUNT_ID/ \
--index-document index.html
- Disable the Block Public Access settings so the bucket can serve content publicly:
# Allow public access for website hosting
aws s3api put-public-access-block \
--bucket ai-devops-agent-frontend-$ACCOUNT_ID \
--public-access-block-configuration "BlockPublicAcls=false,IgnorePublicAcls=false,BlockPublicPolicy=false,RestrictPublicBuckets=false"
What does this command do?
By default, S3 blocks all public access to protect your data. For a static website, you need to turn off these protections so visitors can load your HTML, CSS, and JavaScript files through a browser.
- Add a bucket policy that grants public read access to all objects. Create a policy file by running:
# Create a bucket policy file for public read access
cat > /tmp/bucket-policy.json << EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "PublicReadGetObject",
"Effect": "Allow",
"Principal": "*",
"Action": ["s3:GetObject"],
"Resource": ["arn:aws:s3:::ai-devops-agent-frontend-$ACCOUNT_ID/*"]
}
]
}
EOF
- Apply the bucket policy by running:
# Apply the public read policy to the bucket
aws s3api put-bucket-policy \
--bucket ai-devops-agent-frontend-$ACCOUNT_ID \
--policy file:///tmp/bucket-policy.json
What does the bucket policy do?
This policy grants s3:GetObject permission to everyone ("Principal": "*"). That means any visitor can download (read) objects from the bucket, which is exactly what a web browser does when loading your frontend files.
- Verify the bucket is configured correctly by running:
aws s3api get-bucket-website --bucket ai-devops-agent-frontend-$ACCOUNT_ID
- You should see a JSON response showing IndexDocument with a suffix of index.html.
Seeing an Access Denied error?
If the put-bucket-policy command fails, your account-level Block Public Access settings might still be enabled. Check by running aws s3control get-public-access-block --account-id $ACCOUNT_ID and disable them if needed.
help me fix an Access Denied error when applying my S3 bucket policy for static website hosting
Update the frontend with your Lambda Function URL and upload
The app.js file still has a placeholder for the API URL. You need to replace it with your actual Lambda Function URL before uploading.
- In frontend/app.js, find this line at the top of the file:
const API_URL = "your-lambda-function-url-here";
- Replace your-lambda-function-url-here with your actual Lambda Function URL from the previous step, so the line looks like this:
const API_URL = "[[LAMBDA_FUNCTION_URL=https://xxxx.lambda-url.us-east-1.on.aws]]";
Find your Function URL quickly
If you lost your Lambda Function URL, retrieve it by running:
aws lambda get-function-url-config --function-name ai-devops-agent --query FunctionUrl --output text
- Save frontend/app.js.
- Upload all three frontend files to the S3 bucket by running:
# Upload all frontend files to S3
aws s3 cp frontend/index.html s3://ai-devops-agent-frontend-$ACCOUNT_ID/
aws s3 cp frontend/style.css s3://ai-devops-agent-frontend-$ACCOUNT_ID/
aws s3 cp frontend/app.js s3://ai-devops-agent-frontend-$ACCOUNT_ID/
What does this command do?
Each aws s3 cp command uploads a local file to the root of your S3 bucket. Since the bucket has website hosting enabled, these files will be served directly to browsers at the bucket's website endpoint.
- You should see three upload: lines confirming each file was copied.
Upload failed with a permissions error?
Make sure your AWS CLI user has s3:PutObject permission on the bucket. The IAM role you created earlier already has s3:* for the Lambda function, but the upload runs as your CLI user.
help me fix S3 upload permissions when deploying my frontend files
Test the full application end-to-end
Everything is deployed. Time to verify the full flow works from your browser.
- Print your website URL by running:
echo "http://ai-devops-agent-frontend-$ACCOUNT_ID.s3-website-us-east-1.amazonaws.com"
- Copy this URL and open it in your browser.
- You should see the AI DevOps Agent chat interface with a dark background, a header, and an input field at the bottom.
- Type list my S3 buckets in the message input and press **Enter** or click **Send**.
- Watch the streaming response. You should see the agent's thinking appear, followed by a tool call message showing it selected the list_s3_buckets tool, and then the actual list of buckets in your account.
- Now type create an S3 bucket called test-agent-bucket with versioning enabled and press **Enter**.
- You should see a red-bordered interrupt message asking for your approval, with **Approve** and **Reject** buttons.
- Click **Reject** to test the safety flow. The agent should respond confirming the action was cancelled.
What is happening behind the scenes?
Your browser sends a message to the Lambda Function URL. The Lambda runs your LangGraph agent, which calls Bedrock for reasoning. When the agent decides to create a bucket, the interrupt() pauses the graph and sends a confirmation request back to your browser via SSE.
When you click Approve or Reject, the frontend calls the /resume endpoint, which continues the graph execution with your decision.
Chat UI loads but messages fail?
Open your browser's developer tools (F12) and check the Console tab for errors. A CORS error means your Lambda Function URL was not configured with CORS enabled. A network error means the API_URL in your uploaded app.js might not match your actual Function URL.
Double check that you saved app.js with the correct URL before uploading, and that the URL does not have a trailing slash.
help me debug why my deployed frontend cannot reach my Lambda Function URL
- Commit your changes and push to GitHub by running:
# Commit and push the final deployed state
git add -A
git commit -m "Deploy frontend to S3 and connect to Lambda backend"
git push
- Verify your push succeeded by checking your GitHub repository in a browser. You should see the latest commit message.
Git push rejected?
If you see a rejected error, run git pull --rebase first, then try pushing again.
help me fix a rejected git push to my GitHub repository
Your AI DevOps Agent is fully deployed and working. You have a public URL anyone can visit to interact with your agent, backed by serverless infrastructure that costs nearly nothing to run.
Secret mission
Add a Live Agent Trace Panel
Your agent works, but its internal reasoning is invisible. What if you could watch LangGraph think in real-time? In this secret mission, you'll add a sidebar that visualizes each node of the state machine as it executes, turning your chat into a live debugging tool.
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. This project uses AWS resources that have minimal ongoing costs, but you should be aware of what is running.
Cost warning
Your Lambda function falls well within the free tier (1M requests and 400,000 GB-seconds per month). S3 storage for the frontend costs fractions of a cent. Bedrock API calls are pay-per-token, charged against your AWS credits.
Total ongoing cost is under $1/month for occasional demo usage. If you want to avoid any charges, choose Pause or Delete below.
Resources you used:
- Lambda function (AI DevOps Agent backend).
- IAM role (with Bedrock and S3 permissions).
- S3 bucket (frontend static website hosting).
- S3 buckets created by the agent during testing.
- Bedrock model access (Mistral Ministral 3B).
- GitHub repository.
- Local project files and Python virtual environment.
Keep everything running
No action needed. Choose this if you are still actively building or want to keep the project live for portfolio demos and interviews.
- Total cost is negligible (under $1/month) for occasional demo usage.
- Your Lambda function and S3 frontend will remain accessible via their public URLs.
- Bedrock charges only when you actively make API calls through the chat interface.
Pause - I'll come back to this later
Shut down the live deployment to avoid any charges, but keep your code on GitHub so you can redeploy later.
- Delete the Lambda function by running:
aws lambda delete-function --function-name ai-devops-agent
- Delete the S3 frontend bucket (empty it first, then delete):
aws s3 rm s3://your-frontend-bucket-name --recursive
aws s3 rb s3://your-frontend-bucket-name
- Keep your GitHub repository intact for future redeployment.
- Keep your local project files and virtual environment in case you want to develop further.
Delete - I don't want to use this again
Remove all project resources and start fresh. This is irreversible for cloud resources.
- Delete the Lambda function:
aws lambda delete-function --function-name ai-devops-agent
- Delete the IAM role (detach policies first, then delete the role):
aws iam detach-role-policy --role-name ai-devops-agent-role --policy-arn arn:aws:iam::aws:policy/AmazonBedrockFullAccess
aws iam detach-role-policy --role-name ai-devops-agent-role --policy-arn arn:aws:iam::aws:policy/AmazonS3FullAccess
aws iam delete-role --role-name ai-devops-agent-role
- Delete the S3 frontend bucket:
aws s3 rm s3://your-frontend-bucket-name --recursive
aws s3 rb s3://your-frontend-bucket-name
- Delete any S3 buckets the agent created during testing:
aws s3 rb s3://your-test-bucket-name --force
- Delete your local project folder and virtual environment.
- Optionally delete the GitHub repository from your GitHub settings if you no longer need it.
That's a Wrap!
That's a Wrap!
Nice work! You just built and deployed a production-grade AI DevOps agent that interprets natural language and executes real AWS operations. That is seriously impressive.
You've learned how to:
- Built an AI agent with LangGraph that uses tool-calling to execute real AWS operations via the Bedrock Converse API.
- Implemented human-in-the-loop safety patterns with LangGraph's interrupt() system to require user approval before destructive actions.
- Deployed a full-stack serverless application with a real-time streaming chat UI using Server-Sent Events, AWS Lambda, and S3 static hosting.
- Secret Mission: Added a live agent trace panel that visualizes each step of the LangGraph state machine as it executes in real-time.
Ready to quiz yourself?