Gartner said in an August 2025 press release that “Forty percent of enterprise applications will be integrated with task-specific AI agents by the end of 2026, up from less than 5% in 2025.” A serverless AI agent on AWS is one of the cheapest ways to test that idea, because you pay per event and per token instead of keeping an idle fleet warm.
Here is the shape of the build. An alert lands on an EventBridge bus. A Step Functions state machine wakes up, asks a Bedrock model what to check, runs read-only tools inside Lambda, and stops for a human click before it touches anything that changes state. When the model reaches a conclusion, the result goes to DynamoDB and back onto the bus as a new event. Nothing runs when nothing happens.
AWS has added plenty of moving parts around this pattern in the last few weeks. OpenAI’s GPT-6.1 Terra became generally available on Amazon Bedrock on October 26, 2026, and existing Lambda functions now carry the name Lambda Event Functions. If you would rather hand the whole loop to a managed runtime, our walkthrough on how to deploy a Bedrock AgentCore agent covers that route. The version below keeps every step visible, which makes it easier to debug and cheaper to run at low volume.
Don't miss new tech stories on Google
Add Shattered once in the Google app and our stories show up in your news suggestions.
Why Build a Serverless AI Agent on AWS Right Now #
Agents fail in boring ways. A model call times out, a tool returns too much text, a human never answers the approval email. Plain code handles those cases badly, because the state of the run lives in a process that can disappear. Step Functions solves the problem by writing every transition to durable history, so a run that waits three days for an approval costs you nothing while it waits.
AWS itself describes Lambda plus Bedrock as a sensible starting architecture for early agents, and positions Step Functions and EventBridge for the more structured workflows that come next. That matches how most teams grow. You start with one function that calls a model, then you need retries, then you need a for approval, and at that point a state machine is cheaper than rewriting your glue code a third time.
The surrounding platform moved fast in autumn 2026. In October, AWS announced Lambda Web Functions, a compute type that handles HTTP requests directly, and it renamed the existing function type Lambda Event Functions. The rename needs no code changes or redeployment. Everything in this build is an event function. In September, EventBridge introduced an enhanced Custom event bus, while buses created before that continue to run unchanged under the name Custom event bus Classic. The build below uses the Classic type because it is the one every account already has.
According to AWS’s October 29 announcement, Step Functions workflows can also be exposed to AI agents as tools through Bedrock AgentCore Gateway, with the agent choosing when to run a workflow and what input to pass. That is the mirror image of this build. Here the workflow drives the model. There, the model drives the workflow. Both patterns are legitimate, and the advanced section shows how to turn this state machine into a tool later.
Adoption data explains why the plumbing matters. Gartner’s May 2025 poll of 147 CIOs and IT leaders found that 24% had deployed a few AI agents and 4% had deployed more than a dozen. Most organizations are still at the early stage, where one well-guarded agent beats a dozen loose ones. The weekly AWS serverless roundup for October 24 to 30 shows how quickly new building blocks keep arriving, which is another reason to keep your own loop thin and replaceable.
Before you pick Lambda for the tool layer, it helps to know where it sits against the alternatives. Our Lambda, Azure Functions and Cloud Run comparison covers the pricing gap and the cold-start trade-offs. For a tool runner that wakes up a few thousand times a day, Lambda’s free tier of 1 million requests and 400,000 GB-seconds per month usually makes the compute line item vanish.
Architecture: Which Service Does What #
The agent is a loop with a gate. Step Functions owns the loop and the gate. Lambda owns the two pieces of code that need a runtime. Bedrock owns the reasoning. EventBridge owns the doorway in and the doorway out. Keeping those jobs separate is what lets you swap the model, add a tool, or change the approval channel without touching the other parts.
| Component | Job in this agent | Billing unit |
|---|---|---|
| EventBridge bus and rule | Receives alerts, filters by severity, starts the state machine | Per event published, plus any enabled capabilities |
| Step Functions (Standard) | Runs the loop, retries, waits for human approval | Per state transition, including retries |
| Lambda: decide | Calls Bedrock and returns the next action | Requests plus GB-seconds |
| Lambda: run_tool | Executes tools and enforces the approval rule | Requests plus GB-seconds |
| Amazon Bedrock Converse API | Reasoning and tool selection | Input and output tokens, by model and service tier |
| SNS and HTTP API | Sends the approval email, receives the click | Per message and per million API requests |
| DynamoDB (on-demand) | Stores run summaries and idempotency records | Per read and write request |
Why Standard workflows and not Express? Standard workflows can for a callback token and keep a full execution history. Express workflows suit high-volume, short-lived work and bill by requests and duration instead. An agent that may wait on a human belongs on Standard. If you later split out a stateless classification step that runs thousands of times a minute, that piece can move to Express.
A few design rules shape every file that follows. The model never executes anything. It only proposes tool calls, and a separate function runs them. Any tool on the risky list requires an approved decision, and the check lives in the Lambda that runs the tool, not only in the state machine graph. Every run carries an iteration cap. Every tool that changes state writes an idempotency record first, because Step Functions will happily retry a task that already succeeded halfway.
| Orchestration option | Best fit | Main trade-off |
|---|---|---|
| Step Functions loop (this build) | Visible, auditable runs with human gates | You write the loop and manage transcript size |
| Lambda durable functions | Code-first teams that want long-running logic in one function | Less visual history than a state machine |
| Bedrock AgentCore runtime | Managed agent hosting with memory and tool gateway | More platform to learn, new billing dimensions |
| Single Lambda with a while loop | Prototypes | No durable , run state dies with the process |
If any Lambda detail below feels unfamiliar, the AWS Lambda serverless tutorial walks through functions, IAM roles and API Gateway from scratch. This build assumes you can already deploy a function and read a CloudWatch log.
Prerequisites and Versions #
You need an AWS account where you can create IAM roles, Lambda functions, state machines, an SNS topic and a DynamoDB table. Use a sandbox account if you can. The build creates real resources, and the approval link is a real endpoint on the public internet until you delete the stack.
- Python 3.13 locally and as the Lambda runtime (
python3.13). - AWS CLI v2 , latest version, configured with credentials for the sandbox account.
- AWS SAM CLI , latest version, for building and deploying the stack.
- Amazon Bedrock access to one model that supports tool use through the Converse API, enabled in the Region you deploy to.
- An email address you can open during the test, for the approval message.
- A shell with
bashandcurl. No Docker is required.
On model choice, the field is crowded. AWS says Claude Haiku 5.5, Sonnet 5.5 and Opus 5.5 are all on Bedrock, with Haiku 5.5 positioned as the lower-priced option, and GPT-6.1 Terra is generally available as of October 26. For a triage loop that mostly picks among three tools, a small, fast model is usually enough. Confirm in the Bedrock console that your pick supports tool use in the Converse API before you commit, and copy the exact model ID or inference profile ID from the console rather than typing one from memory.
python3 --version
aws --version
sam --version
aws sts get-caller-identity
aws configure get region
aws bedrock list-foundation-models --query "modelSummaries[].modelId" --output text | tr '\t' '\n' | head -40
aws bedrock list-inference-profiles --query "inferenceProfileSummaries[].inferenceProfileId" --output text | tr '\t' '\n' | head -40
The last two commands list model IDs and inference profile IDs visible to your account. Some models only accept calls through an inference profile, so note which kind of ID you need. You will also need the matching ARNs for the IAM policy in Step 9. A profile ARN and the foundation model ARNs it routes to are both required when a profile spans Regions.
Expected output from the first three commands looks like this. Your version numbers will differ if you installed later.
Python 3.13.x
aws-cli/2.x.x Python/3.x.x Linux/x86_64
SAM CLI, version 1.x.x
{
"UserId": "AIDAEXAMPLE",
"Account": "123456789012",
"Arn": "arn:aws:iam::123456789012:user/you"
}
Steps 1-3: Guardrails, Project Scaffold, and the Tool Catalog #
Step 1: Set cost and access guardrails before you write code. Agents spend money in a loop, so put a ceiling in place first. Create a monthly cost budget in AWS Budgets with an email alert at 80 percent of whatever number you are comfortable losing in a bad week. Pick one Region and stay in it, because Bedrock availability, inference profiles and the enhanced EventBridge bus all vary by Region. Then open the Bedrock console and confirm your chosen model is enabled for the account. A missing enablement is the most common reason the first call fails, and it fails with an access error that looks like an IAM problem.
Step 2: Scaffold the SAM project. One source folder holds all five Lambda handlers and one shared tools module. A single code folder keeps the build simple, and each function just points at a different handler. The state machine definition lives in its own file so you can lint it separately.
mkdir -p ops-agent/src ops-agent/statemachine ops-agent/events
cd ops-agent
touch template.yaml samconfig.toml
touch src/tools.py src/decide.py src/run_tool.py src/request_approval.py src/approve.py src/finalize.py
touch statemachine/agent.asl.yaml
touch events/alert.json
find . -type f | sort
Step 3: Define the tool catalog. The catalog has three tools. get_metric_summary reads recent CloudWatch statistics. lookup_runbook returns the written procedure for an alert type. restart_service is the dangerous one, and it sits on the risky list. In production you would replace the body of restart_service with a real call such as an ECS forced deployment. Here it records the request in DynamoDB so you can test the full approval path without breaking anything.
The Bedrock Converse API takes tool definitions as a list of toolSpec objects, each with a name, a description and a JSON schema. Write the descriptions for the model, not for yourself. A vague description is the main reason agents pick the wrong tool, so say plainly when each tool should and should not be used.
import datetime
import os
import time
import boto3
from botocore.exceptions import ClientError
cloudwatch = boto3.client("cloudwatch")
table = boto3.resource("dynamodb").Table(os.environ["TABLE_NAME"])
RISKY = {"restart_service"}
RUNBOOKS = {
"high_cpu": "1. Check for a deploy in the last hour. 2. Compare the last 30 minutes of CPU to the previous day. 3. If CPU stays above 85 percent after 15 minutes, restart the service with operator approval.",
"high_latency": "1. Check CPU and memory first. 2. Look for a dependency timeout. 3. Escalate to the owning team if latency persists after a restart.",
"error_spike": "1. Compare error rate to the last deploy time. 2. Roll back if the spike started within 10 minutes of a deploy. 3. Otherwise page the owning team.",
}
TOOL_SPECS = [
{"toolSpec": {
"name": "get_metric_summary",
"description": "Read recent CloudWatch statistics for one metric. Use this first to confirm whether an alert is still happening. Returns average and maximum per 5-minute period.",
"inputSchema": {"json": {
"type": "object",
"properties": {
"namespace": {"type": "string"},
"metric_name": {"type": "string"},
"dimension_name": {"type": "string"},
"dimension_value": {"type": "string"},
"minutes": {"type": "integer", "description": "Look-back window, default 30"},
},
"required": ["namespace", "metric_name", "dimension_name", "dimension_value"],
}},
}},
{"toolSpec": {
"name": "lookup_runbook",
"description": "Return the written runbook for an alert type. Valid types: high_cpu, high_latency, error_spike. Read-only.",
"inputSchema": {"json": {
"type": "object",
"properties": {"alert_type": {"type": "string"}},
"required": ["alert_type"],
}},
}},
{"toolSpec": {
"name": "restart_service",
"description": "Restart a service. This changes production state and needs human approval. Use only after metrics confirm the problem and the runbook calls for a restart.",
"inputSchema": {"json": {
"type": "object",
"properties": {
"service": {"type": "string"},
"reason": {"type": "string"},
},
"required": ["service", "reason"],
}},
}},
]
def get_metric_summary(namespace, metric_name, dimension_name, dimension_value, minutes=30):
minutes = max(5, min(int(minutes), 180))
end = datetime.datetime.now(datetime.timezone.utc)
start = end - datetime.timedelta(minutes=minutes)
resp = cloudwatch.get_metric_statistics(
Namespace=namespace,
MetricName=metric_name,
Dimensions=[{"Name": dimension_name, "Value": dimension_value}],
StartTime=start,
EndTime=end,
Period=300,
Statistics=["Average", "Maximum"],
)
points = sorted(resp["Datapoints"], key=lambda p: p["Timestamp"])
return {
"metric": metric_name,
"points": [
{"t": p["Timestamp"].isoformat(), "avg": round(p["Average"], 2), "max": round(p["Maximum"], 2)}
for p in points
],
}
def lookup_runbook(alert_type):
return {"alert_type": alert_type, "runbook": RUNBOOKS.get(alert_type, "No runbook found for this alert type.")}
def restart_service(service, reason, run_id, tool_id):
try:
table.put_item(
Item={
"pk": run_id,
"sk": f"action#{tool_id}",
"service": service,
"reason": reason[:500],
"ts": int(time.time()),
},
ConditionExpression="attribute_not_exists(pk)",
)
except ClientError as err:
if err.response["Error"]["Code"] == "ConditionalCheckFailedException":
return {"status": "already_recorded", "service": service}
raise
return {"status": "restart_requested", "service": service}
def dispatch(name, args, run_id, tool_id):
if name == "get_metric_summary":
return get_metric_summary(**args)
if name == "lookup_runbook":
return lookup_runbook(**args)
if name == "restart_service":
return restart_service(args["service"], args.get("reason", ""), run_id, tool_id)
raise ValueError(f"Unknown tool: {name}")
Two details deserve a second look. The minutes value is clamped so a confused model cannot request a year of data. And restart_service writes a conditional record keyed on the run ID and the tool-use ID. If Step Functions retries the Lambda after a timeout, the second attempt finds the record, returns already_recorded, and does nothing twice.
Steps 4-5: The Decide and Run-Tool Functions #
Step 4: Write the decide function. This Lambda makes exactly one Bedrock call per invocation and returns a decision. It does not loop. The state machine loops. That separation means each model call is its own retryable, observable step, and a crash never loses the transcript, because the transcript travels in the state.
Three behaviors matter here. First, the function converts Bedrock throttling into a custom exception name, BedrockThrottled, which the state machine can match in a retry rule. Boto3 raises a generic ClientError for every service error, and Step Functions would otherwise see only that class name. Second, it enforces the iteration cap before calling the model, so a runaway loop stops without spending a token. Third, it prints one JSON line per call with token counts, which gives you a free cost dashboard later.
import json
import os
import boto3
from botocore.exceptions import ClientError
from tools import RISKY, TOOL_SPECS
bedrock = boto3.client("bedrock-runtime")
MODEL_ID = os.environ["MODEL_ID"]
MAX_ITERATIONS = int(os.environ.get("MAX_ITERATIONS", "6"))
SYSTEM = [{"text": (
"You are an on-call triage agent. Gather evidence with tools before you conclude. "
"Call read-only tools first. Request restart_service only when metrics confirm the problem "
"and the runbook allows it. Never claim an action succeeded unless a tool result says so. "
"When finished, reply with plain text: probable cause, evidence, action taken or recommended."
)}]
class BedrockThrottled(Exception):
pass
def handler(event, context):
iteration = int(event.get("iteration", 0)) + 1
messages = event.get("messages") or [
{"role": "user", "content": [{"text": "Alert received:\n" + json.dumps(event["alert"])[:4000]}]}
]
if iteration > MAX_ITERATIONS:
return {
"action": "final",
"messages": messages,
"iteration": iteration,
"final_text": "Stopped: iteration cap reached before a conclusion.",
}
try:
resp = bedrock.converse(
modelId=MODEL_ID,
system=SYSTEM,
messages=messages,
toolConfig={"tools": TOOL_SPECS},
inferenceConfig={"maxTokens": 1024, "temperature": 0},
)
except ClientError as err:
code = err.response["Error"]["Code"]
if code in ("ThrottlingException", "ModelNotReadyException", "ServiceUnavailableException"):
raise BedrockThrottled(code) from err
raise
message = resp["output"]["message"]
messages = messages + [message]
usage = resp.get("usage", {})
print(json.dumps({
"event": "decide",
"run_id": event.get("run_id"),
"iteration": iteration,
"stop": resp["stopReason"],
"in": usage.get("inputTokens"),
"out": usage.get("outputTokens"),
}))
tool_uses = [b["toolUse"] for b in message["content"] if "toolUse" in b]
if resp["stopReason"] == "tool_use" and tool_uses:
tools = [{"id": t["toolUseId"], "name": t["name"], "input": t["input"]} for t in tool_uses]
return {
"action": "tool",
"messages": messages,
"iteration": iteration,
"tools": tools,
"needs_approval": any(t["name"] in RISKY for t in tools),
}
text = "".join(b.get("text", "") for b in message["content"]).strip()
return {"action": "final", "messages": messages, "iteration": iteration, "final_text": text}
A model can return several toolUse blocks in one message. The Converse API then expects a matching toolResult for every one of them in the next user message, and a missing result produces a validation error. The function therefore returns a list of tools, not a single tool. If you want to run OpenAI models on Bedrock for this role, the cost and tooling changes in our report on OpenAI agents on Bedrock are worth reading first, since tool-call behavior differs between model families.
Step 5: Write the run-tool function. This Lambda receives the tool list plus the approval decision, runs each tool, and appends one user message holding all the results. The approval rule is enforced here a second time. Even if someone edits the state machine and routes a risky tool around the approval step, the function refuses to run it without an approved decision. Tool failures come back to the model as error results instead of crashing the run, which lets the model adapt.
import json
from tools import RISKY, dispatch
MAX_RESULT_CHARS = 3000
def handler(event, context):
decision = event["approval"].get("decision")
blocks = []
for tool in event["tools"]:
if tool["name"] in RISKY and decision != "approved":
blocks.append({"toolResult": {
"toolUseId": tool["id"],
"content": [{"text": "Denied: an operator did not approve this action."}],
"status": "error",
}})
continue
try:
output = dispatch(tool["name"], tool["input"], event["run_id"], tool["id"])
raw = json.dumps(output)
if len(raw) > MAX_RESULT_CHARS:
output = {"truncated": raw[:MAX_RESULT_CHARS]}
blocks.append({"toolResult": {
"toolUseId": tool["id"],
"content": [{"json": output}],
"status": "success",
}})
except Exception as exc:
blocks.append({"toolResult": {
"toolUseId": tool["id"],
"content": [{"text": f"{type(exc).__name__}: {exc}"[:500]}],
"status": "error",
}})
print(json.dumps({"event": "run_tool", "run_id": event["run_id"], "tools": [t["name"] for t in event["tools"]], "decision": decision}))
return {"messages": event["messages"] + [{"role": "user", "content": blocks}]}
The truncation guard protects the state payload. Step Functions limits how much data can pass between states, and a transcript that grows by several kilobytes per loop will hit that wall on a long run. Truncating tool output is the cheap fix. The advanced section covers the durable one, which moves the transcript into DynamoDB.
Steps 6-7: The Approval Gate and the Run Record #
Step 6: Build the approval path. Step Functions supports a callback pattern. A task invokes a Lambda and hands it a one-time task token, then the whole execution sleeps until something calls SendTaskSuccess or SendTaskFailure with that token. While it sleeps, you pay for no compute. Two small functions implement the pattern. The first emails the operator a link that carries the token. The second receives the click and completes the task.
The obvious design, a link that approves on a plain GET request, has a real flaw. Corporate mail scanners and link previewers open every URL in an email, and they would approve your restart before any human read the message. The design below makes the GET request show a page with two buttons and makes the POST request do the work. Scanners follow links but do not submit forms.
import os
import urllib.parse
import boto3
from tools import RISKY
sns = boto3.client("sns")
def handler(event, context):
token = urllib.parse.quote(event["token"], safe="")
link = f"{os.environ['APPROVAL_BASE_URL']}/approval?token={token}"
risky = [t for t in event["tools"] if t["name"] in RISKY]
lines = [
f"Run: {event['run_id']}",
f"Alert: {event['alert'].get('title', 'unknown')}",
"The agent wants to run:",
]
for t in risky:
lines.append(f" {t['name']} {t['input']}")
lines += ["", f"Review and decide: {link}"]
sns.publish(
TopicArn=os.environ["TOPIC_ARN"],
Subject=f"Approval needed: {event['alert'].get('service', 'service')}"[:100],
Message="\n".join(lines),
)
return {"status": "sent"}
python
import base64
import html
import json
import urllib.parse
import boto3
sfn = boto3.client("stepfunctions")
PAGE = (
"<!doctype html><html><body><h1>Agent action approval</h1>"
"<form method='post' action='approval'>"
"<input type='hidden' name='token' value='{token}'>"
"<button name='decision' value='approved'>Approve</button> "
"<button name='decision' value='rejected'>Reject</button>"
"</form></body></html>"
)
def page(status, body):
return {"statusCode": status, "headers": {"content-type": "text/html"}, "body": body}
def handler(event, context):
method = event["requestContext"]["http"]["method"]
if method == "GET":
token = (event.get("queryStringParameters") or {}).get("token", "")
return page(200, PAGE.format(token=html.escape(token, quote=True)))
if method != "POST":
return page(405, "<p>Method not allowed</p>")
body = event.get("body") or ""
if event.get("isBase64Encoded"):
body = base64.b64decode(body).decode("utf-8")
form = urllib.parse.parse_qs(body)
token = form.get("token", [""])[0]
decision = form.get("decision", [""])[0]
if not token or decision not in ("approved", "rejected"):
return page(400, "<p>Bad request</p>")
try:
sfn.send_task_success(taskToken=token, output=json.dumps({"decision": decision}))
except (sfn.exceptions.TaskTimedOut, sfn.exceptions.TaskDoesNotExist, sfn.exceptions.InvalidToken):
return page(410, "<p>This approval request has expired or was already answered.</p>")
print(json.dumps({"event": "approval", "decision": decision}))
return page(200, f"<p>Recorded: {decision}</p>")
The task token works as a bearer credential. Anyone holding it can answer the request, so treat the email like a password reset link. For a real deployment, put the HTTP API behind an authorizer, or replace the email click with a Slack interactive message that verifies the signer. The sandbox version here is enough to prove the pattern. If you later host the page on a Lambda Web Function instead of API Gateway, our breakdown of Lambda Web Functions pricing shows how active-CPU billing changes the math.
Step 7: Write the finalize function. When the loop ends, one last Lambda stores the outcome. It writes a summary item with a time-to-live attribute so old runs expire on their own, and it returns a trimmed version for the completion event. Keep the event small. Consumers downstream should look up the full record by run ID.
import json
import os
import time
import boto3
table = boto3.resource("dynamodb").Table(os.environ["TABLE_NAME"])
RETENTION_SECONDS = 90 * 86400
def handler(event, context):
final = event["final"]
text = final.get("final_text", "")
table.put_item(Item={
"pk": event["run_id"],
"sk": "summary",
"alert": json.dumps(event["alert"])[:4000],
"summary": text[:4000],
"iterations": final["iteration"],
"ttl": int(time.time()) + RETENTION_SECONDS,
})
print(json.dumps({"event": "finalize", "run_id": event["run_id"], "iterations": final["iteration"]}))
return {"run_id": event["run_id"], "summary": text[:1500], "iterations": final["iteration"]}
Step 8: The Step Functions State Machine #
This file is the heart of the build. It reads like a flowchart, and that is the point. You can open the execution in the console and watch the agent move from Decide to Route to RunTool and back again, with the input and output of each state one click away.
The flow runs as follows. Init reshapes the EventBridge event into the working state: a run ID taken from the execution name, the alert detail, an empty transcript and a zero counter. Decide calls the Lambda with retries tuned for Bedrock throttling. Route sends final answers to Finalize, risky tool calls to RequestApproval, and everything else to a pass-through state that stamps the approval as not required. RunTool executes. Carry rebuilds the state for the next loop, which also keeps stale fields from piling up.
Comment: Ops triage agent loop with a human approval gate
StartAt: Init
States:
Init:
Type: Pass
Parameters:
run_id.$: $$.Execution.Name
alert.$: $.detail
messages: []
iteration: 0
Next: Decide
Decide:
Type: Task
Resource: arn:aws:states:::lambda:invoke
Parameters:
FunctionName: ${DecideFunctionArn}
Payload.$: $
ResultSelector:
decision.$: $.Payload
ResultPath: $.step
Retry:
- ErrorEquals: [BedrockThrottled]
IntervalSeconds: 2
BackoffRate: 2.0
MaxAttempts: 5
JitterStrategy: FULL
- ErrorEquals: [Lambda.ServiceException, Lambda.AWSLambdaException, Lambda.SdkClientException, Lambda.TooManyRequestsException]
IntervalSeconds: 2
BackoffRate: 2.0
MaxAttempts: 3
Catch:
- ErrorEquals: [States.ALL]
ResultPath: $.error
Next: FailRun
Next: Route
Route:
Type: Choice
Choices:
- Variable: $.step.decision.action
StringEquals: final
Next: Finalize
- Variable: $.step.decision.needs_approval
BooleanEquals: true
Next: RequestApproval
Default: SkipApproval
SkipApproval:
Type: Pass
Result:
decision: not_required
ResultPath: $.approval
Next: RunTool
RequestApproval:
Type: Task
Resource: arn:aws:states:::lambda:invoke.waitForTaskToken
TimeoutSeconds: 3600
Parameters:
FunctionName: ${RequestApprovalFunctionArn}
Payload:
token.$: $$.Task.Token
run_id.$: $.run_id
alert.$: $.alert
tools.$: $.step.decision.tools
ResultPath: $.approval
Catch:
- ErrorEquals: [States.Timeout]
ResultPath: $.approval
Next: ApprovalTimedOut
- ErrorEquals: [States.ALL]
ResultPath: $.error
Next: FailRun
Next: RunTool
ApprovalTimedOut:
Type: Pass
Result:
decision: rejected
reason: timeout
ResultPath: $.approval
Next: RunTool
RunTool:
Type: Task
Resource: arn:aws:states:::lambda:invoke
Parameters:
FunctionName: ${RunToolFunctionArn}
Payload:
run_id.$: $.run_id
messages.$: $.step.decision.messages
tools.$: $.step.decision.tools
approval.$: $.approval
ResultSelector:
messages.$: $.Payload.messages
ResultPath: $.tool_result
Retry:
- ErrorEquals: [Lambda.ServiceException, Lambda.AWSLambdaException, Lambda.SdkClientException, Lambda.TooManyRequestsException]
IntervalSeconds: 2
BackoffRate: 2.0
MaxAttempts: 3
Catch:
- ErrorEquals: [States.ALL]
ResultPath: $.error
Next: FailRun
Next: Carry
Carry:
Type: Pass
Parameters:
run_id.$: $.run_id
alert.$: $.alert
messages.$: $.tool_result.messages
iteration.$: $.step.decision.iteration
Next: Decide
Finalize:
Type: Task
Resource: arn:aws:states:::lambda:invoke
Parameters:
FunctionName: ${FinalizeFunctionArn}
Payload:
run_id.$: $.run_id
alert.$: $.alert
final.$: $.step.decision
ResultSelector:
result.$: $.Payload
ResultPath: $.summary
Next: PublishCompleted
PublishCompleted:
Type: Task
Resource: arn:aws:states:::events:putEvents
Parameters:
Entries:
- EventBusName: ${BusName}
Source: ops.agent
DetailType: agent.completed
Detail:
run_id.$: $.run_id
summary.$: $.summary.result
End: true
FailRun:
Type: Fail
Error: AgentFailed
Cause: The agent loop hit an unrecoverable error. Check the execution history.
Read the Decide retry block closely. Throttling gets five attempts with exponential backoff and full jitter, because Bedrock capacity errors tend to clear in seconds and synchronized retries make them worse. Generic Lambda service errors get three attempts. Anything else falls through to Catch, which sends the run to FailRun and surfaces it to the failure alarm you will add in Step 12.
The approval timeout deserves a note. If nobody answers within an hour, the state does not fail the run. It records a rejection and lets the model see the denial, so the final summary can say the restart was recommended but never approved. A silent failure here would hide exactly the cases that most need a human follow-up.
Step 9: The SAM Template, the EventBridge Rule, and Least-Privilege IAM #
The template ties everything together. It creates the bus, the table, the SNS topic, the HTTP API, five functions, the state machine and the rule that starts it. Each function gets only the permissions it uses. The decide function can call Bedrock on the specific model ARNs you pass in and nothing else. The run-tool function can read CloudWatch metrics and write to one table. The approve function can complete tasks, and that is all it can do.
The EventBridge rule filters on source, detail type and severity, so low-severity noise never starts a run. SAM’s EventBridgeRule event type builds the rule, the target role and a dead-letter queue in one block. The retry policy tells EventBridge how long to keep trying if the state machine is unreachable. Because the rule sits on a custom bus, nothing from your other AWS activity can trigger it by accident.
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: Event-driven ops triage agent (EventBridge, Step Functions, Lambda, Bedrock)
Parameters:
ModelId:
Type: String
Description: Bedrock model ID or inference profile ID copied from the console
ModelArns:
Type: CommaDelimitedList
Description: ARNs for the inference profile and the foundation models it routes to
ApprovalEmail:
Type: String
Globals:
Function:
Runtime: python3.13
Timeout: 60
MemorySize: 512
CodeUri: src/
Tracing: Active
Environment:
Variables:
TABLE_NAME: !Ref RunsTable
Resources:
AgentBus:
Type: AWS::Events::EventBus
Properties:
Name: ops-agent-bus
RunsTable:
Type: AWS::DynamoDB::Table
Properties:
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- AttributeName: pk
AttributeType: S
- AttributeName: sk
AttributeType: S
KeySchema:
- AttributeName: pk
KeyType: HASH
- AttributeName: sk
KeyType: RANGE
TimeToLiveSpecification:
AttributeName: ttl
Enabled: true
ApprovalTopic:
Type: AWS::SNS::Topic
Properties:
Subscription:
- Protocol: email
Endpoint: !Ref ApprovalEmail
ApprovalApi:
Type: AWS::Serverless::HttpApi
Properties:
DefaultRouteSettings:
ThrottlingBurstLimit: 10
ThrottlingRateLimit: 5
DecideFunction:
Type: AWS::Serverless::Function
Properties:
Handler: decide.handler
Timeout: 90
Environment:
Variables:
MODEL_ID: !Ref ModelId
MAX_ITERATIONS: "6"
Policies:
- Statement:
- Effect: Allow
Action: bedrock:InvokeModel
Resource: !Ref ModelArns
RunToolFunction:
Type: AWS::Serverless::Function
Properties:
Handler: run_tool.handler
Policies:
- DynamoDBCrudPolicy:
TableName: !Ref RunsTable
- Statement:
- Effect: Allow
Action: cloudwatch:GetMetricStatistics
Resource: "*"
RequestApprovalFunction:
Type: AWS::Serverless::Function
Properties:
Handler: request_approval.handler
Environment:
Variables:
TOPIC_ARN: !Ref ApprovalTopic
APPROVAL_BASE_URL: !Sub "https://${ApprovalApi}.execute-api.${AWS::Region}.amazonaws.com"
Policies:
- SNSPublishMessagePolicy:
TopicName: !GetAtt ApprovalTopic.TopicName
ApproveFunction:
Type: AWS::Serverless::Function
Properties:
Handler: approve.handler
Policies:
- Statement:
- Effect: Allow
Action:
- states:SendTaskSuccess
- states:SendTaskFailure
Resource: "*"
Events:
Approval:
Type: HttpApi
Properties:
ApiId: !Ref ApprovalApi
Path: /approval
Method: ANY
FinalizeFunction:
Type: AWS::Serverless::Function
Properties:
Handler: finalize.handler
Policies:
- DynamoDBCrudPolicy:
TableName: !Ref RunsTable
AgentLogs:
Type: AWS::Logs::LogGroup
Properties:
RetentionInDays: 30
AgentStateMachine:
Type: AWS::Serverless::StateMachine
Properties:
Type: STANDARD
DefinitionUri: statemachine/agent.asl.yaml
DefinitionSubstitutions:
DecideFunctionArn: !GetAtt DecideFunction.Arn
RunToolFunctionArn: !GetAtt RunToolFunction.Arn
RequestApprovalFunctionArn: !GetAtt RequestApprovalFunction.Arn
FinalizeFunctionArn: !GetAtt FinalizeFunction.Arn
BusName: !Ref AgentBus
Tracing:
Enabled: true
Logging:
Level: ERROR
IncludeExecutionData: false
Destinations:
- CloudWatchLogsLogGroup:
LogGroupArn: !GetAtt AgentLogs.Arn
Policies:
- LambdaInvokePolicy:
FunctionName: !Ref DecideFunction
- LambdaInvokePolicy:
FunctionName: !Ref RunToolFunction
- LambdaInvokePolicy:
FunctionName: !Ref RequestApprovalFunction
- LambdaInvokePolicy:
FunctionName: !Ref FinalizeFunction
- EventBridgePutEventsPolicy:
EventBusName: !Ref AgentBus
Events:
HighSeverityAlert:
Type: EventBridgeRule
Properties:
EventBusName: !Ref AgentBus
Pattern:
source:
- ops.alerts
detail-type:
- alert.raised
detail:
severity:
- high
- critical
RetryPolicy:
MaximumRetryAttempts: 4
MaximumEventAgeInSeconds: 3600
DeadLetterConfig:
Type: SQS
FailedRunsAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
Namespace: AWS/States
MetricName: ExecutionsFailed
Dimensions:
- Name: StateMachineArn
Value: !Ref AgentStateMachine
Statistic: Sum
Period: 300
EvaluationPeriods: 1
Threshold: 1
ComparisonOperator: GreaterThanOrEqualToThreshold
TreatMissingData: notBreaching
AlarmActions:
- !Ref ApprovalTopic
Outputs:
StateMachineArn:
Value: !Ref AgentStateMachine
ApprovalBaseUrl:
Value: !Sub "https://${ApprovalApi}.execute-api.${AWS::Region}.amazonaws.com"
RunsTableName:
Value: !Ref RunsTable
Two lines in that file are intentional compromises. cloudwatch:GetMetricStatistics uses a wildcard resource because CloudWatch read actions do not support resource-level scoping. And states:SendTaskSuccess is allowed on every resource because the task token itself is the capability. If you want tighter control, constrain who can reach the HTTP API instead of trying to scope that action.
Steps 10-11: Deploy the Stack and Fire a Test Alert #
Step 10: Build and deploy. Run the build, then the guided deploy. Answer the prompts with a stack name of ops-agent, your Region, your model ID, the comma-separated ARNs and your email. SAM stores the answers in samconfig.toml so later deploys are one command. Within a minute of the stack finishing, AWS sends a subscription confirmation to your inbox. Click the confirm link, or the approval email will never arrive.
sam validate --lint
sam build
sam deploy --guided --stack-name ops-agent --capabilities CAPABILITY_IAM
aws cloudformation describe-stacks --stack-name ops-agent --query "Stacks[0].Outputs" --output table
The stack output lists the state machine ARN and the base URL of the approval API. Keep both in a scratch file. If sam validate --lint prints warnings about the DefinitionUri, make sure you are running it from the project root and that the ASL file exists at the path in the template.
Step 11: Seed a metric and send an alert. The agent needs something to find. Push a few synthetic CPU readings into a custom CloudWatch namespace, then publish an alert event to the bus. CloudWatch can take a couple of minutes to make fresh custom datapoints queryable, so wait before sending the alert.
for v in 91 94 96 93 95
do
aws cloudwatch put-metric-data --namespace Custom/Ops --metric-name CpuPercent \
--dimensions Service=checkout-api --value $v
sleep 2
done
cat > events/alert.json <<'EOF'
{
"Entries": [{
"EventBusName": "ops-agent-bus",
"Source": "ops.alerts",
"DetailType": "alert.raised",
"Detail": "{\"severity\":\"high\",\"service\":\"checkout-api\",\"title\":\"CPU above 90 percent for 10 minutes\",\"alert_type\":\"high_cpu\",\"namespace\":\"Custom/Ops\",\"metric\":\"CpuPercent\",\"dimension_name\":\"Service\",\"dimension_value\":\"checkout-api\"}"
}]
}
EOF
aws events put-events --cli-input-json file://events/alert.json
A successful publish returns a zero failed-entry count. The execution should appear in the Step Functions console within seconds.
{
"FailedEntryCount": 0,
"Entries": [
{ "EventId": "11111111-2222-3333-4444-555555555555" }
]
}
Watch the run in the console, or list it from the terminal. A typical path goes Init, Decide, Route, SkipApproval, RunTool for the metric read, around the loop again for the runbook, then to a third Decide that asks for restart_service. At that point the execution turns to RequestApproval and stays there. Check your inbox, open the link, and click Approve. The run resumes, records the action, and finishes with a summary.
aws stepfunctions list-executions --state-machine-arn "$STATE_MACHINE_ARN" --max-results 3
aws stepfunctions describe-execution --execution-arn "$EXECUTION_ARN" --query "{status:status,output:output}"
aws dynamodb query --table-name "$TABLE_NAME" \
--key-condition-expression "pk = :p" \
--expression-attribute-values '{":p":{"S":"'"$RUN_ID"'"}}'
Expected output after approval looks like the sample below. Your wording will differ because the model writes the summary, and the exact number of iterations depends on how it chooses tools.
{
"status": "SUCCEEDED",
"output": "{\"run_id\":\"a1b2c3d4-...\",\"summary\":{\"result\":{\"run_id\":\"a1b2c3d4-...\",\"summary\":\"Probable cause: sustained CPU saturation on checkout-api. Evidence: five-minute averages above 90 percent across the last window, runbook step 3 met. Action taken: restart requested and approved by an operator.\",\"iterations\":4}}}"
}
Items in DynamoDB for the run:
sk = action#tooluse_xxxxxxxx service = checkout-api reason = ...
sk = summary iterations = 4
Now test the unhappy paths, because they are where the design earns its keep. Send the same alert again and click Reject. The run should finish with a summary that says the restart was denied. Send it a third time and ignore the email for an hour, or temporarily shorten TimeoutSeconds to 60 and redeploy. The run should end with the same denial and a timeout reason in the execution history.
Step 12: Logging, Alarms, and Cost Controls #
Step 12: Watch the agent and cap what it can spend. The template already ships three safety nets: an alarm on failed executions, a 30-day retention period on the state machine log group, and an iteration cap of six. The Lambda functions print one JSON line per decision with token counts, and CloudWatch Logs Insights can parse those lines without any extra setup. Run the query below against the decide function’s log group once a day during the first week.
fields @timestamp, run_id, iteration, in, out
| filter event = "decide"
| stats sum(in) as input_tokens, sum(out) as output_tokens, count() as model_calls by bin(1h)
| sort @timestamp desc
Bedrock tokens will be the biggest line on the bill, not Lambda. Bedrock pricing varies by provider, model and modality, and the pricing page lists four service tiers: Standard, Flex, Priority and Reserved. According to the page AWS updated on October 30, Flex and batch inference run at a 50 percent discount to Standard, while Priority reportedly carries a 75 percent premium. For triage that nobody is watching live, Flex is worth testing if your chosen model offers it. AWS also says selected models support batch inference at up to 50 percent below on-demand prices, which suits nightly sweeps better than live alerts.
Lambda cost is easy to estimate from the published list rates: $0.20 per million requests and $0.0000166667 per GB-second, after a monthly free tier of 1 million requests and 400,000 GB-seconds. The table below assumes about 12 Lambda invocations per alert, each running 1.5 seconds at 512 MB, which works out to 0.75 GB-seconds per call and 9 GB-seconds per alert. That is illustrative arithmetic on the x86 list rate in the example region, not a measured bill.
| Alerts per month | Lambda invocations | GB-seconds | Request charge | Compute charge | Lambda total |
|---|---|---|---|---|---|
| 1,000 | 12,000 | Not listed | $0.00 | $0.00 | $0.00 |
| 10,000 | 120,000 | Not listed | $0.00 | $0.00 | $0.00 |
| 100,000 | 1.2 million | 900,000 | $0.04 | Not listed | Not listed |
| 1,000,000 | Not listed | Not listed | $2.20 | Not listed | Not listed |
Even at a million alerts a month, Lambda stays small beside the model bill. Step Functions adds roughly 20 state transitions per alert in this design. AWS prices Standard workflows per transition, retries included, and reportedly gives 4,000 free transitions per month, so check the current rate on the Step Functions pricing page before you extrapolate. EventBridge bills per event, and each 64 KB chunk of payload counts as one event, which is another reason to keep the completion event short.
For the broader picture of how these services fit together, the cloud computing section collects the AWS, Azure and Google Cloud coverage in one place.
Two more cost habits pay off early. Set a reserved concurrency limit on the decide function, so a flood of alerts cannot open hundreds of parallel model calls. And turn on EventBridge’s dead-letter queue alarm, because an alert that never reached the state machine costs nothing but can hurt a lot.
Common Pitfalls That Break Agent Builds #
These six mistakes account for most of the failed first deployments. Each one is easy to avoid once you know it exists.
- Approving on a GET request. Email scanners and chat link previews fetch every URL they see. A GET that approves an action will approve it before a human reads the message. Show a form on GET and act only on POST.
- Skipping a tool result. If the model emits two tool calls and you answer only one, the next Converse call fails with a validation error. Always return one result per tool-use ID, even if that result is an error message.
- Dropping the tool config on later turns. Once the transcript contains tool-use or tool-result blocks, the Converse API expects the tool configuration on every call. Removing it to force a final answer breaks the request. Stop the loop in code instead, as the iteration cap does.
- Letting the transcript grow without limit. Step Functions caps the size of the data passed between states. A verbose tool that returns pages of JSON will push a long run over the edge. Truncate tool output, and move the transcript to DynamoDB if runs routinely exceed ten loops.
- Retrying actions that are not idempotent. Retries are a feature of the state machine, which means a tool can run twice after a timeout. Key every state-changing action on the run ID and the tool-use ID, and write a conditional record before the side effect.
- Publishing to the bus that triggers you. The completion event goes back to the same bus as the alert. That is safe only because the rule matches
ops.alertsand notops.agent. Loosen the pattern and you get an infinite loop with a bill attached. AWS reportedly extended recursive-loop detection to EventBridge custom buses in October, but do not rely on it as your only guard.
A seventh trap sits outside the code. Teams grant broad Bedrock permissions during testing, then forget to narrow them. The template in Step 9 scopes the policy to specific model ARNs from the start, which costs five minutes now and avoids an audit finding later.
Troubleshooting: 10 Errors and Their Fixes #
- AccessDeniedException from Bedrock. Check that the model is enabled for your account in this Region, and that the policy lists both the inference profile ARN and the foundation model ARNs behind it. A profile that routes across Regions needs the model ARNs in each Region.
- ValidationException about toolResult blocks. The previous assistant message contained a tool call that has no matching result. Compare the tool-use IDs in the transcript with the IDs in the next user message. The run-tool function must answer all of them.
- ValidationException about message order. The first message must come from the user, and roles must alternate. If you seed the transcript yourself, check that two user messages never sit side by side.
- The execution sits on RequestApproval and no email arrives. The SNS subscription is still unconfirmed. Look for the AWS confirmation email, including the spam folder, and confirm it. Then start a new run, since the earlier message was never delivered.
- The approval page says the request has expired. The run already timed out, or someone clicked twice. The token works once. Check the execution history for a recorded rejection with a timeout reason.
- States.Runtime: the JSONPath could not be found. A state read a field that the previous state did not write. The usual culprit is
$.approvalon the path that skips approval. Make sureSkipApprovalwrites its result to that exact path. - The EventBridge rule never fires. Confirm you published to
ops-agent-busand not the default bus, and that source, detail type and severity match exactly. Patterns are case-sensitive. Check the rule’sFailedInvocationsmetric and the dead-letter queue. - The decide function times out. A long transcript or a slow model can exceed the 90-second limit. Raise the timeout, shorten the transcript, or choose a faster model. Remember to keep the state machine’s own retry count in mind, since each retry repeats the call.
- CloudWatch returns empty datapoints. Custom metrics need a minute or two to appear, and the dimension name and value must match the ones in the alert detail exactly. Run the same query from the CLI to confirm.
- Repeated ThrottlingException errors. Your retry rule may not be matching. The Lambda must raise
BedrockThrottled, and the Retry block must list that exact name. If throttling persists, lower concurrency, try a different tier, or request a quota increase.
A few extra checks help when none of those fit. Open the failed execution in the console and read the exception state, since the error name usually points at the right layer. Run the failing Lambda alone with sam local invoke using the state that went into it, which you can copy from the execution input tab. And turn the state machine log level up to ALL for one test run, then back to ERROR so you do not log execution data longer than you need to.
Advanced Tips for Production Hardening #
Move the Transcript Out of the State
The simplest change with the biggest payoff is storing the conversation in DynamoDB and passing only a pointer through the state machine. The decide function reads the transcript by run ID, appends the new message, writes it back, and returns just the action. Payload size stops mattering, runs of thirty loops become possible, and the execution history in the console gets easier to read because it no longer shows kilobytes of JSON per state. The cost is one extra read and one extra write per loop, both on-demand and tiny.
Expose the Workflow as an Agent Tool
Once the triage flow works, other agents can call it. AWS’s October 29 announcement says Step Functions workflows can be registered as tools behind Bedrock AgentCore Gateway, with standard Step Functions and gateway charges applying. A planning agent could then request a triage run for a given service and read back the summary, without knowing anything about EventBridge or approval emails. AWS also reported a Step Functions step powered by AgentCore for agentic reasoning inside a workflow, dated July 20, which suits cases where only one branch needs open-ended thinking. Both features are recent, so check Regional availability and current pricing before you design around them.
If you run coding agents alongside ops agents, the same event-driven habits carry over. Our guide to building Cursor cloud agents shows how another vendor structures isolated environments and secrets, which is a useful contrast to the IAM-first approach used here.
Route by Cost, Guard by Policy
Not every alert needs the same model. A cheap, fast model can classify the alert and decide whether it is worth a full investigation. Only the ambiguous cases escalate to a larger model. Add a classification state before Decide, put the model ID in the state input, and let a Choice state pick the path. Pair that with Amazon Bedrock Guardrails by adding a guardrail configuration to the Converse call, so unsafe or off-topic output is filtered before it reaches a tool. Keep the guardrail on the model’s input and output, and keep the approval gate on the tools. They protect different things.
Other upgrades are worth a look as the system matures:
- Lambda durable functions. AWS documents them as a Lambda capability for longer-running, event-driven workflows, including remediation flows that combine EventBridge and Bedrock. They suit teams that prefer to write the loop in code instead of ASL. The AWS announcement dated October 16 reportedly extended them to GovCloud.
- EventBridge Scheduler. Use it for periodic sweeps, such as asking the agent to review open alerts every morning. AWS lists a free tier of 14 million invocations per month and then $1 per million, according to its pricing page.
- The enhanced Custom event bus. Launched in September 2026, it adds organization-wide sharing, optional ordering, content-based deduplication, synchronous Lambda targets and built-in retention. It uses separate ingress and egress pricing, so model your volumes before migrating.
- Archive and replay. Archive the alert stream, then replay last month’s alerts against a new model or prompt in a test stack. That is the cheapest regression test an agent can get.
- Edge placement. If the approval page needs to load fast for operators worldwide, compare the options in ourCloudflare Workers and Lambda@Edge comparison before adding a CDN layer.
- Memory pricing awareness. If you graduate to AgentCore Memory, note that AWS changed short-term memory billing on October 6 from $0.25 per 1,000 new events to usage-based rates of $1.00 per GB ingested, $0.20 per GB retrieved and $0.10 per GB-month stored.
The Complete Working Project #
The finished repository has ten files. Every file appears in full above, so assembling it is a copy and paste job. The tree below shows where each piece lives, and the script after it runs the whole sequence from an empty folder to a tested stack.
ops-agent/
template.yaml Step 9: bus, table, topic, API, functions, state machine, rule, alarm
samconfig.toml written by sam deploy --guided
statemachine/
agent.asl.yaml Step 8: the loop and the approval gate
src/
tools.py Step 3: tool specs, runbooks, dispatcher
decide.py Step 4: one Bedrock Converse call per invocation
run_tool.py Step 5: executes tools, enforces approval
request_approval.py Step 6: emails the approval link
approve.py Step 6: form page and SendTaskSuccess
finalize.py Step 7: writes the run summary
events/
alert.json Step 11: test alert
bash
#!/usr/bin/env bash
set -euo pipefail
STACK=ops-agent
MODEL_ID="paste-your-model-or-profile-id"
MODEL_ARNS="paste-profile-arn,paste-model-arn"
EMAIL="[email protected]"
sam validate --lint
sam build
sam deploy --stack-name "$STACK" --capabilities CAPABILITY_IAM --resolve-s3 \
--parameter-overrides ModelId="$MODEL_ID" ModelArns="$MODEL_ARNS" ApprovalEmail="$EMAIL"
echo "Confirm the SNS subscription email, then press Enter to send a test alert."
read -r _
for v in 91 94 96 93 95
do
aws cloudwatch put-metric-data --namespace Custom/Ops --metric-name CpuPercent \
--dimensions Service=checkout-api --value "$v"
done
sleep 120
aws events put-events --cli-input-json file://events/alert.json
SM_ARN=$(aws cloudformation describe-stacks --stack-name "$STACK" \
--query "Stacks[0].Outputs[?OutputKey=='StateMachineArn'].OutputValue" --output text)
aws stepfunctions list-executions --state-machine-arn "$SM_ARN" --max-results 1
Delete the stack when you finish testing. The approval endpoint is public, the SNS subscription keeps your inbox on the hook, and the table keeps its data until the time-to-live sweep removes it. A single sam delete removes every resource the template created.
Frequently Asked Questions #
How much does a serverless AI agent on AWS cost to run?
At low volume the Lambda portion is free, because the monthly free tier covers 1 million requests and 400,000 GB-seconds. The real cost is Bedrock tokens, which depend on the model, the service tier and how many loops each alert needs. Log token counts from day one and multiply by the current price for your model.
Should I use Step Functions or Bedrock AgentCore for the agent loop?
Use Step Functions when you want a visible, auditable flow with retries and human gates that you control step by step. Use AgentCore when you want a managed runtime with memory and tool access and you accept more platform surface. The two also combine, since workflows can be exposed to agents as tools.
Which Bedrock model works best for tool-calling agents?
Any model that supports tool use in the Converse API can run this loop. GPT-6.1 Terra became generally available on Bedrock on October 26, 2026, and other families are listed in the Bedrock console. Start with a small, fast model, measure how often it picks the right tool, and move up only if it fails.
Is it safe to let an AI agent restart production services?
Only with guardrails. Keep a short list of risky tools, require a human decision for them, enforce that rule inside the tool runner as well as the workflow, and make every action idempotent. Start with read-only tools and add write tools one at a time.
Do I need the new EventBridge enhanced Custom event bus?
No. The Classic bus used in this build works as before, and AWS says existing buses continue unchanged. The enhanced bus is worth evaluating if you need cross-account sharing, ordering or deduplication.
Why does the Lambda in this build count as an Event Function?
AWS renamed existing Lambda functions to Lambda Event Functions in October 2026, when it introduced Lambda Web Functions for direct HTTP handling. The rename needs no code changes, so every handler here keeps working as written.
How do I stop the agent from looping forever?
Cap the iterations in code, as the decide function does, and cap concurrency at the function level. Add an alarm on failed executions and a budget alert on the account. Three independent limits mean no single mistake can run up a surprise bill.
Can I swap SNS email for Slack approvals?
Yes. Replace the SNS publish in the request-approval function with a Slack message that carries the task token in a button payload, and verify Slack’s request signature in the approval handler. The state machine does not change.