Households run on “later”, not “now”: fall asleep to the fan, curtains at sunrise, lights off when the film ends. Home Assistant’s built-in voice timers only chime. This guide adds real scheduled actions to a voice assistant — lights, aircon or curtains, any room, at a time or after a delay, in English, Thai or Mandarin — using the community Scheduler component and three small scripts that never claim more than they have verified. Complete YAML, ready to adapt.
One primitive, three verbs. The Scheduler component can create a one-shot schedule that runs a service once and then deletes itself. Everything else is three scripts the language model can call: schedule, list, cancel. Each returns a spoken message in the language the person used, and the assistant is told to speak it verbatim.
Verify before speaking. “Sure, at six” is worthless if nothing was actually created. The schedule script waits two seconds, finds the schedule entity it just asked for, checks it is on and has a real next-trigger time, and only then says the time — the verified time, read back from the entity, not the time it hoped for. Cancelling works the same way in reverse: it counts what is left afterwards and admits it if anything survived.
The rules live in the tool descriptions. A language model reads a tool’s description at exactly the moment it decides whether to use it. So the description is where the routing lives: do not use the timer tool for this, convert “six a.m.” to 06:00, treat “don’t open the curtains at six” as a cancellation, never say something is cancelled without calling this. In our testing this is far more reliable than putting the same rules in the system prompt.
Fail closed. An impossible combination — curtains in a room that has none, a time that does not parse — returns a message that says nothing was scheduled. The model cannot invent a success it was never handed.
Schedules clean themselves up. repeat_type: single
with the start and end date set to the fire date means the entity fires once and is
gone. Nothing accumulates; “what’s scheduled?” is always the truth.
scheduler.add and scheduler.remove
services are the only ones used.at_time or
in_minutes. A clock time already past today rolls to tomorrow.
Aircon-on forces cool mode, because climate.turn_on restores
whatever mode the unit remembers — and “heat” at six in the morning in Bangkok is
not what anyone asked for. Edit entity_map and the room options
to match your house.alias: Schedule a device action (at a time or after a delay)
icon: mdi:calendar-clock
mode: queued
description: >-
Schedules a device action to happen later — either AT a clock time or IN a number of minutes. Use this
for ANY request of the form 'open/close/turn on/turn off <device> at <time>' or '... in <N> minutes/hours'
(e.g. 'open the curtains at 6 a.m. tomorrow', 'turn off the bedroom lights in 20 minutes', 'ปิดแอร์อีกครึ่งชั่วโมง').
Do NOT use the timer tool for these — a timer only chimes, it cannot perform a device action. Convert
spoken times to 24-hour HH:MM ('six a.m.' → '06:00', 'half past nine tonight' → '21:30'); if the time
has already passed today it is scheduled for tomorrow automatically. Convert durations to minutes ('an
hour' → 60). Pass exactly one of at_time or in_minutes. Returns a 'message' field in the language you
pass as 'language' — speak that message back to the user verbatim; it states the VERIFIED time the action
will run. If the message says nothing was scheduled, tell the user that.
fields:
action:
name: Action
description: 'What to do: ''open'' or ''close'' (curtains), ''turn_on'' or ''turn_off'' (lights, aircon).'
required: true
example: open
selector:
select:
options:
- open
- close
- turn_on
- turn_off
device:
name: Device
description: 'Which device class: ''curtains'', ''lights'' or ''aircon''.'
required: true
example: curtains
selector:
select:
options:
- curtains
- lights
- aircon
room:
name: Room
description: >-
The room. If the user names a room, pass it exactly as one of the options. If they don't, pass the
room the user is speaking from.
required: true
example: Living Room
selector:
select:
options:
- Living Room
- Bedroom
- Stu Room
- Cooking Room
- Shower Room
- Bath Room
at_time:
name: At time
description: >-
Clock time to run the action, 24-hour HH:MM (e.g. '06:00', '21:30'). Use this OR in_minutes, not
both.
required: false
example: 06:00
selector:
text: null
in_minutes:
name: In minutes
description: Delay in whole minutes from now (e.g. 20, 90). Use this OR at_time, not both.
required: false
example: 20
selector:
number:
min: 1
max: 1440
mode: box
language:
name: Language
description: >-
The language the user spoke in: 'th' Thai, 'zh' Chinese, 'en' English. The message is returned in
this language. Defaults to 'en'.
required: false
example: th
selector:
select:
options:
- en
- th
- zh
sequence:
- alias: Resolve inputs
variables:
lang: '{{ language | default(''en'') }}'
act: '{{ action | default('''') }}'
dev: '{{ device | default('''') }}'
rm: '{{ room | default('''') }}'
entity_map:
curtains:
Living Room: cover.living_room_curtains
lights:
Living Room: light.living_room
Bedroom: light.bedroom
Stu Room: light.stu_room
Cooking Room: light.cooking_room
Shower Room: light.shower_room
Bath Room: light.bath_room
aircon:
Living Room: climate.living_room
Bedroom: climate.bedroom
Stu Room: climate.stu_room
target_entity: '{{ entity_map.get(dev, {}).get(rm, '''') }}'
svc: >-
{% if dev == 'curtains' and act == 'open' %}cover.open_cover{% elif dev == 'curtains' and act ==
'close' %}cover.close_cover{% elif dev == 'lights' and act in ['turn_on','open'] %}light.turn_on{%
elif dev == 'lights' and act in ['turn_off','close'] %}light.turn_off{% elif dev == 'aircon' and
act in ['turn_on','open'] %}climate.set_hvac_mode{% elif dev == 'aircon' and act in ['turn_off','close']
%}climate.set_hvac_mode{% else %}{% endif %}
svc_data: >-
{% if dev == 'aircon' and act in ['turn_on','open'] %}{{ {'hvac_mode': 'cool'} }}{% elif dev ==
'aircon' and act in ['turn_off','close'] %}{{ {'hvac_mode': 'off'} }}{% else %}{{ {} }}{% endif
%}
has_delay: '{{ in_minutes is defined and in_minutes is not none and (in_minutes | int(0)) > 0 }}'
has_time: >-
{{ at_time is defined and at_time is not none and (at_time | string) is match('^([01]?\d|2[0-3]):[0-5]\d$')
}}
fire_at: >-
{% set n = now() %}{% if in_minutes is defined and in_minutes is not none and (in_minutes | int(0))
> 0 %}{{ (n + timedelta(minutes=(in_minutes | int))).isoformat() }}{% elif at_time is defined and
at_time is not none and (at_time | string) is match('^([01]?\d|2[0-3]):[0-5]\d$') %}{% set hh =
(at_time | string).split(':')[0] | int %}{% set mm = (at_time | string).split(':')[1] | int %}{%
set t = n.replace(hour=hh, minute=mm, second=0, microsecond=0) %}{% if t <= n %}{% set t = t + timedelta(days=1)
%}{% endif %}{{ t.isoformat() }}{% else %}{{ '' }}{% endif %}
- alias: Validate
choose:
- conditions:
- condition: template
value_template: '{{ target_entity == '''' or svc == '''' or fire_at == '''' }}'
sequence:
- variables:
message: >-
{% if lang == 'th' %}ขอโทษค่ะ ตั้งเวลาไม่ได้ ไม่ได้ตั้งอะไรไว้เลย{% elif lang == 'zh' %}抱歉,无法设置定时,什么也没有安排。{%
else %}Sorry — I couldn't set that schedule. Nothing was scheduled.{% endif %}
reason: target={{ target_entity }} svc={{ svc }} fire_at={{ fire_at }}
- variables:
result_vars:
message: '{{ message }}'
scheduled: false
reason: '{{ reason }}'
- stop: invalid request
response_variable: result_vars
- alias: Compute names and times
variables:
fire_dt: '{{ as_datetime(fire_at) }}'
fire_date: '{{ as_datetime(fire_at).strftime(''%Y-%m-%d'') }}'
fire_hhmm: '{{ as_datetime(fire_at).strftime(''%H:%M'') }}'
is_tomorrow: '{{ as_datetime(fire_at).date() != now().date() }}'
sched_name: >-
Voice: {{ act }} {{ rm }} {{ dev }} @ {{ as_datetime(fire_at).strftime('%d/%m %H:%M') }} [{{ now().strftime('%H%M%S')
}}]
- alias: Create the schedule
action: scheduler.add
continue_on_error: true
data:
name: '{{ sched_name }}'
weekdays:
- daily
start_date: '{{ fire_date }}'
end_date: '{{ fire_date }}'
repeat_type: single
timeslots:
- start: '{{ fire_hhmm }}'
actions:
- service: '{{ svc }}'
entity_id: '{{ target_entity }}'
service_data: '{{ svc_data }}'
- delay:
seconds: 2
- alias: Verify the schedule entity exists
variables:
sched_entity: >-
{{ states.switch | selectattr('attributes.friendly_name', 'eq', 'Scheduler ' ~ sched_name) | map(attribute='entity_id')
| list | first | default('') }}
sched_ok: >-
{{ sched_entity != '' and is_state(sched_entity, 'on') and state_attr(sched_entity, 'next_trigger')
is not none }}
- alias: Build the spoken message
variables:
room_th: >-
{{ {'Living Room': 'ห้องนั่งเล่น', 'Bedroom': 'ห้องนอน', 'Stu Room': 'ห้องสตู', 'Cooking Room':
'ห้องครัว', 'Shower Room': 'ห้องอาบน้ำ', 'Bath Room': 'ห้องน้ำ'}.get(rm, rm) }}
room_zh: >-
{{ {'Living Room': '客厅', 'Bedroom': '卧室', 'Stu Room': 'Stu的房间', 'Cooking Room': '厨房', 'Shower Room':
'淋浴间', 'Bath Room': '浴室'}.get(rm, rm) }}
dev_en: '{{ {''curtains'': ''curtains'', ''lights'': ''lights'', ''aircon'': ''aircon''}.get(dev,
dev) }}'
dev_th: '{{ {''curtains'': ''ม่าน'', ''lights'': ''ไฟ'', ''aircon'': ''แอร์''}.get(dev, dev) }}'
dev_zh: '{{ {''curtains'': ''窗帘'', ''lights'': ''灯'', ''aircon'': ''空调''}.get(dev, dev) }}'
verb_en: >-
{{ {'open': 'open', 'close': 'close', 'turn_on': 'turn on', 'turn_off': 'turn off'}.get(act, act)
}}
verb_th: '{{ {''open'': ''เปิด'', ''close'': ''ปิด'', ''turn_on'': ''เปิด'', ''turn_off'': ''ปิด''}.get(act,
act) }}'
verb_zh: '{{ {''open'': ''打开'', ''close'': ''关闭'', ''turn_on'': ''打开'', ''turn_off'': ''关闭''}.get(act,
act) }}'
when_en: >-
{% if has_delay %}in {{ in_minutes | int }} minutes, at {{ fire_hhmm }}{% else %}at {{ fire_hhmm
}}{% if is_tomorrow %} tomorrow{% endif %}{% endif %}
when_th: >-
{% if has_delay %}อีก {{ in_minutes | int }} นาที เวลา {{ fire_hhmm }} น.{% else %}เวลา {{ fire_hhmm
}} น.{% if is_tomorrow %} พรุ่งนี้{% endif %}{% endif %}
when_zh: >-
{% if has_delay %}{{ in_minutes | int }}分钟后,{{ fire_hhmm }}{% else %}{% if is_tomorrow %}明天{% endif
%}{{ fire_hhmm }}{% endif %}
message: >-
{% if sched_ok %}{% if lang == 'th' %}จะ{{ verb_th }}{{ dev_th }}{{ room_th }}{{ when_th }}ค่ะ{%
elif lang == 'zh' %}将于{{ when_zh }}{{ verb_zh }}{{ room_zh }}{{ dev_zh }}。{% else %}I'll {{ verb_en
}} the {{ rm | lower }} {{ dev_en }} {{ when_en }}.{% endif %}{% else %}{% if lang == 'th' %}ขอโทษค่ะ
ตั้งเวลาไม่ได้ ไม่ได้ตั้งอะไรไว้เลย{% elif lang == 'zh' %}抱歉,无法设置定时,什么也没有安排。{% else %}Sorry — I
couldn't set that schedule. Nothing was scheduled.{% endif %}{% endif %}
result_vars:
message: '{{ message }}'
scheduled: '{{ sched_ok }}'
schedule_entity: '{{ sched_entity }}'
fires_at: '{{ fire_at }}'
target: '{{ target_entity }}'
service: '{{ svc }}'
- alias: Return
stop: done
response_variable: result_varsVoice: … by script 1 — and reports them sorted by fire
time, with a verified “in 7 hours 28 minutes” per item so the model never does the
arithmetic itself.alias: List scheduled actions
icon: mdi:calendar-search
mode: queued
description: >-
Reports pending scheduled device actions (set with schedule_action: 'at <time>' or 'in <N> minutes').
Use this whenever the user asks what is scheduled, what timers/schedules are set, WHEN something will
happen, or HOW LONG UNTIL something happens ('when will the curtains open', 'how long until the aircon
turns off', 'อีกนานแค่ไหนม่านจะเปิด', '还有多久窗帘会打开'). Do NOT use the built-in timer status — it only knows
chime timers. If the user names a device or room, pass it as a filter so the answer is about that device
only. ALWAYS call this tool before answering, even if you answered a similar question earlier in this
conversation — schedules change and only this tool knows the current state. Returns 'message' in the
language you pass as 'language'; speak it verbatim. Each item carries 'relative' (verified time remaining,
e.g. 'in 7 hours 28 minutes') — read it out for 'how long until' questions, never calculate it yourself.
If the message says nothing is scheduled for the device, say exactly that.
fields:
language:
name: Language
description: 'The language the user spoke in: ''th'' Thai, ''zh'' Chinese, ''en'' English. Defaults
to ''en''.'
required: false
example: th
selector:
select:
options:
- en
- th
- zh
device:
name: Device
description: >-
Optional filter: 'curtains', 'lights' or 'aircon'. Pass it when the user asks about a specific device.
required: false
example: curtains
selector:
select:
options:
- curtains
- lights
- aircon
room:
name: Room
description: 'Optional filter: the room, exactly as one of the options.'
required: false
example: Living Room
selector:
select:
options:
- Living Room
- Bedroom
- Stu Room
- Cooking Room
- Shower Room
- Bath Room
sequence:
- variables:
lang: '{{ language | default(''en'') }}'
dev: '{{ device | default('''') }}'
rm: '{{ room | default('''') }}'
items: >-
{% set ns = namespace(rows=[]) %}{% for s in states.switch if s.entity_id.startswith('switch.schedule_voice')
and s.state == 'on' and s.attributes.next_trigger is not none %}{% set fn = s.attributes.friendly_name
| replace('Scheduler Voice: ', '') %}{% set parts = fn.split(' @ ')[0].split(' ') %}{% if (not dev
or dev == parts[-1]) and (not rm or rm == (parts[1:-1] | join(' '))) %}{% set ns.rows = ns.rows
+ [{'entity': s.entity_id, 'action': parts[0], 'room': parts[1:-1] | join(' '), 'device': parts[-1],
'at': s.attributes.next_trigger | string}] %}{% endif %}{% endfor %}{{ ns.rows | sort(attribute='at')
}}
count: '{{ items | count }}'
- variables:
room_th:
Living Room: ห้องนั่งเล่น
Bedroom: ห้องนอน
Stu Room: ห้องสตู
Cooking Room: ห้องครัว
Shower Room: ห้องอาบน้ำ
Bath Room: ห้องน้ำ
room_zh:
Living Room: 客厅
Bedroom: 卧室
Stu Room: Stu的房间
Cooking Room: 厨房
Shower Room: 淋浴间
Bath Room: 浴室
dev_th:
curtains: ม่าน
lights: ไฟ
aircon: แอร์
dev_zh:
curtains: 窗帘
lights: 灯
aircon: 空调
verb_en:
open: open
close: close
turn_on: turn on
turn_off: turn off
verb_th:
open: เปิด
close: ปิด
turn_on: เปิด
turn_off: ปิด
verb_zh:
open: 打开
close: 关闭
turn_on: 打开
turn_off: 关闭
- variables:
dev_lbl_en: >-
{% if dev and rm %}the {{ rm | lower }} {{ dev }}{% elif dev %}the {{ dev }}{% elif rm %}the {{
rm | lower }}{% else %}{% endif %}
dev_lbl_th: >-
{% set d = {'curtains': 'ม่าน', 'lights': 'ไฟ', 'aircon': 'แอร์'}.get(dev, '') %}{% set r = {'Living
Room': 'ห้องนั่งเล่น', 'Bedroom': 'ห้องนอน', 'Stu Room': 'ห้องสตู', 'Cooking Room': 'ห้องครัว',
'Shower Room': 'ห้องอาบน้ำ', 'Bath Room': 'ห้องน้ำ'}.get(rm, '') %}{{ d ~ r }}
dev_lbl_zh: >-
{% set d = {'curtains': '窗帘', 'lights': '灯', 'aircon': '空调'}.get(dev, '') %}{% set r = {'Living
Room': '客厅', 'Bedroom': '卧室', 'Stu Room': 'Stu的房间', 'Cooking Room': '厨房', 'Shower Room': '淋浴间',
'Bath Room': '浴室'}.get(rm, '') %}{{ r ~ d }}
message: >-
{% if count | int == 0 %}{% if lang == 'th' %}{% if dev_lbl_th %}ไม่มีรายการตั้งเวลาสำหรับ{{ dev_lbl_th
}}ค่ะ{% else %}ไม่มีรายการตั้งเวลาค่ะ{% endif %}{% elif lang == 'zh' %}{% if dev_lbl_zh %}{{ dev_lbl_zh
}}没有任何定时安排。{% else %}没有任何定时安排。{% endif %}{% else %}{% if dev_lbl_en %}Nothing is scheduled for {{
dev_lbl_en }}.{% else %}Nothing is scheduled.{% endif %}{% endif %}{% else %}{% set ns = namespace(txt=[])
%}{% for r in items %}{% set secs = ((as_datetime(r.at) - now()).total_seconds()) | int %}{% set
h = secs // 3600 %}{% set m = (secs % 3600) // 60 %}{% set rel = ('in ' ~ (h ~ ' hour' ~ ('s' if
h != 1 else '') ~ ' ' if h > 0 else '') ~ (m ~ ' minute' ~ ('s' if m != 1 else '') if (m > 0 or
h == 0) else '')) | trim %}{% set rel_th = ('อีก ' ~ (h ~ ' ชั่วโมง ' if h > 0 else '') ~ (m ~ '
นาที' if (m > 0 or h == 0) else '')) | trim %}{% set rel_zh = ((h ~ '小时' if h > 0 else '') ~ (m
~ '分钟' if (m > 0 or h == 0) else '') ~ '后') %}{% set day = '' if as_datetime(r.at).date() == now().date()
else (' พรุ่งนี้' if lang == 'th' else ('明天' if lang == 'zh' else ' tomorrow')) %}{% if lang ==
'th' %}{% set ns.txt = ns.txt + [verb_th.get(r.action, r.action) ~ dev_th.get(r.device, r.device)
~ room_th.get(r.room, r.room) ~ ' เวลา ' ~ as_datetime(r.at).strftime('%H:%M') ~ ' น.' ~ day ~ '
(' ~ rel_th ~ ')'] %}{% elif lang == 'zh' %}{% set ns.txt = ns.txt + [day ~ as_datetime(r.at).strftime('%H:%M')
~ verb_zh.get(r.action, r.action) ~ room_zh.get(r.room, r.room) ~ dev_zh.get(r.device, r.device)
~ '(' ~ rel_zh ~ ')'] %}{% else %}{% set ns.txt = ns.txt + [verb_en.get(r.action, r.action) ~ '
the ' ~ (r.room | lower) ~ ' ' ~ r.device ~ ' at ' ~ as_datetime(r.at).strftime('%H:%M') ~ day ~
' (' ~ rel ~ ')'] %}{% endif %}{% endfor %}{% if lang == 'th' %}มี {{ count }} รายการค่ะ: {{ ns.txt
| join(', ') }}{% elif lang == 'zh' %}共有{{ count }}项安排:{{ ns.txt | join(';') }}。{% else %}{{ count
}} scheduled: {{ ns.txt | join('; ') }}.{% endif %}{% endif %}
result_vars:
message: '{{ message }}'
count: '{{ count }}'
items: >-
{% set ns = namespace(l=[]) %}{% for r in items %}{% set secs = ((as_datetime(r.at) - now()).total_seconds())
| int %}{% set h = secs // 3600 %}{% set m = (secs % 3600) // 60 %}{% set rel = ('in ' ~ (h ~
' hour' ~ ('s' if h != 1 else '') ~ ' ' if h > 0 else '') ~ (m ~ ' minute' ~ ('s' if m != 1 else
'') if (m > 0 or h == 0) else '')) | trim %}{% set ns.l = ns.l + [{'entity': r.entity, 'action':
r.action, 'room': r.room, 'device': r.device, 'at': r.at, 'relative': rel, 'minutes_until': secs
// 60}] %}{% endfor %}{{ ns.l }}
- stop: done
response_variable: result_varsalias: Cancel scheduled actions
icon: mdi:calendar-remove
mode: queued
description: >-
Cancels pending scheduled device actions that were set with schedule_action. Use this for ANY request
to undo, stop or prevent a scheduled action, however it is phrased: 'cancel the curtain schedule', 'don't
open the curtains at six', 'never mind the aircon later', 'stop the lights turning off', 'the curtains
have been cancelled' (treat a statement that something is cancelled as a request to cancel it), 'ยกเลิกที่ตั้งเวลาไว้',
'อย่าเปิดม่านตอนหกโมง', '取消窗帘的定时'. Never reply that something is cancelled or will not happen without
calling this tool — only its message is the truth. Filter by device and/or room if the user names them;
pass nothing to cancel ALL scheduled actions (confirm first if more than one would be cancelled and
they didn't say 'all'). Returns a 'message' field in the language you pass as 'language' — speak it
back verbatim.
fields:
device:
name: Device
description: 'Optional filter: ''curtains'', ''lights'' or ''aircon''.'
required: false
example: curtains
selector:
select:
options:
- curtains
- lights
- aircon
room:
name: Room
description: 'Optional filter: the room, exactly as one of the options.'
required: false
example: Living Room
selector:
select:
options:
- Living Room
- Bedroom
- Stu Room
- Cooking Room
- Shower Room
- Bath Room
language:
name: Language
description: 'The language the user spoke in: ''th'', ''zh'' or ''en''. Defaults to ''en''.'
required: false
example: th
selector:
select:
options:
- en
- th
- zh
sequence:
- variables:
lang: '{{ language | default(''en'') }}'
dev: '{{ device | default('''') }}'
rm: '{{ room | default('''') }}'
targets: >-
{% set ns = namespace(l=[]) %}{% for s in states.switch if s.entity_id.startswith('switch.schedule_voice')
%}{% set fn = s.attributes.friendly_name | replace('Scheduler Voice: ', '') %}{% set parts = fn.split('
@ ')[0].split(' ') %}{% set r_room = parts[1:-1] | join(' ') %}{% set r_dev = parts[-1] %}{% if
(dev == '' or dev == r_dev) and (rm == '' or rm == r_room) %}{% set ns.l = ns.l + [s.entity_id]
%}{% endif %}{% endfor %}{{ ns.l }}
n: '{{ targets | count }}'
- repeat:
for_each: '{{ targets }}'
sequence:
- action: scheduler.remove
continue_on_error: true
data:
entity_id: '{{ repeat.item }}'
- delay:
seconds: 1
- variables:
left: '{{ states.switch | selectattr(''entity_id'', ''in'', targets) | list | count }}'
message: >-
{% if n | int == 0 %}{% if lang == 'th' %}ไม่มีรายการตั้งเวลาที่ตรงกันค่ะ{% elif lang == 'zh' %}没有匹配的定时安排。{%
else %}There was nothing scheduled that matches.{% endif %}{% elif left | int == 0 %}{% if lang
== 'th' %}ยกเลิกแล้ว {{ n }} รายการค่ะ{% elif lang == 'zh' %}已取消{{ n }}项安排。{% else %}Cancelled {{
n }} scheduled action{{ 's' if n | int > 1 else '' }}.{% endif %}{% else %}{% if lang == 'th' %}ขอโทษค่ะ
ยกเลิกไม่สำเร็จ ยัง{{ left }}รายการค้างอยู่{% elif lang == 'zh' %}抱歉,取消失败,仍有{{ left }}项未取消。{% else
%}Sorry — {{ left }} of {{ n }} could not be cancelled and are still scheduled.{% endif %}{% endif
%}
result_vars:
message: '{{ message }}'
cancelled: '{{ (n | int) - (left | int) }}'
remaining: '{{ left }}'
- stop: done
response_variable: result_vars