Sync Alexa Shopping List with AnyList in Home Assistant, (3rd Revision, Without Node-RED)

Sync Alexa Shopping List with AnyList in Home Assistant, (3rd Revision, Without Node-RED)

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_list from the Alexa Devices integration
  • todo.anylist_shopping_list from 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:

  1. Alexa Devices exposes the Alexa shopping list as a Home Assistant todo entity.
  2. AnyList exposes the AnyList shopping list as a Home Assistant todo entity.
  3. A Home Assistant automation reads both lists with todo.get_items.
  4. The side that changed becomes the source of truth.
  5. 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_list
  • todo.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 name
  • completed: whether the item is checked off
  • uid: the todo item ID when the integration provides one
  • key: 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;"
      >
        &#128722;&#128203;&#128259;
      </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.


Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply