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. 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.” https://www.gartner.com/en/newsroom/press-releases/2025-08-26-gartner-predicts-40-percent-of-enterprise-apps-will-feature-task-specific-ai-agents-by-2026-up-from-less-than-5-percent-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 https://builder.aws.com/content/3LHcpG5XKMry0KAYxeNPkFFF0tu/gpt-61-terra-on-amazon-bedrock-building-fast-cost-efficient-enterprise-ai-agents-on-aws 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 https://shattered.io/deploy-aws-bedrock-agentcore-agent-2026/ 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 https://www.google.com/preferences/source?hl=en&q=https%3A%2F%2Fshattered.io%2F 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 pause 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 https://shattered.io/aws-lambda-web-functions-launch-2026/ , 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 https://dev.to/aws-builders/weekly-aws-serverless-roundup-october-24-30-2026-4k00 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 https://shattered.io/aws-lambda-vs-azure-functions-vs-cloud-run-2026/ 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 pause 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 pause, run state dies with the process | If any Lambda detail below feels unfamiliar, the AWS Lambda serverless tutorial https://shattered.io/aws-lambda-serverless-tutorial-2026/ 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 bash and curl . 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. python 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. python 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 https://shattered.io/aws-bedrock-openai-agents-cost-cut-2026/ 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. python 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. python 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