[ Home Assistant Scripting — Quick Start ]

[ Overview ]

Home Assistant's script integration is a reusable
sequence of actions you give a name and call from anywhere. It's
the "function" primitive of HA — the same way a function
in any language wraps a sequence so you can invoke it by name from
multiple callers.

Where things sit:

  script      A named sequence of actions.  Triggered by being
              called (from an automation, dashboard tap, voice
              command, another script, etc.).  No trigger of its own.
  automation  A trigger + optional conditions + an action sequence.
              Fires on its own when the trigger condition is met.
  scene       A snapshot of entity states.  Activating it sets
              everything in the scene at once.  No logic, just state.
  blueprint   A template for automations or scripts with named
              inputs.  Instantiate once per device/scenario via the UI
              instead of hand-rolling each variant.

If you're calling the same sequence from more than one automation,
or want a way to test a sequence by clicking a button in
Developer Tools, it should be a script. If a sequence runs on a
fixed event (sunset, motion sensor, time of day) and isn't reused
elsewhere, it can live inline in the automation.

This guide covers scripts specifically. The same syntax patterns
(action, variables, choose, repeat,
parallel) apply identically inside automations — learn
scripts and you've learned the action-sequence language for the
whole platform.

[ Where scripts live ]

Two physical layouts, same logical result. Pick whichever matches
how the rest of your configuration.yaml is organised.

Option A — inline in configuration.yaml

  Define scripts under a top-level script: key. Fast and obvious
  for two or three scripts, gets noisy fast as your config grows.

Option B — split file scripts.yaml

  In configuration.yaml:
script: !include scripts.yaml
  Then in scripts.yaml (one entry per script ID at the root):
my_script_id:
  alias: "Friendly name"
  sequence:
    - ...
  This is what the HA UI Script Editor writes to by default.

Option C — packages/

  Group related scripts + automations + sensors + helpers per feature
  area in packages/<feature>.yaml files. The
  official packages docs
  cover the loader directive. This is how the bigger guides on this
  site (alarm-system, outdoor-lights) recommend organising once your
  config grows past one file.

The script ID (the YAML key) is permanent — it's what the
service-call script.<id> resolves to. Renaming the alias
is harmless; renaming the ID breaks every caller.

[ Anatomy of a script ]

Every script has the same shape. Minimum viable:
flash_the_lanai:
  sequence:
    - action: light.turn_on
      target:
        entity_id: light.lanai
      data:
        brightness_pct: 100
Add optional metadata as the script grows:
flash_the_lanai:
  alias: "Lanai: bright flash"
  description: "Briefly bumps the lanai light to 100% as a visual ping"
  icon: mdi:lightbulb-alert
  mode: single
  max_exceeded: silent
  fields:
    duration_seconds:
      description: How long to hold the light at 100% before stepping back down
      default: 3
      selector:
        number:
          min: 1
          max: 30
          unit_of_measurement: "s"
  sequence:
    - variables:
        prior_brightness: "{{ state_attr('light.lanai', 'brightness') | int(0) }}"
        prior_on: "{{ is_state('light.lanai', 'on') }}"
    - action: light.turn_on
      target:
        entity_id: light.lanai
      data:
        brightness_pct: 100
    - delay:
        seconds: "{{ duration_seconds | int(3) }}"
    - if:
        - condition: template
          value_template: "{{ prior_on }}"
      then:
        - action: light.turn_on
          target:
            entity_id: light.lanai
          data:
            brightness: "{{ prior_brightness }}"
      else:
        - action: light.turn_off
          target:
            entity_id: light.lanai
The pieces, in plain English:

  alias           Human-readable name shown in the UI.  Optional.
  description     Long-form description.  Useful in
                  Developer Tools → Services as inline help.
  icon            Material Design Icon (mdi:*) for dashboard
                  buttons + the script list.
  mode            How concurrent calls behave (single / restart /
                  queued / parallel) — see next section.
  max             Cap on queued / parallel calls; defaults to 10.
  max_exceeded    What logging level to use when max is hit
                  (silent / log / warning / error / critical).
  fields          Named, type-checked parameters the caller
                  supplies.  See variables & fields.
  variables       Local variables computed once at the top of
                  the sequence.  Often used to snapshot state before
                  modifying it.
  sequence        The actual list of actions.  Required.

