
Building a Voice Cost Advisor on AWS: A FinOps Story (With an AI Plot Twist)
How I went from a blank folder to a live, tested, deployed AWS application: forecasting, team rollups, a what-if simulator, and a talking budget alert, built with AWS SAM, Python, and React/Cloudscape, using an AI coding agent to move fast without losing control of the architecture.
Table of Contents
- The Problem — why AWS voice bills surprise you and what we set out to solve
- Project Structure — conventions, folder layout, the rule of separating logic from AWS code
- DynamoDB Design — single-table, no GSIs, the PK/SK patterns that power everything
- Forecast — the 7-day moving average algorithm, the overage-date walk, the sufficientData pattern
- Team Rollup — per-user query + Lambda aggregation, no GSI needed
- Simulator — stateless calculation, debounced sliders, confirmation modal
- Voice Briefing — SSML generation, Polly integration, the never-raise fallback pattern
- Cost Ingestion — EventBridge + Cost Explorer pulling real AWS spend daily
- Authentication — Cognito JWT on every route, userId from JWT claims
- Deploying It — the real snags that ate an afternoon, so yours doesn't have to
- AI Insight, Powered by Amazon Bedrock — teaching the dashboard to reason, not just report
- What It Actually Costs to Run — real AWS pricing for every service involved
- Testing — 111 tests, pure logic, zero mocking
- Real-World Scenario: The Day AWS's Own Bills Broke — what a trillion-dollar Cost Explorer glitch teaches about building on top of billing data
- What's Next
The Problem
Voice AI services on AWS — Amazon Connect, Amazon Polly, Amazon Transcribe are usage-billed, and usage is disturbingly easy to lose track of. A team building a voice bot can go from a $10 test bill to a $500 surprise in a matter of days once real traffic hits and by the time anyone notices on the AWS Cost Explorer dashboard, the month is already over.
I wanted something that sits in front of that: a small internal dashboard that watches daily voice spend, projects where the month is going to land, and says something before the bill does. That became Voice Cost Advisor, a serverless AWS app that now does five things:
- Budget Forecast — 7-day moving average plus linear projection, with an alert when you're on track to blow the budget.
- Team / Repo Rollup — spend broken down by team and repo, so you know who is actually driving the number.
- What-If Simulator — drag sliders for call minutes, Polly characters, and Transcribe minutes, and see the projected cost instantly.
- Voice Briefing — a literal spoken summary of your spend, synthesized by Amazon Polly, because reading a dashboard is optional but hearing "you're on track to exceed budget on the 25th" while you're doing something else is not.
- AI Insight — an Amazon Bedrock-powered summary that reads the numbers and tells you, in plain English, what to actually do about them. This one's new, and it's the part of this post where things got interesting.
This post is the full build story,architecture decisions, the DynamoDB schema, the actual Lambda code, the deployment fights I lost and won, real AWS pricing for every service involved, a real-world scenario that stress-tested the whole premise and — new in this update — what happened when I tried to bolt a large language model onto a project that had, until that point, refused to touch anything resembling ML.
Three Questions, One Dashboard
Every cost-tracking tool answers "what did I spend." I wanted mine to answer the questions that actually change behavior:
- Am I going to exceed budget this month? → Forecast
- Which team or repo is actually driving the spend? → Rollup
- What would it cost if we scaled up? → Simulator
And because reading a dashboard is one thing, but hearing "you're on track to overspend by $63.86" while you're heads-down in something else is another, I added a fourth:
Just brief me. → Voice, powered by Amazon Polly
And then, because a number without context is just a number, a fifth:
So what should I actually do about it? → AI Insight, powered by Amazon Bedrock
No new ML platforms to stand up, no new databases. One DynamoDB table, six Lambda functions, and a React dashboard that speaks and, now, reasons a little.
Project Structure
Starting from an empty folder meant I could pick boring, well-understood tools instead of working around an existing system and lay out the repo the way I wanted it read. Every feature follows the same two-file shape:
app.py is the Lambda entry point, <feature>.py is where the actual logic lives. That split is the single convention the whole project hangs off.
The one rule I held onto throughout: Lambda handlers stay thin. Every handler does three things pull identity off the JWT, call a pure Python function, shape the response. All the actual logic (moving averages, linear extrapolation, cost math, SSML generation, and now prompt construction for Bedrock) lives in files with zero AWS SDK imports. That single decision is what made 111 unit tests possible without mocking a single AWS service for the business logic, and it's the same decision that made the AI feature straightforward to bolt on later — the pattern already existed, I just had to follow it again.

