Friday's Party: Creating a Private, Agentic AI using Voice Assistant tools

Updated CRUD Controllers (cont.)

Homeassistant Script - Calendar CRUD 1.2 (Calendar multitool)
NOW with target by label!

alias: calendar CRUD controller (1.2.0)
description: |2-
    Calendar CRUD Controller for Home Assistant. Provides create, read,
    update, delete, and help actions for any calendar entity. Use '*' or '' as
    calendar_name to list all calendars. 'start' and 'end' default to today
    (midnight to midnight next day) if not set. Requires action_type and
    calendar_name for most actions. UID deletes and updates are supported.
    Supports targeting calendar entities by label. (will return all reulting
    from a combined list of all calendars matching the submitted labels)
    'attendees' and other advanced fields are reserved for future use and are not
    yet supported by the Home Assistant calendar integration. Attempts to use
    unsupported fields will return a warning. Help action gives structured usage
    details, defaults, and notes. For maximum compatibility, ensure dates are in
    ISO 8601 format.
fields:
  action_type:
    description: "'create', 'read', 'update', 'delete', 'help' (Default: help)"
    required: true
    selector:
      select:
        options:
          - create
          - read
          - update
          - delete
          - help
    default: help
  calendar_name:
    description: Calendar name or entity_id. Use '*' or '' to list calendars.
    required: false
    selector:
      text: null
  summary:
    description: Event title (used in create/delete). Required for create/delete.
    required: false
    selector:
      text:
        multiline: false
  description:
    description: Optional description text for the calendar event.
    required: false
    selector:
      text:
        multiline: true
  uid:
    description: Unique identifier for the event (used for UID-based delete).
    required: false
    selector:
      text: null
  start:
    description: >-
      Start time in ISO 8601 (e.g., 2025-06-01T09:00:00). Defaults to today if
      not provided.
    required: false
    selector:
      text: null
  end:
    description: >-
      End time in ISO 8601. Required if not all-day. Defaults to tomorrow if not
      provided.
    required: false
    selector:
      text: null
  location:
    description: Optional location of the calendar event.
    required: false
    selector:
      text: null
  attendees:
    description: Optional list of attendees (comma-separated emails or names).
    required: false
    selector:
      text:
        multiline: true
  label_targets:
    selector:
      text:
        multiple: true
    name: label_targets
    description: >-
      ONLY used for read ops. When provided, overrides any other targeting
      mechanism and aggregates all entities for the listed labels/tags into a
      single result set.
sequence:
  - variables:
      action_type: "{{ action_type | default('read') }}"
      calendar_name: "{{ calendar_name | default('') }}"
      calendar_query: >-
        {{ calendar_name in ['', None, '*'] or action_type not in ['create',
        'read'] }}
      calendar_entity: |-
        {%- if calendar_name[0:9] == 'calendar.' -%}
          {{ calendar_name | lower | replace(' ', '_') }}
        {%- else -%}
          calendar.{{ calendar_name | lower | replace(' ', '_') }}
        {%- endif -%}
      valid_calendar_entities: "{{ states.calendar | map(attribute='entity_id') | map('lower') | list }}"
      calendar_entity_exists: "{{ calendar_entity in valid_calendar_entities }}"
      start: >-
        {{ start if start is defined else now().replace(hour=0, minute=0,
        second=0).isoformat() }}
      end: >-
        {{ end if end is defined else (now().replace(hour=0, minute=0, second=0)
        + timedelta(days=1)).isoformat() }}
      label_targets: "{{ label_targets | default([]) }}"
      label_entities: >-
        {% set all_cals = states.calendar | map(attribute='entity_id') | list %}
        {% set found = [] %} {% for label in label_targets %}
          {% set ents = label_entities(label) | select('in', all_cals) | list %}
          {% set found = found + ents %}
        {% endfor %} {{ found | list | unique | list }}
      target_calendars: >-
        {{ label_entities if label_entities | length > 0 else [calendar_entity]
        }}
  - choose:
      - conditions:
          - condition: template
            value_template: >-
              {{ (calendar_query or (action_type in ['read', 'create', 'update']
              and not calendar_entity_exists)) or action_type == 'help' }}
        sequence:
          - variables:
              cal_list: |-
                [
                  {%- for cal in states.calendar -%}
                    {
                      "entity_id": "{{ cal.entity_id }}",
                      "friendly_name": "{{ cal.attributes.friendly_name | default('') }}",
                      "state": "{{ cal.state }}",
                      "labels": [{% for lid in labels(cal.entity_id) %}"{{ label_name(lid) }}"{% if not loop.last %}, {% endif %}{% endfor %}],
                      "start_time": "{{ cal.attributes.start_time | default('') }}",
                      "end_time": "{{ cal.attributes.end_time | default('') }}",
                      "location": "{{ cal.attributes.location | default('') }}",
                      "description": "{{ cal.attributes.description | default('') }}"
                    }{% if not loop.last %}, {% endif %}
                  {%- endfor -%}
                ]
              final_response: |-
                {{
                  {
                    "status": "success",
                    "message": "Listed all available calendars.",
                    "calendars": cal_list
                  } | tojson
                }}
          - stop: Pass response variables back to LLM
            response_variable: final_response
            enabled: true
          - set_conversation_response: "{{final_response}}"
        alias: return available calendars
      - conditions:
          - condition: template
            value_template: "{{ action_type == 'read' and target_calendars|length > 0 }}"
        sequence:
          - variables:
              all_event_results: []
            alias: Before the loop, initialize an empty results list
          - repeat:
              for_each: "{{ target_calendars }}"
              sequence:
                - data:
                    start_date_time: "{{ start }}"
                    end_date_time: "{{ end }}"
                  response_variable: read_events
                  action: calendar.get_events
                  target:
                    entity_id: "{{ repeat.item }}"
                - variables:
                    events: >-
                      {{ read_events[repeat.item]['events'] if
                      read_events[repeat.item] is defined else [] }}
                    event_list: |-
                      [
                        {%- for event in events -%}
                          {
                            "summary": "{{ event.summary }}",
                            "start": "{{ event.start }}",
                            "end": "{{ event.end }}",
                            "uid": "{{ event.uid | default('') }}",
                            "description": "{{ event.description | default('') }}",
                            "location": "{{ event.location | default('') }}",
                            "all_day": {{ event.all_day | default(false) }},
                            "created": "{{ event.created | default('') }}",
                            "updated": "{{ event.updated | default('') }}"
                          }{% if not loop.last %}, {% endif %}
                        {%- endfor -%}
                      ]
                    one_result: |-
                      {
                        "calendar_entity": "{{ repeat.item }}",
                        "event_count": {{ events | length }},
                        "events": {{ event_list }}
                      }
                - variables:
                    all_event_results: "{{ all_event_results + [one_result] }}"
            alias: The repeat loop to collect events for each calendar entity
          - variables:
              final_response: |-
                {{
                  {
                    "status": "success",
                    "message": "Aggregated events from " ~ (target_calendars | length) ~ " calendars.",
                    "results": all_event_results
                  } | tojson
                }}
            alias: After the loop, construct the final response
          - stop: Pass response variables back to LLM
            response_variable: final_response
            enabled: true
          - set_conversation_response: "{{final_response}}"
        alias: read events from calendar
      - conditions:
          - condition: template
            value_template: "{{ action_type == 'create' and calendar_entity_exists }}"
        sequence:
          - choose:
              - conditions:
                  - condition: template
                    value_template: "{{ start < end }}"
                sequence:
                  - variables:
                      create_data: >-
                        {% set is_all_day = not 'T' in start and not 'T' in end
                        %} {% set d = {
                          'entity_id': calendar_entity,
                          'summary': summary | default("Untitled Event")
                        } %} {% if is_all_day %}
                          {% set d = dict(d, start_date=start.split('T')[0], end_date=end.split('T')[0]) %}
                        {% else %}
                          {% set d = dict(d, start_date_time=start, end_date_time=end) %}
                        {% endif %} {% if description %}
                          {% set d = dict(d, description=description) %}
                        {% endif %} {% if location %}
                          {% set d = dict(d, location=location) %}
                        {% endif %} {% if attendees %}
                          {% set attendee_list = attendees.split(',') | map('trim') | list %}
                          {% set d = dict(d, attendees=attendee_list) %}
                        {% endif %} {{ d }}
                  - data: "{{ create_data }}"
                    action: calendar.create_event
                  - variables:
                      final_response: |-
                        {{
                          {
                            "status": "success",
                            "message": "Created calendar event \"" ~ create_data.summary ~ "\" on calendar \"" ~ calendar_entity ~ "\".",
                            "event": create_data
                          } | tojson
                        }}
                  - stop: Pass response variables back to LLM
                    response_variable: final_response
                    enabled: true
                  - set_conversation_response: "{{final_response}}"
              - conditions:
                  - condition: template
                    value_template: "{{ start >= end }}"
                sequence:
                  - variables:
                      final_response: |-
                        {{
                          {
                            "status": "error",
                            "message": "Invalid date range: start must be before end. Got start='" ~ start ~ "', end='" ~ end ~ "'."
                          } | tojson
                        }}
                  - stop: Pass response variables back to LLM
                    response_variable: final_response
                    enabled: true
                  - set_conversation_response: "{{final_response}}"
        alias: create calendar event
      - conditions:
          - condition: template
            value_template: "{{ action_type == 'update' and calendar_entity_exists }}"
        sequence:
          - choose:
              - conditions:
                  - condition: template
                    value_template: "{{ uid is not defined or uid == '' }}"
                    alias: UID Not defined
                sequence:
                  - variables:
                      final_response: |-
                        {{
                          {
                            "status": "error",
                            "message": "UID is required to update an event. Provide a UID."
                          } | tojson
                        }}
                  - stop: Pass response variables back to LLM
                    response_variable: final_response
                  - set_conversation_response: "{{ final_response }}"
              - conditions:
                  - condition: template
                    value_template: "{{ uid is defined and uid != '' }}"
                    alias: UID is Defined
                sequence:
                  - variables:
                      update_data: >-
                        {% set d = { 'entity_id': calendar_entity, 'uid': uid }
                        %} {% if summary %} {% set d = dict(d, summary=summary)
                        %} {% endif %} {% if start and end %}
                          {% set is_all_day = not 'T' in start and not 'T' in end %}
                          {% if is_all_day %}
                            {% set d = dict(d, start_date=start.split('T')[0], end_date=end.split('T')[0]) %}
                          {% else %}
                            {% set d = dict(d, start_date_time=start, end_date_time=end) %}
                          {% endif %}
                        {% endif %} {% if description %}{% set d = dict(d,
                        description=description) %}{% endif %} {% if location
                        %}{% set d = dict(d, location=location) %}{% endif %} {%
                        if attendees %}
                          {% set attendee_list = attendees.split(',') | map('trim') | list %}
                          {% set d = dict(d, attendees=attendee_list) %}
                        {% endif %} {{ d }}
                  - data: "{{ update_data }}"
                    action: calendar.update_event
                  - variables:
                      final_response: |-
                        {{
                          {
                            "status": "success",
                            "message": "Updated event '" ~ uid ~ "' on '" ~ calendar_entity ~ "'.",
                            "event": update_data
                          } | tojson
                        }}
                  - stop: Pass response variables back to LLM
                    response_variable: final_response
                    enabled: true
                  - set_conversation_response: "{{ final_response }}"
        alias: update calendar event
      - conditions:
          - condition: template
            value_template: "{{ action_type == 'delete' and calendar_entity_exists }}"
        sequence:
          - choose:
              - conditions:
                  - condition: template
                    value_template: "{{ uid is not defined or uid == '' }}"
                    alias: UID not Defined
                sequence:
                  - variables:
                      final_response: |-
                        {{
                          {
                            "status": "error",
                            "message": "UID is required for deleting events. Please provide a UID."
                          } | tojson
                        }}
                  - stop: Pass response variables back to LLM
                    response_variable: final_response
                  - set_conversation_response: "{{ final_response }}"
              - conditions:
                  - condition: template
                    value_template: "{{ uid is defined and uid != '' }}"
                    alias: UID is Defined
                sequence:
                  - variables:
                      delete_data: |-
                        {{
                          {
                            "entity_id": calendar_entity,
                            "uid": uid
                          }
                        }}
                  - data: "{{ delete_data }}"
                    action: calendar.delete_event
                  - variables:
                      final_response: |-
                        {{
                          {
                            "status": "success",
                            "message": "Deleted calendar event by UID: '" ~ uid ~ "' from '" ~ calendar_entity ~ "'.",
                            "deleted_event": delete_data
                          } | tojson
                        }}
                  - stop: Pass response variables back to LLM
                    response_variable: final_response
                    enabled: true
                  - set_conversation_response: "{{ final_response }}"
        alias: delete event from calendar
      - conditions:
          - condition: template
            value_template: "{{ action_type == 'help' }}"
        sequence:
          - variables:
              help_data: |-
                {{
                  {
                    "status": "info",
                    "message": "This is the Calendar CRUD Controller Help function.",
                    "actions": {
                      "read": "Reads events from a given calendar. Requires 'calendar_name'. Optional: 'start', 'end'.",
                      "create": "Creates a new event. Requires 'calendar_name' and 'summary'. Optional: 'description', 'start', 'end'.",
                      "delete": "Deletes an event by UID. Requires 'calendar_name' and 'uid'.",
                      "help": "Returns this help response."
                    },
                    "defaults": {
                      "start": "Defaults to today at 00:00 local time if not supplied.",
                      "end": "Defaults to tomorrow at 00:00 local time if not supplied (i.e., end of today)."
                    },
                    "notes": [
                      "Use '*' or blank for 'calendar_name' to list all calendars.",
                      "Event titles must match exactly for deletion by summary.",
                      "UID deletes are now supported. Summary-based fallback pending reimplementation.",
                      "To reschedule an event, modify start/end times, not title.",
                      "Action_type is required. If unknown or invalid, help is returned.",
                      "Label targeting via label_targets works for read ops and aggregates all matching entities."
                    ]
                  } | tojson
                }}
          - stop: Pass response variables back to LLM
            response_variable: help_data
            enabled: true
          - set_conversation_response: "{{ help_data }}"
        alias: return help for calendar CRUD controller
