{"slug": "serverless-ai-agent-on-aws-12-steps-90-min-2026", "title": "Serverless AI Agent on AWS: 12 Steps, 90 Min [2026]", "summary": "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.", "body_md": "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.\n\nHere 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.\n\nAWS 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.\n\nDon't miss new tech stories on Google\n\nAdd Shattered once in the Google app and our stories show up in your news suggestions.\n\n[Add now](https://www.google.com/preferences/source?hl=en&q=https%3A%2F%2Fshattered.io%2F)\n\n## Why Build a Serverless AI Agent on AWS Right Now\n\nAgents 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.\n\nAWS 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.\n\nThe 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.\n\nAccording 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.\n\nAdoption 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.\n\nBefore 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.\n\n## Architecture: Which Service Does What\n\nThe 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.\n\n| Component | Job in this agent | Billing unit | \n|---|---|---|\n| EventBridge bus and rule | Receives alerts, filters by severity, starts the state machine | Per event published, plus any enabled capabilities | \n| Step Functions (Standard) | Runs the loop, retries, waits for human approval | Per state transition, including retries | \n| Lambda: decide | Calls Bedrock and returns the next action | Requests plus GB-seconds | \n| Lambda: run_tool | Executes tools and enforces the approval rule | Requests plus GB-seconds | \n| Amazon Bedrock Converse API | Reasoning and tool selection | Input and output tokens, by model and service tier | \n| SNS and HTTP API | Sends the approval email, receives the click | Per message and per million API requests | \n| DynamoDB (on-demand) | Stores run summaries and idempotency records | Per read and write request | \n\nWhy 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.\n\nA 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.\n\n| Orchestration option | Best fit | Main trade-off | \n|---|---|---|\n| Step Functions loop (this build) | Visible, auditable runs with human gates | You write the loop and manage transcript size | \n| Lambda durable functions | Code-first teams that want long-running logic in one function | Less visual history than a state machine | \n| Bedrock AgentCore runtime | Managed agent hosting with memory and tool gateway | More platform to learn, new billing dimensions | \n| Single Lambda with a while loop | Prototypes | No durable pause, run state dies with the process | \n\nIf 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.\n\n## Prerequisites and Versions\n\nYou 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.\n\n- **Python 3.13** locally and as the Lambda runtime (`python3.13` ).\n- **AWS CLI v2** , latest version, configured with credentials for the sandbox account.\n- **AWS SAM CLI** , latest version, for building and deploying the stack.\n- **Amazon Bedrock access** to one model that supports tool use through the Converse API, enabled in the Region you deploy to.\n- **An email address** you can open during the test, for the approval message.\n- **A shell** with`bash` and`curl` . No Docker is required.\n\nOn 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.\n\n```\npython3 --version\naws --version\nsam --version\naws sts get-caller-identity\naws configure get region\naws bedrock list-foundation-models --query \"modelSummaries[].modelId\" --output text | tr '\\t' '\\n' | head -40\naws bedrock list-inference-profiles --query \"inferenceProfileSummaries[].inferenceProfileId\" --output text | tr '\\t' '\\n' | head -40\n```\n\nThe 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.\n\nExpected output from the first three commands looks like this. Your version numbers will differ if you installed later.\n\n```\nPython 3.13.x\naws-cli/2.x.x Python/3.x.x Linux/x86_64\nSAM CLI, version 1.x.x\n{\n    \"UserId\": \"AIDAEXAMPLE\",\n    \"Account\": \"123456789012\",\n    \"Arn\": \"arn:aws:iam::123456789012:user/you\"\n}\n```\n\n## Steps 1-3: Guardrails, Project Scaffold, and the Tool Catalog\n\n**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.\n\n**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.\n\n```\nmkdir -p ops-agent/src ops-agent/statemachine ops-agent/events\ncd ops-agent\ntouch template.yaml samconfig.toml\ntouch src/tools.py src/decide.py src/run_tool.py src/request_approval.py src/approve.py src/finalize.py\ntouch statemachine/agent.asl.yaml\ntouch events/alert.json\nfind . -type f | sort\n```\n\n**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.\n\nThe 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.\n\n``` python\nimport datetime\nimport os\nimport time\n\nimport boto3\nfrom botocore.exceptions import ClientError\n\ncloudwatch = boto3.client(\"cloudwatch\")\ntable = boto3.resource(\"dynamodb\").Table(os.environ[\"TABLE_NAME\"])\n\nRISKY = {\"restart_service\"}\n\nRUNBOOKS = {\n    \"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.\",\n    \"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.\",\n    \"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.\",\n}\n\nTOOL_SPECS = [\n    {\"toolSpec\": {\n        \"name\": \"get_metric_summary\",\n        \"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.\",\n        \"inputSchema\": {\"json\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"namespace\": {\"type\": \"string\"},\n                \"metric_name\": {\"type\": \"string\"},\n                \"dimension_name\": {\"type\": \"string\"},\n                \"dimension_value\": {\"type\": \"string\"},\n                \"minutes\": {\"type\": \"integer\", \"description\": \"Look-back window, default 30\"},\n            },\n            \"required\": [\"namespace\", \"metric_name\", \"dimension_name\", \"dimension_value\"],\n        }},\n    }},\n    {\"toolSpec\": {\n        \"name\": \"lookup_runbook\",\n        \"description\": \"Return the written runbook for an alert type. Valid types: high_cpu, high_latency, error_spike. Read-only.\",\n        \"inputSchema\": {\"json\": {\n            \"type\": \"object\",\n            \"properties\": {\"alert_type\": {\"type\": \"string\"}},\n            \"required\": [\"alert_type\"],\n        }},\n    }},\n    {\"toolSpec\": {\n        \"name\": \"restart_service\",\n        \"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.\",\n        \"inputSchema\": {\"json\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"service\": {\"type\": \"string\"},\n                \"reason\": {\"type\": \"string\"},\n            },\n            \"required\": [\"service\", \"reason\"],\n        }},\n    }},\n]\n\ndef get_metric_summary(namespace, metric_name, dimension_name, dimension_value, minutes=30):\n    minutes = max(5, min(int(minutes), 180))\n    end = datetime.datetime.now(datetime.timezone.utc)\n    start = end - datetime.timedelta(minutes=minutes)\n    resp = cloudwatch.get_metric_statistics(\n        Namespace=namespace,\n        MetricName=metric_name,\n        Dimensions=[{\"Name\": dimension_name, \"Value\": dimension_value}],\n        StartTime=start,\n        EndTime=end,\n        Period=300,\n        Statistics=[\"Average\", \"Maximum\"],\n    )\n    points = sorted(resp[\"Datapoints\"], key=lambda p: p[\"Timestamp\"])\n    return {\n        \"metric\": metric_name,\n        \"points\": [\n            {\"t\": p[\"Timestamp\"].isoformat(), \"avg\": round(p[\"Average\"], 2), \"max\": round(p[\"Maximum\"], 2)}\n            for p in points\n        ],\n    }\n\ndef lookup_runbook(alert_type):\n    return {\"alert_type\": alert_type, \"runbook\": RUNBOOKS.get(alert_type, \"No runbook found for this alert type.\")}\n\ndef restart_service(service, reason, run_id, tool_id):\n    try:\n        table.put_item(\n            Item={\n                \"pk\": run_id,\n                \"sk\": f\"action#{tool_id}\",\n                \"service\": service,\n                \"reason\": reason[:500],\n                \"ts\": int(time.time()),\n            },\n            ConditionExpression=\"attribute_not_exists(pk)\",\n        )\n    except ClientError as err:\n        if err.response[\"Error\"][\"Code\"] == \"ConditionalCheckFailedException\":\n            return {\"status\": \"already_recorded\", \"service\": service}\n        raise\n    return {\"status\": \"restart_requested\", \"service\": service}\n\ndef dispatch(name, args, run_id, tool_id):\n    if name == \"get_metric_summary\":\n        return get_metric_summary(**args)\n    if name == \"lookup_runbook\":\n        return lookup_runbook(**args)\n    if name == \"restart_service\":\n        return restart_service(args[\"service\"], args.get(\"reason\", \"\"), run_id, tool_id)\n    raise ValueError(f\"Unknown tool: {name}\")\n```\n\nTwo 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.\n\n## Steps 4-5: The Decide and Run-Tool Functions\n\n**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.\n\nThree 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.\n\n``` python\nimport json\nimport os\n\nimport boto3\nfrom botocore.exceptions import ClientError\n\nfrom tools import RISKY, TOOL_SPECS\n\nbedrock = boto3.client(\"bedrock-runtime\")\nMODEL_ID = os.environ[\"MODEL_ID\"]\nMAX_ITERATIONS = int(os.environ.get(\"MAX_ITERATIONS\", \"6\"))\n\nSYSTEM = [{\"text\": (\n    \"You are an on-call triage agent. Gather evidence with tools before you conclude. \"\n    \"Call read-only tools first. Request restart_service only when metrics confirm the problem \"\n    \"and the runbook allows it. Never claim an action succeeded unless a tool result says so. \"\n    \"When finished, reply with plain text: probable cause, evidence, action taken or recommended.\"\n)}]\n\nclass BedrockThrottled(Exception):\n    pass\n\ndef handler(event, context):\n    iteration = int(event.get(\"iteration\", 0)) + 1\n    messages = event.get(\"messages\") or [\n        {\"role\": \"user\", \"content\": [{\"text\": \"Alert received:\\n\" + json.dumps(event[\"alert\"])[:4000]}]}\n    ]\n\n    if iteration > MAX_ITERATIONS:\n        return {\n            \"action\": \"final\",\n            \"messages\": messages,\n            \"iteration\": iteration,\n            \"final_text\": \"Stopped: iteration cap reached before a conclusion.\",\n        }\n\n    try:\n        resp = bedrock.converse(\n            modelId=MODEL_ID,\n            system=SYSTEM,\n            messages=messages,\n            toolConfig={\"tools\": TOOL_SPECS},\n            inferenceConfig={\"maxTokens\": 1024, \"temperature\": 0},\n        )\n    except ClientError as err:\n        code = err.response[\"Error\"][\"Code\"]\n        if code in (\"ThrottlingException\", \"ModelNotReadyException\", \"ServiceUnavailableException\"):\n            raise BedrockThrottled(code) from err\n        raise\n\n    message = resp[\"output\"][\"message\"]\n    messages = messages + [message]\n    usage = resp.get(\"usage\", {})\n    print(json.dumps({\n        \"event\": \"decide\",\n        \"run_id\": event.get(\"run_id\"),\n        \"iteration\": iteration,\n        \"stop\": resp[\"stopReason\"],\n        \"in\": usage.get(\"inputTokens\"),\n        \"out\": usage.get(\"outputTokens\"),\n    }))\n\n    tool_uses = [b[\"toolUse\"] for b in message[\"content\"] if \"toolUse\" in b]\n    if resp[\"stopReason\"] == \"tool_use\" and tool_uses:\n        tools = [{\"id\": t[\"toolUseId\"], \"name\": t[\"name\"], \"input\": t[\"input\"]} for t in tool_uses]\n        return {\n            \"action\": \"tool\",\n            \"messages\": messages,\n            \"iteration\": iteration,\n            \"tools\": tools,\n            \"needs_approval\": any(t[\"name\"] in RISKY for t in tools),\n        }\n\n    text = \"\".join(b.get(\"text\", \"\") for b in message[\"content\"]).strip()\n    return {\"action\": \"final\", \"messages\": messages, \"iteration\": iteration, \"final_text\": text}\n```\n\nA 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.\n\n**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.\n\n``` python\nimport json\n\nfrom tools import RISKY, dispatch\n\nMAX_RESULT_CHARS = 3000\n\ndef handler(event, context):\n    decision = event[\"approval\"].get(\"decision\")\n    blocks = []\n\n    for tool in event[\"tools\"]:\n        if tool[\"name\"] in RISKY and decision != \"approved\":\n            blocks.append({\"toolResult\": {\n                \"toolUseId\": tool[\"id\"],\n                \"content\": [{\"text\": \"Denied: an operator did not approve this action.\"}],\n                \"status\": \"error\",\n            }})\n            continue\n        try:\n            output = dispatch(tool[\"name\"], tool[\"input\"], event[\"run_id\"], tool[\"id\"])\n            raw = json.dumps(output)\n            if len(raw) > MAX_RESULT_CHARS:\n                output = {\"truncated\": raw[:MAX_RESULT_CHARS]}\n            blocks.append({\"toolResult\": {\n                \"toolUseId\": tool[\"id\"],\n                \"content\": [{\"json\": output}],\n                \"status\": \"success\",\n            }})\n        except Exception as exc:\n            blocks.append({\"toolResult\": {\n                \"toolUseId\": tool[\"id\"],\n                \"content\": [{\"text\": f\"{type(exc).__name__}: {exc}\"[:500]}],\n                \"status\": \"error\",\n            }})\n\n    print(json.dumps({\"event\": \"run_tool\", \"run_id\": event[\"run_id\"], \"tools\": [t[\"name\"] for t in event[\"tools\"]], \"decision\": decision}))\n    return {\"messages\": event[\"messages\"] + [{\"role\": \"user\", \"content\": blocks}]}\n```\n\nThe 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.\n\n## Steps 6-7: The Approval Gate and the Run Record\n\n**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.\n\nThe 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.\n\n``` python\nimport os\nimport urllib.parse\n\nimport boto3\n\nfrom tools import RISKY\n\nsns = boto3.client(\"sns\")\n\ndef handler(event, context):\n    token = urllib.parse.quote(event[\"token\"], safe=\"\")\n    link = f\"{os.environ['APPROVAL_BASE_URL']}/approval?token={token}\"\n    risky = [t for t in event[\"tools\"] if t[\"name\"] in RISKY]\n    lines = [\n        f\"Run: {event['run_id']}\",\n        f\"Alert: {event['alert'].get('title', 'unknown')}\",\n        \"The agent wants to run:\",\n    ]\n    for t in risky:\n        lines.append(f\"  {t['name']} {t['input']}\")\n    lines += [\"\", f\"Review and decide: {link}\"]\n    sns.publish(\n        TopicArn=os.environ[\"TOPIC_ARN\"],\n        Subject=f\"Approval needed: {event['alert'].get('service', 'service')}\"[:100],\n        Message=\"\\n\".join(lines),\n    )\n    return {\"status\": \"sent\"}\npython\nimport base64\nimport html\nimport json\nimport urllib.parse\n\nimport boto3\n\nsfn = boto3.client(\"stepfunctions\")\n\nPAGE = (\n    \"<!doctype html><html><body><h1>Agent action approval</h1>\"\n    \"<form method='post' action='approval'>\"\n    \"<input type='hidden' name='token' value='{token}'>\"\n    \"<button name='decision' value='approved'>Approve</button> \"\n    \"<button name='decision' value='rejected'>Reject</button>\"\n    \"</form></body></html>\"\n)\n\ndef page(status, body):\n    return {\"statusCode\": status, \"headers\": {\"content-type\": \"text/html\"}, \"body\": body}\n\ndef handler(event, context):\n    method = event[\"requestContext\"][\"http\"][\"method\"]\n\n    if method == \"GET\":\n        token = (event.get(\"queryStringParameters\") or {}).get(\"token\", \"\")\n        return page(200, PAGE.format(token=html.escape(token, quote=True)))\n\n    if method != \"POST\":\n        return page(405, \"<p>Method not allowed</p>\")\n\n    body = event.get(\"body\") or \"\"\n    if event.get(\"isBase64Encoded\"):\n        body = base64.b64decode(body).decode(\"utf-8\")\n    form = urllib.parse.parse_qs(body)\n    token = form.get(\"token\", [\"\"])[0]\n    decision = form.get(\"decision\", [\"\"])[0]\n\n    if not token or decision not in (\"approved\", \"rejected\"):\n        return page(400, \"<p>Bad request</p>\")\n\n    try:\n        sfn.send_task_success(taskToken=token, output=json.dumps({\"decision\": decision}))\n    except (sfn.exceptions.TaskTimedOut, sfn.exceptions.TaskDoesNotExist, sfn.exceptions.InvalidToken):\n        return page(410, \"<p>This approval request has expired or was already answered.</p>\")\n\n    print(json.dumps({\"event\": \"approval\", \"decision\": decision}))\n    return page(200, f\"<p>Recorded: {decision}</p>\")\n```\n\nThe 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](https://shattered.io/lambda-web-functions-pricing-active-cpu-billing-math-2026/) shows how active-CPU billing changes the math.\n\n**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.\n\n``` python\nimport json\nimport os\nimport time\n\nimport boto3\n\ntable = boto3.resource(\"dynamodb\").Table(os.environ[\"TABLE_NAME\"])\nRETENTION_SECONDS = 90 * 86400\n\ndef handler(event, context):\n    final = event[\"final\"]\n    text = final.get(\"final_text\", \"\")\n    table.put_item(Item={\n        \"pk\": event[\"run_id\"],\n        \"sk\": \"summary\",\n        \"alert\": json.dumps(event[\"alert\"])[:4000],\n        \"summary\": text[:4000],\n        \"iterations\": final[\"iteration\"],\n        \"ttl\": int(time.time()) + RETENTION_SECONDS,\n    })\n    print(json.dumps({\"event\": \"finalize\", \"run_id\": event[\"run_id\"], \"iterations\": final[\"iteration\"]}))\n    return {\"run_id\": event[\"run_id\"], \"summary\": text[:1500], \"iterations\": final[\"iteration\"]}\n```\n\n## Step 8: The Step Functions State Machine\n\nThis 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.\n\nThe 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.\n\n```\nComment: Ops triage agent loop with a human approval gate\nStartAt: Init\nStates:\n  Init:\n    Type: Pass\n    Parameters:\n      run_id.$: $$.Execution.Name\n      alert.$: $.detail\n      messages: []\n      iteration: 0\n    Next: Decide\n\n  Decide:\n    Type: Task\n    Resource: arn:aws:states:::lambda:invoke\n    Parameters:\n      FunctionName: ${DecideFunctionArn}\n      Payload.$: $\n    ResultSelector:\n      decision.$: $.Payload\n    ResultPath: $.step\n    Retry:\n      - ErrorEquals: [BedrockThrottled]\n        IntervalSeconds: 2\n        BackoffRate: 2.0\n        MaxAttempts: 5\n        JitterStrategy: FULL\n      - ErrorEquals: [Lambda.ServiceException, Lambda.AWSLambdaException, Lambda.SdkClientException, Lambda.TooManyRequestsException]\n        IntervalSeconds: 2\n        BackoffRate: 2.0\n        MaxAttempts: 3\n    Catch:\n      - ErrorEquals: [States.ALL]\n        ResultPath: $.error\n        Next: FailRun\n    Next: Route\n\n  Route:\n    Type: Choice\n    Choices:\n      - Variable: $.step.decision.action\n        StringEquals: final\n        Next: Finalize\n      - Variable: $.step.decision.needs_approval\n        BooleanEquals: true\n        Next: RequestApproval\n    Default: SkipApproval\n\n  SkipApproval:\n    Type: Pass\n    Result:\n      decision: not_required\n    ResultPath: $.approval\n    Next: RunTool\n\n  RequestApproval:\n    Type: Task\n    Resource: arn:aws:states:::lambda:invoke.waitForTaskToken\n    TimeoutSeconds: 3600\n    Parameters:\n      FunctionName: ${RequestApprovalFunctionArn}\n      Payload:\n        token.$: $$.Task.Token\n        run_id.$: $.run_id\n        alert.$: $.alert\n        tools.$: $.step.decision.tools\n    ResultPath: $.approval\n    Catch:\n      - ErrorEquals: [States.Timeout]\n        ResultPath: $.approval\n        Next: ApprovalTimedOut\n      - ErrorEquals: [States.ALL]\n        ResultPath: $.error\n        Next: FailRun\n    Next: RunTool\n\n  ApprovalTimedOut:\n    Type: Pass\n    Result:\n      decision: rejected\n      reason: timeout\n    ResultPath: $.approval\n    Next: RunTool\n\n  RunTool:\n    Type: Task\n    Resource: arn:aws:states:::lambda:invoke\n    Parameters:\n      FunctionName: ${RunToolFunctionArn}\n      Payload:\n        run_id.$: $.run_id\n        messages.$: $.step.decision.messages\n        tools.$: $.step.decision.tools\n        approval.$: $.approval\n    ResultSelector:\n      messages.$: $.Payload.messages\n    ResultPath: $.tool_result\n    Retry:\n      - ErrorEquals: [Lambda.ServiceException, Lambda.AWSLambdaException, Lambda.SdkClientException, Lambda.TooManyRequestsException]\n        IntervalSeconds: 2\n        BackoffRate: 2.0\n        MaxAttempts: 3\n    Catch:\n      - ErrorEquals: [States.ALL]\n        ResultPath: $.error\n        Next: FailRun\n    Next: Carry\n\n  Carry:\n    Type: Pass\n    Parameters:\n      run_id.$: $.run_id\n      alert.$: $.alert\n      messages.$: $.tool_result.messages\n      iteration.$: $.step.decision.iteration\n    Next: Decide\n\n  Finalize:\n    Type: Task\n    Resource: arn:aws:states:::lambda:invoke\n    Parameters:\n      FunctionName: ${FinalizeFunctionArn}\n      Payload:\n        run_id.$: $.run_id\n        alert.$: $.alert\n        final.$: $.step.decision\n    ResultSelector:\n      result.$: $.Payload\n    ResultPath: $.summary\n    Next: PublishCompleted\n\n  PublishCompleted:\n    Type: Task\n    Resource: arn:aws:states:::events:putEvents\n    Parameters:\n      Entries:\n        - EventBusName: ${BusName}\n          Source: ops.agent\n          DetailType: agent.completed\n          Detail:\n            run_id.$: $.run_id\n            summary.$: $.summary.result\n    End: true\n\n  FailRun:\n    Type: Fail\n    Error: AgentFailed\n    Cause: The agent loop hit an unrecoverable error. Check the execution history.\n```\n\nRead 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.\n\nThe 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.\n\n## Step 9: The SAM Template, the EventBridge Rule, and Least-Privilege IAM\n\nThe 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.\n\nThe 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.\n\n```\nAWSTemplateFormatVersion: \"2010-09-09\"\nTransform: AWS::Serverless-2016-10-31\nDescription: Event-driven ops triage agent (EventBridge, Step Functions, Lambda, Bedrock)\n\nParameters:\n  ModelId:\n    Type: String\n    Description: Bedrock model ID or inference profile ID copied from the console\n  ModelArns:\n    Type: CommaDelimitedList\n    Description: ARNs for the inference profile and the foundation models it routes to\n  ApprovalEmail:\n    Type: String\n\nGlobals:\n  Function:\n    Runtime: python3.13\n    Timeout: 60\n    MemorySize: 512\n    CodeUri: src/\n    Tracing: Active\n    Environment:\n      Variables:\n        TABLE_NAME: !Ref RunsTable\n\nResources:\n  AgentBus:\n    Type: AWS::Events::EventBus\n    Properties:\n      Name: ops-agent-bus\n\n  RunsTable:\n    Type: AWS::DynamoDB::Table\n    Properties:\n      BillingMode: PAY_PER_REQUEST\n      AttributeDefinitions:\n        - AttributeName: pk\n          AttributeType: S\n        - AttributeName: sk\n          AttributeType: S\n      KeySchema:\n        - AttributeName: pk\n          KeyType: HASH\n        - AttributeName: sk\n          KeyType: RANGE\n      TimeToLiveSpecification:\n        AttributeName: ttl\n        Enabled: true\n\n  ApprovalTopic:\n    Type: AWS::SNS::Topic\n    Properties:\n      Subscription:\n        - Protocol: email\n          Endpoint: !Ref ApprovalEmail\n\n  ApprovalApi:\n    Type: AWS::Serverless::HttpApi\n    Properties:\n      DefaultRouteSettings:\n        ThrottlingBurstLimit: 10\n        ThrottlingRateLimit: 5\n\n  DecideFunction:\n    Type: AWS::Serverless::Function\n    Properties:\n      Handler: decide.handler\n      Timeout: 90\n      Environment:\n        Variables:\n          MODEL_ID: !Ref ModelId\n          MAX_ITERATIONS: \"6\"\n      Policies:\n        - Statement:\n            - Effect: Allow\n              Action: bedrock:InvokeModel\n              Resource: !Ref ModelArns\n\n  RunToolFunction:\n    Type: AWS::Serverless::Function\n    Properties:\n      Handler: run_tool.handler\n      Policies:\n        - DynamoDBCrudPolicy:\n            TableName: !Ref RunsTable\n        - Statement:\n            - Effect: Allow\n              Action: cloudwatch:GetMetricStatistics\n              Resource: \"*\"\n\n  RequestApprovalFunction:\n    Type: AWS::Serverless::Function\n    Properties:\n      Handler: request_approval.handler\n      Environment:\n        Variables:\n          TOPIC_ARN: !Ref ApprovalTopic\n          APPROVAL_BASE_URL: !Sub \"https://${ApprovalApi}.execute-api.${AWS::Region}.amazonaws.com\"\n      Policies:\n        - SNSPublishMessagePolicy:\n            TopicName: !GetAtt ApprovalTopic.TopicName\n\n  ApproveFunction:\n    Type: AWS::Serverless::Function\n    Properties:\n      Handler: approve.handler\n      Policies:\n        - Statement:\n            - Effect: Allow\n              Action:\n                - states:SendTaskSuccess\n                - states:SendTaskFailure\n              Resource: \"*\"\n      Events:\n        Approval:\n          Type: HttpApi\n          Properties:\n            ApiId: !Ref ApprovalApi\n            Path: /approval\n            Method: ANY\n\n  FinalizeFunction:\n    Type: AWS::Serverless::Function\n    Properties:\n      Handler: finalize.handler\n      Policies:\n        - DynamoDBCrudPolicy:\n            TableName: !Ref RunsTable\n\n  AgentLogs:\n    Type: AWS::Logs::LogGroup\n    Properties:\n      RetentionInDays: 30\n\n  AgentStateMachine:\n    Type: AWS::Serverless::StateMachine\n    Properties:\n      Type: STANDARD\n      DefinitionUri: statemachine/agent.asl.yaml\n      DefinitionSubstitutions:\n        DecideFunctionArn: !GetAtt DecideFunction.Arn\n        RunToolFunctionArn: !GetAtt RunToolFunction.Arn\n        RequestApprovalFunctionArn: !GetAtt RequestApprovalFunction.Arn\n        FinalizeFunctionArn: !GetAtt FinalizeFunction.Arn\n        BusName: !Ref AgentBus\n      Tracing:\n        Enabled: true\n      Logging:\n        Level: ERROR\n        IncludeExecutionData: false\n        Destinations:\n          - CloudWatchLogsLogGroup:\n              LogGroupArn: !GetAtt AgentLogs.Arn\n      Policies:\n        - LambdaInvokePolicy:\n            FunctionName: !Ref DecideFunction\n        - LambdaInvokePolicy:\n            FunctionName: !Ref RunToolFunction\n        - LambdaInvokePolicy:\n            FunctionName: !Ref RequestApprovalFunction\n        - LambdaInvokePolicy:\n            FunctionName: !Ref FinalizeFunction\n        - EventBridgePutEventsPolicy:\n            EventBusName: !Ref AgentBus\n      Events:\n        HighSeverityAlert:\n          Type: EventBridgeRule\n          Properties:\n            EventBusName: !Ref AgentBus\n            Pattern:\n              source:\n                - ops.alerts\n              detail-type:\n                - alert.raised\n              detail:\n                severity:\n                  - high\n                  - critical\n            RetryPolicy:\n              MaximumRetryAttempts: 4\n              MaximumEventAgeInSeconds: 3600\n            DeadLetterConfig:\n              Type: SQS\n\n  FailedRunsAlarm:\n    Type: AWS::CloudWatch::Alarm\n    Properties:\n      Namespace: AWS/States\n      MetricName: ExecutionsFailed\n      Dimensions:\n        - Name: StateMachineArn\n          Value: !Ref AgentStateMachine\n      Statistic: Sum\n      Period: 300\n      EvaluationPeriods: 1\n      Threshold: 1\n      ComparisonOperator: GreaterThanOrEqualToThreshold\n      TreatMissingData: notBreaching\n      AlarmActions:\n        - !Ref ApprovalTopic\n\nOutputs:\n  StateMachineArn:\n    Value: !Ref AgentStateMachine\n  ApprovalBaseUrl:\n    Value: !Sub \"https://${ApprovalApi}.execute-api.${AWS::Region}.amazonaws.com\"\n  RunsTableName:\n    Value: !Ref RunsTable\n```\n\nTwo 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.\n\n## Steps 10-11: Deploy the Stack and Fire a Test Alert\n\n**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.\n\n```\nsam validate --lint\nsam build\nsam deploy --guided --stack-name ops-agent --capabilities CAPABILITY_IAM\n\naws cloudformation describe-stacks --stack-name ops-agent --query \"Stacks[0].Outputs\" --output table\n```\n\nThe 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.\n\n**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.\n\n```\nfor v in 91 94 96 93 95\ndo\n  aws cloudwatch put-metric-data --namespace Custom/Ops --metric-name CpuPercent \\\n    --dimensions Service=checkout-api --value $v\n  sleep 2\ndone\n\ncat > events/alert.json <<'EOF'\n{\n  \"Entries\": [{\n    \"EventBusName\": \"ops-agent-bus\",\n    \"Source\": \"ops.alerts\",\n    \"DetailType\": \"alert.raised\",\n    \"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\\\"}\"\n  }]\n}\nEOF\n\naws events put-events --cli-input-json file://events/alert.json\n```\n\nA successful publish returns a zero failed-entry count. The execution should appear in the Step Functions console within seconds.\n\n```\n{\n    \"FailedEntryCount\": 0,\n    \"Entries\": [\n        { \"EventId\": \"11111111-2222-3333-4444-555555555555\" }\n    ]\n}\n```\n\nWatch 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.\n\n```\naws stepfunctions list-executions --state-machine-arn \"$STATE_MACHINE_ARN\" --max-results 3\naws stepfunctions describe-execution --execution-arn \"$EXECUTION_ARN\" --query \"{status:status,output:output}\"\naws dynamodb query --table-name \"$TABLE_NAME\" \\\n  --key-condition-expression \"pk = :p\" \\\n  --expression-attribute-values '{\":p\":{\"S\":\"'\"$RUN_ID\"'\"}}'\n```\n\nExpected 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.\n\n```\n{\n    \"status\": \"SUCCEEDED\",\n    \"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}}}\"\n}\n\nItems in DynamoDB for the run:\n  sk = action#tooluse_xxxxxxxx   service = checkout-api   reason = ...\n  sk = summary                   iterations = 4\n```\n\nNow 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.\n\n## Step 12: Logging, Alarms, and Cost Controls\n\n**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.\n\n```\nfields @timestamp, run_id, iteration, in, out\n| filter event = \"decide\"\n| stats sum(in) as input_tokens, sum(out) as output_tokens, count() as model_calls by bin(1h)\n| sort @timestamp desc\n```\n\nBedrock 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.\n\nLambda 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.\n\n| Alerts per month | Lambda invocations | GB-seconds | Request charge | Compute charge | Lambda total | \n|---|---|---|---|---|---|\n| 1,000 | 12,000 | Not listed | $0.00 | $0.00 | $0.00 | \n| 10,000 | 120,000 | Not listed | $0.00 | $0.00 | $0.00 | \n| 100,000 | 1.2 million | 900,000 | $0.04 | Not listed | Not listed | \n| 1,000,000 | Not listed | Not listed | $2.20 | Not listed | Not listed | \n\nEven 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.\n\nFor the broader picture of how these services fit together, the [cloud computing section](https://shattered.io/category/cloud-computing/) collects the AWS, Azure and [Google Cloud](https://shattered.io/google-cloud-run-deploy-docker-12-steps-2026/) coverage in one place.\n\nTwo 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.\n\n## Common Pitfalls That Break Agent Builds\n\nThese six mistakes account for most of the failed first deployments. Each one is easy to avoid once you know it exists.\n\n1. **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.\n2. **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.\n3. **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.\n4. **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.\n5. **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.\n6. **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.alerts` and not`ops.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.\n\nA 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.\n\n## Troubleshooting: 10 Errors and Their Fixes\n\n1. **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.\n2. **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.\n3. **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.\n4. **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.\n5. **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.\n6. **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 sure`SkipApproval` writes its result to that exact path.\n7. **The EventBridge rule never fires.** Confirm you published to`ops-agent-bus` and not the default bus, and that source, detail type and severity match exactly. Patterns are case-sensitive. Check the rule’s`FailedInvocations` metric and the dead-letter queue.\n8. **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.\n9. **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.\n10. **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.\n\nA 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.\n\n## Advanced Tips for Production Hardening\n\n### Move the Transcript Out of the State\n\nThe 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.\n\n### Expose the Workflow as an Agent Tool\n\nOnce 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.\n\nIf you run coding agents alongside ops agents, the same event-driven habits carry over. Our guide to [building Cursor cloud agents](https://shattered.io/build-cursor-cloud-agents-2026/) shows how another vendor structures isolated environments and secrets, which is a useful contrast to the IAM-first approach used here.\n\n### Route by Cost, Guard by Policy\n\nNot 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.\n\nOther upgrades are worth a look as the system matures:\n\n- **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.\n- **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.\n- **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.\n- **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.\n- **Edge placement.** If the approval page needs to load fast for operators worldwide, compare the options in our[Cloudflare Workers and Lambda@Edge comparison](https://shattered.io/cloudflare-workers-vs-aws-lambda-edge-2026/) before adding a CDN layer.\n- **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.\n\n## The Complete Working Project\n\nThe 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.\n\n```\nops-agent/\n  template.yaml                 Step 9: bus, table, topic, API, functions, state machine, rule, alarm\n  samconfig.toml                written by sam deploy --guided\n  statemachine/\n    agent.asl.yaml              Step 8: the loop and the approval gate\n  src/\n    tools.py                    Step 3: tool specs, runbooks, dispatcher\n    decide.py                   Step 4: one Bedrock Converse call per invocation\n    run_tool.py                 Step 5: executes tools, enforces approval\n    request_approval.py         Step 6: emails the approval link\n    approve.py                  Step 6: form page and SendTaskSuccess\n    finalize.py                 Step 7: writes the run summary\n  events/\n    alert.json                  Step 11: test alert\nbash\n#!/usr/bin/env bash\nset -euo pipefail\n\nSTACK=ops-agent\nMODEL_ID=\"paste-your-model-or-profile-id\"\nMODEL_ARNS=\"paste-profile-arn,paste-model-arn\"\nEMAIL=\"[email protected]\"\n\nsam validate --lint\nsam build\nsam deploy --stack-name \"$STACK\" --capabilities CAPABILITY_IAM --resolve-s3 \\\n  --parameter-overrides ModelId=\"$MODEL_ID\" ModelArns=\"$MODEL_ARNS\" ApprovalEmail=\"$EMAIL\"\n\necho \"Confirm the SNS subscription email, then press Enter to send a test alert.\"\nread -r _\n\nfor v in 91 94 96 93 95\ndo\n  aws cloudwatch put-metric-data --namespace Custom/Ops --metric-name CpuPercent \\\n    --dimensions Service=checkout-api --value \"$v\"\ndone\nsleep 120\naws events put-events --cli-input-json file://events/alert.json\n\nSM_ARN=$(aws cloudformation describe-stacks --stack-name \"$STACK\" \\\n  --query \"Stacks[0].Outputs[?OutputKey=='StateMachineArn'].OutputValue\" --output text)\naws stepfunctions list-executions --state-machine-arn \"$SM_ARN\" --max-results 1\n\n# Teardown when finished:\n# sam delete --stack-name \"$STACK\" --no-prompts\n```\n\nDelete 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.\n\n## Frequently Asked Questions\n\n**How much does a serverless AI agent on AWS cost to run?**\n\nAt 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.\n\n**Should I use Step Functions or Bedrock AgentCore for the agent loop?**\n\nUse 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.\n\n**Which Bedrock model works best for tool-calling agents?**\n\nAny 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.\n\n**Is it safe to let an AI agent restart production services?**\n\nOnly 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.\n\n**Do I need the new EventBridge enhanced Custom event bus?**\n\nNo. 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.\n\n**Why does the Lambda in this build count as an Event Function?**\n\nAWS 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.\n\n**How do I stop the agent from looping forever?**\n\nCap 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.\n\n**Can I swap SNS email for Slack approvals?**\n\nYes. 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.", "url": "https://wpnews.pro/news/serverless-ai-agent-on-aws-12-steps-90-min-2026", "canonical_source": "https://shattered.io/serverless-ai-agent-aws-lambda-bedrock-step-functions-12-steps-2026/", "published_at": "2026-11-02 07:13:59+00:00", "updated_at": "2026-11-02 07:19:16.842444+00:00", "lang": "en", "topics": ["ai-agents", "ai-products", "ai-tools", "artificial-intelligence", "large-language-models"], "entities": ["AWS", "Amazon Bedrock", "Step Functions", "EventBridge", "Lambda", "DynamoDB", "OpenAI", "Gartner"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/serverless-ai-agent-on-aws-12-steps-90-min-2026", "markdown": "https://wpnews.pro/news/serverless-ai-agent-on-aws-12-steps-90-min-2026.md", "text": "https://wpnews.pro/news/serverless-ai-agent-on-aws-12-steps-90-min-2026.txt", "jsonld": "https://wpnews.pro/news/serverless-ai-agent-on-aws-12-steps-90-min-2026.jsonld"}}