Many household money leaks are small and quiet. A washing machine breaks a few months before its guarantee runs out, and the repair gets paid without anyone asking the seller. A free trial turns into a monthly charge. A card payment has no receipt by the time someone needs one. None of this is hard to spot. It is just easy to miss.
An agent sounds like a good fit: read the records, point at what needs a decision. The catch is that a language model writing about guarantees and money can sound certain about things nobody checked, like a refund being owed or a deadline that does not exist. Hestia, built for the AWS Agents for Humans hackathon (Everyday Agents track), tries a narrow version: the model reads and points, plain Python works out the dates and amounts, and the household decides. Here is how its two Strands agents are built and what stops them from overstating.
Both agents use Strands Agents 1.53.0 and call Claude Haiku 4.5 on Amazon Bedrock through the EU inference profile. Both run inside the same Lambda function, the one that handles POST routes, and neither has a tool that writes a record, prepares a notice or sends anything. The review agent reads a household through four tools and writes a short briefing. The reading agent has no tools and turns pasted text into proposed records. Both work inside a stored demo copy of a fictional household whose access lasts 30 minutes.
Each review builds a fresh Agent with a BedrockModel (temperature=0.2, streaming=False, 700 output tokens), the tools and a system prompt, then calls it once, asking it to use every tool once and write the briefing.
The tools are plain Python closures over the loaded private copy and the review date:
| Tool | Reads |
|---|---|
review_repair_evidence(appliance_id) |
one appliance, its seller and any saved case |
audit_subscriptions() |
trials, price changes and duplicates |
check_receipts_and_utilities() |
missing receipts and bills above baseline |
read_case_timeline() |
saved case status, next step and recent events |
Strands builds each schema from the signature and docstring, so wrapping is one line. From src/hestia/agents/household_agent.py:
def tool_functions(state: dict[str, Any], today: date) -> dict[str, Callable[..., str]]:
...
def audit_subscriptions() -> str:
"""Inspect recorded recurring charges for trial end dates, price changes and overlaps.
Amounts are recorded monthly charges, not measured waste or savings.
"""
...
def strands_tools(state: dict[str, Any], today: date) -> list[Any]:
"""Wrap the workspace callables as Strands tools (schemas come from signatures and docs)."""
from strands import tool
return [tool(func) for func in tool_functions(state, today).values()]
The sentence the model reads to pick a tool is the sentence a reviewer reads in the source. And because each tool closes over one private copy, no tool takes an argument that could reach another household.
Tool outputs are clipped to 1600 characters. After the run, the trace is rebuilt by pairing toolUse and toolResult blocks in agent.messages, and token usage comes from result.metrics.accumulated_usage. Both are stored with the briefing.
The system prompt says: use the tools, never state or imply entitlement to a refund, repair or amount, never invent deadlines, use only amounts and dates from tool outputs, do not draft the notice, and write under 180 words in three sections (What I checked, Decisions waiting for you, Suggested next step).
A prompt is a request, not a control. So the code checks the answer.
guard_narrative runs locally on the finished briefing. Any reason it returns withholds the whole briefing. It fires when the text:
An empty reply is withheld too.
The amount check matters most. Tools write "EUR 185.00", a bare "13.99" or a count of cents, so the guard strips ISO dates from the tool outputs (a date must not lend its digits to an invented figure), normalises every number and also reads whole numbers as cents. From the tests:
assert ha.guard_narrative("A €1,399.00 fee requires review.", ["1399 minor units"]) == []
assert ha.guard_narrative("A €14.00 fee requires review.", outputs) # 14 only appears in dates
assert ha.guard_narrative("A €777.77 fee requires review.", outputs)
A withheld briefing is labelled as withheld, and the tool trace still shows, because the tool outputs come from the tools, not from the model's text. The guard does not patch text; a guard that rewrote model output would be a second author nobody reviewed.
Its limits: it is a local pattern check, not Amazon Bedrock Guardrails. It does not check dates or amounts without a currency, and a wording its patterns miss would pass. The 180 word ceiling is only an instruction; the code enforces 3200 characters.
When the model is not available, the review route runs the same tools directly (the repair tool once for each appliance with a recorded repair) and returns their outputs with no narrative and a visible reason. It answers HTTP 200 either way.
Reasons decided before any model call are model_not_configured, session_cap, daily_cap and budget_unconfirmed. Once the run starts, they are model_timeout and model_error:<ExceptionClass>. The timeout is a 20 second join on a worker thread, so the thread is not stopped and the attempt stays counted.
Typing a receipt is dull, so the household can paste a receipt, order email or statement excerpt of up to 6000 characters. A second Agent reads it with tools=[] and temperature=0.0.
The prompt asks for {"records": [...]} and nothing else: a field only when the text states it, no guessed date, price, email or model number, integer cents, at most 20 records. The code does not trust the reply. It parses from the first { to the last }, then normalise_extracted keeps at most 20 records, three kinds (appliance, transaction, subscription), their allowed keys and plain values only.
The proposal is stored as a staged draft in the same form manual entry uses. Household records change only after the household reviews the draft, corrects what is wrong, and commits it with confirmed: true.
Extraction accuracy is unmeasured. The control is that a person confirms every fact.
The demo is a public link with no login, so limits live on the server: 3 reviews and 3 text readings per private copy, one shared daily limit of 200 reviews and readings per UTC day for both agents, 700 output tokens and a 20 second timeout. A review can make several model requests in its tool loop, so the daily limit counts reviews and readings, not requests. Cost per call is unmeasured.
The daily counter is one S3 object per day. The first call creates it with If-None-Match: *; later calls write the new count with If-Match on its ETag, making up to four attempts when a race is lost. If the count cannot be confirmed, the model is not called.
One lesson from it: the Lambda roles have no s3:ListBucket, and without it S3 answers a read of a missing key with AccessDenied, not NoSuchKey. The first release of the agent took that as an unconfirmed budget, and the live review silently fell back to tools only. The fix treats AccessDenied on that read as "maybe missing" and proves absence with the conditional create. A real permission failure still fails closed, and a test covers it with a fake S3 that imitates the missing permission.
Two CloudFormation stacks run in eu-west-1: the web app on Amazon CloudFront over a private S3 bucket, and an API Gateway HTTP API over two Python 3.11 Lambda functions. The reader answers every GET and its role denies bedrock:*. The writer handles POST routes and may invoke only the one Haiku inference profile and its foundation model. Both roles deny ses:* and object deletes.
The Lambda package installs strands-agents==1.53.0 and checks inside the bundle that Agent, tool and BedrockModel import. The backend ships from the CI artifact of the exact commit: an operator prepares a CloudFormation change set, reads it, then runs the execute step, which refuses a change set that removes or replaces a resource and then checks that both functions report the commit. The web app ships through a GitHub OIDC workflow that refuses to publish unless the live /healthz names the approved backend commit with a live model and sending off.
The live revision is f8694a0e2e95df688e46e53c9a3e2e9da090e401 for the web app and both functions. This release moves the sample free trial (Fitness Stream Pro) to end three days after each copy opens, so the review names it on any day.
No CI test calls Bedrock. Both runners accept an agent_factory, so tests inject fakes that answer well, answer badly, raise or hang. Storage tests use a fake S3 that enforces If-None-Match and If-Match. The suites cover tool schemas, tools-only mode, the guard, trace pairing, fallbacks, the counter, caps, fail-closed reading and replay. Playwright checks the briefing card and the paste tab, using fixture responses wherever a model reply is needed, and CI adds ruff and an 85% branch coverage gate.
The deployed model is called by the production acceptance workflow, run by hand against the live URL. Runs for this release: