cd /news/ai-agents/serverless-ai-agent-on-aws-12-steps-… · home › topics › ai-agents › article
[ARTICLE · art-162344] src=shattered.io ↗ pub= topic=ai-agents verified=true sentiment=· neutral

Serverless AI Agent on AWS: 12 Steps, 90 Min [2026]

A walkthrough published by Shattered lays out a 12-step, roughly 90-minute build for a serverless AI agent on AWS that wires an EventBridge bus to a Step Functions state machine, Amazon Bedrock model calls, read-only tools in Lambda, and a human approval click before any state-changing action, with results written to DynamoDB and returned to the bus as a new event. The guide cites Gartner's August 2025 press release projecting that 40% of enterprise applications will be integrated with task-specific AI agents by the end of 2026, up from less than 5% in 2025, and notes that OpenAI's GPT-6.1 Terra became generally available on Amazon Bedrock on October 26, 2026. It also reports that AWS renamed existing Lambda functions to Lambda Event Functions with no code changes or redeployment required, and that EventBridge's enhanced Custom event bus, introduced in September 2026, sits alongside the older Custom event bus Classic used in the build.

by read41 min views4 publishedNov 2, 2026
Serverless AI Agent on AWS: 12 Steps, 90 Min [2026]
Image: Shattered (auto-discovered)

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.

Add now

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 withbash andcurl . 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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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 matchesops.alerts and 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 #

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. States.Runtime: the JSONPath could not be found. A state read a field that the previous state did not write. The usual culprit is$.approval on the path that skips approval. Make sureSkipApproval writes its result to that exact path.
  7. The EventBridge rule never fires. Confirm you published toops-agent-bus and not the default bus, and that source, detail type and severity match exactly. Patterns are case-sensitive. Check the rule’sFailedInvocations metric and the dead-letter queue.
  8. 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.
  9. 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.
  10. Repeated ThrottlingException errors. Your retry rule may not be matching. The Lambda must raiseBedrockThrottled , 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.

── more in #ai-agents 4 stories · sorted by recency
── more on @aws 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/serverless-ai-agent-…] indexed:0 read:41min 2026-11-02 · —