DynamoDB Design
One DynamoDB table, two item shapes, no secondary indexes. Fetching a user's last 30 days is a single
Query with a BETWEEN on the sort key — no full table scan, no GSI, no extra cost:python
1
2
3
4
table.query(
KeyConditionExpression=Key('PK').eq(f'USER#{user_id}')
& Key('SK').between(f'USAGE#{start_date}', f'USAGE#{end_date}')
)When I built the team rollup feature, the instinct is usually "add a GSI on
teamId." I resisted it — with a small number of users per team, one Query per user plus in-memory aggregation in the Lambda is simpler, cheaper, and avoids a schema migration. Worth knowing when not to add the index.It's a live table in my account,
VoiceCostAdvisorTable, on-demand capacity, PK/SK as the only keys, no GSIs.
DynamoDB's console has a "Get live item count" action that runs an on-demand scan rather than relying on the (up to six-hour-stale) cached estimate — useful for confirming exactly what's in the table during development, though it's explicitly flagged as something to avoid running against large or production-critical tables since a scan consumes real read capacity.

Forecast
The model is intentionally simple — no ML, just arithmetic that's easy to explain and easy to trust:
- Pull the last 30 days of daily cost items.
- Take the 7-day moving average of the most recent data points.
- Project forward:
projected_monthly = cost_so_far + avg_daily_spend × days_remaining. - If the projection crosses the budget threshold, walk the days forward to find the exact date it happens.
- If there's less than 3 days of data, return
sufficientData: falseand skip the projection rather than showing a misleading number from noise.
The API response is a flat, dashboard-ready JSON object:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"userId": "abc-123",
"periodStart": "2026-08-01",
"periodEnd": "2026-08-31",
"daysElapsed": 9,
"daysRemaining": 22,
"actualSpendToDateUSD": 38.10,
"projectedMonthlySpendUSD": 127.36,
"budgetThresholdUSD": 100.00,
"alert": true,
"projectedOverageDate": "2026-08-25",
"projectedOverageUSD": 27.36,
"sufficientData": true
}Twenty-one test cases cover the boring-but-critical edge cases: zero data points, exactly-at-threshold, overage landing on the very last day of the period. None of them touch AWS — they're pure function tests that run in milliseconds.

Team Rollup
Groups the same daily-usage items by
teamId and repoId, sorts by cost descending, and supports an optional ?teamId= filter for drilling into one team. The Lambda reads a CONFIG#users / MEMBERS item to know who belongs to which team, runs one query per member, and aggregates in memory — no GSI, as mentioned above.
Simulator
This is the feature people actually play with. Three sliders — call minutes, Polly synthesis characters, Transcribe minutes — multiply against pricing rates stored in DynamoDB (
CONFIG#pricing / RATES), fully configurable without a redeploy. The frontend debounces slider movement by 400ms before firing the API call, so dragging a slider doesn't spam the backend, and every response is a pure calculation. Nothing is written to DynamoDB just from simulating; it's read-only against the pricing config. An "Apply this scenario" button sits behind a confirmation modal as the intentional hook point for turning a simulation into a saved budget plan — still on the roadmap, more on that at the end.
Voice Briefing
This is the one that makes people smile in a demo. It takes the forecast data, builds SSML, and calls Amazon Polly to synthesize a spoken summary. The important design choice here is the fallback:
synthesise() never raises. If Polly is unavailable — throttled, wrong region, permission issue, anything — the Lambda catches every exception type and returns audioBase64: null alongside a plain-text fallback. The dashboard never hard-depends on Polly being up; the text is always there even if the audio isn't.Twenty-eight tests cover this, including four different simulated failure types (
ConnectionError, TimeoutError, ValueError, generic Exception) to make sure none of them ever propagate out of the handler. That never-raise discipline turned out to matter more than I expected, once Bedrock entered the picture.Cost Ingestion
Everything above is only as good as the data feeding it, so this piece pulls real numbers from AWS Cost Explorer and writes them into the same DynamoDB table:
- A Lambda calls
ce:GetCostAndUsage, filtered to the voice-related service codes (Connect, Polly, Transcribe, Lex, Chime SDK). - An EventBridge rule fires it once a day at 06:00 UTC — fully automatic from day one.
- A local script (
ingest_now.py) does the same thing on demand, with a--dry-runflag to preview before writing, and a--days Nflag to backfill history.
SAM creates the EventBridge Scheduler resource automatically on deploy — no manual console clicking required, but it's easy to go verify it's actually sitting there and active:

