Everyone wants the same assistant: something that reads their email, keeps their calendar straight, wrangles the kids' schedules, and tells them what to cook, all without learning a new app. The technology to build it has been here for a while. The reason most people still do not have one is trust. You are not going to hand an autonomous agent your inbox and your calendar and hope it behaves.
So the design has to earn that trust. The rule this assistant is built around is that it never sends an email or changes your calendar on its own. It reads what it needs, shows you a draft or a proposed change, and waits for you to approve it. That is what makes it something you can leave running.
This post builds it: an assistant you talk to on WhatsApp, running on Claude via Amazon Bedrock, that sorts your email, spots a clash between a work call and the school pickup, and plans the week's meals. It costs about $35 a month on AWS Lambda. The full code is on GitHub: subeshb1/household-agent.
The shape in one picture
Three things wake the agent up: an inbound WhatsApp message, an email poll on a timer, and a scheduler for the morning brief. They all run the same core loop, and the loop's one rule is that it never acts on the outside world without a green light from you.

The trick that makes this safe is in how the tools are split.
Read tools run; write tools only ask
The agent has two classes of tool. Read tools (search email, list calendar, list tasks) run immediately, because they have no side effect you care about. Write tools (draft a reply, create an event, add a task) never execute inside the model's turn. They stage a pending action and ping you on WhatsApp. The action only happens later, after you approve.
pythonREAD_TOOLS = {"get_thread", "list_events", "list_tasks"} WRITE_TOOLS = {"draft_email_reply", "propose_calendar_event", "accept_invite", "add_task"}
Dispatch is where the gate lives. A read tool calls the connector. A write tool builds a PendingApproval, saves it, sends an approval card, and returns "staged" to the model, which then writes its summary. Nothing irreversible happens in the loop.
pythondef dispatch_tool(self, tc): if tc.name in READ_TOOLS: content = self._run_read_tool(tc) # acts now, no side effects elif tc.name in WRITE_TOOLS: content = self._stage_write_tool(tc) # stages an approval, acts NEVER ... def _stage_write_tool(self, tc): approval = PendingApproval(id="ap_" + uuid4().hex[:8], kind=tc.name, ...) self.ctx.store.put_approval(approval) self.ctx.messaging.send_approval_card(self.user, approval) return {"status": "staged_for_approval", "approval_id": approval.id}
Email is safe even at the moment of execution, because "execute" for a reply just means saving a Gmail draft. The agent never sends. You do, from your drafts folder, if you want to.
Build it without touching a single account
Here is a practical problem that stops most of these projects before they start: to see the agent do anything, you first need Gmail OAuth, a Google Cloud project, and a WhatsApp Business account. That is a lot of setup to stand up before you know the logic even works.
The way around it is to define every external service, and the model itself, as an interface with a simple in-memory stand-in behind it. The agent core talks to the interface, so it runs and is fully testable before any real account exists. Wiring the real services becomes a later, mechanical step.
pythonclass EmailClient(Protocol): def list_new(self, since) -> list[EmailMessage]: ... def get_thread(self, thread_id) -> list[EmailMessage]: ... def create_draft(self, thread_id, body) -> EmailDraft: ... class LLMClient(Protocol): def run(self, system, messages, tools) -> LLMResponse: ...
The in-memory email client serves a realistic Tuesday inbox from seed data: a school notice, a coach emailing that football moved to 5pm, a work email needing numbers before a 2pm review, and a grocery newsletter that should be ignored. The stand-in for Claude is a scripted policy that reproduces the decisions the real model would make, sequenced the same way tool use works: read first, then stage writes, then summarise. Its only job is to prove the orchestration is correct before a single token is spent.
What a morning looks like
Here is the email triage step, printed as the WhatsApp thread it produces:
[assistant (needs your tap)] Draft reply ready: Sam's football moved to 5pm this Wednesday Hi Coach, Thanks for letting me know. Yes, Sam can make the earlier 17:00 start on Wednesday. See you then. Best, Alex Reply: APPROVE ap_af64d634 / EDIT ap_af64d634 / SKIP ap_af64d634 [assistant] Morning Alex. I went through 4 new emails. 2 draft replies are waiting for your OK, and I've queued a reminder about non-uniform day tomorrow. The grocery newsletter I left alone.
The safety property is not a promise in a README, it is a test. After triage, before any approval, there are zero Gmail drafts. One arrives only when an approval comes back.
pythondef test_nothing_executes_before_approval(self): agent = build_mock_app() run_email_poll(agent) self.assertEqual(len(agent.ctx.email.drafts), 0) # staged, not created def test_approve_creates_the_draft(self): agent = build_mock_app() run_email_poll(agent) football = next(m for m in agent.ctx.messaging.outbox if m.kind == "approval_card" and "football" in m.text.lower()) run_inbound(agent, f"APPROVE {football.approval_id}") self.assertEqual(len(agent.ctx.email.drafts), 1) # now it exists
$ python3 -m unittest discover -s tests -t tests Ran 11 tests in 0.003s OK
Eleven tests, no network, no credentials. That is the point: the logic is fully verified before any real service is involved.
Swapping in the real brain: Claude on Bedrock
The model is just another interface, so going live is one new class. On AWS I use Amazon Bedrock rather than the Anthropic API, because then there is no API key to manage; the runtime's IAM role carries bedrock:InvokeModel. The BedrockLLM maps our internal message and tool shapes onto the Bedrock Converse API and maps the response back.
pythonclass BedrockLLM: def __init__(self, region="ap-southeast-2", model="global.anthropic.claude-sonnet-5"): self._client = boto3.client("bedrock-runtime", region_name=region) def run(self, system, messages, tools): resp = self._client.converse( modelId=self.model, system=[{"text": system}], messages=[_to_converse(m) for m in messages], toolConfig={"tools": [_to_tool_spec(t) for t in tools]}, inferenceConfig={"maxTokens": 1024}, ) # map text + toolUse blocks back into our LLMResponse
Two details worth knowing. global.anthropic.claude-sonnet-5 is a region-portable inference profile, which saves you matching model IDs to regions. And Claude Sonnet 5 rejects the temperature parameter, so do not set it in inferenceConfig.
Now the same connectors, driven by the real model. The morning brief, from Claude on Bedrock:
Morning brief for today: ⚠️ Conflict: Vendor sync (16:45-17:30) overlaps with Mia's swimming pickup (17:00-18:00). Need to decide who covers pickup or shift the sync. Also: Client review 14:00-15:00 (work, no clash), Parents evening invite pending (18:30-19:30). Task open: renew library books.
It found the clash between the work call and the school pickup on its own, across two calendars. The email triage staged the coach reply and the reminders, ignored the newsletter, and the approve round-trip created exactly one draft. Identical behaviour to the test harness, now driven by the real model.
Deploying to AWS Lambda
For a single household, the workload is tiny and bursty: two cron jobs and a webhook. Lambda is the cheap, obvious host, and it scales to zero. The handler is thin, because all the logic already exists and is tested.
pythondef handler(event, context): agent = build_bedrock_app(region=os.environ.get("AWS_REGION")) trig = (event or {}).get("trigger", "morning_brief") if trig == "morning_brief": run_morning_brief(agent) elif trig == "email_poll": run_email_poll(agent) ... return {"messages": [{"kind": m.kind, "text": m.text} for m in agent.ctx.messaging.outbox]}
Package it (boto3 is in the Lambda runtime, so there is nothing to vendor), create the function, invoke it:
bashaws lambda create-function --function-name household-agent \ --runtime python3.12 --handler lambda_function.handler \ --role "$ROLE_ARN" --timeout 120 --memory-size 512 \ --tags Owner=you@example.com \ --zip-file fileb://deploy.zip aws lambda invoke --function-name household-agent \ --payload '{"trigger":"morning_brief"}' out.json
$ cat out.json {"trigger": "morning_brief", "messages": [{"kind": "text", "text": "Morning brief for today:\n\n⚠️ Conflict: Vendor sync ..."}]}
The agent runs on Bedrock, inside Lambda, returning the brief.
Notice the Owner=you@example.com tag on the create call. In a locked-down AWS account, a Service Control Policy may deny lambda:CreateFunction unless a specific tag is present. My first, untagged attempt failed with an explicit deny that looked like a hard wall. It was not: the account required an Owner tag on every function (every existing Lambda had one), and adding it let the deploy through. If your create is denied "by a service control policy", check what tags the existing resources carry before assuming the service is off-limits.
Secrets go in Parameter Store, not Secrets Manager
The real connectors need credentials (Google OAuth refresh token, WhatsApp token). These go in SSM Parameter Store as SecureString values under a /household-agent/ prefix. Standard SecureString parameters are free; Secrets Manager charges per secret per month, and a single household needs none of its extra features. The loader checks an environment variable first, so local and test runs never touch AWS.
pythondef get_secret(name, region="ap-southeast-2"): env_key = name.upper().replace("-", "_") if env_key in os.environ: # local / mock / CI return os.environ[env_key] resp = boto3.client("ssm", region_name=region).get_parameter( Name="/household-agent/" + name, WithDecryption=True) return resp["Parameter"]["Value"]
The runtime role needs ssm:GetParameter on /household-agent/* and kms:Decrypt. The Bedrock brain needs no secret at all.
What it costs
For one household, month by month:
| Component | Estimate |
|---|---|
| Bedrock (Claude), the brain | $15 to 45, the dominant cost |
| Lambda + API Gateway | $0 to 3, inside the free tier at this volume |
| DynamoDB (on-demand) | $0 to 1 |
| SSM Parameter Store | $0 (free SecureString) |
| WhatsApp (Meta Cloud API) | $0 to 5 |
| Gmail / Calendar / Tasks | $0 |
| Total | about $25 to 50, typically $35 |
Almost all of it is model usage. The hosting rounds to nothing because Lambda idles at zero, which is exactly what you want for something that runs a couple of times a day.
Cleanup
Tear down what you created, and confirm it is gone.
bashaws lambda delete-function --function-name household-agent aws iam delete-role-policy --role-name household-agent-role --policy-name bedrock-invoke aws iam detach-role-policy --role-name household-agent-role \ --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole aws iam delete-role --role-name household-agent-role # confirm nothing is left billing aws lambda list-functions --query "Functions[?contains(FunctionName,'household')].FunctionName"
Where this leaves you
The agent is deliberately small. The cleverness is in two decisions, not in the infrastructure: every external service sits behind an interface, and every action that touches the outside world is staged for your approval instead of executed. Those two choices are what make it safe to leave running and possible to build without a pile of credentials.
The brain and the deploy path are done: real Claude Sonnet 5 on Bedrock, running on Lambda, with the full orchestration under test. What is left is the connector layer — Gmail, Google Calendar, Google Tasks and WhatsApp each need their real implementation behind the interface they already have, with credentials in Parameter Store. That swap happens in one place, and nothing above it changes.
The code, including the test harness, the tests, and the Lambda packaging, is on GitHub: subeshb1/household-agent.
Subesh Bhandari
Engineer · Writer · Builder
Join the conversation
Comments are powered by GitHub.