Spoken daily reminders on Windows, with the same local voice as claude-audio-feedback (requirement!). Each reminder is a pre-rendered WAV played by a Windows Scheduled Task.
Deliberately independent of WSL and Claude Code. Nothing needs to be open at the time a reminder speaks. WSL is only needed ahead of time, to render the audio and to edit the list.
Built 2026-08-31 (v1, one task per reminder). Rewritten 2026-09-02 (v2, one planner task). Last extended 2026-09-15 (one-off and yearly dates).
This folder is a copy, not the live install. The running code lives in
~/.claude/scripts/tts/(WSL) and%USERPROFILE%\.claude\reminders\(Windows). See Keeping this copy in sync.
windows/reminders.example.json ships with one entry per repeat rule, so you
can see every shape and then replace them with your own. No personal data is
kept in this project.
| Time | Name | Repeat | Early ping |
|---|---|---|---|
| 08:00 | birthday-example | every year, 10 December | - |
| 09:00 | rent | day 1 of every month | - |
| 09:15 | dentist | once, on 2026-10-07 | 30 min |
| 09:30 | standup | every Monday | 5 min |
| 12:30 | lunch | every day | - |
| 18:00 | game-night | every 2 weeks from 2026-01-06 | 30 min |
| 19:00 | bin-day | 1st, 3rd and 5th Thursday | 30 min |
Run reminder to list whatever you actually have.
reminders.json name, time, text, lead, repeat rule
|
| reminder.py (WSL) edits the list, then renders any WAV whose
| text is new or changed, using the Kokoro venv
| from claude-audio-feedback
v
wav/<name>.wav spoken at the event time
wav/<name>-lead.wav spoken `lead` minutes earlier
|
| dispatch.ps1 (Windows) owns ALL the date maths. Works out every ping
| in the next 36 h, and registers one one-shot
| task per ping:
| ClaudeReminderRun_<wav>__<MMdd-HHmm>
| Prunes tasks that are no longer wanted.
v
wscript.exe play-hidden.vbs plays the WAV with no window flashing up
(falls back to play.ps1 if that file is missing)
One planner task, ClaudeReminder_Dispatch, runs dispatch.ps1.
The v1 design used one daily Scheduled Task per reminder. It could not express what was needed:
- "5th Friday of the month" is impossible.
schtasks /sc MONTHLY /MOaccepts only FIRST, SECOND, THIRD, FOURTH and LAST. LAST is not the 5th: in September 2026 the last Friday is the 4th one. - Anchoring "every 2 weeks from date X" to a StartBoundary is fiddly and fails quietly.
New-ScheduledTaskTriggercannot build a monthly trigger at all.
So all rules live in dispatch.ps1 instead, and Task Scheduler only ever gets
plain one-shot times.
This PC is switched off overnight. A nightly planner would never run. The planner therefore fires at logon, plus every 5 minutes from 06:00 to 09:00. Each run plans 36 hours ahead, so one morning run covers the rest of the day and the next morning.
state/plan.stamp holds the plan it last applied. A run whose plan is unchanged
exits at once, so the repeat costs almost nothing.
| File | Where it belongs | What it is |
|---|---|---|
reminder.py |
~/.claude/scripts/tts/reminder.py |
The CLI. Edits reminders.json, renders changed WAVs, calls dispatch.ps1. |
reminder_hook.py |
~/.claude/scripts/tts/reminder_hook.py |
UserPromptExpansion hook. Answers the read-only /reminder commands with no model call, so they cost no tokens. |
bin-reminder |
~/bin/reminder |
Plain terminal wrapper. Hides the venv path and filters PyTorch warnings. |
| File | Where it belongs |
|---|---|
reminder.md |
~/.claude/commands/reminder.md - the /reminder slash command |
settings.hook.json |
merge into ~/.claude/settings.json - the UserPromptExpansion hook |
| File | Where it belongs | What it is |
|---|---|---|
dispatch.ps1 |
%USERPROFILE%\.claude\reminders\dispatch.ps1 |
The planner. All date maths. -Preview <days> prints upcoming pings without touching the scheduler. -Force rebuilds even if the plan is unchanged. |
install_dispatcher.ps1 |
same folder | Run once. Registers ClaudeReminder_Dispatch and removes the old v1 per-reminder tasks. |
play.ps1 |
same folder | Plays wav\<Name>.wav. Now only a fallback and a handy way to test by hand. |
dispatch-hidden.vbs |
same folder | Launched by the planner task. Runs dispatch.ps1 with no window. install_dispatcher.ps1 falls back to a plain powershell.exe action if this file is missing. |
reminders.example.json |
copy to reminders.json in the same folder |
Starter list, one entry per repeat rule. |
scheduled-tasks/ClaudeReminder_Dispatch.template.xml |
- | The planner task definition, with your user, PC name and SID replaced by placeholders. Normally you do not need it: install_dispatcher.ps1 builds the task correctly from scratch. |
Generated data is deliberately not in this project: wav/*.wav,
state/rendered.json and state/plan.stamp. They hold your own reminder text
as files and as audio. reminder sync rebuilds all of them from
reminders.json in about two minutes.
install_dispatcher.ps1 holds the schedule: at logon, plus every 5 minutes
from 06:00 to 09:00. Re-running it overwrites anything changed by hand in the
Windows Task Scheduler window.
So change the schedule in install_dispatcher.ps1 and re-run it, rather than in
the Task Scheduler UI.
schtasks.exe /query /tn "ClaudeReminder_Dispatch" /xml shows what is really
registered.
Needs claude-audio-feedback installed first. This project uses two things from it:
- its venv and Kokoro model, to render the WAV files, and
- its
play-hidden.vbs, so a reminder does not flash a console window when it speaks. Without that filedispatch.ps1falls back toplay.ps1, which still works but flashes.
-
Windows files:
W="$(wslpath -u "$(cmd.exe /c 'echo %USERPROFILE%' 2>/dev/null | tr -d '\r')")/.claude/reminders" mkdir -p "$W/wav" "$W/state" cp windows/dispatch.ps1 windows/install_dispatcher.ps1 windows/play.ps1 windows/dispatch-hidden.vbs "$W/" cp windows/reminders.example.json "$W/reminders.json"
Then edit
$W/reminders.jsonand put your own reminders in it. -
WSL files:
cp wsl/reminder.py wsl/reminder_hook.py ~/.claude/scripts/tts/ mkdir -p ~/bin && cp wsl/bin-reminder ~/bin/reminder && chmod +x ~/bin/reminder cp claude-code/reminder.md ~/.claude/commands/
~/.profilealready adds~/binto PATH for login shells. For VS Code terminals, add this to~/.bashrc:case ":$PATH:" in *":$HOME/bin:"*) ;; *) PATH="$HOME/bin:$PATH" ;; esac
-
Merge the hook from
claude-code/settings.hook.jsoninto~/.claude/settings.json. Keep your other hooks. -
Register the planner task (from WSL is fine):
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$(wslpath -w "$W/install_dispatcher.ps1")" -
Render and plan:
reminder sync reminder next 14
| Environment variable | Default | Meaning |
|---|---|---|
CLAUDE_REMINDERS_DIR |
<Windows user profile>/.claude/reminders, found by asking cmd.exe |
Where reminders.json, wav/ and state/ live. |
CLAUDE_TTS_VOICE |
af_nicole |
Kokoro voice used when rendering. |
Nothing in the code hardcodes a user name or a home folder.
reminder list every reminder
reminder list same
reminder next [days] preview upcoming pings (default 14)
reminder help full usage
reminder add <name> --at HH:MM --repeat <spec> [--lead <min>] --text <words...>
reminder edit <name> [--at HH:MM] [--repeat <spec>] [--lead <min>] [--text <words...>]
reminder delete <name>
reminder sync re-render voices and rebuild tasks
/reminder ... works the same inside Claude Code. The read-only commands
(help, list, next, no argument) are answered by the hook and cost no
tokens. add, edit, delete and sync go to Claude, so it can explain an
error.
| Spec | Meaning |
|---|---|
daily |
every day |
mon … sun |
every week on that day |
<n>weeks@YYYY-MM-DD |
every n weeks, counting from that date |
<weekday>:<nths> |
e.g. fri:1,3,5 - 1st, 3rd and 5th Friday |
day:<n> |
e.g. day:15 - the 15th of every month |
once@YYYY-MM-DD |
a single date, then never again |
yearly@MM-DD |
e.g. yearly@03-30 - every 30 March |
--lead <min> adds a second ping that many minutes earlier, spoken as
"In N minutes. <text>". Default 30. --lead 0 disables it.
Several weekdays in one entry (mon+wed+fri), every n days, an end date, and
different lead times for the same entry. These need a code change in both
reminder.py (the spec parser) and dispatch.ps1 (Test-Due).
A once entry is not deleted after it fires. list marks it (past).
- Missed pings. Each run task uses
-StartWhenAvailablewith an EndBoundary 20 minutes after its time ($graceMinindispatch.ps1). A ping missed because the PC slept still fires shortly after wake. An older one is skipped, not shouted hours late. - 29 February fires on 28 February in normal years.
day:31never fires in short months. Pre-existing limit, not used.- Run-task names include the time (
__MMdd-HHmm). A daily reminder appears twice inside the 36 h window, and one name per WAV would let the later ping overwrite the sooner one.
| Problem | Cause | Fix in the code |
|---|---|---|
Every triggered run failed silently (LastTaskResult = 1) |
A fresh non-interactive powershell.exe from Task Scheduler does not inherit a permissive execution policy |
-ExecutionPolicy Bypass in every task action |
Register-ScheduledTask returned "Access is denied" |
An AtLogOn trigger without -User means "any user", which needs elevation |
New-ScheduledTaskTrigger -AtLogOn -User "$env:USERDOMAIN\$env:USERNAME" |
| A repeating daily trigger could not be built | New-ScheduledTaskTrigger has no repetition parameter for -Daily |
Borrow the Repetition block from a throwaway -Once trigger |
/reminder with no argument errored out |
argparse required a subcommand | reminder.py defaults to list |
| A console window flashed on every reminder | Task Scheduler launching powershell.exe gets a console before -WindowStyle Hidden can hide it |
The run tasks call wscript.exe with play-hidden.vbs, which is GUI-subsystem and never gets a console |
| A console window still flashed up to 5 times each morning | Same cause, but the planner task was missed when the run tasks were fixed | The planner task calls wscript.exe with dispatch-hidden.vbs |
# Play a WAV directly, no scheduler:
powershell.exe -NoProfile -File "%USERPROFILE%\.claude\reminders\play.ps1" -Name CinemaReminder
# Preview the date rules without touching the scheduler:
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%USERPROFILE%\.claude\reminders\dispatch.ps1" -Preview 35
# Re-plan now, ignoring the stamp:
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%USERPROFILE%\.claude\reminders\dispatch.ps1" -Force
# What is armed right now?
schtasks.exe /query /fo csv /nh | findstr ClaudeReminder
# Did the planner succeed?
Get-ScheduledTaskInfo -TaskName "ClaudeReminder_Dispatch" | Select LastRunTime, LastTaskResultLastTaskResult = 267011 means "has not run yet". That is normal for a future
one-shot task.
T=~/.claude/scripts/tts
W="$(wslpath -u "$(cmd.exe /c 'echo %USERPROFILE%' 2>/dev/null | tr -d '\r')")/.claude/reminders"
R=~/projects/reminder
cp $T/reminder.py $T/reminder_hook.py $R/wsl/
cp ~/bin/reminder $R/wsl/bin-reminder
cp ~/.claude/commands/reminder.md $R/claude-code/
jq '{hooks: {UserPromptExpansion: .hooks.UserPromptExpansion}}' ~/.claude/settings.json > $R/claude-code/settings.hook.json
cp "$W/dispatch.ps1" "$W/install_dispatcher.ps1" "$W/play.ps1" "$W/dispatch-hidden.vbs" $R/windows/Code only, on purpose. reminders.json, wav/ and state/ are your own
data and must stay out of here. The task template is already generic, so there
is nothing to re-export either.