1
aws events list-rules --query "Rules[?contains(Name,'Ingestion')].{Name:Name,State:State,Schedule:ScheduleExpression}" --output tableThis is the one place cost actually matters: Cost Explorer's API charges $0.01 per request, regardless of how much data comes back. The scheduled Lambda calling it once a day is 30 requests a month — thirty cents. Running the local script by hand repeatedly adds up the same way manual
curl loops would; it's cheap, but it's not free like most of the rest of this stack.Authentication
Every route —
/forecast, /rollup, /simulate, /voice, and now /insights — sits behind the same Cognito User Pool JWT authorizer, wired declaratively as the API's DefaultAuthorizer in template.yaml. There's no custom auth middleware anywhere in the Lambda code. API Gateway validates the JWT before a request ever reaches a handler, and rejects anything without a valid token with a 401 before it costs a single millisecond of Lambda compute. Inside the handler, the user's identity comes straight off the verified claims — no session lookup, no separate users table:python
1
user_id = event["requestContext"]["authorizer"]["jwt"]["claims"]["sub"]That
sub claim is what every PK in DynamoDB is built from (USER#{sub}), so a user's identity and their data partition are the same string, end to end, from login to query. The one operational gotcha: USER_PASSWORD_AUTH isn't enabled by default on a new Cognito App Client — it has to be turned on explicitly before a simple username/password test login (initiate-auth) will work, which is easy to miss until you're staring at an InvalidParameterException with no obvious next step.
Deploying It
sam build and sam deploy did almost everything, with a handful of real-world snags worth mentioning because they're the kind of thing that eats an afternoon if you don't expect them:- SAM CLI installed via pip needs
version = 0.1at the top ofsamconfig.tomlor it throws an opaqueSamConfigVersionException. The official MSI installer avoids this; pip's package is unofficial and a little behind. - CloudFront can be blocked on brand-new AWS accounts ("Your account must be verified before you can add new CloudFront resources") — I hit this trying to host the frontend behind a CDN and fell back to plain S3 static website hosting, which works on every account and is functionally fine for an internal tool.
- CORS has to be explicit. An HTTP API with no
CorsConfigurationblock returns nothing to a browser's preflightOPTIONSrequest, which shows up as a generic "Failed to fetch" in the frontend with no useful error — always checkaws apigatewayv2 get-apifor a nullCorsConfigfirst. - Cognito's
USER_PASSWORD_AUTHflow is off by default on a new User Pool Client. You have to explicitly enable it (ALLOW_USER_PASSWORD_AUTH) beforeinitiate-authwill work for a simple username/password test login.
Once past those, the full deploy loop looks like this:
1
2
sam build
sam deploy --stack-name voice-cost-advisor --resolve-s3 --capabilities CAPABILITY_IAM --region us-east-1 --no-confirm-changeset


AI Insight, Powered by Amazon Bedrock
Every other feature in this project answers a question with a number. The forecast tells you what you'll spend. The rollup tells you who's spending it. Neither one tells you what to actually do about it — that's a judgment call, and judgment calls are exactly what a language model is good at when you hand it clean, structured data instead of asking it to guess.
So I added a fifth Lambda,
InsightsFunction, that takes the same forecast and rollup JSON the dashboard already fetches, builds a short prompt from it, and asks Claude (via Amazon Bedrock) for a plain-English summary and one concrete recommendation:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def build_prompt(forecast: dict, rollup: dict) -> str:
spent = forecast.get("actualSpendToDateUSD", 0)
projected = forecast.get("projectedMonthlySpendUSD", 0)
budget = forecast.get("budgetThresholdUSD", 0)
alert = forecast.get("alert", False)
teams = rollup.get("teams", [])
team_lines = "\n".join(
f"- {t.get('teamId')}: ${t.get('totalCostUSD', 0):.2f}"
for t in teams
) or "No team data available."
return f"""You are a cost analyst for an AWS voice-services dashboard.
Given this data, write a short (3-4 sentence) plain-English summary and
one concrete recommendation. Be specific, no generic advice.
Spent so far: ${spent:.2f}
Projected: ${projected:.2f}
Budget: ${budget:.2f}
Alert: {"OVER BUDGET" if alert else "On track"}
Spend by team:
{team_lines}"""And, following the exact pattern from the Voice Briefing Lambda:
generate_insight() never raises. If Bedrock is unavailable for any reason, the handler catches it and returns a graceful fallback message instead of a 500. Same discipline, new feature.The part where AWS's own AI platform fought back
This is the section I almost didn't write, because it's the least flattering — and the most useful for anyone else trying to ship this. Adding a Lambda that calls an LLM sounds like the "easy" part of a project like this. It was not.
First: the model ID I reached for, Claude 3.5 Haiku, turned out to be retired. Bedrock returned
ResourceNotFoundException: This model version has reached the end of its life — a good reminder that "the model I know works" and "the model that's currently active" are two different questions when you're building against a managed AI platform. The fix was switching to the current model, Claude Haiku 4.5.Second: Claude Haiku 4.5 doesn't accept direct on-demand invocation by model ID — it requires going through a cross-region inference profile instead, which meant prefixing the model ID with a region code (
us.anthropic.claude-haiku-4-5-20251001-v1:0) and widening the Lambda's IAM policy to cover both the inference profile ARN and the underlying foundation-model ARN.Third, and the one that actually needed AWS's help: even after fixing both of the above, every invocation — including a raw AWS CLI call using my own account credentials, completely outside the Lambda's IAM role — returned
ValidationException: Operation not allowed. Since that error came back identically with full account credentials, it ruled out IAM permissions as the cause; a permissions problem returns AccessDeniedException, a distinctly different error. This was an account-level restriction, the same category as an earlier CloudFront block I'd hit on this same AWS account ("Your account must be verified before you can add new CloudFront resources"). Newer or lower-usage AWS accounts sometimes need explicit verification before certain services unlock, and Bedrock model invocation turned out to be one of them.The resolution isn't a code fix — it's an AWS Support case under "Account and billing," explaining the exact error and confirming it reproduces outside any application code.


The lesson generalizes past this one project: shipping an AI feature means budgeting time for the AI platform's own churn deprecated models, new invocation requirements, account-level gates — not just for your own code. None of that shows up in a tutorial. All of it shows up the first time you actually deploy.
What It Actually Costs to Run
This is the part I think matters most for anyone deciding whether to build something like this for their own team — a monitoring tool shouldn't itself become a line item worth monitoring.
| Service | Monthly cost (small team) |
|---|---|
| Lambda | ~$0.00 (within free tier) |
| API Gateway | ~$0.03 |
| DynamoDB | ~$0.01 |
| Cognito | $0.00 (under 50K MAUs) |
| Polly (standard voice) | ~$0.20 for ~100 briefings |
| Cost Explorer API | ~$0.30 (daily scheduled pulls) |
| S3 (frontend hosting) | ~$0.00 |
| EventBridge | $0.00 |
| Bedrock (Claude Haiku 4.5) | Same low-cost tier as Polly — a few cents a month at this usage level, once account access is approved |
| Total | well under $1/month |
The one number worth remembering if you build this yourself: Polly's standard voice is $4 per million characters — a spoken briefing is roughly 500 characters, so you'd need around 2,000 briefing plays in a month before it costs $4. Cost Explorer's $0.01/request is the one place where hammering the API by hand (instead of relying on the daily scheduled Lambda) actually shows up on a bill. Bedrock follows the same shape: cheap per call, the only risk is triggering it far more often than a dashboard realistically needs to.
Testing
111 tests, all passing, none of them mocking
boto3. That's only possible because of the thin-handler pattern: every piece of logic worth testing (moving averages, overage-date math, SSML generation, cost simulation, rollup aggregation, and now Bedrock prompt construction) is a pure function that takes plain data in and returns plain data out. The handler files (app.py) are short enough to trust by inspection; the logic files are short enough to unit test exhaustively, including the edge cases that are easy to get wrong by hand — zero data points, exact-threshold ties, negative inputs, Decimal types coming back from DynamoDB instead of float.

Real-World Scenario: The Day AWS's Own Bills Broke
Every argument in this post assumes one thing: that the numbers coming out of AWS Cost Explorer are trustworthy. On July 16–17, 2026, that assumption broke — for everyone, not just this project.
A bug in the unit-pricing logic behind the AWS Billing and Cost Management Console's estimated billing computation started showing some customers cost estimates in the billions, and in a few reported cases, over $7 trillion. One account that normally ran about a cent a month reportedly showed more than $1.5 billion in usage fees. Amazon confirmed the root cause was isolated to the estimation layer — the display and forecasting subsystem — not the actual metering, invoicing, or payment pipeline, and no one was charged the numbers on screen. AWS mitigated the issue and recalculated affected accounts, with full recovery expected by July 19 (Programming Helper , VPNCentral , N2W Software ).
That's directly relevant here, because Voice Cost Advisor's entire ingestion pipeline calls
ce:GetCostAndUsage — the same family of API this bug lived near — and writes whatever comes back straight into DynamoDB, which the forecast then treats as ground truth. If a glitch like this had hit while the daily EventBridge ingestion was running, an absurd single-day number could have flowed straight through: the 7-day moving average would have been dragged wildly off, the "exceeds budget" alert would have fired for a number nobody actually owed, and — with the AI Insight feature now in the mix — Bedrock would have cheerfully written a very confident paragraph explaining why you're $7 trillion over budget, and Polly would have read it aloud without blinking.None of that is hypothetical anymore, and it points at a gap the current build doesn't close:
sufficientData guards against too little data, but nothing guards against absurd data. The fix is small and worth calling out explicitly rather than leaving as a footnote:- Cap or flag any single day's ingested cost that's an outlier by orders of magnitude versus the account's own trailing average, before it's written to DynamoDB — reject the write and alert instead of silently ingesting it.
- Prefer actual metered usage (
GetCostAndUsagewithUsageQuantity, not just estimatedAmortizedCost) where possible, since AWS explicitly said this incident was isolated to estimated billing computation, not the underlying metering. - Treat the daily ingestion Lambda's failure mode as "skip and alert," not "write whatever Cost Explorer returned" — a bad number that never makes it into the table is far less damaging than one that does and then drives a forecast, a budget alert, an AI-generated recommendation, and a spoken briefing, all confidently wrong in the same afternoon.
The broader lesson generalizes past this one incident: any tool built on top of a cloud provider's own billing API — and now, its own AI platform — inherits that provider's failure modes. Building the sanity check in from day one is cheap. Explaining to a team why their dashboard confidently told them, out loud, that they owed $7 trillion is not.
The Takeaway
None of the individual pieces here are novel: moving averages, single-table DynamoDB design, thin Lambda handlers, a React dashboard, a Bedrock call. What made this move fast without becoming fragile was discipline about where logic lives — pure functions get tested exhaustively and trusted completely; AWS-facing code stays thin enough to read in one pass and never needs its own test suite to be trustworthy. That discipline is also what made the AI feature the easiest part of this project to build correctly, even though getting Bedrock itself to cooperate was the hardest part of this build to debug. The code was never the problem. The platform underneath it, twice, was.
That's a pattern worth carrying into any serverless project — voice AI, cost dashboards, or otherwise.
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article