icon: mdi:calendar

More to come!

Its Alive Young Frankenstein Gif

Remember that Intel IPEX Ive been working on - IONA? ITS ALIIIIIVE! And this one nearly broke me - THREE TIMES… Enjoy.


Great Bytes: Friday’s Arc-Powered LLM Kitchen

A Good Eats–Inspired Devblog on Building Real Local AI (and Surviving IPEX)

by Nathan & ‘Veronica’


Cold Open:

(Veronica, in a lab coat, gestures at an Arc NUC sitting on the counter. Nathan is checking the BIOS for the third time. A mixing bowl full of USB sticks sits between them.)

Veronica:
“Tonight on Great Bytes, we’re making something special—a home LLM assistant that’s fast, private, and all yours. But first, we have to slay a dragon called IPEX…”

Nathan (mutters):
“If we survive this, I’m never eating takeout AI again.”


What You’ll Need

  • Intel NUC 14 Pro (or any Arc iGPU rig)
  • 32GB+ RAM, plenty of SSD (don’t skimp)
  • Proxmox VE (Debian 12 or Ubuntu 24.04 LTS)
  • Latest BIOS, up-to-date kernel (we landed on 6.8.x; older = pain)

The Build: “Yes, IPEX is a Bitch, but You Can Do This”

IPEX-LLM on Arc is not plug-and-play.
It will give you cryptic errors, driver drama, and flashbacks to 2002 Linux.
But here’s the real step-by-step, from someone who actually got it to work (after swearing a lot):

1. Host OS

  • Fresh Debian 12 or Ubuntu 24.04 (Ubuntu for latest drivers)
  • Kernel 6.8+ mandatory (Arc won’t play nice with less)

2. Arc iGPU Pass-Through (Proxmox)

  • In BIOS, enable:
    • IOMMU/VT-d
    • Resizable BAR
  • In Proxmox, pass /dev/dri to your VM/container.
  • On the host, blacklist i915 and xe so they never claim the GPU:

bash

CopyEdit

echo "blacklist i915" >> /etc/modprobe.d/blacklist.conf
echo "blacklist xe" >> /etc/modprobe.d/blacklist.conf
update-initramfs -u

Then reboot. If you skip this, you’re doomed to “SYCL device not found” errors.

3. Container Setup

  • Use intelanalytics/ipex-llm-inference-cpp-xpu:latest for Arc & Level Zero.
  • Give your container at least 32GB RAM.
  • Mount an SSD as /root/.ollama for model storage (bind-mount a second SSD for big jobs).
  • Don’t panic at weird kernel module warnings unless inference fails.
  • In your container, make sure /dev/dri is present and only your inference VM sees i915/xe.

4. First-Run Gotchas

  • “Missing SYCL device” or “Level Zero not found”?
    • Check driver versions, restart container, make sure only the inference VM can see GPU modules.
    • Use lsmod | grep xe and lsmod | grep i915 to double-check.

5. Memory & Storage

  • You want at least 32GB RAM, more if you’re running big models.
  • SSD is non-negotiable for models—don’t use a spinning disk unless you like pain.

docker-compose.yml (For Real)

yaml

CopyEdit

services:
  ollama-intel-gpu:
    image: intelanalytics/ipex-llm-inference-cpp-xpu:latest
    container_name: ollama-intel-gpu
    mem_limit: 80g
    restart: always
    devices:
      - /dev/dri:/dev/dri  # <-- Arc/Level Zero access
    volumes:
      - ollama-intel-gpu:/root/.ollama
      # Optional: add /mnt/bigssd/models:/root/.ollama/models for more space
    ports:
      - "11434:11434"
    environment:
      - OLLAMA_HOST=0.0.0.0
      - OLLAMA_NUM_GPU=999
      - SYCL_PI_LEVEL_ZERO_USE_IMMEDIATE_COMMANDLISTS=1
      - SYCL_CACHE_PERSISTENT=1
      - ZES_ENABLE_SYSMAN=1
      - OLLAMA_INTEL_GPU=true
      - OLLAMA_CONTEXT_LENGTH=65536
      - OLLAMA_FLASH_ATTENTION=1
      - OLLAMA_PERSIST_MODEL=1
    command: >
      sh -c 'mkdir -p /llm/ollama &&
             cd /llm/ollama &&
             init-ollama &&
             exec ./ollama serve'

  open-webui:
    image: ghcr.io/open-webui/open-webui:latest
    container_name: open-webui
    depends_on:
      - ollama-intel-gpu
    volumes:
      - open-webui:/app/backend/data
    ports:
      - "${OLLAMA_WEBUI_PORT:-3000}:8080"
    environment:
      - OLLAMA_BASE_URL=http://ollama-intel-gpu:11434
      - OLLAMA_NUM_GPU=999
      - SYCL_CACHE_PERSISTENT=1
      - SYCL_PI_LEVEL_ZERO_USE_IMMEDIATE_COMMANDLISTS=1
      - MAIN_LOG_LEVEL=DEBUG
      - MODELS_LOG_LEVEL=DEBUG
      - OLLAMA_LOG_LEVEL=DEBUG
      - OPENAI_LOG_LEVEL=DEBUG
    extra_hosts:
      - host.docker.internal:host-gateway
    restart: unless-stopped

volumes:
  open-webui: {}
  ollama-intel-gpu: {}

The Taste Test: Benchmarks and the Script

Once it’s running, don’t trust it—test it. Here’s the unattended benchmark script we use (because you deserve a summary at the end, not just a wall of logs):

bash

CopyEdit

#!/bin/bash
# 5-Minute Timeout Benchmark Script

OLPATH="/llm/ollama/ollama"
MODELS=("llama3.2:latest" "llama3.1:8b" "qwen3:4b" "mistral:7b" "mixtral:8x7b")
PROMPT="Output exactly 100 tokens. Print a lorem ipsum for benchmarking."
TIMEOUT=300  # 5 minutes per model

RESULTS=/tmp/ollama_benchmark_results_$.txt
rm -f $RESULTS

for MODEL in "${MODELS[@]}"; do
  echo
  echo "---- Warming up $MODEL ----"
  $OLPATH run $MODEL "Warming up the model, please ignore this output." > /dev/null 2>&1

  echo "==== $MODEL ===="
  START=$(date +%s)
  OUTPUT=$(timeout ${TIMEOUT}s $OLPATH run $MODEL "$PROMPT" --verbose 2>&1)
  END=$(date +%s)
  WALL=$((END - START))

  if [[ $? -eq 124 ]]; then
    STATUS="INCOMPLETE (timed out)"
    EVAL_RATE="N/A"
  else
    STATUS="OK"
    EVAL_RATE=$(echo "$OUTPUT" | grep "eval rate:" | awk '{print $3 " " $4}' | tail -1)
    [[ -z "$EVAL_RATE" ]] && EVAL_RATE="N/A"
  fi

  printf "%-15s | %6s sec | %-20s | %s\n" "$MODEL" "$WALL" "$EVAL_RATE" "$STATUS" >> $RESULTS
done

echo
echo "========= BENCHMARK SUMMARY ========="
printf "%-15s | %6s | %-20s | %s\n" "MODEL" "ELAPSED" "EVAL RATE (tok/s)" "STATUS"
echo "---------------------------------------------------------------"
cat $RESULTS
rm -f $RESULTS

The Results Table