The HA UI Script Editor writes all of these for you.  Hand-editing is
fine and often clearer for review — the YAML round-trips cleanly
through the editor either way.

[ The four modes ]

A script's mode: controls what happens when the script is
called again while a previous run is still in progress.  This is the
single most important knob to get right; the wrong mode is the source
of about 80% of "why didn't my script work" debugging.

single(default)
  Only one run at a time.  Calls that arrive while the script is running
  are dropped.  Best for: scripts whose result should be atomic (door
  open + TTS announcement, scene activation, lock command).  The drop
  is logged at warning level by default; set max_exceeded: silent
  if it's expected and noisy.

restart
  A new call cancels the in-progress run and starts fresh.  Best for:
  scripts that need to react to the latest state of the world,
  discarding partial work from earlier calls (RGB feedback flashes,
  "return to home" PTZ reposition, dim-to-N transition that
  should snap to the latest target).

queued
  Calls are queued and run in order, one at a time, up to max
  (default 10).  Best for: sequences that need to all complete
  eventually but where order matters and overlap is bad (camera
  snapshot pipelines, TTS announcements you don't want stepping
  on each other).

parallel
  Each call runs in its own concurrent invocation, up to max.
  Best for: stateless "fan-out" scripts that take a single
  entity and act on it (e.g., per-room notification dispatch where
  each call targets a different room).  Avoid for anything that
  touches shared state without locks.

Cheat sheet for picking

  Multiple calls arrive close together — what should happen?

      "Only the first one matters, drop the rest"        → single
      "Only the latest matters, cancel-and-restart"       → restart
      "All of them matter, run in order"                 → queued
      "All of them matter, run simultaneously"           → parallel

[ Calling a script ]

Scripts have no triggers of their own.  Something else has to call them.
Five common callers:

1. From an automation's actions
action:
  - action: script.flash_the_lanai
    data:
      duration_seconds: 5
  Note: action: script.<id> is the service-call form;
  action: script.turn_on with entity_id: is the alternate
  form. The first is preferred — it's shorter and supports
  field passing directly.

2. From another script

  Same syntax as from an automation. Scripts can call scripts freely;
  watch for accidental infinite loops if a chain ends up calling back
  into itself.

3. From a dashboard tap action

  On any Lovelace card with tap support (button-card, mushroom, the
  built-in entity card), set:
tap_action:
  action: call-service
  service: script.flash_the_lanai
  data:
    duration_seconds: 5
4. From Developer Tools (manual testing)

  Developer Tools → Services, pick script.<your_id>,
  fill in fields, click Call Service.  Each call shows up in the
  script's trace history immediately, with every step + the data
  that flowed through.  This is the fastest debug loop.

5. From a voice command (Assist / Alexa / Google)

  Expose the script as an entity in Settings → Voice Assistants
  → Expose.  "Hey Home Assistant, run lanai flash"
  now invokes script.flash_the_lanai.

  From an intent_script entry (custom-sentence wiring):
  always async-dispatch via script.turn_on with a target:
  if the script does anything slow.  See the callout below.

Any of these can pass data: for declared fields.  Fields without
defaults are required at call time; fields with defaults are optional.
Async-dispatch from intent_script — the bug
that looks like everything else

If you call a script directly from an intent_script action
block, the intent BLOCKS until the script returns. Voice satellites
(M5Stack Atom Echo, Voice Preview, Wyoming, ESPHome) have a short
intent-response timeout — typically ~5–10 seconds —
and will show a red error ring when the timeout fires even though
the script eventually completes successfully.  The TTS response
plays late or not at all, and the next utterance can't fire
until the previous one's script returns.

Symptoms:
  — Voice satellite shows red ring after every voice command,
    but the script clearly ran (TV turned on, bag got filled, etc.)
  — TTS response ("filling your bag now") plays late,
    sometimes after the script's actual effect happened
  — Rapid-fire utterances queue weirdly — second
    utterance ignored until the first script returns
  — Phone Assist UI shows a spinner that never resolves
Fix: dispatch asynchronously via script.turn_on + target:.
The intent returns immediately, TTS plays, satellite goes back to
listening, and the long-running script runs in the background where
nothing is waiting on it.
# wrong — intent blocks for the full script duration
intent_script:
  FillABag:
    speech:
      text: "Filling your bag now."
    action:
      service: script.volcano_fill_bag   # ← direct call, blocks

