Inspiration
Every house has an operating record. Mine was spread across a drawer of manuals, a warranty email I could never find, a calendar reminder I kept snoozing, and a plumber's business card on the fridge. When the furnace filter was overdue, I didn't know it. When the plumber rang the doorbell, I was on a call and did not know who it was.
An assistant that lives in the kitchen should be able to answer "when did we last change the furnace filter," "what does F21 mean on the washer," and "book someone for the water heater," and it should be able to tell me that the person at the door is the plumber I booked. HomeLedger is that record, exposed to an assistant through an MCP server, with the front door and the home's sensors feeding it.
What it does
HomeLedger is a self-hosted MCP server that gives an assistant read and write access to a household's appliances, warranties, manuals, maintenance schedule, service visits, and door and sensor events.
Nine tools, in an order frozen after the first deploy: list_appliances, get_appliance, maintenance_due, log_maintenance, recent_events, ask_manual, book_service, get_visit, and echo_confirm. Eight register unconditionally; echo_confirm is a developer-only elicitation probe that the demo runtime deliberately leaves enabled, because it is the smallest thing that proves an elicitation round trip works against the live endpoint. Three read-only JSON resources — the household, the appliance list, the maintenance schedule — plus four ui:// MCP Apps widgets, and one prompt, seasonal-checklist, that builds a checklist from the household's own appliances.
Four flows are live:
- Ask. "When is the furnace filter due?" is answered by
maintenance_due: a spoken sentence naming the overdue filter, with the full list of due items in structured content beside it. - Book. "Book a plumber for the water heater Tuesday."
book_servicepauses the conversation through MCP elicitation to pick a provider and an arrival window, reports progress while it checks availability, asks for a final confirmation, and writes the visit.get_visitreads it back. - Arrive. The plumber presses the Ring doorbell. A signed webhook lands, the visit correlator matches the press to the booked window, pulls a snapshot from the recording, asks Claude for one plain sentence about what is visible, and the display shows "the doorbell matched a booked visit" with the photo, without being asked.
- Alert. Water reaches the Ring Flood & Freeze sensor by the water heater. The alert is on the display within seconds, the water heater's maintenance gains a leak check, and when the water is gone the assistant says so rather than repeating the alarm.
MCP Apps widgets render the appliance list, a single appliance, the maintenance calendar, and a service visit inside the assistant's frame. Five tools carry a _meta.ui.resourceUri pointing at one of them.
Every tool returns spoken text with no JSON in it and typed structured content that validates against a declared output schema. Spoken lists stop at five items. Those are Alexa+'s published functional requirements for add-ons, and the contract tests enforce them.
ask_manual is the exception worth stating plainly: the tool is deployed, reachable, and answers inside its budget, but it has never returned a real manual passage. The Knowledge Base behind it is provisioned and cannot be queried. Challenges explains why, and Disclosures lists it with everything else that isn't real yet.
How I built it
MCP server. TypeScript, @modelcontextprotocol/server v2, one Express route at /mcp. One server factory registers every tool, resource, and prompt, and two transport branches share it. Requests from current clients (spec revision 2026-07-28) go to the stateless handler and natively get multi-round-trip input_required results. Requests from 2025-era clients, which is what the Alexa+ MCP Toolkit documents (initialize with protocolVersion: 2025-03-26, then Mcp-Session-Id), are detected and routed to a per-session NodeStreamableHTTPServerTransport, where the SDK's legacy shim turns the same handler's input requests into real elicitation/create calls over the session. Handlers are written once.
Booking. book_service is one handler that asks three questions — provider, arrival window, confirmation — and emits progress 0 through 3 while it checks availability. Between rounds it carries its own state in the SDK's signed requestState, HMAC'd with a ten-minute TTL and bound to the method and the calling client, so nothing about a half-finished booking lives in server memory. SHA-256 derives the visit ID from the household, the appliance, the provider, and the window start rather than generated randomly, so an ordinary client retry of the confirm round overwrites the same row instead of double-booking.
Widgets. Four MCP Apps widgets, each a single ui:// HTML resource with no external assets. The view side of the host handshake is hand-written against the wire protocol published by @modelcontextprotocol/ext-apps — ui/initialize, then ui/notifications/initialized, then the host's tool-result, tool-input and host-context notifications, with tools/call proxied back over the same postMessage channel. The package's view SDK isn't self-contained, and its dependency-inclusive bundle is 418 KB; inlining it into four single-file widgets would have added a bundler step per widget or four copies of a 400-KB payload, for a wire contract that is small, stable, and documented. The server keeps the package as a dependency for its 561-byte ./server export, and a test pins the hand-written side to the SDK's own mime type and meta key.
Data. DynamoDB single table keyed by household, with GSIs for "what is due" and "what visits are scheduled," behind a repository interface. An in-memory implementation shares a contract test suite with the DynamoDB one, and the DynamoDB one runs against DynamoDB Local in Docker. Seed data uses fixed IDs, and the deployed smoke resets the household before seeding because a random-ID seed silently quadrupled the demo appliances during the first four smoke runs.
Manuals. Manuals live in S3 at manuals/<householdId>/<docId>.pdf, each beside a .metadata.json sidecar carrying the appliance id and the title, which is what makes a metadata filter possible at retrieval time. A Bedrock Knowledge Base on an S3 Vectors index ingests that prefix with Titan Text Embeddings v2; ask_manual calls Retrieve with an equals filter on the appliance, asks for three passages, caches each question for ten minutes, and returns the passages with their page numbers without composing anything — the client model writes the answer, the way Alexa+ would. All that infrastructure is real and applied. None of it has ever ingested or returned a document, for the reason in Challenges.
Container. A linux/arm64 image built from a multi-stage Dockerfile with pnpm deploy, listening on 0.0.0.0:8000/mcp as a non-root user. It answers the exact initialize handshake the Alexa+ documentation shows and returns an Mcp-Session-Id.
Runtime. Amazon Bedrock AgentCore Runtime in stateful mode, fronted by AgentCore's JWT authorizer with a Cognito user pool as issuer (client-credentials flow, scope homeledger/mcp, the client secret held in Secrets Manager). Stateful mode matters: AgentCore pins a session to one microVM, which is what makes an in-process session map safe. The first thing the deployed runtime taught me is that AgentCore doesn't forward the public invocation host as the Host header; it forwards an internal, cell-specific name, so the SDK's DNS-rebinding allowlist has to be off behind the authorizer.
Infrastructure. Terraform, split into a root for the demo environment and reusable modules for the AgentCore runtime (execution role and permissions), the Cognito machine-to-machine issuer, and the manuals Knowledge Base (S3 bucket, S3 Vectors bucket and index, Bedrock Knowledge Base, data source, IAM role), each with typed and validated inputs, terraform-docs READMEs, and plan-time tests that run offline against a mocked provider. Nothing is applied from a laptop: GitHub Actions assumes an OIDC role, plans on pull requests, and on merge builds the ARM64 image, pushes it to ECR by commit SHA, and applies. A second workflow seeds the live table and runs the smoke test against the deployed endpoint with both client generations. A third parks the demo between test windows and brings it back — dispatch-only, destroying the runtime and nothing else, and redeploying an image already in ECR rather than rebuilding, which is what makes the restore take seconds. Both halves have been run against the live account.
Simulated Alexa+ client. The MCP Toolkit is a private preview and the organizers confirmed entrants cannot call Alexa+ itself, so the interaction layer is a Next.js web client shaped like an Echo Show 8 (768×480 canvas at 1.667 scale, Alexa+'s published card colors) driving the deployed MCP server through an agent on the Anthropic API, not a Strands agent on Bedrock. Bedrock model invocation is blocked account-wide on this account — the same block ask_manual is behind — so a Bedrock-backed agent could not make one call; the Anthropic API sidesteps the block entirely and is the same path Claude Code already uses to drive this server. Everything else in the original design stands: an MCP client over the documented transport, elicitation rendered as a card rather than chat text, and a debug drawer showing the JSON-RPC traffic (each method sent, its status and timing, and the elicitation and progress messages arriving, capped at the last 100 lines), the negotiated protocol version, the session ID, and per-call timing.
Ring. Official Partner API only. Ring posts the account link and signed webhooks to an API Gateway HTTP API; a Lambda verifies the HMAC over the raw body, claims each request id once in DynamoDB, answers inside Ring's five seconds, and publishes to EventBridge. A visit correlator matches a doorbell press to a booked visit's window, marks it arrived, pulls the snapshot from the recording with its Ring watermark intact, and asks Claude for one sentence about what is visible — never who. A card goes to the simulator over an API Gateway WebSocket, and the assistant reads the details back through get_visit. The Ring Sensors Flood & Freeze sensor, on Amazon Sidewalk, raises an alert and adds a leak check to the appliance's maintenance through a rule table; its events arrive by webhook, and a status poll every two minutes backs them up, so a missed delivery costs at most two minutes. Access tokens last four hours and a scheduled Lambda refreshes them; the link has held unattended overnight.
How the required tech is called at runtime. Every conversational turn is a real Streamable HTTP request from the simulator's agent to the AgentCore invocation URL with a Cognito bearer token. Every proactive card starts as a real Ring event — a signed webhook, with the sensor status poll behind it. Nothing in the demo is a recorded response.
Tests. Vitest unit tests for every handler. Contract tests drive the server with both client generations, @modelcontextprotocol/client v2 in-process and @modelcontextprotocol/sdk 1.x over a real socket, and assert tool order, structured content against the output schemas, spoken text, session creation, and elicitation on both branches.
CI builds the artifact, not just the code, and that distinction was paid for. The test job installs the whole pnpm workspace; the container copies only two packages into its build context, so the two disagree about what the workspace contains and the tests can be green for a workspace edge the image cannot resolve. Three merges passed test and then failed the deploy's own image build before anyone noticed. Now an image job builds the same Dockerfile the deploy builds, on native arm64 because that is what AgentCore runs on, so the disagreement fails a pull request instead of a merge. It holds no AWS credentials and pushes nothing — it needs none, and a pull-request-triggered job is the wrong place to hand them out. main requires three checks to merge — test, terraform, image — and all three deliberately live in the one workflow that has no path filter, so every pull request can satisfy them. The Terraform plan job is not among them, and that is the second lesson from the same family: it only triggers on certain paths, so requiring it meant a documentation-only pull request waited forever on a check that would never arrive. A required check that never reports doesn't fail, it blocks.
Challenges
The spec moved under the hackathon. The rules link the 2025-11-25 MCP revision. The 2026-07-28 revision removed sessions, removed initialize, and replaced server-initiated elicitation with multi round-trip requests. The Alexa+ docs still show the 2025 handshake. A server written to the linked pages would be a legacy server on day one, and a server written only to the current revision could never serve the documented Alexa+ client. Serving both from one handler was the central design problem, and the SDK documents the sessionful path as "legacy preservation," not as a recommended configuration. I built an echo_confirm spike before anything else to prove the shim delivers elicitation to a 2025-era client over a session. It did, with no changes to the documented code.
A provisioned Knowledge Base is not a usable one. Terraform created the manuals bucket, the S3 Vectors bucket and index, the Bedrock Knowledge Base, its data source, and its IAM role cleanly, on the first apply. Bedrock model invocation is blocked account-wide on this account, so the first ingestion job failed: starting one makes the Knowledge Base role call Titan to embed the documents. That much I expected. The sharper finding came next. Retrieval embeds the query, not only the documents — the question has to become a vector before it can be compared against the index — so Retrieve is refused server-side as well, against a Knowledge Base that exists, holds a valid index, has a working role and data source, and is wired into the runtime. There is no degraded mode. It cannot be written to and it cannot be read from, and every control-plane API reports it healthy. Terraform reports success for a resource that cannot do its job, which means a binary "is it provisioned" check is the wrong abstraction: provisioned-but-unusable is a distinct state and tooling has to model it. The generalisation I would carry anywhere: ask how far a capability dependency reaches, not merely which operation obviously needs it. Twice now I modelled that reach and twice it went one step further than the model. (A support case on the account block was closed on the grounds that hackathon projects should fit inside the beginner quota. That is a quota answer to an access problem. A quota throttles — the call succeeds, just not as often, and it says ThrottlingException. This account returns Error 002: Access to Bedrock models is not allowed for this account to every principal that asks, including the Knowledge Base's own service role. A probe today returns it still.)
Blast radius is not consequence radius, and every safety mechanism I built measured the wrong one. The hackathon expects resources to be live only during test windows, so I built a dispatch-only teardown for the AgentCore runtime and spent most of that effort on scope. The scope is genuinely tight, and structurally rather than by policy: the runtime is the only conditional resource in the entire configuration, so an apply with an empty image URI cannot express a wider destroy however it is asked. On top of that the job plans first, reads the plan back as JSON, and refuses to apply if it would create anything or delete anything but the runtime's exact address. Then I ran it. It behaved perfectly — one resource destroyed, Cognito, the Knowledge Base, the table, the images and the data all untouched — and it invalidated every invocation URL I had ever issued, because AgentCore generates the runtime's id at create time as a random suffix on the ARN, and the URL is that ARN encoded into a path. I went looking for the alias layer. Named endpoints exist, and the ?qualifier=DEFAULT in the URL is an endpoint name rather than a version, so for a while it looked like the answer. The endpoint ARN pattern settles it in one line: .../runtime/<name>-[a-zA-Z0-9]{10}/runtime-endpoint/<name>. The endpoint is a child of the runtime. It inherits the id that rotates, and dies with its parent. Across 170-odd control-plane commands there is no alias, no custom domain, no DNS binding — the only real indirection is a Gateway, which is not a transparent proxy and would very likely break the elicitation this project is built around. The generalisation is the sharper sibling of the one above: a change can touch exactly one resource and still break every consumer downstream of an identifier that resource owns. Blast radius counts resources; consequence radius counts addresses, and the two are not related. The question a review has to ask beside "what does this destroy" is "what names does this destroy, and who is holding them" — and for any platform-generated id, whether the platform offers an alias layer at all, before you hand the id to anyone. The fix was documentation, because there is no code fix: the warning now sits in the teardown workflow's confirmation prompt, where the person about to pull the lever reads it.
A smoke test that passes against a broken system is worse than none, because it manufactures confidence. The ask_manual check used to look for F21 in the returned passage. The server's own fixture retriever, which it falls back to whenever no Knowledge Base is configured, returns canned text that matches the smoke's exact question on four keywords and contains F21. So the run could have printed SMOKE OK against a system with no Knowledge Base, no index, no ingestion job, and no Bedrock call at all. It now pins a document title that no fixture carries, so only real ingested content can pass. The check models five states rather than two, and in each blocked one it skips loudly — naming exactly what was not proven and quoting the server's own error — instead of passing silently or dying on a property access that tells an operator nothing about the system under test. It still makes the call in every state, because tools/list proves registration and never invocation, and because the day the block lifts an error result becomes passages and the run says so. One blocked embedding call had already taken down the coverage of the eight tools that do work; that is what this rewrite was really fixing.
Gated access everywhere. The MCP Toolkit is available to "select partners." Ring's sensor support and dynamic scopes are in an early-access program with no documented request path. Staging webhooks used to need a support-provisioned trial; Ring removed that step on 2026-09-11, and authorising an account on the app's Test page is now enough. Each of these is a friction-log entry, with a workaround that lets the build continue.
Ring has no live snapshot. Snapshots come from recordings only, so a "who is at the door" flow has to wait after the event and request the latest frame in range — and how long to wait is undocumented, so it has to be measured. Ring's new Throw Away the Key encryption makes it harder still: on a TAKE device the partner API returns content it cannot read, so the demo doorbell has to have TAKE paused for the whole judging window. Worth knowing before you design around it.
A proxy re-encodes failures in its own vocabulary. The first run of the simulator against the deployed runtime went idle past AgentCore's timeout, and the next call came back not as the container's 404 but as AgentCore's own JSON-RPC error, -32010 Received error (404) from runtime, inside an HTTP 200. Every test had forged the lost session the way the local server reports it, so the client recognised the origin's failure and not the proxy's, and failed honestly instead of repairing the session. The fix is one predicate and a test that forges the failure at the boundary the client actually sees. Tests that fake a failure at its source prove you understand the source, not the thing in between you will really talk to.
A real sensor is not a fixture, and a model fills silence with the worst reading. Every fixture had a flood detected, then a flood cleared. A damp cloth across the real sensor's probes read wet, dry, wet, dry, wet, dry inside a minute, and each wet reading raised a fresh alert: three notices for one cloth. Worse, the event list the assistant reads carried no status, and every one of those alerts had already cleared within seconds — so the model, asked what happened, said water was still being detected. Nothing was wrong in the pipeline; the gap was in what the tool said. The fix is two lines of policy: a raise within two minutes of the same alert clearing re-opens it instead of raising another, and every alert in recent_events now says "still active" or "cleared at 10:49 AM". The generalisation: for anything safety-shaped, a tool has to state the current state explicitly, because a model will not assume the reassuring one.
Two product lines share a name. Ring's "Contact Sensor (2nd Gen)" is a Z-Wave device that needs an Alarm base station. Ring's "Flood and Freeze Sensor (2nd Gen)" is an Amazon Sidewalk device that needs no base station. The Partner API's sensor family is the Sidewalk line. The first shopping list had the wrong one.
Small things that cost a deploy each. On a brand-new Terraform state, terraform output -raw exits 0 with a warning instead of failing, so a "create the ECR repo if the output is missing" guard never fired. GitHub's OIDC token now carries owner and repo IDs in its subject claim, so the classic trust-policy pattern silently fails role assumption. AgentCore routes a legacy client's DELETE session termination to a fresh microVM even though every POST in the same session routed correctly. Each one is a friction-log entry with the exact fix.
Accomplishments
The booking flow is the one I would show first, and it runs on both protocol generations against the deployed runtime. In smoke run 35616643237, a 2025-era client initializes a session through AgentCore, and book_service completes three elicitation rounds — provider, arrival window, confirmation — emits progress 0 through 3, and writes a real visit (Kettle Creek Water Heaters, visit_eivz3iyo4k6eujkw) in 1807 ms. get_visit reads the same visit back in 308 ms. A 2026-07-28 client lists and calls tools statelessly on the same endpoint in the same run, and the same handler serves both — no transport-specific branch in any tool.
The numbers from that run: cold connect 936 ms modern, 948 ms legacy. tools/list 709 ms, list_appliances 743 ms modern and 371 ms legacy, maintenance_due 734 ms, ask_manual 837 ms, echo_confirm with its elicitation round trip 665 ms, get_visit 308 ms — every single-round call under a second against a 3-second budget. book_service is the one call not held to that budget, and deliberately so: it spans three human round trips and a deliberate availability delay.
That run is worth reading with its context: it is the one taken immediately after tearing the whole runtime down and putting it back. Teardown destroyed exactly one resource in 4 m 48 s (0 added, 0 changed, 1 destroyed); bring-up recreated it in 5 seconds by redeploying an image already sitting in ECR, rather than rebuilding; the smoke above then passed against the new runtime. The Cognito client id, the Knowledge Base id, the DynamoDB table and its seeded household all came through byte-identical. So the demo can be parked and restored on demand, and the restore is seconds rather than a fresh deploy — which is what makes "live only during test windows" a real operating mode rather than an intention.
The runtime also survives ordinary deploys without moving. Terraform replaces this resource on exactly two changes — a rename, or an artifact-type flip between container and code — so image pushes, environment changes and role changes all update in place and keep the same ARN. Confirmed live at 0 to add, 1 to change, 0 to destroy on an image bump, and by an earlier runtime that reached its fourteenth in-place version on one unchanged ARN.
The simulator has driven the deployed runtime with a real model. On 2026-09-25, against the runtime redeployed from main and a green smoke run (36180822526), the web client connected through AgentCore (resolved by name, nine tools), rendered the appliances widget from live data, and booked a plumber through three cards — provider, arrival window in the household's time zone, confirmation — writing visit_v2q7wii3joxoolvw and rendering the visit widget. A deliberately slow booking, 75 seconds on the first card and 92 seconds end to end, completed in one call with no session rebuild and each question asked once. A declined booking rendered as ordinary flow, not an error. And ask_manual, still behind the Bedrock block, failed visibly with the server's own sentence while the model said it could not look it up — no invented answer. On 2026-09-27 the idle case passed too: after 35 minutes past AgentCore's session timeout, the next question found its session gone, and the simulator rebuilt it once, replayed the call, answered correctly, and said on screen that it had — the repair AgentCore's own error shape had defeated in the first run.
The Ring pipeline has run end to end with the real hardware. On 2026-09-29 the owner's Ring account was linked through the Token Exchange and Account Link URLs, and the four-hour access token then refreshed itself unattended through the night. A doorbell press with a booked visit: Ring's event at 15:41:24.1Z, the signed webhook 2 s later, the visit matched and marked arrived, the snapshot fetched on the first attempt in 3,908 ms, and the card with the photo on the display at 15:41:32.0Z — 7.9 seconds from button to screen. A second press with no visit booked showed nothing and was recorded, which is the design. A flood trip on the Flood & Freeze sensor reached the pipeline by webhook 2–5 s after the sensor saw it, ahead of the two-minute poll, raised the alert, and put a leak check on the water heater.
Every tool responds in spoken text a person could read aloud, with the full data in typed structured content beside it, and the smoke asserts the frozen tool order and the widget wiring on all five widget-backed tools on every run.
What I learned
Write handlers for the newest protocol and let the SDK shim the past; the reverse doesn't work. Treat every "coming soon" in a platform's docs as a feature flag with a fixture behind it. Ask how far a capability dependency reaches, not merely which operation needs it — the honest answer is usually further than the first failing call suggests. Ask the same question about identifiers: before handing out an address, find out whether the platform can ever give you the same one back, because blast radius and consequence radius are different quantities and only one of them shows up in a plan. Rehearse the operational paths — teardown, bring-up, restore — while a mistake is still cheap; the first execution of a path should never be the one that matters. Put the friction log in the repo on day one, because the decisions it drove are the architecture.
What's next
Ingest the manuals and let ask_manual answer from real passages, the moment the account's Bedrock block clears. Real Alexa+ once the MCP Toolkit opens. Multi-household account linking, which the partition keys already allow. The MCP tasks extension for the availability check once a TypeScript runtime exists.
Disclosures
The demo household is my real home, with appliances given fictional names.
The service-provider marketplace that book_service offers is a static, clearly labeled sample in the repo; it is the one simulated data source. Every provider row carries a required sample: true field, and no booking leaves the system.
The Alexa+ client is a simulation, as the organizers permitted; the server is built to the published Alexa+ MCP Toolkit contract and is exercised by a real 2025-era MCP client. The push channel that carries doorbell and sensor cards to it is this simulation's own WebSocket, not an Alexa+ or MCP capability.
The Ring doorbell and Flood & Freeze sensor are real devices on the author's own Ring account, reached only through Ring's Partner API. Snapshot descriptions say what is visible and never who.
One thing in the video is staged: the arrival scene was filmed on Thursday, October 22, against a visit whose window I moved up in the database, because book_service only offers windows two to four days out and the deadline was Friday noon. The booking shown on screen is the real Friday visit, untouched. The press, the signed webhook, the correlation, the snapshot, and the push to the display are all real and ran unmodified. The flood alert, booking, and maintenance scenes are not staged.
ask_manual has never retrieved a real passage: the Knowledge Base is provisioned but unqueryable under the account's Bedrock block, and where no Knowledge Base is configured the tool serves a small in-memory fixture set. The simulator runs locally against the deployed server and is not hosted; its agent is Claude on the Anthropic API.
Built With
- amazon-api-gateway
- amazon-bedrock-agentcore
- amazon-bedrock-knowledge-bases
- amazon-cognito
- amazon-dynamodb
- amazon-ecr
- amazon-eventbridge
- amazon-s3-vectors
- amazon-sidewalk
- amazon-web-services
- anthropic-claude
- aws-lambda
- aws-secrets-manager
- docker
- express.js
- github-actions
- mcp-apps
- model-context-protocol
- next.js
- node.js
- react
- ring-partner-api
- terraform
- typescript
- vitest
Log in or sign up for Devpost to join the conversation.