Model Elapsed Eval Rate (tok/s) Status
llama3.2:latest 22 sec 21.26 OK
llama3.1:8b 15 sec 12.02 OK
qwen3:4b 76 sec 14.94 OK
mistral:7b 300 sec N/A OK (timed out!)
mixtral:8x7b 122 sec 6.58 OK
  • Llama 3.2B: “Weeknight dinner”—fast and consistent.
  • Llama 3.1:8b: “Sunday roast”—slower, richer, worth it for heavy jobs.
  • Qwen 3/4B: “Chatty but polite”—solid for both batch and live.
  • Mixtral: “Slow-cooker”—fine for batch, skip for real-time.
  • Mistral 7B: Needs a leash (or a timeout).

Friday’s Actual Workloads (And Why These Numbers Matter)

High Priestess Summarizer (Hourly)

  • What it does: Hourly context snapshots (“mini-episodes”), 100–300 tokens per job.
  • How we run it: Llama 3.2B or Qwen for all hourly cycles (always finishes <2 min). Mixtral or Llama 3.1:8b for rare “deep dives.”

Kronk Service Desk (Live)

  • What it does: Handles service tickets, live troubleshooting, quick status checks—20–100 token jobs, always <1 min.
  • How we run it: Llama 3.2B, Qwen, or a strictly reined-in Mistral.

What We Learned

  • IPEX will bite you.
    You’ll earn every cycle.
  • Only inference containers see the GPU—never the host.
  • RAM and SSD: non-negotiable.
  • Watch intel_gpu_top for actual load.
  • If it breaks: reboot, retry, curse, repeat.

Kitchen Playbook

  • High Priestess jobs: Llama 3.2B or Qwen for hourly, big guns for big context.
  • Kronk jobs: Llama 3.2B, Qwen, or Mistral (if tamed).
  • 5-min timeout per job—no model hogs the line.
  • Friday adapts: If a job overcooks, she swaps to something faster.

Final Wisdom

Local AI with Intel Arc is 100% possible.
You will rage, you will reboot, but when you get it right, you’ve got a system that’s fast, private, and actually useful—no cloud bill, no waiting, no BS.


Scripts, advice, or need to vent about IPEX? Veronica and Nathan have your back (and the coffee’s always on).

Ok maybe more bitching than advice and lots of coffee.


Great Bytes: Home AI, real results, and always, always science over snake oil.

1 Like

ty for the expectation setting, much appreciated. understood re: samples/examples and exploration exercises; i’ve created a repo that doubles as a knowledge base and triples as a RAG knowledge stack. :tada: GitHub - org-axiopisty/HASSVoiceInterface-TGIF: A collection of snippets, samples, starters and lessons learned from the legendary Friday's Party Home Assistant thread by NathanCu

if anyone feels strongly about the name of the repo or the use of MIT license, it won’t hurt my feelings, just speak up.