# right — intent fires script async, returns immediately
intent_script:
  FillABag:
    speech:
      text: "Filling your bag now."
    action:
      service: script.turn_on
      target:
        entity_id: script.volcano_fill_bag
Rule of thumb: any intent_script action that invokes a
script with a delay:, wait_template:, or
wait_for_trigger: longer than ~2 seconds should be async-
dispatched. Quick service-call sequences (turning on an automation,
setting fan_mode) can stay synchronous — they return in
milliseconds and the satellite never notices.

Note: intent_script is unrelated to the script: /
scripts.yaml blocks that the rest of this page is about.
It lives at the top level of configuration.yaml alongside
automation: and climate:, and exists specifically to
wire custom-sentence intents (under
config/custom_sentences/<lang>/….yaml) to
real-world actions. The action block's syntax matches the
service-call shape but the dispatch model is different —
hence the foot-gun.

[ Variables & fields ]

Three places to introduce values in a script.

1. fields: — named parameters from the caller

  Declared at the script top-level. Strongly typed via selector:.
  Shows up in the UI service-call form. Accessible inside the sequence
  as a Jinja variable with the field name.
fields:
  target_brightness:
    description: 1-100 brightness percent
    default: 80
    selector:
      number:
        min: 1
        max: 100
        unit_of_measurement: "%"

sequence:
  - action: light.turn_on
    target:
      entity_id: light.lanai
    data:
      brightness_pct: "{{ target_brightness }}"
2. variables: — locals computed inside the script

  Declared as an action step.  Best for snapshotting state before you
  modify it, or for derived values you reference multiple times.
sequence:
  - variables:
      prior_state: "{{ states('light.lanai') }}"
      prior_brightness: "{{ state_attr('light.lanai', 'brightness') | int(0) }}"
      target_color: [255, 147, 41]
  - action: light.turn_on
    target:
      entity_id: light.lanai
    data:
      rgb_color: "{{ target_color }}"
  # ... later, restore using prior_brightness
3. Inline Jinja templates — one-off expressions

  Anywhere a YAML value is expected, you can use a template instead.
data:
  brightness_pct: "{{ 100 if is_state('sun.sun', 'below_horizon') else 40 }}"
  message: "Triggered at {{ now().strftime('%I:%M %p') }}"
Where Jinja runs vs doesn't

  Templates only resolve in fields HA explicitly marks as templatable
  (data:, conditions:, delay:, variables:,
  most number / string fields).  They do NOT resolve in alias:,
  id:, icon:, or in the top-level structural keys.

  If a template doesn't render as expected, paste it into
  Developer Tools → Template to debug.  Templates that
  raise errors fail the script step silently unless you wrap with
  default() — e.g.,
  {{ state_attr('light.lanai', 'brightness') | int(0) }}.

[ Common patterns ]

1. Chained scripts — script.<a> calls script.<b>
# Caller
action:
  - action: script.alarm_response_main

# script.alarm_response_main internally:
sequence:
  - action: script.send_push_alert
  - action: script.flash_lights_red
  - action: script.cast_alert_video
  Each sub-script is independently testable from Developer Tools.

2. Conditional branching — choose:

  Pick one of N branches based on conditions.  Like switch/case.
sequence:
  - choose:
      - conditions:
          - condition: state
            entity_id: alarm_control_panel.home_alarm
            state: armed_away
        sequence:
          - action: script.full_lockdown
      - conditions:
          - condition: state
            entity_id: alarm_control_panel.home_alarm
            state: armed_home
        sequence:
          - action: script.perimeter_only
    default:
      - action: notify.mobile_app_magikh0e_phone
        data:
          message: "Alarm in unexpected state, no action taken"
3. Loops — repeat:

  Three variants:
# Fixed count
- repeat:
    count: 5
    sequence:
      - action: light.toggle
        target:
          entity_id: light.lanai
      - delay:
          seconds: 1

# While a condition holds (re-checked each iteration)
- repeat:
    while:
      - condition: state
        entity_id: binary_sensor.front_door_door
        state: "on"
    sequence:
      - action: notify.mobile_app_magikh0e_phone
        data:
          message: "Door still open"
      - delay:
          minutes: 10

