My earlier Alexa shopping list setup for Home Assistant worked, and the later REVISITED version made it much nicer to use, but it still had a lot of moving parts. Node-RED pulled the Alexa shopping list, cleaned up duplicates, passed new items through OpenAI for aisle/category sorting, wrote a local text file, exposed helper entities back to Home Assistant, and then Home Assistant synced that file-side view with AnyList.
That made sense at the time because Home Assistant did not have a direct way to read and write the Alexa shopping list. The practical bridge was Node-RED with the Alexa Remote node.
That part has changed.
As of Home Assistant 2026.7, the core Alexa Devices integration includes a to-do list platform. In plain English: your Alexa shopping and to-do lists can now appear in Home Assistant as normal todo entities.
That means the core sync no longer needs Node-RED at all. Home Assistant can now sit in the middle and sync two standard todo entities:
todo.alexa_shopping_listfrom the Alexa Devices integrationtodo.anylist_shopping_listfrom my AnyList integration
The AnyList side is still handled by my AnyList custom integration, which I introduced in the Home Assistant Community forum thread. The code is on GitHub. Alexa Devices solves the Alexa-to-Home-Assistant side. It does not solve Alexa-to-AnyList by itself. The automation below is the glue between the two.
What changed from the old method?
In the old REVISITED version, Node-RED was responsible for getting Alexa data into a shape that Home Assistant could use. Home Assistant was not comparing Alexa and AnyList directly. It was comparing AnyList with a text representation of the shopping list that Node-RED maintained.
That old flow had some useful extras. It could categorize items by supermarket aisle, use a category memory, expose a custom dashboard-friendly text format, and show a loading state while the list was rebuilt. If you want that exact custom dashboard experience, the previous Node-RED-based article is still useful.
For the basic shopping-list sync, though, it is now unnecessary. The new version is much simpler:
- Alexa Devices exposes the Alexa shopping list as a Home Assistant todo entity.
- AnyList exposes the AnyList shopping list as a Home Assistant todo entity.
- A Home Assistant automation reads both lists with
todo.get_items. - The side that changed becomes the source of truth.
- The automation adds, removes, or updates only the differences on the destination side.
That gives me the practical three-part setup I wanted in the first place:
Alexa shopping list <-> Home Assistant <-> AnyList
Example entity names
The automation below uses readable example entity IDs:
todo.alexa_shopping_listtodo.anylist_shopping_list
Those names are intentionally obvious so the automation is easier to read. In my own system, Home Assistant generated longer entity IDs. The logic is the same.
The sync automation
This automation watches both todo entities. When Alexa changes, Alexa is treated as the source and AnyList is updated to match. When AnyList changes, AnyList is treated as the source and Alexa is updated to match.
It also listens for a local event named shopping_list_sync_alexa_source_requested. I use that after Home Assistant starts so the automation can do a fresh Alexa-source comparison once both integrations have had time to load.
alias: Shopping List Sync | Alexa Todo <-> AnyList
description: >
Two-way sync between the Alexa Devices shopping list
todo.alexa_shopping_list and the AnyList shopping list
todo.anylist_shopping_list. Source of truth is whichever side triggered. The
startup refresh automation fires a local event that treats Alexa as the source
after Home Assistant starts.
triggers:
- alias: Alexa todo changed - Alexa is the source
entity_id:
- todo.alexa_shopping_list
id: alexa_source
trigger: state
- alias: AnyList todo changed - AnyList is the source
entity_id:
- todo.anylist_shopping_list
id: anylist_source
trigger: state
attribute: items_signature
- alias: Startup refresh requested - Alexa is the source
event_type: shopping_list_sync_alexa_source_requested
id: alexa_source
trigger: event
conditions: []
actions:
- alias: Define shopping list sync entities
variables:
alexa_todo_entity: todo.alexa_shopping_list
anylist_todo_entity: todo.anylist_shopping_list
sync_destination_entity: |-
{{ anylist_todo_entity if trigger.id == 'alexa_source'
else alexa_todo_entity }}
- alias: Stop early if either shopping list entity is unavailable
if:
- alias: Check both todo entities are available before syncing
condition: template
value_template: |-
{{ states(alexa_todo_entity) in ['unknown', 'unavailable']
or states(anylist_todo_entity) in ['unknown', 'unavailable'] }}
then:
- alias: Stop because one side cannot be read
stop: Shopping list todo entity is unavailable
- alias: Refresh AnyList before Alexa-source comparisons
choose:
- alias: Alexa is source, so refresh AnyList before comparing lists
conditions:
- alias: Continue only when Alexa triggered the sync
condition: template
value_template: "{{ trigger.id == 'alexa_source' }}"
sequence:
- alias: Refresh AnyList data from the integration
action: anylist.refresh
data: {}
- alias: Give AnyList refresh a few seconds to settle
delay: "00:00:03"
- alias: Read all active and completed items from both shopping lists
action: todo.get_items
target:
entity_id:
- todo.alexa_shopping_list
- todo.anylist_shopping_list
data:
status:
- needs_action
- completed
response_variable: shopping_lists_response
- alias: Normalize Alexa and AnyList items into comparable JSON lists
variables:
alexa_items_json: >-
{% set todo_items = shopping_lists_response.get(alexa_todo_entity,
{}).get('items', []) %} {% set ns = namespace(items=[]) %} {% for item
in todo_items %}
{% set summary = item.summary | trim %}
{% if summary %}
{% set ns.items = ns.items + [{
'summary': summary,
'completed': item.status == 'completed',
'uid': item.uid | default(summary, true),
'status': item.status,
'key': (summary | lower | trim)
}] %}
{% endif %}
{% endfor %} {{ ns.items | to_json }}
anylist_items_json: >-
{% set todo_items = shopping_lists_response.get(anylist_todo_entity,
{}).get('items', []) %} {% set ns = namespace(items=[]) %} {% for item
in todo_items %}
{% set summary = item.summary | trim %}
{% if summary %}
{% set ns.items = ns.items + [{
'summary': summary,
'completed': item.status == 'completed',
'uid': item.uid | default(summary, true),
'status': item.status,
'key': (summary | lower | trim)
}] %}
{% endif %}
{% endfor %} {{ ns.items | to_json }}
source_items_json: >-
{{ alexa_items_json if trigger.id == 'alexa_source' else
anylist_items_json }}
destination_items_json: >-
{{ anylist_items_json if trigger.id == 'alexa_source' else
alexa_items_json }}
source_signature: >-
{% set src = source_items_json | from_json(default=[]) %} {% set ns =
namespace(vals=[]) %} {% for item in src | sort(attribute='key') %}
{% set ns.vals = ns.vals + [item.key ~ '|' ~ ('1' if item.completed else '0')] %}
{% endfor %} {{ ns.vals | join(',') }}
destination_signature: >-
{% set dst = destination_items_json | from_json(default=[]) %} {% set ns
= namespace(vals=[]) %} {% for item in dst | sort(attribute='key') %}
{% set ns.vals = ns.vals + [item.key ~ '|' ~ ('1' if item.completed else '0')] %}
{% endfor %} {{ ns.vals | join(',') }}
- alias: Stop when both sides already contain the same items and completion states
if:
- alias: Compare source and destination signatures
condition: template
value_template: "{{ source_signature == destination_signature }}"
then:
- alias: Stop because no sync changes are needed
stop: Lists already synchronized
- alias: Calculate destination additions, removals, and completion updates
variables:
destination_add_json: >-
{% set src = source_items_json | from_json(default=[]) %} {% set
dst_keys = (destination_items_json | from_json(default=[])) |
map(attribute='key') | list %} {% set ns = namespace(items=[]) %} {% for
item in src %}
{% if item.key not in dst_keys %}
{% set ns.items = ns.items + [item] %}
{% endif %}
{% endfor %} {{ ns.items | to_json }}
destination_remove_json: >-
{% set src_keys = (source_items_json | from_json(default=[])) |
map(attribute='key') | list %} {% set dst = destination_items_json |
from_json(default=[]) %} {% set ns = namespace(items=[]) %} {% for item
in dst %}
{% if item.key not in src_keys %}
{% set ns.items = ns.items + [item] %}
{% endif %}
{% endfor %} {{ ns.items | to_json }}
destination_update_json: >-
{% set src = source_items_json | from_json(default=[]) %} {% set dst =
destination_items_json | from_json(default=[]) %} {% set ns =
namespace(items=[]) %} {% for s in src %}
{% set match = (dst | selectattr('key', 'eq', s.key) | list | first | default(none)) %}
{% if match is not none and match.completed != s.completed %}
{% set ns.items = ns.items + [{
'uid': match.uid | default(match.summary, true),
'summary': s.summary,
'completed': s.completed
}] %}
{% endif %}
{% endfor %} {{ ns.items | to_json }}
- alias: Add items that exist in the source but not the destination
repeat:
for_each: "{{ destination_add_json | from_json(default=[]) }}"
sequence:
- alias: Add missing item to destination list
action: todo.add_item
target:
entity_id: "{{ sync_destination_entity }}"
data:
item: "{{ repeat.item.summary }}"
- alias: >-
Mark newly added destination item completed when the source item is
completed
if:
- alias: Check whether the source item is completed
condition: template
value_template: "{{ repeat.item.completed }}"
then:
- alias: Set the newly added destination item to completed
action: todo.update_item
target:
entity_id: "{{ sync_destination_entity }}"
data:
item: "{{ repeat.item.summary }}"
status: completed
- alias: Remove destination items that no longer exist in the source
repeat:
for_each: "{{ destination_remove_json | from_json(default=[]) }}"
sequence:
- alias: Remove extra item from destination by UID
action: todo.remove_item
target:
entity_id: "{{ sync_destination_entity }}"
data:
item: "{{ repeat.item.uid | default(repeat.item.summary, true) }}"
- alias: Update destination completion states to match the source
repeat:
for_each: "{{ destination_update_json | from_json(default=[]) }}"
sequence:
- alias: Set destination item status to completed or needs_action
action: todo.update_item
target:
entity_id: "{{ sync_destination_entity }}"
data:
item: "{{ repeat.item.uid | default(repeat.item.summary, true) }}"
status: "{{ 'completed' if repeat.item.completed else 'needs_action' }}"
mode: queued
max: 10
Optional startup refresh automation
The main sync automation includes a custom event trigger. This small automation fires that event shortly after Home Assistant starts. I use Alexa as the startup source because Alexa is where voice-added shopping items usually begin in my house.
alias: Shopping List Sync | Startup Alexa Refresh
triggers:
- trigger: homeassistant
event: start
conditions: []
actions:
- delay: "00:00:45"
- event: shopping_list_sync_alexa_source_requested
event_data: {}
mode: single
How the automation avoids loops
Two-way sync automations can easily create loops if they write blindly. This one avoids that by normalizing both lists into comparable JSON first.
Each item is reduced to a few fields:
summary: the visible item namecompleted: whether the item is checked offuid: the todo item ID when the integration provides onekey: a lowercase trimmed version of the item name
The automation then builds a signature from item name plus completion state. If both signatures match, it stops immediately. If they differ, it calculates exactly what the destination needs:
- items to add
- items to remove
- items whose completed/needs-action state must be updated
That is why the automation can run in queued mode without constantly bouncing changes between Alexa and AnyList. A write from one side may trigger the other entity, but the next run should see matching signatures and stop.
Why refresh AnyList first?
The AnyList custom integration polls AnyList and also refreshes after local changes. Still, when Alexa is the source, I want to compare Alexa against the freshest AnyList state I can reasonably get. That is why the automation calls:
action: anylist.refresh
and then waits three seconds before reading both lists. This is not complicated, but it prevents a lot of stale-data weirdness when the source change came from outside AnyList.
A note about duplicate items
This automation matches items by normalized name. That is intentional for my shopping-list use case. If both sides contain milk, they are treated as the same item even if the underlying integrations have different internal IDs.
The tradeoff is that duplicate items with the same name are not preserved as separate entries. For groceries, that is usually what I want. If I need two cartons of milk, I would rather write milk x2 than maintain two separate identical items and hope every app handles that gracefully.
What still needs the AnyList integration?
The new Alexa Devices todo support is excellent, but it only brings Alexa lists into Home Assistant. It does not know anything about AnyList.
The AnyList integration is still the piece that makes AnyList usable from Home Assistant. It exposes AnyList shopping lists as standard todo entities, supports adding/checking/removing items, includes a refresh service, and provides list signature attributes that are useful for automations like this one.
One practical benefit is category-aware item creation. If AnyList already knows that milk belongs in Dairy, adding milk from Home Assistant can still land in the right AnyList category instead of becoming an uncategorized item.
Dashboard card grouped by AnyList categories
The old dashboard card grouped items by the category numbers that Node-RED wrote into shopping_list.txt. With the AnyList integration in the middle, the dashboard can now read categories directly from the AnyList todo entity instead.
The key attribute is items_by_category on todo.anylist_shopping_list. The card below loops over that grouped data and draws the same aisle-style separators I used before, but without needing the Node-RED text-file format.
The refresh button at the beginning of the card does three things: it asks Home Assistant to update the Alexa todo entity, refreshes AnyList, and then fires the same shopping_list_sync_alexa_source_requested event used by the startup automation. Tapping an item updates the AnyList todo item, and the sync automation then carries that completed/needs-action state back to Alexa.
type: custom:tailwindcss-template-card
entity: todo.anylist_shopping_list
content: |-
<div class="flex flex-wrap gap-2 justify-center p-2">
{% set groups = state_attr('todo.anylist_shopping_list', 'items_by_category') | default([], true) %}
{% set groups = groups if groups is iterable and groups is not string else [] %}
{% set item_count = namespace(value=0) %}
{% for group in groups %}
{% set item_count.value = item_count.value + (group.get('items', []) | count) %}
{% endfor %}
<div class="bg-accent rounded-lg p-3">
<div
onclick="
hass.callService('homeassistant', 'update_entity', {entity_id: 'todo.alexa_shopping_list'});
hass.callService('anylist', 'refresh', {});
setTimeout(() => hass.callApi('POST', 'events/shopping_list_sync_alexa_source_requested', {source: 'dashboard_kitchen'}), 1500);
"
class="hover:scale-105 transition-all cursor-pointer"
style="font-size:14px;"
>
🛒📋🔃
</div>
</div>
{% if item_count.value > 0 %}
<div class="self-center px-1" style="font-size:28px; line-height:1; opacity:0.35;">|</div>
{% endif %}
{% if item_count.value == 0 %}
<div class="bg-accent rounded-lg p-3">
<span style="font-size:14px;">Shopping list is empty</span>
</div>
{% else %}
{% set rendered_group = namespace(value=false) %}
{% for group in groups %}
{% set group_items = group.get('items', []) %}
{% if group_items | count > 0 %}
{% if rendered_group.value %}
<div class="self-center px-1" style="font-size:28px; line-height:1; opacity:0.35;">|</div>
{% else %}
{% set rendered_group.value = true %}
{% endif %}
{% for item in group_items %}
{% set item_name_display = item.get('name', '') | trim %}
{% if item_name_display %}
{% set is_completed = item.get('status') == 'completed' %}
{% set next_status = 'needs_action' if is_completed else 'completed' %}
{% set text_decoration = 'line-through' if is_completed else 'none' %}
{% set opacity = '0.65' if is_completed else '1' %}
{% set todo_item = item.get('uid') or item_name_display %}
<div
class="bg-accent rounded-lg p-3 cursor-pointer hover:scale-105 transition-all"
style="opacity: {{ opacity }};"
data-item="{{ todo_item | e }}"
data-next-status="{{ next_status }}"
onclick="hass.callService('todo', 'update_item', {entity_id: 'todo.anylist_shopping_list', item: this.dataset.item, status: this.dataset.nextStatus});"
>
<span style="font-size:14px; text-decoration: {{ text_decoration }};">
{{ item_name_display }}
</span>
</div>
{% endif %}
{% endfor %}
{% endif %}
{% endfor %}
{% endif %}
</div>
ignore_line_breaks: true
always_update: false
parse_jinja: true
code_editor: Ace
entities:
- todo.anylist_shopping_list
bindings: []
actions: []
debounceChangePeriod: 100
plugins:
daisyui:
enabled: true
url: https://cdn.jsdelivr.net/npm/daisyui@latest/dist/full.css
theme: dark - dark
overrideCardBackground: false
tailwindElements:
enabled: false
This is only the shopping-list card from my larger kitchen dashboard. It assumes tailwindcss-template-card is installed and that the AnyList todo entity exposes items_by_category.
Final result
The old Node-RED setup was a good workaround when Home Assistant had no native Alexa shopping-list entity. With Home Assistant 2026.7, the core structure is much cleaner:
- Alexa voice commands update the Alexa shopping list.
- Alexa Devices exposes that list as
todo.alexa_shopping_list. - AnyList exposes its list as
todo.anylist_shopping_list. - Home Assistant syncs the two todo entities directly.
For my day-to-day shopping list, this is the version I wanted all along: Alexa for voice entry, AnyList for actual shopping, and Home Assistant as the automation layer between them.