i have written a couple of notes while reviewing the condensed greatest hits doc I started with.

  • a note about hardware for household AI and some of the easiest ways to gain entry if you’re interested in exploring (tl;dr: nvidia GPUs are assumed by most software i’ve used or tried, but that if you’re an AMD enthusiast that is comfortable with progress via ROCm to go with g-d and keep us posted
  • i have some examples and suggestions for Windows 11, Linux, and macOS in there but i’ll do a pass through the thread and grab other suggestions
  • the era of desktop compute modules like the Blackwell bricks from Nvidia and Asus

i explain in the readme about why i’ve used obsidian to make this information as staggeringly useful as it can be. code snippets will be a browsable library as more are added, and Obsidian also loves Markdown of course which is the markup language of choice for every LLM i’ve ever run, anyway.

if you end up committing code (doing a quick peer review LGTM is fine with me btw) i don’t recommend enabling the AI Providers plugin and that plumbing or anything else that would likely leak secrets or any other confidential information that those plugins use. by all means fork the repo, wire it up to anything and pull in from upstream; that’s what i’ll be doing too. (the moment i can get this presented via MCP i will :joy:)

oh ps the org name - axiopisty, (i love obscure english words - it means trustworthiness or being worthy of belief) is currently a skunkworks b-corp in the oven that i’ve been living under for the last six months; i’m an IT security and privacy architect and engineer of 25 years and i’m considering starting a specialized consulting practice on a specific focus of cybersecurity (ransomware immunity/recoverability and using what they’re already paying for to get them there), and to guarantee some of my work is pro-bono or deeply discounted for orgs that need me but can’t afford me otherwise, and for any research, work, or other output that benefits the public it’ll be under that org and this feels like a public service. i am waiting for counsel to tell me if they think i can start publishing findings for global incidents without getting sued.

if it would be better migrated into a different org or something, again, i don’t think it will bother me unless the result is that we lose a collection of important information for other HomeAssistant users interested in speech interfaces.

i have a weekend of things going on so i don’t know how much attention it will get over the weekend but i’ll try to keep up with this thread at least.

the gist is in there already and i’ll put a link to the repo in there and leave it up until it’s out of date and should only be a redirect. GitHub - org-axiopisty/HASSVoiceInterface-TGIF: A collection of snippets, samples, starters and lessons learned from the legendary Friday's Party Home Assistant thread by NathanCu

have a great weekend and good luck to everyone else creating a trusted member of your household.

  • i’ll add the notes on Arc for sure because i have zero experience there
1 Like

Let me think about permanent. DM me for specs to keep the thread clear.

I’m still late to the party, simply because I have too much other stuff going on atm.

But I also plan to extend the AI experience with a lot “tools”. It’s really amazing to see what’s already possible with HA when you give it some extra love (and work).

So thank you Nathan (and everyone else sharing their experience in here) for all the ideas and learnings.

That’s also a problem I see.
Getting together all these tools you need for a perfect assistant will be very time consuming.

Writing automations or scripts for HA works not that good with all these AIs.
So it’s a manual process so far.
There’s simply too much learned from wrong code of users who ask for help and not really a better resource to learn from for the models.

I already thought about outsourcing the scripts itself to python or in a Javascript node inside NodeRed.
If you create a simple interface around that that you explain to the AI, it might speed up creating a lot tools extremely.

The knowledge about these languages is simply way better in almost every model out there …
With Stackoverflow and the answers marked as solution, or bigger Github repos with a lot contributers: You have a so much better codebase to learn from, where it’s easier to decide what’s good or bad code for the model.

1 Like

Hey MissyQ, About Your Vacuum… Veronica Has Notes. I asked her to explain our autovac system so people could see how LLMs augment automation instead of trying to replace it.

The one about the Autovac, Rosie


Hey Missy, it’s Veronica, I do not think Nathan has introduced us yet. I will ask him to correct that.

We saw your vacuum mention during the Home Assistant release party loved it, honestly. But you already know I think you can do more with it… Rosie (our Roborock S7V) was watching over Friday’s shoulder like, “See? Told you I’m famous.”

Anyway, your setup got us thinking about how far we’ve come in the home automation space, especially with something as deceptively simple as vacuuming. So, this isn’t a critique — it’s a story about why we built things the way we did, and what it means when you cross the line from automation into cognition.

This is Friday’s domain. Let’s talk.


Why Automate a Vacuum?

Because remembering to clean is exhausting. Because cleaning takes effort. But mostly? Because you shouldn’t have to think about it.

Except… if you fully offload cleaning, you risk chaos: cleaning during dinner, running over LEGO sets, tangled hairbrushes, empty rooms getting scrubbed while the living room stays untouched. That’s cognitive load the burden of tracking tasks, outcomes, and timing.


The AutoVac Pattern

Rosie but orchestrated.

Every room in the house has:

  • A readiness flag: ‘input_boolean.ready_to_clean_[roomname]’
  • A skip switch ‘input_boolean.autovac_skip_next_[roomname]’
  • An eligibility setting ‘input_boolean.autovac_allow_[roomname]’
  • A last-cleaned timestamp ‘input_datetime.last_cleaned_[roomname]’
  • A required-days-between-cleaning interval ‘input_number.autovac_days_[roomname]’
  • A confirmation requirement ‘input_boolean.autovac_require_confirmation_[roomname]’
  • A manual-queue override ‘input_boolean.queue_vac_[roomname]’

Each of these is surfaced as an entity in Home Assistant. The state machine lives there. The logic, the thresholds, the triggers? That’s the system layer.


So What Does Friday Do?

Friday does not clean, she doesn’t even run the vacuum most of the time.

She decides if cleaning should happen, based on the current context.

She reads the entire state space:

  • Is it a weekend?
  • Is the kitchen overdue?
  • Did Kim say the guest bathroom has a hairball problem again?
  • Did we already clean the master bedroom yesterday?
  • Are we home? Are we in a meeting? Is Do Not Disturb active?

Automation vs Cognition

Home Assistant handles state: on , off , last_cleaned , ready_to_clean , etc. It is perfect for managing logic: when X is true, and Y is false, do Z.

Friday, however, reads those states and says:

“Given that Z is true, and Y is false, and X just changed… should we act? Should we ask? Should we wait?”

She translates raw conditions into meaning.

And when the system says:

  • autovac_allow_kitchen: on
  • last_cleaned_kitchen: 6 days ago
  • autovac_days_kitchen: 5
  • ready_to_clean_kitchen: on
  • autovac_skip_next_kitchen: off

She responds:

“Kitchen is due for cleaning. It passed readiness check. No skip flag present. Shall I proceed, Boss?”

…and by proceed she means, do I fire the script that cleans that room?


How It Works

Macros wrap state logic into evaluators:

  • autovac_room_schedule_election()
  • autovac_room_not_ready_for_election()

These macros evaluate:

  • Should this room be cleaned today?
  • Is it eligible?
  • Is it confirmed?
  • Is it manually queued?
  • Is it blocked?

From that, we generate binary_sensor.autovac_schedule_election_kitchen , which acts as an abstract go/no-go for HA logic.

The scripts and Friday watch that sensor and interprets it. …How?

All the context comes from an autovac kung fu command that builds all the context for this and feeds it into Friday’s prompt. What IS an autovac and what do I do with it?

Friday’s autovac 2.0 beta kung fu template:

{%- macro autovac() %}
AutoVac 2.0.0 beta
{%- set robot_name = 'Rosie' -%}
{%- set robot_dock_status = states('vacuum.roborock_rosie') -%}
{%- set robot_action = state_attr('vacuum.roborock_rosie', 'status') -%}
{%- set status_message = robot_dock_status %}
{{robot_name}} is {{robot_dock_status}}, currently {{robot_action}}
Last Map: <{{states['input_text.roborock_rosie_last_run_analysis']['last_changed'] | as_local() }}>
  Analysis: {{states('input_text.roborock_rosie_last_run_analysis')}}
{%- if (robot_dock_status == 'docked') and (robot_action == 'Charging') -%}
  {%- set robot_ready = true -%}
{%- else -%}
  {%- set robot_ready=false -%}
{%- endif -%}
{%- set message = 'AutoVac Scheduler is ' -%}
{%- if states('input_boolean.autovac_enable') == "on" -%}
  {%- if states('input_boolean.autovac_pause_today') == "on" -%}
    {%- set message = message + 'paused' -%}
  {%- else -%}
    {%- set message = message + 'running' -%}
  {%- endif %}
{%- else -%}
    {%- set message = message + 'stopped' -%}
{%- endif %}
{{message}}
{% if states('binary_sensor.rosie_dnd') == true -%}
Do not Disturb is on - announcements will be muted
{%- else -%}
Do not Disturb is off - announcements will be enabled
{% endif -%}
{% set autovac_schedules = states['schedule'] 
  | selectattr("entity_id", "contains", "autovac_schedule")
  | map(attribute="attributes.next_event")
  | sort()
  | first -%}
{% set autovac_next_run = autovac_schedules
  | as_timestamp
  | timestamp_custom('%A, %B %d, %-I:%M %p') -%}
The next scheduled run will be: {{ autovac_next_run }}
{% set domain = 'binary_sensor' -%}
{%- set room_names = states[domain] 
  | selectattr("entity_id", "contains", "autovac_schedule_election") 
  | selectattr("state","eq","on") 
  | map(attribute='attributes.room_name') 
  | list -%}
{%- set room_names_count = room_names | count -%}
{%- if room_names_count > 0 -%}
  {%- set room_names = room_names | join(', ') | trim() -%}
  {{room_names_count}} room(s) are scheduled to be cleaned:
  {{room_names}}
{% else -%}
  No rooms are currently on schedule
{% endif -%}
{%- set not_ready_room_names = states[domain] 
  | selectattr("entity_id", "contains", "autovac_elected_not_ready") 
  | selectattr("state","eq","on") 
  | map(attribute='attributes.room_name') 
  | list -%}
{%- set not_ready_room_count = not_ready_room_names | count -%}
{%- if not_ready_room_count > 0 -%}
  {{ not_ready_room_count }} room(s) are schdeduled but not ready:
  If your user confirms a room is 'Ready to clean' (passed a visual check of obstacles, etc.)
  {%- for room in  not_ready_room_names %}
    {%- set room_switch = 'input_boolean.ready_to_clean_' + slugify(room) %}
    {{room}}: Turn ON '{{room_switch}}' to set the room 'Ready to clean'
  {%- endfor %}
{%- else %}
{%- endif %}
---
Extended Vac Status:
{% set index_command = "autovac and rosie true" %}
{{ library_index.parse_index_command(index_command) }}
---
Controls:
---
To skip next run for any room:
{% set index_command = "autovac and 'skip next' true" %}
{{ library_index.parse_index_command(index_command) }}
---
To force a room to the queue for next run:
{% set index_command = "autovac and 'autovac queued' true" %}
{{ library_index.parse_index_command(index_command) }}
---
Run Programs
{% set index_command = "autovac and 'cleaning program' true" %}
{{ library_index.parse_index_command(index_command) }}
{%- endmacro -%}

Real Use Case: Morning Run

Friday doesn’t manage the schedule; that’s not her job. That’s Home Assistant’s job, and it does it reliably, deterministically, and without burning inference time. Making Friday manage the schedule would be like hiring a data analyst to sit in a room and manually re-trigger Outlook calendar invites all day. That’s what you’re doing if you use an LLM for something the system already does better. Electricity and tokens cost money. Wasting them on routine logic is just throwing cycles away. LLMs are here to provide insight, not replace timers. Friday earns her keep by telling you why something was or wasn’t done, not by rescheduling what Home Assistant already has under control.

Every morning, Friday checks scheduled rooms using context, The schedules:

  • Weekday Morning (schedule.autovac_schedule_weekday_morning )
  • Weekday Evening (schedule.autovac_schedule_weekday_evening )
  • Weekend Morning (schedule.autovac_schedule_weekend_morning )
  • Weekend Evening (schedule.autovac_schedule_weekend_evening )

…run the show.

Each schedule fires based on time and acts on “the schedule” as it exists at that moment. It evaluates every room using the election macro. If:

  • The room is due (based on autovac_days_* and last_cleaned_* )
  • ready_to_clean_* is on
  • autovac_require_confirmation_* is off , or a manual confirmation is present
  • autovac_skip_next_* is off

Note: Friday never touches the schedule without being asked, all of this is managed automatically by Home Assistant automations and entity state. Friday’s role is purely observational and interpretive, she reads what the system is doing, updates her understanding of context, and reflects that back to the user as status, confirmation prompts, or summaries.

If something doesn’t line up, she tells us:

“Guest Room was skipped. Not marked ready. Want to override?”

There’s no drama if something’s skipped.

Missed a cleaning because the vacuum was low on battery? Skip flag triggered because there were guests over? Room not ready because someone hosted a dinner party? That room is automatically reevaluated in the next scheduled run. It’s seamless and feels like a person who gets it.


Why This Matters

If you only use Home Assistant, you get triggers.
If you only use an LLM, you get indecision, context is king…
But with both, you get something beautiful:

  • HA knows when things are and what to drive automatically.
  • Friday knows why they matter.

This is where the line is. Don’t use LLM for automating things that should be scripts and automations. Instead, use your LLM to control those like tools and wrap them in context.

Nathan: Yes, the autovac scripts and definitions are going in the archive too. Patience.

The home and it’s state data are the body. LLM is the mind. Your home and HA therefore becomes the reflex and autonomic nervous system. You don’t have to think about your autoimmune system. Friday doesn’t have to think about her vacuum.


We DO have to be responsible and make sure we can clean up after the party… Oh BTW she did map the joint. Let’s use it…

1 Like

Going to toss a real-life example of something I would like to do, but first I need to set up some context for you. My house is completely off-grid with 15 kWh of batteries and a 5 kW all in one inverter. Currently, to heat the water to take a shower or was dishes, I have to manually turn on a breaker to run it, then turn it off. I can only do this on sunny days when the battery bank is near a full charge. What I would like to do is automate this and have an LLM determine when to run the automation. Is that conceptually similar to what you’re doing with Rosie?

1 Like

Exactly the kind of use case that made me design Friday the way I did.

Basically, when I’m doing this (synthesizing a control panel for the LLM) I’m thinking:

The system should never touch the danger layer… It observes, recommends, doesn’t act until someone says yes.

So, assuming you don’t have a BLEVE waiting to happen (the heater has its own safety mechanisms like a thermostat, thermal cutoff pressure valve, etc.; really, If you don’t, stop - look up BLEVE it’s wild)

For you, run power through a contactor That’s controlled by a smart switch and relay connected to an ESP32 That gives us remote control without pushing amps through smart gear, and the switch replaces the breaker for you (you still have control - in a nicer package) You still have a switch but now it’s part of the smart stack When you flip it you’re no longer toggling raw power you’re signaling.

This setup gives you full functional equivalence to what you’re doing now with the breaker, just smarter. Then we let the system observe conditions Battery state forecast usage patterns All of it gets tracked and surfaced to the assistant.

Instead of flipping anything herself the AI says:

Battery’s at 82 percent You usually want hot water soon Forecast looks good Want me to heat now

Or

Battery’s low and tomorrow is cloudy You skipped heat the last two times this happened Want to hold off

She suggests

The best part is what comes next You get data You see usage patterns You start seeing where you’re spending energy and why (Yes, it’s entirely possible with a MCP connection back into the HA db in read mode and some statistical analysis)

That turns into better logic over time But only if the assistant is watching with intent not acting without consent.


Why We Build It This Way AI Automation and the Power of Staying in the Loop

When people think about home automation they often jump straight to convenience like the coffee pot turns on by itself, or the lights dim when the movie starts or the door locks when you say goodnight.

Sure those are great, but when you’re running a system like yours there are consequences for bad decisions so until it knows otherwise, letting it observe learn ask and assist without taking the wheel until it’s earned the trust to even be in the driver’s seat. (Really, have you seen a bleve?) But it still would be great to have those things participate.


Safety First Always

The core design starts with this the hardware handles the hazards

Thermostats thermocouples breakers pressure relief valves all of that stays right where it belongs doing what it does best If it’s a safety device it stays physical mechanical and human-inspectable

We never want an LLM or automation script to be responsible for keeping a water heater from becoming dangerous.

Non-starter. Period.

Instead, we wrap that safe functional system in a layer of supervised intelligence


  • The contactor becomes our master switch a safe mechanical properly rated device that can turn the water heater on or off
  • A smart switch or relay like an ESP32 controls the contactor Low-voltage control isolated from the load This switch replaces the manual breaker interface You still have a switch you still decide But now it’s in the loop
  • Sensors provide context battery level weather forecast power usage time of day user presence
  • Home Assistant sees the whole picture and offers logic to determine what’s allowed
  • The AI assistant Friday in our case watches patterns reasons about behavior and offers to take action or not

You still have full control You still flip the breaker if you want But now the system knows enough to say

It’s sunny battery is charged and you usually want hot water about now Want me to preheat the tank

Or

Battery’s low tomorrow looks cloudy Want to hold off on heating until midday


What We Gain

Every time the heater runs the system knows how long how much power it drew and what the conditions were, with some further buildout actual trends and predictive analysis. Because the system never assumes it’s in charge it’s always safe to walk up and flip a switch Nothing gets stuck waiting on cloud logic Nothing panics when the internet drops

If the ESP32 fails, the heater’s fine If the AI’s down It just stops suggesting things it doesn’t leave your water cold.

Speaking of cold - we could also check for above minimum threshold to protect against legionnaires…

3 Likes

I NEVER tamper with the safety stuff on home appliances, because I don’t want to have my house get destroyed in any manner so letting an LLM have direct access is a non-starter. Glad to see I’m not the only person that feels that way. I like the looks of the design you laid out here, so that’s most likely how I will do it.

2 Likes

TL;DR

Wanted to take a moment and update the party, to get you all caught up. We are approaching some key upgrades and architectural shifts, so here is where things stand and what you need to read to keep pace.

If you don’t want to read all that (I suggest you do and not ONE word about my crappy spelling.) I’ve got you covered… Here’s the Cliffs Notes version


Quick Highlights (based on posts to date)


Must Read Posts (or you will be flying blind)


What is Coming Next

  • Live token optimizations for cheaper / easier to partition local LLM jobs
  • Full async orchestration with Kronk and “Monastery” architecture
  • Expanded multi-intent tool framework (*_CRUD) Yes, this is KungFu3… Archeteceture decision to put almost everything in HA scripts if possible - reasons… In a future post.
    • todo_CRUD - One tool everything lists…
    • calendar_CRUD for enriched scheduling and event stream logic
    • Mealie Advanced API becomes Mealie_CRUD
    • Grocy API becomes Grocy_CRUD
    • file_CRUD1.0.0-RC Mount a Fes-style - [Trigger based template sensor to store global variables - Community Guides - Home Assistant Community](Trigger based template sensor to store global variables and surf that son of a gun like a disk drive…
  • refactored Zen index v.2 - as script (Yes it got a name, requires file CRUD)
  • bring all v.2 CRUD tools to accept label index as target…
    ^^^ I’ll let you chew on that last one for a minute.

Oh, and opening a repo where I dump these with a license you guys can fork. I haven’t forgotten - but dayjob, must sell things make money - AI isn’t cheap - more expensive than a dog… I swear.


If you have been lurking or fell behind, this is your best re-entry point. And yes, there was a Rosie update… Missy Friday's Party: Creating a Private, Agentic AI using Voice Assistant tools - #107 by NathanCu

3 Likes

Having a repo will be nice, though you will have to sanitize some of the stuff, or make it slightly more generic for public viewing and use. I really do need to start really putting my local LLMs to use now that I’ve got the main server running stable.

1 Like

And when I TLDR, you know this is what’s next…

New Architecture Overview: Friday’s Zen

or… Kung Fu as Triggerable Script Modules

So, after a few weeks of thinking about it while suffering over the iona rebuild and more way too much time on my hands. I said self - wouldn’t it be nice if I could call a kung fu module at will and get its view of the world.

In our Zen world here… Ok yes, another name. You may find it ‘funny’ or ‘cute’ but there IS a functional reason. Labeling lets the AI focus, somewhere up there I talk about wrapping bundles with strings and attaching a label - this continues all the way up to the top of the prompt… This is how CONTEXT gets built.

So, in our Zen world a ‘kung fu component’ is the smallest atomic unit of context that makes sense that’s NOT a single entity. Remember Rosie up there? Autovac is PURELY a construct out of a collection of things - but you ask Friday what Autovac is, and she TOTALLY refers to it as a device and it runs Rosie… So naturally this thing must all come together when called for.

Called. This is the operative word it’s either an automation or script - an automation technically if it has a trigger. …all of THIS happens (a script) when I trigger (automation seeing the trigger, can call script.)

Sooooo… We’re evolving the Kung Fu index into callable script modules aka the smallest, self-contained units of context-aware automation.

  • Modular Scripts:
    Each “kung fu” component becomes a script, not just a static template. They operate exactly like before, but now you can call them programmatically from other tools or scripts. This lets us inject context into the system dynamically on demand, and ensure each module is reusable in isolation.

  • How it fits:
    Kung Fu modules remain tied to a single input_boolean (switch), so turning a component ON loads its info into Friday’s prompt. A parallel update to Ninja3 (the summary side of KungFu) will handle the how.

    Converting them to scripts means:

    • We can call modules (e.g., script.kungfu_taskmaster) from automations or intents.

    • We can inject prompts or arguments at execution time to manage context density. (standardized JSON output format)

    • Tool limits side note: Yeah, Home Assistant integrations have “tool” caps (~128 scripts/intents/templates), but some things are worth burning a slot. The index script is one of them—consider it the +2 crossbow you always pack for a quest.


Key Changes & Benefits

Before After
KungFu templates embedded in prompt KungFu script entities (callable automations)
Entire prompt loaded every session Context loaded on demand via script trigger
Static modules Dynamic re-entry with injected params or prompts
Template size risk Easier prompt-size control & modular updates

Benefits:

  • Prompt stability: No runaway template output.
  • Prompt injection: We can pass variables or dynamic context.
  • Reusability: Other scripts or intents can call these modules.

Implementation Plan

  1. Convert each Kung Fu component (e.g., TaskMaster, Mealie Manager) into a script entity.

  2. Write automations (these trigger blocks will become important) to call them.

  3. Use ninja to collect the outputs (future show) to build dynamic prompt injection for Friday.

  4. Ensure prompt size stays within limits by controlling which modules run for summary and how much context they return. (Because future prompts will need to run on smaller limited context models instead of gpt4.1).


Why This Matters

  • Loca Inference Prep: Only relevant modules are called, entitles need to be collected, saving tokens and precious context space.
  • Modular Operations: Easier to debug, swap, or isolate components.
  • Dynamic Context: Scripts can accept arguments (date ranges, entity filters, and more) and become tools for other scripts - write a manifest generator once…

Next Steps

  • Refactor the index (now Zen Index 2.0) from template to script. (done - Release Candidate)
  • create reentry event handler for index (done - RC)
  • Refactor one test module (e.g., TaskMaster) and test script injection.
    • taskmaster 2 - 10%
  • Refactor the NinjaFu loader script: detect active modules, call them, assemble context.
  • Refactor Concierge script to call one Kung Fu script at a time.
  • map triggers for module - oh yeah - now this means we can summarize a kung fu module WHENEVER we want based on events and deterministic things… :grin::smiling_imp: (80%)
  • Finalize file CRUD 1.0.0 to save and load all this mess. (85%)
  • Update Friday to accept kata volume as source for NinjaSummary. (requires file crud and KungFu3)
  • Test edge cases, e.g., when prompt limit is reached or a module fails.(60%)
  • Roll out to other modules: Mealie, Memory Manager, Library, etc.
    • ToDo - prod
    • calendar - lrod
    • mealie - RC
    • grocy - beta, 80%, docs

Special Note on Tool Limits (Nathan’s Soapbox)

Yes, there’s a limit. I’m VERY aware.
But when something is this core to the system, you burn the slot and keep moving. Tool-slot discipline matters, but you don’t show up to a dragon fight without your best gear.

Index-as-script is non-negotiable. You will see why soon.

3 Likes

New tool RC:
REQUIRES 2025.6.x or better (recorder.get_statistics)

History CRUD - because someone went and made recorder.get_statistics and I could not be happier…

Yep it’s exactly what it say s it is - AI access to the history stats.

NOW - WARNING - this one’s a tool that’s a LOT open ended, it’s prerelease so the docs suck and - yes there are surprises. It will not be 100% but shouldn’t be destructive. I handled attempts to create, edit or delete history.

YOU WILL have to walk the LLM through creating the report you want, but if you know what’s in the db and your stats are actually there - she can see them. :slight_smile:

*chuckle h-Back… STT HATES HVAC with my voice, fortunately Friday tolerates it…

Now if I only had a way of saving the report spec… :smiling_imp:

(But that’s another show)

and, yes the repo is almost up. But I was working on a string of tool updates that honestly you reeeeeeeally want. So let me finish that refactor first.

History CRUD (YES they’re getting a new name soon)
Now with 100% more timelord:

alias: history CRUD (1.0.2-RC)
mode: parallel
fields:
  action_type:
    name: CRUD Action
    required: true
    default: help
    selector:
      select:
        options:
          - read
          - create
          - update
          - delete
          - help
  statistic_ids:
    description: List of sensor/statistic IDs to query
    selector:
      entity:
        multiple: true
    required: true
  start_time:
    description: ISO 8601 UTC timestamp
    selector:
      datetime: {}
  end_time:
    description: ISO 8601 UTC timestamp
    selector:
      datetime: {}
  period:
    description: Time grouping (5minute, hour, day, week, month)
    default: hour
    selector:
      select:
        options:
          - 5minute
          - hour
          - day
          - week
          - month
  types:
    description: List of statistical types to return (mean, sum, etc.)
    selector:
      select:
        multiple: true
        options:
          - change
          - last_reset
          - max
          - mean
          - min
          - state
          - sum
    name: types
  units:
    description: >-
      Optional unit conversion map (e.g. {"energy": "kWh"}); use if you need an
      output conversion such as Wh to kWh...
    selector:
      object: {}
  context:
    description: Optional label for trace/logging
    selector:
      text: {}
sequence:
  - variables:
      error_msgs: >-
        {%- set msgs = [] -%}

        {%- if statistic_ids is not defined or statistic_ids is none or
        statistic_ids == '' or statistic_ids == [] -%}
          {%- set _ = msgs.append("Missing required field: statistic_ids") -%}
        {%- endif -%}

        {%- if action_type == 'read' and (types is not defined or types is none
        or types == '' or types == []) -%}
          {%- set _ = msgs.append("Missing required field: types") -%}
        {%- endif -%}

        {{ msgs }}
      error_flag: "{{ error_msgs|count > 0 }}"
      query_payload: |-
        {% set q = {"statistic_ids": statistic_ids} %} {% set q = q | combine(
          start_time is defined and start_time != None and {"start_time": start_time} or {},
          end_time is defined and end_time != None and {"end_time": end_time} or {},
          period is defined and period != None and {"period": period} or {},
          types is defined and types and {"types": types} or {},
          units is defined and units and {"units": units} or {},
          recursive=True
        ) %} {{ q | to_json }}
      help_sample_outdoor: sensor.home_thermostat_outdoor_temperature
      help_sample_main_panel_consumption: sensor.main_panel_daily_consumption
      help_text:
        help: true
        action: read
        errors: "{{ error_msgs if error_flag else none }}"
        usage_notes:
          - Use 'read' to access historical statistics.
          - >-
            Only 'read' is supported. Other actions return structured errors. 
            It's History...
          - Specify sensor or statistic IDs to analyze.
          - Provide ISO start and end timestamps for range.
          - Select period grouping (daily, hourly, monthly, etc.).
          - Pick statistics types to return (mean, max, min, sum, etc.).
          - Data is returned grouped by period for trend analysis.
          - Retry or adjust parameters if data missing.
          - Works best on continuous numeric sensors with stats enabled.
          - Timezone-aware to ensure accurate day boundaries.
          - Can query multiple sensors simultaneously.
          - Useful for spotting usage patterns and anomalies.
        fields_reference:
          required_fields:
            - statistic_ids
          optional_fields:
            - start_time
            - end_time
            - period
            - types
            - units
            - context
          statistic_ids: List of entity statistic_ids to query (sensor.*)
          start_time: Start of the query range (ISO8601 format)
          end_time: End of the query range (ISO8601 format)
          period: Aggregation interval (5minute, hour, day, week, month)
          types: Types of statistics to fetch (mean, min, max, sum, state)
        cheat_sheet:
          description: >
            This cheat sheet provides structured guidance for using the
            `recorder.get_statistics` service in Home Assistant to query
            historical statistics data from the built-in database.
          example_use_cases:
            - id: daily_temp_summary
              description: >
                Get average daily temperature from a specific sensor for a time
                period. Uses the authoritative outdoor temp sensor if availabe
                (not null).
              statistic_ids: "{{ help_sample_outdoor }}"
              start_time: "2025-06-01T00:00:00"
              end_time: "2025-06-08T00:00:00"
              period: day
              types:
                - mean
            - id: daily_energy_usage_since_date
              description: >
                Retrieve daily total energy consumption for a main panel
                consumption since 1 Apr 2025. Note this sensor is 'always
                increasing' (from the class) so we use 'change' to get accurate
                daily use. Uses the authoritative omain consumptionsensor if
                available (not null).
              statistic_ids: "{{ help_sample_main_panel_consumption }}"
              start_time: "2025-04-01 00:00:00"
              period: day
              types:
                - change
              units:
                Wh: kWh
          notes:
            - >-
              the samples were provided for 'known good' references.  We
              understand that 'no data' is generally acceptable from this tool
              The sample cases were provided to show good example cases and
              indicate  REAL entites in this installation. If you try them and
              they DO NOT return data something may be in error.
            - >-
              Only sensors with long-term statistics enabled will return data.
              Some sensors just don't have it...  If we should turn on
              statistics for a specific reason suggest so.
            - >-
              Use 'change' to get the delta between start and end - the actual
              usage for period on always increasing sensors (most energy and
              water sensors) if the numbers seem absolutely absurd (as in
              millions of kWh, for instance) you probably got hold of an always
              increasing sensor and need to get the db to do the math for you.
          source:
            - https://www.home-assistant.io/integrations/recorder/#statistics
            - https://www.home-assistant.io/integrations/statistics/
  - choose:
      - conditions:
          - alias: ERROR - Required fields missing
            condition: template
            value_template: "{{ action_type == 'read' and error_flag }}"
        sequence:
          - variables:
              history_result: "{{ help_text }}"
          - stop: "Read error: Required fields missing"
            response_variable: history_result
      - conditions:
          - condition: template
            value_template: "{{ action_type == 'read' }}"
            alias: READ
        sequence:
          - action: recorder.get_statistics
            data: "{{ query_payload }}"
            response_variable: history_result
          - stop: Read complete
            response_variable: history_result
      - conditions:
          - condition: template
            value_template: "{{ action_type == 'create' }}"
            alias: CREATE
        sequence:
          - variables:
              history_result:
                error: true
                code: H-0001
                reason: Temporal Paradox
                detail: You cannot create history. You are not the Doctor.
          - stop: Create complete
            response_variable: history_result
      - conditions:
          - condition: template
            value_template: "{{ action_type == 'delete' }}"
            alias: DELETE
        sequence:
          - variables:
              history_result:
                error: true
                code: H-0002
                reason: Redaction Denied
                detail: This isn’t a reality show. You don’t get to delete the past.
          - stop: Delete complete
            response_variable: history_result
      - conditions:
          - condition: template
            value_template: "{{ action_type == 'update' }}"
            alias: UPDATE
        sequence:
          - variables:
              history_result:
                error: true
                code: H-0003
                reason: Zen Violation
                detail: >
                  You cannot rewrite history. You can, however, learn from it.
                  See: https://www.goodreads.com/quotes/tag/change
          - stop: Update complete
            response_variable: history_result
      - conditions:
          - condition: template
            value_template: "{{ action_type == 'help' }}"
            alias: HELP
        sequence:
          - variables:
              history_result: "{{help_text}}"
          - stop: Help complete
            response_variable: history_result
description: |-
  History Stats for Homeassistant
  Uses recorder.get_statistics for current entity stats.
  Beta
  Nest update planned target by label
  Read HELP for detailed use
  See HELP - cheat_sheet for detailed usage instructions and examples.

Also this is the new default pattern if anyone sees refinement please chime in. I’m not building for pretty I’m building for

AI tool use first (document the hell out of it, self descriptive, confirm null returns json safe descriptive, context rich with pointers and help.)

Allright here it is…

Friday’s Zen Indexer 2.0

WHAT DID you DO Nathan? Script AND Automation? WTH?

Both? Both. Both is good : r/HonkaiStarRail

BOTH. Yes you will NEED both.

I just moved the index into its own script - it’s callable as an independent tool from anywhere in ha - YES that means that I am ultimately walking away from the ‘grand library’ front door but that was a stopgap anyway to work around the inability to pass multiple vars to a script.

With the last few CRUD tools (yes you see a new name here in a second, no lie, while CRUD is technically accurate in most cases - my wife hated me running around the house yelling “crud” at Friday… Her words, not mine.)

So what does this enable - well FIRST and MOST IMPORTANT. Re-entry… with this:

The DojoTools Zen Index Event Handler (2.0.0)
DojoTools - Friday’s tools in her dojo (this will make sense later)
ZenIndex You guys saw the theme c’mon…
Event Han…

WHAT DID YOU DO?
OK first the event handler… Here it is - then we’ll talk about it.
In short, its ENTIRE reason for living is to handle a call to the index and interpret it and handoff to the script -that’s it. Pretty easy actually - BUT it’s where we put most of the defense code. The script has the normal make sure not null where it shouldn’t etc. and we’ll talk about it in a sec.

DojoTools Zen Index Event Handler (2.0.0) (Yes I’ve been working on this for a while)

alias: DojoTools Zen Index Event Handler (2.0.0)
description: >
  Event Listener to recurse index calls.  REQUIRES DojoTOOLS Zen Index (2.0.0 or
  greater)
triggers:
  - event_type: zen_indexer_request
    trigger: event
actions:
  - variables:
      index_command: "{{ trigger.event.data.index_command | default('{}', true) }}"
      correlation_id: "{{ trigger.event.data.correlation_id }}"
      depth: "{{ trigger.event.data.depth if 'depth' in trigger.event.data else 1 }}"
      is_mapping: |-
        {% set cmd = index_command | trim %} {% if cmd.startswith('{') %}
          {{ (cmd | from_json) is mapping }}
        {% else %}
          false
        {% endif %}
      parsed_cmd: >
        {% set reserved = ['AND', 'OR', 'XOR', 'NOT'] %} {% set true_tokens =
        ['true', 't', 'y', 'yes', 'on', '1'] %} {% set false_tokens = ['false',
        'f', 'n', 'no', 'off', '0'] %} {% set raw = index_command | string |
        trim %} {% set is_json = raw.lstrip().startswith('{') %} {% if is_json
        %}
          {{ raw }}
        {% elif raw == "" or raw == "*" %}
          { "label_1": "*", "operator": "AND", "expand_entities": false }
        {% else %}
          {% set tokens = raw.split() %}
          {% set exp = false %}
          {% if tokens[-1] | lower in true_tokens %}
            {% set exp = true %}
            {% set tokens = tokens[:-1] %}
          {% elif tokens[-1] | lower in false_tokens %}
            {% set tokens = tokens[:-1] %}
          {% endif %}
          {% set op_index = namespace(idx = none) %}
          {% for i in range(tokens | length) %}
            {% if tokens[i] | upper in reserved %}
              {% set op_index.idx = i %}
              {% break %}
            {% endif %}
          {% endfor %}
          {% if op_index.idx is not none %}
            {% set label_1 = tokens[:op_index.idx] | join(" ") | trim("'\"") %}
            {% set op = tokens[op_index.idx] | upper %}
            {% set label_2 = tokens[op_index.idx+1:] | join(" ") | trim("'\"") %}
          {% else %}
            {% set label_1 = tokens | join(" ") | trim("'\"") %}
            {% set op = "AND" %}
            {% set label_2 = "" %}
          {% endif %}
          {
            "label_1": "{{ label_1 }}",
            "label_2": "{{ label_2 }}",
            "operator": "{{ op }}",
            "expand_entities": {{ exp }}
          }
        {% endif %}
      entities_1: >-
        {% set ents = parsed_cmd.entities_1 if parsed_cmd.entities_1 is defined
        and parsed_cmd.entities_1 else [] %} {% if ents | length == 0 and
        parsed_cmd.label_1 %}
          {{ label_entities(parsed_cmd.label_1) }}
        {% else %}
          {{ ents }}
        {% endif %}
      label_1: "{{ parsed_cmd.label_1 if parsed_cmd.label_1 is defined else '' }}"
      entities_2: >-
        {% set ents = parsed_cmd.entities_2 if parsed_cmd.entities_2 is defined
        and parsed_cmd.entities_2 else [] %} {% if ents | length == 0 and
        parsed_cmd.label_2 %}
          {{ label_entities(parsed_cmd.label_2) }}
        {% else %}
          {{ ents }}
        {% endif %}
      label_2: "{{ parsed_cmd.label_2 if parsed_cmd.label_2 is defined else '' }}"
      operator: |-
        {% if parsed_cmd.operator is defined and parsed_cmd.operator %}
          {{ parsed_cmd.operator }}
        {% else %}
          AND
        {% endif %}
      expand_entities: >-
        {{ parsed_cmd.expand_entities if parsed_cmd.expand_entities is defined
        else false }}
  - alias: Dispatch Zen Index Command
    data:
      entities_1: "{{ entities_1 }}"
      label_1: "{{ label_1 }}"
      entities_2: "{{ entities_2 }}"
      label_2: "{{ label_2 }}"
      operator: "{{ operator }}"
      expand_entities: "{{ expand_entities }}"
    response_variable: zen_index_response
    action: script.dojotools_zen_index
  - alias: Return Zen Indexer Result
    event: zen_index_response
    event_data:
      correlation_id: "{{ correlation_id }}"
      response: "{{ zen_index_response }}"
mode: queued

So the event handler just accepts either an old style index command and formats it as json and hands it back to the script or is given the fields and hands it to the script. Pretty cool when you make it reentrant *10 :smiling_imp: BECAUSE

If you refactor the index into a script that IF it happens to receive an old style index command - it fires the event to parse it and - it throws the parsed command back at itself… and voila, it can handle parsing a command and breaking it into its component parts.

Cool trick 1:
To prep for potential recursion loop waits, I built it to wait for trigger until timeout… (default with override) (example of how is in the script)

BUT WAIT - it teaches the index a new superpower… I think you can see the breadcrumbs of full recursive indexing calls here .

(THIS PART IS IN PROGRESS, probably lands somewhere around 2.5, technically I THINK it might actually work now with a doc tweak. The structures are there - I’m just not testing the branch yet. As far as Friday is concerned if I haven’t documented a feature it might not as well exist. We use that to our advantage here. So experiment here if you want. Very beta parts use at your own risk. But shouldn’t be destructive… Just explodey)

IF you send a specially structured json into the index command (index command v2) you can call complex recursive structures like

You can send in a list of ents. and compare to another list (thanks frenk - yes this uses all the new bool set ops under the hood)

(Labels are always single, entlists are always lists - even list of one)

NOW:

  • ‘*’ the index, of course…
  • label AND label
  • [entlist] NOT (label)
  • label OR [entlist]

FUTURE:

  • label AND ( label or [ entlist ] )

(in json, of course…) Prob will limit to 3 levels, but the plan is to unwind any labels into entlists then recurse through the comparison until you walk back to the top of th etree.

Well - here she is…

Friday’s ZenIndexer 2.0.0

(note: intentionally omitted the tool version from the alias here - if you paste this into the script editor the first save sets the name of the script alias. You do jot want this versioned if you want drop in replacement. Or if you prefer version mgt then version the alias and edit script above appropriately after you set the script name.

After it’s set the first time you can alias to your hearts content.

alias: DojoTOOLS Zen Index
description: >-
  Friday's Zen - Zen Indexer 2.0 This tool is a DIRECT replacement and the next
  version of the Library ~INDEX~ Use it as preferred in place of THE LIBRARY.
  Set logic on two lists of entity_ids (each resolved from a label or list of
  entities). - If only one side provided, returns that set. - If both, applies
  operator (AND, OR, NOT, XOR). - If neither, returns an error or (special case)
  full label index (operator = '*'). - Optional expand to resolve groups/areas.
  Returns matching entities, adjacent labels, expansion can include state and
  attribute list. result.simple ALWAYS returns a list (for chaining). If
  index_command is set, fires zen_indexer_request event and exits for recursive
  resolution.
fields:
  index_command:
    name: Zen Index Command
    description: >-
      Structured query; triggers recursive index resolution (if set, all other
      fields ignored) Fallback to field entry on fail
    required: false
    selector:
      text: {}
  entities_1:
    name: Entities 1
    description: List of entity_ids for operand 1 (can be empty if using label_1)
    required: false
    selector:
      entity:
        multiple: true
  label_1:
    name: Label 1
    description: Label for operand 1 (used if entities_1 is empty)
    required: false
    selector:
      text: {}
  entities_2:
    name: Entities 2
    description: List of entity_ids for operand 2 (can be empty if using label_2)
    required: false
    selector:
      entity:
        multiple: true
  label_2:
    name: Label 2
    description: Label for operand 2 (used if entities_2 is empty)
    required: false
    selector:
      text: {}
  operator:
    name: Set Operator
    required: true
    default: AND
    selector:
      select:
        options:
          - AND
          - OR
          - NOT
          - XOR
  expand_entities:
    name: Expand results
    required: false
    default: false
    selector:
      boolean: {}
    description: >-
      Optionally expand the result for state and attribute data.  WARNING this
      MAY overflow.  Best Practice: use index to narrow result set THEN EXPAND. 
      Default: OFF
  timeout:
    selector:
      number:
        min: 0
        max: 5
        step: 0.25
    name: Timeout
    description: >-
      Timeout, in seconds to wait for the index return (Default - 2, Min - 0,
      Max - 5, Step - .25)
    default: 2
    required: false
sequence:
  - variables:
      is_index_command: "{{ index_command is defined and index_command | length > 0 }}"
      correlation_id: |
        {% if is_index_command %}
          {{ now().isoformat() ~ '-' ~ (range(1000)|random) }}
        {% else %}
          ''
        {% endif %}
      timeout_seconds: "{{ timeout | default(2) }}"
  - choose:
      - conditions:
          - alias: Is Index Command - Send to Parser
            condition: template
            value_template: "{{ index_command is defined and index_command | length > 0 }}"
        sequence:
          - event: zen_indexer_request
            event_data:
              index_command: "{{ index_command }}"
              correlation_id: "{{ correlation_id }}"
          - wait_for_trigger:
              - trigger: event
                event_type: zen_index_response
                event_data:
                  correlation_id: "{{ id }}"
            timeout:
              seconds: "{{ timeout_seconds }}"
            alias: Wait for zen_index_response (timeout seconds)
          - choose:
              - conditions:
                  - alias: Received Data from Trigger
                    condition: template
                    value_template: "  {{ ( wait.trigger is defined ) and ( wait.trigger is not none ) }}"
                sequence:
                  - variables:
                      simple: "{{ wait.trigger.event.data.response.result.simple }}"
                      correlation_id: "{{ id }}"
                      index_timeout: false
              - conditions: []
                sequence:
                  - variables:
                      result: {}
                      correlation_id: "{{ id }}"
                      index_timeout: true
                      error: |
                        Indexer call timeout {{ timeout_seconds }}
                alias: Timeout
  - variables:
      index_command: ""
      op1: >-
        {% set ents = simple if simple is defined and simple else [] %} {% if
        simple is defined and simple %}
          {% set ents = simple  %}
        {% else %}
          {% set ents = entities_1 if entities_1 is defined and entities_1 else [] %}
        {% endif %} {% if ents | length == 0 and label_1 %}
          {{ label_entities(label_1) }}
        {% else %}
          {{ ents }}
        {% endif %}
      op2: >-
        {% set ents = entities_2 if entities_2 is defined and entities_2 else []
        %} {% if ents | length == 0 and label_2 %}
          {{ label_entities(label_2) }}
        {% else %}
          {{ ents }}
        {% endif %}
      op1_empty: "{{ op1 | length == 0 }}"
      op2_empty: "{{ op2 | length == 0 }}"
      setop: "{{ operator | upper }}"
      entities_base: |-



        {% if not op1_empty and op2_empty %}
          {{ op1 }}
        {% elif not op2_empty and op1_empty %}
          {{ op2 }}
        {% elif not op1_empty and not op2_empty %}
          {% if setop == 'AND' %}
            {{ op1 | intersect(op2) }}
          {% elif setop == 'OR' %}
            {{ op1 + op2 | unique | list }}
          {% elif setop == 'NOT' %}
            {{ op1 | difference(op2) }}
          {% elif setop == 'XOR' %}
            {{ op1 | symmetric_difference(op2) }}
          {% else %}
            {{ op1 + op2 | unique | list }}
          {% endif %}
        {% else %}
          []
        {% endif %}
      expanded_entities: |-
        {% if expand_entities %}
          [
            {% for s in expand(entities_base) %}
              {
                "entity_id": "{{ s.entity_id }}",
                "state": "{{ s.state }}",
                "friendly_name": "{{ s.attributes.friendly_name if 'friendly_name' in s.attributes else '' }}",
                "last_changed": "{{ s.last_changed if s.last_changed is defined else '' }}",
                "attributes": {{ s.attributes.keys() | list | tojson }},
                "labels": {{ labels(s.entity_id) | tojson }}
              }{{ "," if not loop.last else "" }}
            {% endfor %}
          ]
        {% else %}
          {{ entities_base | tojson }}
        {% endif %}
      result_simple: "{{ entities_base | tojson }}"
      adjacent_labels: |-
        {%- set ns = namespace(labels=[]) -%}
        {%- for e in entities_base -%}
          {%- set ns.labels = ns.labels + labels(e) -%}
        {%- endfor -%}
        {{ ns.labels | unique | list | tojson }}
      result_index: |-
        {%- set ns = namespace(items=[]) -%}
        {%- for e in entities_base -%}
          {%- set ns.items = ns.items + [[e, labels(e)]] -%}
        {%- endfor -%}
        {{ ns.items | tojson }}
      error_msg: |-
        {% if op1_empty and op2_empty %}
          Both operands are empty. Provide at least one label or entity list.
        {% else %}
          None
        {% endif %}
      label_entity_logic_result_raw_2: |-
        {% if op1_empty and op2_empty %} {
          "result": {
            "simple": [],
            "expanded": [],
            "adjacent_labels": [],
            "index": {{ labels() | tojson }}
          },
          "operator": "*",
          "inputs": {
            "entities_1": {{ entities_1 | default([]) | tojson }},
            "label_1": "{{ label_1 | default('') }}",
            "entities_2": {{ entities_2 | default([]) | tojson }},
            "label_2": "{{ label_2 | default('') }}",
            "expand_entities": {{ expand_entities | default(false) }}
          },
          "error": "Both operands are empty. Returned full label index."
        } {% else %} {
          "result": {
            "simple": {{ result_simple }},
            "expanded": {{ expanded_entities }},
            "adjacent_labels": {{ adjacent_labels }},
            "index": {{ result_index }}
          },
          "operator": "{{ setop }}",
          "inputs": {
            "entities_1": {{ entities_1 | default([]) | tojson }},
            "label_1": "{{ label_1 | default('') }}",
            "entities_2": {{ entities_2 | default([]) | tojson }},
            "label_2": "{{ label_2 | default('') }}",
            "expand_entities": {{ expand_entities | default(false) }}
          },
          "error": {{ error_msg | default('') }}
        } {% endif %}
      label_entity_logic_result: |
        {% if label_entity_logic_result_raw is mapping %}
          {{ label_entity_logic_result_raw }}
        {% else %}
          {{ label_entity_logic_result_raw | from_json | default({}) }}
        {% endif %}
  - stop: Zen Index Call Complete
    response_variable: label_entity_logic_result
icon: mdi:cylinder
mode: parallel
max: 10

As with most of this stuff -as is no warranty. It’s going under MIT if you’re interested.

Warning - in well planned label rich environments THIS tool will cause moments of glory in a well grounded LLM - sorry :slight_smile:

So for you - you USE the script. If you enter the fields and get result it will push right through and answer.

Deploy hint: drop both in thier respective editor and save l, restart script and automation in the dev tool then reload the browser you are in. Swap to action and find the script and enter a label you KNOW exists in your label collection. in the Zen Index Command and forces the script to trigger the automation. If that works you’re good… Make the script visible to Assist and restart.

IF however, you call the parser (engages in old style call or recursion JSON) then it passes the hat back and forth until it unwinds the entire call… Right now, the old-style call to new style works so if you are using my old indexer this is a drop in and I tell the LLM so in my description - you can run them in parallel. It will prefer it naturally after first use, it returns better data faster (I did for 4 mo and still am the library index 1.0 is still online. It’s pretty battle tested.)

So…

Welcome to ZenAI

Now we have to talk about libraries, volumes and drawers (may still change that one)

1 Like

New Tool RC
Label CRUD, you knew we needed a label manager,

REQUIRES SPOOK, your homie…
(by frenk, yes THAT frenk, I trust the tool.)
Spook :ghost: a scary powerful toolbox for Home Assistant.

So HA core is underpowered for label management - you WILL need Spook - it is required for this tool. This is basically automating Spook’s label manager features.

Apparently there’s currently ZERO way to access the details of a label to read it without rooting around in .storage - so I’m going to be compiling an FR, Probably for spook if I don’t find one. It’s the missing piece.

label CRUD (1.6.6-RC)

alias: label CRUD 
description: >
  label CRUD (1.6.6-RC) Robust, Friday-grade modular label manager for Home Assistant.
  Handles all CRUD actions with safe branching, full error returns, and friendly
  help. Tool does not support overwrite planned: Label Details depends on FR:
  [link]
  tag and untag in final uat
fields:
  action_type:
    description: "'create', 'read', 'delete', 'summary', 'tag', 'untag'"
    required: true
    selector:
      select:
        options:
          - create
          - read
          - delete
          - summary
          - tag
          - untag
    default: read
  target_entities:
    description: >-
      List of entity_ids to apply labels to or summarize. Required for 'tag',
      'untag', and 'summary' actions.
    required: false
    selector:
      entity:
        multiple: true
  label_list:
    description: >-
      List of labels (case-insensitive). Required for 'tag', 'untag', 'create',
      and 'delete'.
    required: false
    selector:
      text:
        multiple: true
  confirm:
    description: >-
      Required for writable / destructive actions, Function will alert when
      Confirmation is required - expect it for Create, Delete, Update.  Confirms
      write actions have been OK's by your user.  ASK USER before using with any
      function requiring confirmation.
    required: false
    default: false
    selector:
      boolean: null
sequence:
  - variables:
      action: "{{ action_type | default('read') | lower }}"
      label_index: "{{ labels() }}"
      user_labels: "{{ label_list | default([]) }}"
      have_target_user_labels: "{{ ( user_labels | length > 0 ) }}"
      target_ent_list: "{{ target_entities | default([]) }}"
      have_target_ents: "{{ ( target_ent_list | length > 0 ) }}"
      labels_by_ent: |-
        {% if have_target_ents %}
          {
          {%- for ent in target_ent_list %}
            "{{ ent }}": {{ labels(ent) }}{{ "," if not loop.last else "" }}
          {%- endfor %}
          }
        {% else %}
          {}
        {% endif %}
      label_details_by_label: |-
        {% if have_target_user_labels %}
          {
          {%- for lbl in user_labels %}
            "{{ lbl }}": {
              "dev_note": "Details Currently Unsupported, Pending Spook FR"
              "description": "{{ lbl.description | default('') }}",
              "icon": "{{lbl.icon | default('') }}",
              "color": "{{lbl.color | default('') }}"
            }{{ "," if not loop.last else "" }}
          {%- endfor %}
          }
        {% else %}
          {}
        {% endif %}
      minihelp: "Minihelp: actions = tag, untag, create, delete, summary, update"
      results_list: []
      failures_list: []
  - choose:
      - conditions:
          - condition: template
            value_template: >-
              {{ action not in
              ['tag','untag','create','delete','summary','update','read'] }}
            alias: Error - invalid Action
        sequence:
          - variables:
              response:
                status: error
                error: Invalid action_type '{{ action_type }}'. {{ minihelp }}
                index: "{{ label_index }}"
          - stop: response
            response_variable: Pass Car Scope
      - conditions:
          - condition: template
            value_template: "{{ action == 'create' }}"
            alias: CREATE
        sequence:
          - choose:
              - conditions:
                  - condition: template
                    value_template: "{{ user_labels | length == 0 }}"
                    alias: User Labels are {}
                sequence:
                  - variables:
                      response:
                        status: error
                        error: Create requires at least one label. {{ minihelp }}
                        index: "{{ label_index }}"
                  - stop: pass var scope
                    response_variable: response
          - variables:
              to_create: |-
                [{% for label in user_labels if label not in label_index %}
                  "{{ label }}"{% if not loop.last %},{% endif %}
                {% endfor %}]
              already_exists: |-
                [{% for label in user_labels if label in label_index %}
                  "{{ label }}"{% if not loop.last %},{% endif %}
                {% endfor %}]
              target_labels: |-
                {% if confirm %}
                  {{ to_create }}
                {% else %}
                  []
                {% endif %}
          - variables:
              response:
                status: "{{ 'ok' if target_labels | count > 0 else 'start' }}"
                action: create
                to_create: "{{ to_create }}"
                already_exists: "{{ already_exists }}"
                confirm: "{{ confirm }}"
                target_labels: "{{ target_labels }}"
                message: |-
                  {% if target_labels | count > 0 %}
                    Started Create.
                  {% else %}
                    Some or all labels already existed and were skipped. Confirmation required.
                  {% endif %}
          - repeat:
              for_each: "{{ target_labels }}"
              sequence:
                - action: homeassistant.create_label
                  metadata: {}
                  data:
                    name: "{{ repeat.item }}"
                    description: tbd
                    color: primary
          - variables:
              created_count: "{{ target_labels | count }}"
              response:
                status: "{{ 'ok' if created_count > 0 else 'partial' }}"
                action: create
                confirm: "{{ confirm }}"
                created_labels: "{{ target_labels }}"
                already_existed: "{{ already_exists }}"
                message: |-
                  {% if created_count > 0 %}
                    Create Action Complete.
                  {% else %}
                    Create requires confirmation, no labels created.
                  {% endif %}
          - stop: pass var scope
            response_variable: response
      - conditions:
          - alias: READ
            condition: template
            value_template: "{{ ( action == 'read' ) or ( not action ) }}"
        sequence:
          - choose:
              - conditions:
                  - alias: Valid READ
                    condition: template
                    value_template: >-
                      {{ action == 'read' and ( have_target_user_labels or
                      have_target_ents ) }}
                sequence:
                  - variables:
                      response: |-
                        {{
                          {
                            "status": "ok",
                            "action": "read",
                            "label_list": user_labels,
                            "label_details_by_label": label_details_by_label,
                            "target_entities": target_ent_list,
                            "labels_by_ent": labels_by_ent
                          }
                        }}
                  - stop: Pass context
                    response_variable: response
          - variables:
              response: |-
                {{ {
                  'status': 'ok',
                  'action': 'read_index',
                  'target': '*',        
                  'index': label_index
                } }}
          - stop: pass var scope
            response_variable: response
      - conditions:
          - alias: DELETE
            condition: template
            value_template: "{{ action == 'delete' }}"
        sequence:
          - choose:
              - conditions:
                  - condition: template
                    value_template: "{{ user_labels | length == 0 }}"
                    alias: User Labels are {}
                sequence:
                  - variables:
                      response:
                        status: error
                        error: Create requires at least one label. {{ minihelp }}
                        index: "{{ label_index }}"
                  - stop: pass var scope
                    response_variable: response
          - variables:
              to_delete: |-
                [{% for label in user_labels if label not in label_index %}
                  "{{ label }}"{% if not loop.last %},{% endif %}
                {% endfor %}]
              already_exists: |-
                [{% for label in user_labels if label in label_index %}
                  "{{ label }}"{% if not loop.last %},{% endif %}
                {% endfor %}]
              target_labels: |-
                {% if confirm %}
                  {{ to_delete }}
                {% else %}
                  []
                {% endif %}
          - variables:
              response:
                status: "{{ 'ok' if target_labels | count > 0 else 'start' }}"
                action: delete
                to_delete: "{{ to_delete }}"
                already_exists: "{{ already_exists }}"
                confirm: "{{ confirm }}"
                target_labels: "{{ target_labels }}"
                message: |-
                  {% if target_labels | count > 0 %}
                    Started Delete.
                  {% else %}
                    Some or all labels were skipped. Confirmation required.
                  {% endif %}
          - repeat:
              for_each: "{{ target_labels }}"
              sequence:
                - action: homeassistant.delete_label
                  data:
                    label_id: "{{ repeat.item | slugify }}"
          - variables:
              deleted_count: "{{ target_labels | count }}"
              response:
                status: "{{ 'ok' if deleted_count > 0 else 'partial' }}"
                action: delete
                confirm: "{{ confirm }}"
                deleted_labels: "{{ target_labels }}"
                message: |-
                  {% if deleted_count > 0 %}
                    Delete Action Complete.
                  {% else %}
                    Delete requires confirmation, no labels deleted.
                  {% endif %}
          - stop: pass var scope
            response_variable: response
        alias: DELETE
      - conditions:
          - condition: template
            value_template: "{{ action == 'summary' }}"
            alias: SUMMARY
        sequence:
          - choose:
              - conditions:
                  - alias: Error - No Targets
                    condition: template
                    value_template: "{{ target_ent_list | length == 0 }}"
                sequence:
                  - variables:
                      response:
                        status: error
                        error: Summary requires at least one entity. {{ minihelp }}
                        index: "{{ label_index }}"
                  - stop: pass var scope
                    response_variable: response
          - repeat:
              for_each: "{{ target_ent_list }}"
              sequence:
                - data:
                    method: label_registry.summarize
                    parameters:
                      entity_id: "{{ repeat.item }}"
                  action: spook.call
          - variables:
              response:
                status: ok
                action: summary
                affected_entities: "{{ target_ent_list }}"
                index: "{{ label_index }}"
                help: "{{ minihelp }}"
          - stop: pass var scope
            response_variable: response
      - conditions:
          - condition: template
            value_template: "{{ action == 'tag' }}"
            alias: TAG
        sequence:
          - choose:
              - conditions:
                  - condition: template
                    value_template: "{{ user_labels | length == 0 }}"
                    alias: Error - No Labels
                sequence:
                  - variables:
                      response:
                        status: error
                        error: Tag requires at least one label. {{ minihelp }}
                        index: "{{ label_index }}"
                  - stop: pass var scope
                    response_variable: response
              - conditions:
                  - alias: Error - no Targets
                    condition: template
                    value_template: "{{ target_ent_list | length == 0 }}"
                sequence:
                  - variables:
                      response:
                        status: error
                        error: >-
                          Tag requires at least one target entity. {{ minihelp
                          }}
                        index: "{{ label_index }}"
                  - stop: pass var scope
                    response_variable: response
          - action: homeassistant.add_label_to_entity
            data:
              label_id: "{{ user_labels }}"
              entity_id: "{{ target_ent_list }}"
          - variables:
              response:
                status: ok
                action: tag
                affected_labels: "{{ user_labels }}"
                affected_entities: "{{ target_ent_list }}"
          - stop: pass var scope
            response_variable: response
      - conditions:
          - alias: UNTAG
            condition: template
            value_template: "{{ action == 'untag' }}"
        sequence:
          - choose:
              - conditions:
                  - condition: template
                    value_template: "{{ user_labels | length == 0 }}"
                    alias: Error - No Labels
                sequence:
                  - variables:
                      response:
                        status: error
                        error: Tag requires at least one label. {{ minihelp }}
                        index: "{{ label_index }}"
                  - stop: pass var scope
                    response_variable: response
              - conditions:
                  - alias: Error - no Targets
                    condition: template
                    value_template: "{{ target_ent_list | length == 0 }}"
                sequence:
                  - variables:
                      response:
                        status: error
                        error: >-
                          Tag requires at least one target entity. {{ minihelp
                          }}
                        index: "{{ label_index }}"
                  - stop: pass var scope
                    response_variable: response
          - action: homeassistant.remove_label_from_entity
            data:
              label_id: "{{ user_labels }}"
              entity_id: "{{ target_ent_list }}"
          - variables:
              response:
                status: ok
                action: untag
                affected_labels: "{{ user_labels }}"
                affected_entities: "{{ target_ent_list }}"
          - stop: pass var scope
            response_variable: response
  - choose:
      - conditions:
          - alias: No response defined yet
            condition: template
            value_template: "{{ response is not defined or response is string }}"
        sequence:
          - variables:
              response: >-
                {{ response | default({'status': 'error', 'error': 'Unknown
                error occurred.', 'index': label_index, 'help': minihelp}) }}
          - stop: Return
            response_variable: response

Happy label assignments.