# For each item in a list
- repeat:
    for_each:
      - notify.mobile_app_phone1
      - notify.mobile_app_phone2
      - notify.mobile_app_tablet
    sequence:
      - action: "{{ repeat.item }}"
        data:
          message: "Multi-device blast"
4. Parallel fan-out — parallel:

  Run several action sequences concurrently within one script.  Useful
  for "do all these things at once, then continue".
sequence:
  - parallel:
      - sequence:
          - action: light.turn_on
            target:
              entity_id: light.living_room
      - sequence:
          - action: media_player.volume_set
            target:
              entity_id: media_player.nest_display
            data:
              volume_level: 1.0
      - sequence:
          - action: notify.mobile_app_magikh0e_phone
            data:
              message: "Inbound"
  # Continues after ALL three sub-sequences complete.
5. Continue-on-error — tolerate individual step failures

  By default a step that errors aborts the rest of the script.  Add
  continue_on_error: true to keep going, useful when targeting
  optional integrations:
sequence:
  - action: switch.turn_off
    target:
      entity_id: switch.optional_record_switch  # may not exist
    continue_on_error: true
  - action: camera.turn_off
    target:
      entity_id: camera.ptz_main
    continue_on_error: true
  - action: notify.mobile_app_magikh0e_phone
    data:
      message: "Camera shut down attempted"

[ Debugging ]

Two tools cover 90% of debugging.

1. Traces — Settings → Automations & Scenes →
Scripts → (pick script) → Traces

  Every script run is recorded as a trace: every step, every condition,
  the actual values of variables and template results at each point.
  Click any step in the timeline to see exactly what data flowed
  through.

  If a script silently does nothing, look at the trace first.  It will
  show you which step quietly returned without effect, what condition
  failed, or what template rendered to None.

2. Developer Tools → Services

  Test any script (or any action) without writing an automation.  Pick
  the service, fill the fields via the typed UI, click Call
  Service.  The script runs immediately and the trace shows up in
  history.

  Tight iteration loop: edit script in editor → reload via
  Configuration → YAML → Reload Scripts → call from
  Dev Tools → check trace.  No restart required for script-only
  changes.

3. Developer Tools → Template

  For Jinja debugging.  Paste a template, see the rendered output
  against current HA state.  Especially useful for:

  - Debugging state_attr('light.lanai', 'brightness')
    when you forget the attribute returns None if the light is off.
  - Sanity-checking time-of-day comparisons before they break at the
    wrong hour.
  - Validating list comprehensions that filter entities.

4. Logs — Settings → System → Logs

  When a script errors instead of silently failing, the error message
  lands in the HA log with the script ID and the offending line.
  Wrap unfamiliar integrations with continue_on_error: true and
  use the log to catch what specifically broke.

[ Common gotchas ]

1. Wrong mode = silent drops or unwanted overlap
   The #1 source of "why doesn't this work". If a script
   isn't firing on every call, check the mode — single
   drops concurrent calls silently unless you also set
   max_exceeded: warning.

2. Templates against None attributes
   state_attr('light.lanai', 'brightness')
   returns None if the light is off, NOT 0. Always wrap with
   a default:
   {{ state_attr('light.lanai', 'brightness') | int(0) }}

3. delay: blocks the run, not the world
   While a script sits in delay:, the run is still "in
   progress" from the mode-tracking perspective. A 10-minute
   delay in a single-mode script means new calls during those
   10 min get dropped. restart mode breaks this if you need
   the latest call to win.

4. Reload Scripts isn't Reload Automations
   Editing a script and clicking Reload Automations doesn't
   pick up the script change. Same vice versa. The right reload
   for the right config block matters.

5. Field defaults vs Jinja defaults
   A field with a default value still resolves to the field-default
   if the caller omits it.  Inline Jinja | default(N) only
   kicks in when the value renders to None or undefined.
   Two different fallback mechanisms.

6. action: script.<id> vs action: script.turn_on
   with entity_id:
   Both work.  action: script.<id> passes data: as
   field values directly.  The script.turn_on form requires
   variables: instead.  Pick one style and stick with it.

7. continue_on_error: true swallows everything
   It catches all errors from that step, not just integration-missing
   ones.  If you have a real bug behind it, you won't see it
   surface as an error.  Use sparingly and remove when no longer
   needed.

[ See Also ]