The shopping list is the most-used software in my house. For years, it was three shopping lists: the list on the Echo, the list in my wife’s notes app, and the items I promised myself to remember. The third list never once produced milk.

The reason is simple, and nobody solved it. Items enter by voice. You stand at the fridge with a carton in your hand and wet hands. You say “add milk”. That is the full interaction. But items get used on a phone, in a store, sorted by aisle, checked off with one hand. The capture surface and the consumption surface are different devices. The good list apps own only one of them.

The shop. list sorted by aisle: pantry items with alexa source chips, and the in-cart rail on the right.

So the list lives in the Echo. There, it is useless in a grocery store. And only the person who spoke can see it.

The API Amazon killed

The obvious repair is a sync from Alexa’s list to a better place. Amazon closed that door on July 1, 2024. On that date, Amazon shut off the official Lists API. Third-party sync died everywhere on that exact date. That is why the AnyList and Todoist integrations stopped on the same day. They never came back.

One unofficial path survived. The Alexa app itself still talks to Amazon over an internal API. The Home Assistant community maintains an integration that speaks that API directly. I want to be precise about credit, because credit matters. I did not reverse-engineer Amazon’s endpoints. lonlazer/ha-alexa-todo-lists did that work. I found it, connected it to a real system, and built everything above it.

I did have to accept the costs of that foundation:

  • The link requires an authenticator-app OTP. So I turned on app-based 2FA for the household Amazon account, only to hold this thing together.
  • The push direction is instant. The pull direction is a 10-minute poll. No realtime path exists anymore.
  • It is the same class of dependency as any unofficial API. Amazon can break it without notice. The symptom will be silent: the lists will simply stop.

The 10-minute poll, plus my own 2-minute reconcile loop, sets the delay. A voice add can take up to twelve minutes to appear in the app. The other direction takes seconds. That asymmetry is not a bug I failed to repair. It is the shape of what is possible now. I designed the app around it. The app does not pretend otherwise.

There is no scratch space

I learned this rule once. I will never forget it. It is in capitals in my notes:

shop IS Alexa. Each item added in the app pushes to Home Assistant and relays to Amazon within seconds. That includes typed items, emailed items, recipe items, and text-message items. The item appears on the real Echo in the real kitchen.

So a test row does not exist. Early on, I ran QA against the live instance. I put junk on my family’s real shopping list. The junk also came back in the sync every two minutes. So a local delete accomplished nothing. Cleanup is two-sided, and the order matters: remove the item upstream first, then locally. In the other order, the item comes right back.

Each later round of work uses a throwaway database with empty sync sources. The production list is a shared family surface. The blast radius of a bug is this: Mom sees __probe2__ at the grocery store.

Merge rules: last write wins, but done always wins

Two-way sync between systems that cannot see each other needs a conflict rule. My rule is last-write-wins per item, with one override: a stale sync never resurrects a check-off.

That single exception covers the realistic failure. You are in the store. You check off milk. Forty seconds later, a poll arrives with a snapshot from before your check-off. Plain LWW puts milk back on your list while you stand in the dairy aisle. Done-wins makes the check-off sticky. The worst case: an item reappears for a short time, and you check it off again. The app does not confidently un-buy your groceries.

Items removed on the Alexa side become done, not deleted. So the history survives. That decision mattered more than anything else in the app.

The history is the real product

Keep each purchase with a timestamp, and the list changes. It stops as a list. It becomes a model of how a household runs. It is the most honest record my family keeps. Nobody lies to a shopping list.

Staples. For each item, the app computes the median interval between purchases from its own done-history. It flags the items that are due. The UI says it in plain language: bought 6 days ago · usually every 5 days. There is no AI in this feature. It is a SQL query over your own behavior. It is the feature the family notices.

The feature also takes feedback. Here it has opinions. When you accept a suggestion, that is a signal. When you dismiss one, the app snoozes it. The snooze scales with your dismissal count. Three dismissals mute the item completely — until you buy the item again. Then the feedback resets, and the item re-enters the rotation. The app has permission to be wrong about you. It does not have permission to repeat itself.

Aisle order that learns your store. A nightly job examines the order of your check-offs while you walk the store. Then it re-sorts the categories to match. No settings, no UI, no configuration by any person. The two houses shop at different stores. So each house learns its own walk alone. One supermarket’s layout never contaminates the other.

Ask-once AI. A keyword map categorizes each new item name first. That is free, and it handles most names. Only a miss escalates to a small fast model. The app writes the answer back into the keyword table. So each item name costs one API call in the full lifetime of the app. You can watch an item hop to its correct aisle one second after you add it. The spend moves toward zero as the vocabulary fills. That is my favorite property of any AI feature I built: it makes itself obsolete.

The same ask-once pattern got reused for canonicalization. The app learns that “oj” and “orange juice” are one product, and that “stick butter” and “butter” are one product. This applies to grouping only. The app never rewrites the display name you typed. The original string is what matches back to Alexa.

The best trick in the app

We eat the same nine dinners. So the app knows them.

Say “Alexa, add tacos”. The app does not add the word tacos. It compiles the meal. It adds the ingredients. It skips the items already on the list. It also skips the items your pantry can supply, under a conservative rule: an item needs at least three purchase events and a recent last purchase before the app assumes your pantry has it.

The mechanics through a two-way sync are the fun part. The trigger item gets an insert, and then an immediate mark as done. So the done-wins rule clears the word “tacos” off the Echo without a fight. I proved the no-loop invariant with an end-to-end test against a mock: second reconcile, zero duplicates, zero re-explosions.

The gotcha this creates makes me laugh: the nine meal names are reserved words. Give a meal a name you would want to literally buy, and you can never put that item on the list again.

Two houses

The list is now per household: one for Muskoka, one for Florida. The split between shared and isolated took real thought.

Per house: the list itself, the purchase history, the staples cadence, the aisle order, the dinner rotation, the Echo binding, and the notifications. When you add taco shells in Florida, no phone rings in Ontario.

Shared: the vocabulary. “sc” still means sour cream in both houses. The category names stay consistent. Language is a family-level fact. Pantry rhythm is a house-level fact.

That migration also produced my favorite recent bug. A database index referenced a new column. The index creation sat in the shared pre-migration block, before the guard that adds the column. So the app crashed on each boot against the real database, while 228 tests passed against fresh ones. Two minutes of downtime, and one permanent lesson: migration tests need a case that starts from an existing database shape, not only from an empty one plus an idempotence check.

The list also feeds the morning brief. The brief pulls the list live at send time. So the “running low” predictions come with one-tap add links in the email.

What occurs when Amazon turns it off

Amazon already did it once. The current path is unofficial, undocumented, and fully at their discretion. One internal change kills the voice leg, with no error message.

I built on it anyway, and I would do it again, because the failure is survivable by design. When the bridge dies, the app still works: phones, email ingestion, recipes, meals, staples, history. All of it lives in my database, on my server. The loss would be one ability: to say “add milk” with wet hands.

That ability, I admit, is the full reason I started.