[ Home Assistant Scripting — Quick Start ]
[ Overview |
Where scripts live |
Anatomy |
The four modes |
Calling a script |
Variables & fields |
Common patterns |
Debugging |
Common gotchas ]
[ 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 ]
DIY Alarm System guide # a 7-step example built almost entirely on scripts
Outdoor lights sunset fade # sun trigger + script-style action sequence
Voice-Activated Dog TV # applied intent_script + the async-dispatch pattern
Snippets # short reusable YAML, several use script-style patterns
Blueprints # the parameterized-template variant of scripts
Home Automation index # back to the section index
HA — script integration docs # official reference
HA — scripts & automation actions # action syntax reference
HA — Jinja templating docs # official template reference
