My Robot Accountant Got an Eviction Notice. So We Moved: Migrating from Bedrock Agents Classic to AgentCore
Ciao Amici ๐ Grab your chai, because the robot accountant is back, and this time we have a moving-day story. If you read my last article, you know I built "The Accountant," a Bedrock Agent that reads my AWS bill every morning, emails me a color-coded report, and once famously reported its own exist

Ciao Amici ๐ Grab your chai, because the robot accountant is back, and this time we have a moving-day story. If you read my last article, you know I built "The Accountant," a Bedrock Agent that reads my AWS bill every morning, emails me a color-coded report, and once famously reported its own existence to me as suspicious activity. That article did well. It did so well, in fact, that it reached exactly the right person to tell me my beloved architecture was standing on borrowed ground. I remember the moment precisely: phone buzzing on the desk, a notification from Dev.to, me expecting another nice comment about the day 16 hallucination. Instead, a community manager from AWS, polite as anything: Bedrock Agents is heading into maintenance mode, consider rewriting this with AgentCore. A link to the announcement sat under it like a small landmine. My stomach did the same thing it did the day I found the idle GPU endpoint. So I did what any of us would do: I read the announcement twice, once fast and once slowly, made more chai, and moved the whole thing. This is the complete story of that migration, box by box, with every line of code, what got easier, what got weirder, and what it cost. By the end, you will be able to move your own Classic agent in an afternoon. First, let us be precise about what is actually happening, because "maintenance mode" causes more panic than it should. I know, because I panicked for about ten minutes before actually reading the page. Amazon Bedrock Agents, the service my first article was built on, is now officially called Amazon Bedrock Agents Classic, and as of July 30, 2026, it is closed to new customers. If your AWS account has never called CreateAgent, that API will simply refuse you with an AccessDeniedException after that date. What it does not mean: Existing agents do not stop working. There is no announced end-of-life date. Nobody is deleting your agents or your action groups. Your bills do not change. What it does mean, and this is the part that matters: The service is frozen. No new features, and critically, a frozen model catalog. The models available to Classic agents today are the models it will have forever. Every new model generation from here on will skip Classic entirely. Any tutorial that says "go to the Bedrock console and create an agent" is now a historical document for anyone with a fresh account. Including, painfully, mine. That second bullet is the real eviction notice. My Accountant would have kept running for years. But an agent that can never get a better brain is an agent on a slowly sinking ship, and an article that new readers cannot follow is not much of an article. Sitting there with my cooling chai, I had to admit it: the community manager was right. Time to pack. The destination AWS points everyone toward is Amazon Bedrock AgentCore, and after living in it for a few weeks, I can report that the move is less scary than it sounds, and in a couple of ways it is a genuine upgrade. AgentCore is not "Bedrock Agents 2." It is a different philosophy. Classic was a managed agent: you configured an agent in the console, AWS owned the orchestration loop, and your code only existed at the edges, inside action group Lambdas. AgentCore flips this. Your agent is your code, written in any framework you like (Strands Agents, LangGraph, CrewAI, plain Python), and AgentCore provides the production infrastructure around it: Runtime: a serverless home that runs your agent in an isolated microVM per session, scales it, and charges you only for what you use. Gateway: turns Lambdas and APIs into MCP tools your agent can call. Memory: managed short-term and long-term memory. Identity: OAuth and credential handling for agents that act on someone's behalf. Observability: tracing for every step and tool call. For The Accountant, a solitary creature with five tools and no users, I needed exactly one of these: Runtime. That is the pleasant surprise of AgentCore for small projects. It is a menu, not a prix fixe. You take the piece you need and ignore the rest until you grow into it. The framework I chose for the agent itself is Strands Agents, AWS's open-source agent SDK, mostly because it is the path of least resistance on AgentCore and its tool model is, as you are about to see, almost comically convenient for this migration. Before touching code, I sat down one evening with a notebook, the old architecture diagram on one screen and the AgentCore docs on the other, and mapped every piece of the old build to its new home. I suggest you do the same for your agent; the whole migration falls out of this one table. Bedrock Agents Classic AgentCore + Strands Effort Agent + instructions in the console Strands Agent with a system prompt, in code Copy and paste Action group Lambda + function schemas Plain Python functions with @tool, same process Delete code, mostly InvokeAgent API in trigger Lambda InvokeAgentRuntime API Twenty lines Console configuration agentcore configure + agentcore launch CLI New, pleasant Bedrock trace console CloudWatch + AgentCore Observability Different, fine IAM read-only policy Unchanged Zero SES email delivery Unchanged Zero EventBridge schedule Unchanged Zero Read that Effort column again. I did, three times, waiting for the catch. The two things I was most protective of, the read-only IAM philosophy and the email delivery my mornings now depend on, moved without a single edit. The heart of the migration is really just one move: the action group Lambda dissolves, and its functions walk across the street to live inside the agent itself. Here is the entire agent, one file, main.py. If you read the first article, you will recognize every boto3 call, because they are the same functions that used to live in the action group Lambda. The difference: they are now decorated with @tool and run in the same process as the agent's reasoning. No Lambda packaging, no function schemas to declare in a console, no messageVersion response envelopes. The docstring is the schema; Strands hands it to the model as the tool description. from bedrock_agentcore.runtime import BedrockAgentCoreApp from strands import Agent, tool import boto3 import json from datetime import datetime, timedelta ce = boto3.client("ce") sm = boto3.client("sagemaker") ec2 = boto3.client("ec2") @tool def get_daily_costs(days: int = 14) -> str: """Fetch daily AWS costs grouped by service for the last N days, in a single Cost Explorer call. Returns JSON.""" end = datetime.utcnow().date() start = end - timedelta(days=days) resp = ce.get_cost_and_usage( TimePeriod={"Start": str(start), "End": str(end)}, Granularity="DAILY", Metrics=["UnblendedCost"], GroupBy=[{"Type": "DIMENSION", "Key": "SERVICE"}], ) out = [] for day in resp["ResultsByTime"]: for grp in day["Groups"]: amount = float(grp["Metrics"]["UnblendedCost"]["Amount"]) if amount > 0.01: out.append({ "date": day["TimePeriod"]["Start"], "service": grp["Keys"][0], "usd": round(amount, 2), }) return json.dumps({"found": len(out), "items": out}) @tool def get_forecast() -> str: """Fetch the Cost Explorer forecast for the remainder of the current month. Returns JSON.""" start = datetime.utcnow().date() + timedelta(days=1) end = start.replace(day=28) + timedelta(days=4) end = end - timedelta(days=end.day) resp = ce.get_cost_forecast( TimePeriod={"Start": str(start), "End": str(end)}, Metric="UNBLENDED_COST", Granularity="MONTHLY", ) return json.dumps( {"projected_remaining_usd": round(float(resp["Total"]["Amount"]), 2)} ) @tool def list_sagemaker_endpoints() -> str: """List all SageMaker inference endpoints with status and creation time. Returns JSON with an explicit count.""" eps = sm.list_endpoints()["Endpoints"] items = [ { "name": e["EndpointName"], "status": e["EndpointStatus"], "created": e["CreationTime"].isoformat(), } for e in eps ] return json.dumps({"found": len(items), "items": items}) @tool def find_unattached_volumes() -> str: """Find EBS volumes in 'available' state (attached to nothing, still billing). Returns JSON with an explicit count.""" vols = ec2.describe_volumes( Filters=[{"Name": "status", "Values": ["available"]}] )["Volumes"] items = [ {"id": v["VolumeId"], "size_gb": v["Size"], "type": v["VolumeType"]} for v in vols ] return json.dumps({"found": len(items), "items": items}) @tool def find_unattached_eips() -> str: """Find Elastic IPs not associated with any instance. Returns JSON with an explicit count.""" addrs = ec2.describe_addresses()["Addresses"] items = [{"ip": a["PublicIp"]} for a in addrs if "AssociationId" not in a] return json.dumps({"found": len(items), "items": items}) SYSTEM_PROMPT = """You are a cautious FinOps analyst reviewing a personal AWS account. The owner is a solo developer running ML experiments, a portfolio site, and occasional demos. Typical monthly spend is 60 to 120 USD. Every day you will: 1. Fetch the last 14 days of daily costs grouped by service, in a single call. 2. Compare today's trajectory against the trailing average. 3. Check idle-prone resources: SageMaker endpoints, unattached EBS volumes and Elastic IPs. 4. Fetch the month-end forecast. 5. Report findings in this exact structure: STATUS: green / yellow / red HEADLINE: one sentence ANOMALIES: bullet list, or "none" RECOMMENDATIONS: bullet list with estimated monthly savings as a range for each, or "none" FORECAST: projected month-end total WATCHER OVERHEAD: today's estimated cost of running this analysis Rules: - Never recommend an action you cannot support with data you fetched in this session. If a tool returned found: 0, there is nothing to recommend about those items. - If you are unsure whether a resource is intentional, say so and ask. Do not assume. - Distinguish between "spend increased" and "spend is anomalous". A planned training run is not an anomaly. - State every savings estimate as a range, never a point figure. - Your own model and runtime usage is expected. Report it under WATCHER OVERHEAD, never under ANOMALIES. """ app = BedrockAgentCoreApp() agent = Agent( system_prompt=SYSTEM_PROMPT, tools=[ get_daily_costs, get_forecast, list_sagemaker_endpoints, find_unattached_volumes, find_unattached_eips, ], ) @app.entrypoint def invoke(payload): prompt = payload.get("prompt", "Run the daily billing review.") result = agent(prompt) return {"report": str(result.message)} if __name__ == "__main__": app.run() Notice three things while the tape gun is still warm. First, the system prompt is my old Classic instruction prompt, nearly verbatim. All five versions of scar tissue from the first experiment, the ranges rule, the "found: 0 means nothing to recommend" rule, the watcher-overhead rule born the day the agent reported itself, all of it transferred untouched. Prompts are luggage. They fly free. Second, Chekhov's empty list is now structural. In the first article, the day 16 hallucination (the agent inventing an idle RDS instance from an empty response) taught me that tools must return explicit counts. In the new build, every tool returns {"found": N, "items": [...]} by construction. Some lessons you write in a prompt. Better lessons you write in the return type. Third, the @app.entrypoint wrapper is the entire integration with AgentCore. Three lines. BedrockAgentCoreApp handles the HTTP server, the session plumbing, and the container contract so that this file runs identically on my laptop and in the cloud. This is where AgentCore genuinely out-classes Classic. With Classic, "development" meant clicking through the console, saving, preparing, testing in the chat panel, and squinting at traces. With AgentCore, my agent is a Python file I can run locally like any other Python file: pip install bedrock-agentcore strands-agents bedrock-agentcore-starter-toolkit python main.py # in another terminal: curl -X POST http://localhost:8080/invocations \ -H "Content-Type: application/json" \ -d '{"prompt": "Run the daily billing review."}' I want you to picture this moment, because it is the moment the migration stopped feeling like a chore. Two terminals side by side on my screen, close to midnight. In the left one, my agent, running as an ordinary Python process. In the right one, a curl command. I pressed enter, and thirty seconds later I was watching my agent call Cost Explorer from my own machine, with my own credentials, printing its reasoning line by line as it thought. The development loop went from minutes per iteration to seconds. This alone justified the move for me emotionally, whatever the maintenance-mode situation. When it behaves locally, deployment is two commands: agentcore configure -e main.py agentcore launch configure asks a few questions, writes a .bedrock_agentcore.yaml, and, helpfully, creates the IAM execution role for you. launch packages the agent and hosts it on AgentCore Runtime, returning an agent runtime ARN. One important errand before celebrating: go attach the read-only billing policy from the first article to that execution role, since the agent's boto3 calls now run under it instead of under an action group Lambda's role. Same policy, new badge holder. The zero-write-permissions philosophy survives the move fully intact: A quick sanity check from the CLI: agentcore invoke '{"prompt": "Run the daily billing review."}' And there it was: STATUS: green, in a brand-new house, first try. Well. Second try. The first try taught me the quirk below. Every migration has one. Mine was the runtime session ID. InvokeAgentRuntime requires a runtimeSessionId, and it must be at least 33 characters long. My first invocation passed the same short daily ID string the old trigger Lambda used, and the API rejected it with a validation error that took me an embarrassing amount of squinting to parse. I read it four times, convinced the problem was my ARN, my region, my IAM, anything but the humble session string. It was the session string. The fix is a UUID4, which is 36 characters and therefore sails through. There is also a conceptual shift hiding in that parameter. In Classic, a session was a conversational thread the service tracked for you. In AgentCore Runtime, the session maps to an isolated microVM; same ID means same warm sandbox, new ID means a fresh one. For The Accountant this is a feature: a fresh UUID every morning gives me the same blank-slate reproducibility I insisted on in the first experiment, now enforced by actual infrastructure isolation rather than by my own discipline. Here is the migrated trigger Lambda, the only other code that changed: import boto3 import json import os import uuid agent_core = boto3.client("bedrock-agentcore") ses = boto3.client("ses") COLORS = {"green": "#2eb67d", "yellow": "#ecb22e", "red": "#e01e5a"} def lambda_handler(event, context): payload = json.dumps({"prompt": "Run the daily billing review."}).encode() resp = agent_core.invoke_agent_runtime( agentRuntimeArn=os.environ["AGENT_RUNTIME_ARN"], runtimeSessionId=str(uuid.uuid4()), # 33+ chars required qualifier="DEFAULT", payload=payload, ) chunks = [] for chunk in resp.get("response", []): chunks.append(chunk.decode("utf-8")) report = json.loads("".join(chunks)).get("report", "") status = "yellow" for line in report.splitlines(): if line.strip().upper().startswith("STATUS:"): status = line.split(":", 1)[1].strip().lower() break html = f""" <div style="font-family:Menlo,Consolas,monospace;max-width:640px"> <div style="background:{COLORS.get(status, '#ecb22e')}; color:#fff;padding:10px 16px;border-radius:6px; font-weight:bold;font-size:16px"> The Accountant · {status.upper()} </div> <pre style="white-space:pre-wrap;font-size:13px; line-height:1.55;padding:8px 4px">{report}</pre> </div> """ ses.send_email( Source=os.environ["FROM_ADDRESS"], Destination={"ToAddresses": [os.environ["TO_ADDRESS"]]}, Message={ "Subject": { "Data": f"The Accountant ยท {status.upper()} ยท daily review" }, "Body": {"Html": {"Data": html}, "Text": {"Data": report}}, }, ) return {"status": status} Swap bedrock:InvokeAgent for bedrock-agentcore:InvokeAgentRuntime in this Lambda's role, point the EventBridge schedule at it, and the morning ritual continues without missing a day. The SES block is character-for-character the one from the first article. My inbox never even noticed the agent changed houses. I did not flip a switch and hope. For seven days I ran both accountants in parallel, old and new, each reviewing the same account every morning, reports arriving side by side in my inbox. Two subject lines, same status word, a minute apart. There was something quietly funny about it, like watching an employee train their own replacement without knowing it. I recommend this to anyone migrating an agent that they actually rely on; it costs a few dollars and buys real confidence. Results of the parity check, honestly reported: Six of seven days: functionally identical findings. Same status, same anomalies or lack of them, forecasts within a dollar or two of each other. Different sentence rhythms, since the orchestration loops differ, but the same accountant's judgment. One divergence, and it favored the new build. Mid-week I terminated a test instance and its Elastic IP sat unattached for a day. Both agents caught it. The v2 agent, though, reported it as "found: 1 unattached Elastic IP" with the address, quoting the tool's count directly in its reasoning. Watching the explicit-count design pay off in the wild, in the exact failure category that produced the day 16 hallucination last time, was the single most satisfying moment of the migration. I may have said "yes" out loud to an empty room. Zero hallucinated resources on either side all week. The scar tissue holds. On day eight I deleted the Classic agent. A small pang, honestly, my cursor hovering over the confirmation button a beat longer than necessary. It found me fifty-five dollars a month and once accused itself of fraud. But its model catalog was frozen in time, and mine is not. Delete. What did the move cost in time and money? Time: one honest afternoon. About four hours end to end, and half of that was me reading AgentCore docs out of curiosity rather than necessity. The mechanical work, converting the Lambda functions to @tool functions, deploying, updating the trigger, was maybe ninety minutes. Your first migration will be slower than your second; the second is nearly mechanical. Money: a rounding error. Component v1 ยท Classic v2 ยท AgentCore Model tokens (Bedrock) ~$3.62 ~$3.55 Cost Explorer API calls ~$1.05 ~$1.05 Agent runtime compute ~$0.08 (Lambda) ~$0.31 (Runtime, per-second) Lambda, EventBridge, SES ~$0.12 ~$0.11 Monthly total ~$4.87 ~$5.02 AgentCore Runtime bills for consumed CPU and memory per second while the agent is actually working, and a sixty-second daily run is simply not much of either. Fifteen cents a month is what my accountant's new apartment costs over the old one. The dominant cost remains, as it always was, the model tokens, and those are identical either way. The economics of the whole idea, five dollars to find fifty-five, are untouched by the migration. The honest ledger, because a migration story that reads like a brochure is worthless. Genuinely better: The local dev loop. Running the agent as a plain Python process and curling it on localhost changed how it feels to work on this project. Prompt iteration that used to mean console round-trips now happens at the speed of saving a file. Tools as functions. The action group ceremony (Lambda packaging, function schemas, response envelopes) is gone. A docstring and a decorator. I deleted more code than I wrote in this migration, which is my favorite genre of engineering. The frozen-catalog problem is solved by design. The model is a string in my code now. When a better one ships, I change one line and relaunch. Structural guardrails. Explicit counts in tool returns, enforced by construction rather than by prompt discipline. Weirder, or at least new: The 33-character session ID rule. Documented, but it will get you once. It got me at 11:40 PM. A new mental model. Classic made the agent feel like an AWS resource you configure. AgentCore makes it a program you own. I prefer the second, but it is a real shift, and teams who liked the console-first workflow will feel it. One more CLI and one more hidden YAML (.bedrock_agentcore.yaml) in your life. Pleasant, but it is another thing. Observability lives in a different place. The Classic trace console was crude but one click away. Now I read traces through CloudWatch and AgentCore's observability tooling, which is more powerful and slightly more assembly-required. And unchanged, by design: the read-only IAM policy, the SES delivery, the EventBridge schedule, the system prompt, and the entire philosophy. The agent looks and complains. The human acts. No maintenance-mode announcement will ever change that rule. If you have a Bedrock Agents Classic agent running today, here is my honest triage: Move soon if your agent is something you are still actively improving, or if your content, docs, or team onboarding depend on others being able to recreate it. New accounts cannot build Classic agents after July 30, 2026, and the frozen model catalog means your agent's ceiling is already set. The migration is an afternoon; the mapping table above is most of the thinking. Move eventually if your agent is stable and internal. Nothing breaks tomorrow. But put it on the roadmap, because every quarter you wait, the gap between the frozen catalog and the current model frontier widens, and that gap is your agent quietly getting relatively dumber. And if you are starting fresh: there is no decision to make. Start on AgentCore, pick a framework, and enjoy the local dev loop from day one. The first article's ideas (read-only tools, explicit counts, ranges not points, teach the agent your normal) all apply verbatim; only the plumbing in it is now historical. So, guys, that is the story of moving day. It started with a comment on my last article that briefly ruined my evening and ended, one afternoon of work later, with the same accountant in a better house: the same paranoid morning reports, the same read-only leash, the same five-dollar salary, but now with a dev loop I actually enjoy, tools that are just Python functions, and a brain that can be upgraded with a one-line change instead of being frozen in 2026 forever. I owe genuine thanks to the community manager who nudged me, because "your architecture is deprecated" is a gift when it arrives early enough to do something about it, and I would much rather write a migration story than a post-mortem. If you have a Classic agent of your own, take the moving map, block out an afternoon, and give your robot its new keys; then come tell me how it went, because the follow-up to this article will feature the best migration stories I hear. Find me on Dev.to or LinkedIn. Until then: keep your permissions read-only, your tool counts explicit, and your model catalogs unfrozen. Ciao! ๐
Key Takeaways
- โขCiao Amici ๐ Grab your chai, because the robot accountant is back, and this time we have a moving-day story
- โขThis story was reported by Dev.to, covering developments in the dev space.
- โขAI advancements continue to reshape industries โ read the full article on Dev.to for complete coverage.
๐ Continue reading the full article:
Read Full Article on Dev.to โShare this article



