Turn a blink(1) USB light into an ambient status display for your desk. It warns you before meetings and glows red while you're in one, chimes on the hour, plays a short rainbow at the end of the day, fires named daily alarms, and lights up when an AI coding agent finishes or gets stuck.
Windows-first, written in Python, no admin rights needed. One batch file sets everything up.
The defaults assume a working day: standup alerts at 08:13, dark from 17:00. Change any of them in your own config (see Config). Example setup walks through a day with the defaults and why they are shaped that way.
- Windows 10 or 11
- A blink(1) USB light. Effects that use both LEDs need an mk2 or newer.
- Python 3.12 or newer (tested on 3.12 and 3.13), on
PATHor through thepylauncher - Optional, for calendar colours: a Microsoft 365 account (Graph), or the classic Outlook desktop app (COM)
git clone https://github.com/jameskbb/blink-light.git
cd blink-light
blink-light.bat devicesEverything runs through blink-light.bat from the repo root. The first run creates .venv, installs requirements.txt, then runs the command: there is no separate install step.
blink-light.bat devices
blink-light.bat light color "#00C853"
blink-light.bat config init
blink-light.bat statusdevices confirms the light is seen. light color proves it works. config init writes blink-light.json, auto-filling device.serial when exactly one device is connected. That file is yours: it is gitignored, so your serial and schedule stay on your machine. Without one, the built-in defaults apply. blink-light.example.json shows every key at its default. status shows what the watcher would do right now.
Running blink-light.bat with no arguments prints a welcome or, if calendar.enabled and calendar.auto_watch_on_launch are both true, starts the background watcher immediately.
For full first-time setup (installing the scheduled tasks, verifying the chime and show actually fire, troubleshooting), see the Runbook.
| Page | Read it when you want to |
|---|---|
| Runbook | set up the scheduled tasks, check they fire, troubleshoot, or remove everything |
| Architecture | understand how the scheduling works, or add a feature |
| Effects | design your own flashes, with pasteable scenes |
| Integrating | flash the light from your own script, bot, or agent: hand this page to it whole |
| Herdr | make the light react when an AI coding agent finishes or gets blocked |
| Example setup | see a complete day with the defaults, and what a real config changes |
| Reference card | print the colours and pin them above the desk (PDF, 2 pages) |
Everything a fresh install does, in one place. Values are the built-in defaults from
blink_light/defaults.py, mirrored in
blink-light.example.json; your own
blink-light.json can override any of them.
There is a printable version of this section:
docs/blink-light-reference-card.pdf.
Two pages, built from the same defaults by tools/build_reference_card.py, for
pinning above the desk.
| When | What | Colour | Length | Preview it |
|---|---|---|---|---|
| 08:13 daily | Standup in 2 minutes: 3 flashes | 🟡 #FFD700 gold |
0.96s | alarm test standup_warning |
| 08:15 daily | Standup now: 6 faster flashes | 🟡 #FFD700 gold |
1.44s | alarm test standup_now |
| :00 every hour | One white breath | ⚪ #FFFFFF |
0.8s | chime test |
| 17:00 daily | Rainbow swirl across both LEDs | 🌈 28 hues | 9.92s | show test |
| 17:15 daily | The same rainbow again, for when you worked through the first one | 🌈 28 hues from #730000 |
9.92s | alarm test rainbow_encore |
| Trigger | What | Colour | Length | Preview it |
|---|---|---|---|---|
| AI agent finishes (Herdr) | One slow breath | 🔵 #4FC3F7 Herdr blue, full |
0.90s | notify run agent_done |
| …and more agents are still waiting | One slow climb out of the dark | 🔵 #14313E → #4FC3F7 Herdr blue, 25% climbing to full |
1.12s | notify run agent_done_more |
| AI agent blocked | 3 quicker breaths | 🔴 #801E00 dim red |
1.26s | notify run agent_blocked |
| GitHub Actions run you triggered fails (opt-in) | 2 pairs of heartbeat thumps | 🟠 #803C00 dim orange-red |
2.40s | notify run ci_failed |
| A pull request review is requested from you (opt-in) | A pulse handed from the top LED to the bottom, twice | 🟣 #442C72 dim violet |
2.24s | notify run pr_review_requested |
| You're @mentioned on a pull request (opt-in) | A slow swell with a flicker at its peak, twice | 🟣 #442C72 dim violet |
2.56s | notify run pr_mentioned |
| Someone reviews your pull request (opt-in) | A two-step climb, twice | 🟣 #1B112E dimmer violet |
1.98s | notify run pr_review_received |
| A new request lands in a queue you watch | A rainbow sweep across both LEDs | 🌈 12 hues from #730000 |
2.46s | notify run triage_request |
Only while watch start is running. This is the background state the light
returns to; everything above plays on top of it.
| Situation | Colour |
|---|---|
| Nothing scheduled | 🟢 #00C853 green |
| In a Busy or Tentative meeting | 🔴 #D50000 red |
| Meeting in 10 minutes | 🔵 #00E5FF cyan, 2 slow blinks |
| Meeting in 5 minutes | 🟣 #FF00C8 magenta, 4 quick blinks |
| Idle 10+ minutes | 🔵 #2962FF blue |
Meetings marked Free, Out of Office or Working Elsewhere are ignored
entirely: no colour, no warning. Only alert_statuses (Tentative and Busy by
default) moves the light.
One LED has to carry a dozen meanings, so the palette is grouped rather than picked per effect. Worth keeping if you re-colour anything:
| Band | Means | Effects |
|---|---|---|
| 🔵 cyan / 🟣 magenta flashes | A meeting is coming | 10 min, then 5 min |
| 🔴 red steady | You are in the meeting | Busy or Tentative |
| 🟡 gold / 🟠 orange / 🔴 dim red | Something wants you | Standup, failed CI, blocked agent |
| 🟣 dim violet | Pull requests | Review requested, @mention, review received |
| 🟢 green / 🔵 blue steady | Your own state | Free, idle |
| ⚪ white | The hour | The chime |
The meeting warnings were yellow and orange until five different flashes ended up in that warm band at once; cyan and magenta are used by nothing else.
A watcher that dies takes these colours with it and nothing else changes, so
the scheduler restarts one that has gone missing; see
Supervising the watcher.
watch status reports running, paused and heartbeat_age_seconds, and
watch start exits non-zero if the watcher does not come up.
17:00 – 07:00 by default the light is off, and the chime and alarms stay
silent. Each effect can opt out with respect_quiet_hours: false, and the
window itself is settings.quiet_hours.
The two rainbows are what opt out. The show fires at exactly 17:00 and the
window is inclusive of its start, so respecting quiet hours would retire the
rainbow permanently; instead it still plays, as the last thing the light does
before going dark. The rainbow_encore alarm at 17:15 opts out for the same
reason: it is a second chance to catch the first one, so it has to reach into
the quiet window to be worth anything.
Because 08:13 and 08:15 sit outside the window, the standup alerts are unaffected.
blink-light.bat alarm status
blink-light.bat chime status
blink-light.bat show status
blink-light.bat notify listEach prints next_slot and when it last fired, plus a reason when something is
not going to fire. Nothing here fires twice for the same moment; see
Architecture for why.
One white pulse every hour, on top of whatever the watcher is showing.
blink-light.bat chime test
blink-light.bat chime install
blink-light.bat chime status
blink-light.bat chime uninstallchime install registers a Windows scheduled task named BlinkLight Hourly
Chime that fires at :00 regardless of whether the watcher or the
autostart loop is running.
| Key | Default | Meaning |
|---|---|---|
enabled |
true |
Master switch. |
color / secondary_color |
#FFFFFF / #000000 |
Pulse colour, and the colour it fades back to. |
on_ms / off_ms |
400 |
Fade-up / fade-down duration. |
count |
1 |
Pulses per chime. |
minute |
0 |
Minute past the hour. Re-run chime install after changing it. |
catch_up_window_seconds |
300 |
How late a missed slot may still fire (covers a laptop waking up). |
respect_quiet_hours |
true |
Skip inside settings.quiet_hours. |
chime now fires only if this hour's pulse is still due; --force ignores
quiet hours, the catch-up window, and the already-fired guard. chime run
runs the loop in the foreground; chime stop asks it to exit. See
Architecture for how the loop, the hourly task and the
watcher all share one dedupe file without double-pulsing.
A rainbow swirl at 17:00, capped at 10 seconds.
blink-light.bat show test
blink-light.bat show now
blink-light.bat show statustest plays it now without consuming today's slot. It runs on the same
scheduler loop as the chime, so nothing extra to install. It is a hue
rotation, not a strobe: both LEDs stay half a turn apart in colour, and every
step is a fade, brightness swelling from 45% to full and back before a clean
fade to black: 29 steps, 9.92s, uploaded to the device so playback stays
smooth even when the machine is busy.
| Key | Default | Meaning |
|---|---|---|
enabled |
true |
Master switch. |
at |
"17:00" |
Time of day, HH:MM. |
scene |
"rainbow_swirl" |
Any non-looping scene in scenes. |
max_seconds |
10 |
Hard cap, enforced at config validate time, not by truncating playback. A longer scene fails validation with its measured duration. |
catch_up_window_seconds |
300 |
How late a missed show may still play. |
respect_quiet_hours |
false |
Off by default here: 17:00 is the first minute of quiet hours, so respecting them would mean the show never plays. |
show now --force ignores quiet hours, the catch-up window and today's guard.
Want a different show? Effects has a sunrise ramp that fits the cap.
Named flashes at wall-clock times. Two ship by default (see the schedule table above): full-brightness gold on purpose, since unlike the Herdr notification these are meant to catch your eye across the room.
blink-light.bat alarm status
blink-light.bat alarm test <name>
blink-light.bat alarm run <name>test plays one now without consuming today's slot; run consumes it.
alarm now fires whatever is currently due: that is what the scheduler loop
calls. Each alarm dedupes on its own key, so the 08:13 and 08:15 pair never
swallow each other.
Alarms live in a top-level alarms list, so adding another is a config entry,
not a code change:
"alarms": [
{
"name": "standup_warning",
"at": "08:13",
"action": { "scene": "standup_warning_scene" },
"enabled": true,
"catch_up_window_seconds": 120,
"respect_quiet_hours": true
}
]| Key | Required | Meaning |
|---|---|---|
name |
yes | Unique. Keys the once-per-day dedupe. |
at |
yes | HH:MM, 24-hour. |
action |
yes | Any action: scene, preset, color, or off. |
days |
no | e.g. ["mon","tue","wed","thu","fri"]. Omit for every day. |
enabled |
no | Default true. |
catch_up_window_seconds |
no | Default 300. |
respect_quiet_hours |
no | Default true. |
Setting alarms in your config replaces the defaults rather than merging,
so "alarms": [] disables all three shipped ones. Add "days" to the standup
pair to skip weekends.
One-shot named flashes for external tools. No slots, no dedupe: it fires whenever you call it.
blink-light.bat notify list
blink-light.bat notify run agent_doneEvents live under notify in blink-light.json, so an integration names an
event and the config decides how it looks:
"notify": {
"agent_done": { "scene": "agent_done_scene" },
"agent_blocked": { "scene": "agent_blocked_scene" }
}Add --quiet-missing for callers that must not fail when an event is
undefined.
There is no server and no port: the CLI verb is the whole interface. A tool triggers a flash by running a command, which means no daemon to keep alive, nothing listening on the machine, and no auth to get wrong. Calling it from your own tool is a few lines: that page is written to be handed to an agent whole.
Shipped integration: Herdr flashes when an AI agent finishes or gets blocked.
herdr plugin link <repo>\integrations\herdrThe scheduler can flash ci_failed for failed workflow runs you triggered, across
repositories. This integration is off by default. It uses your existing GitHub CLI
login and adds no Python dependency.
- Run
gh auth status --hostname github.comto check your login. - Add
"github": { "enabled": true }to your localblink-light.json. - Run
blink-light.bat config validate. - Run
blink-light.bat github check --dry-runto preview the feed without a flash or state change. - Run
blink-light.bat autostart enableto restart the scheduler with the new config.
The first successful poll records existing notifications as a baseline and flashes nothing. Later polls detect new failures by notification ID and update timestamp. A failed rerun can flash again. Quiet hours consume failures silently, so they do not replay in the morning. An absent device also consumes the event.
| Config key | Default | Meaning |
|---|---|---|
github.enabled |
false |
Enable polling in the scheduler. |
github.poll_seconds |
60 |
Minimum interval in seconds. GitHub can require a longer interval. |
github.respect_quiet_hours |
true |
Consume events silently during quiet hours. |
github.actions_failed_event |
"ci_failed" |
Event name in notify. |
github status shows the login source, last poll, watermark, and last flash.
github check polls immediately when enabled. --dry-run shows matches and
would_flash without saving state or touching the light. github test plays the
configured CI failure event by default, even when polling is disabled; --event
picks another GitHub event to play instead.
The feed contains the 50 most recently updated notifications. More than 50 updates between polls can hide a failure. GitHub must create a notification for the run. The integration never marks notifications read. The token stays in memory and never enters the config or state file.
Conditional requests use Last-Modified. Unchanged feeds return HTTP 304 without
consuming the API rate limit. See GitHub notifications.
Rides on the same poll as GitHub Actions Failures above: one integration,
github.enabled, no second credential. All three pull-request toggles are on
by default once GitHub polling is on.
What flashes:
- A review is requested from you.
- You're @mentioned, or team-mentioned, on a pull request.
- Someone else reviews a pull request you authored, bots included.
What does not flash: plain comments and pushes on your own pull request, your
own review replies, logins in ignore_logins, mentions on issues, and the
subscribed, state_change, assign, and comment notification reasons.
The unread rule. A pull request thread that keeps the same reason flashes
once, then stays quiet on later activity: it flashes again only once you've
read it on GitHub, or once its reason changes (a subscribed thread that
becomes a mention, say).
Review detection costs one extra read: GET {pull request}/reviews once
per new update on a pull request you authored, to tell a real review apart
from a plain comment. It also reads your GitHub login once per scheduler run,
cached in memory like the token. Both share the same 5-second budget as the
feed request; a check the budget or a stop request cuts short is logged and
never flashes: the next update tries again.
The first poll after enabling this, or after upgrading from an Actions-only setup, records existing pull-request activity as a silent baseline, the same way the first Actions poll does. Quiet hours consume pull-request activity the same way too.
| Config key | Default | Meaning |
|---|---|---|
github.pr.review_requested |
true |
Flash pr_review_requested when a review is requested from you. |
github.pr.mentioned |
true |
Flash pr_mentioned on an @mention or team mention. |
github.pr.review_received |
true |
Flash pr_review_received when someone else reviews your pull request. |
github.pr.ignore_logins |
[] |
Logins whose reviews never flash, matched without regard to case. |
Muting a noisy bot:
{ "github": { "pr": { "ignore_logins": ["example-review-bot[bot]"] } } }Preview it:
blink-light.bat notify run pr_review_requested
blink-light.bat github test --event review_receivedblink-light.bat autostart enable
blink-light.bat autostart status
blink-light.bat autostart disable
blink-light.bat autostart enable --no-startRegisters a scheduled task named BlinkLight Autostart that starts the
chime loop at every logon, and starts it immediately so you do not have to
reboot to begin. Trigger is logon rather than machine start, because the
blink(1) is a USB device in your interactive session, not something the
pre-logon SYSTEM context can see. disable stops the running loop and removes
the task; --no-start registers the task but waits for the next logon.
Keep both autostart enable (the live runner) and chime install (the
stateless backstop); they cannot double-pulse, and together they cover a
dead loop or a machine asleep at the top of the hour. Both tasks are allowed
to start on battery, so an undocked laptop still keeps the schedule. See
Architecture for why.
startup enable is the older Startup-folder mechanism; it launches the full
calendar watcher instead of just the chime loop, so use it if you want
calendar colours without a login task.
The default config is Outlook-first. Which provider it reads from decides how the resting-colour table above gets its data.
| Provider | Reads | Setup |
|---|---|---|
graph |
Your Microsoft 365 mailbox on the server, the same data the Outlook web app shows | One-time Entra app registration, then calendar login |
outlook |
The classic Outlook desktop client's local cache, over COM | None |
Use graph if you live in the Outlook web app. The COM provider only sees
what the classic desktop profile has cached locally, which can silently omit
meetings, in particular ones you were invited to but did not organise. Graph
reads the mailbox itself, so what the light sees is what the web app sees.
One-time, about five minutes. You register your own app, so nothing here depends on anyone else's tenant:
- Open Entra app registrations → New registration. Name it
blink-light, and under Supported account types pick Accounts in this organizational directory only. - Under Redirect URI, choose Public client/native and enter
http://localhost. - Register, then copy the Application (client) ID and Directory (tenant) ID into
.env(see Secrets below), not intoblink-light.json, which holds behaviour and gets shared as a template. - API permissions → Add a permission → Microsoft Graph → Delegated →
Calendars.Read→ Add. If your tenant requires it, click Grant admin consent (or ask an admin to). - Fill in
.env, set"provider": "graph"inblink-light.json, then run:
copy .env.template .env
rem edit .env, then:
blink-light.bat calendar login
blink-light.bat calendar upcoming --hours 24calendar login opens a browser, falling back to a device code if it cannot,
and caches the refresh token at
%LOCALAPPDATA%\BlinkLight\graph-token-cache.json, outside the repo, so it
cannot be committed by accident. calendar logout removes it. The only scope
requested is Calendars.Read; the light never writes to your calendar.
calendar.alert_statuses is a list of Outlook OlBusyStatus values, and only
those events do anything at all:
| Outlook status | Value | Default |
|---|---|---|
| Free | 0 |
ignored |
| Tentative | 1 |
✅ colour + warnings |
| Busy | 2 |
✅ colour + warnings |
| Out of Office | 3 |
ignored |
| Working Elsewhere | 4 |
ignored |
An ignored event cannot colour the light or fire a warning, and it cannot hide
a real meeting either: a Free block overlapping a Busy one is skipped, and the
Busy meeting still gets its warnings. To make Out of Office count too, set
"alert_statuses": [1, 2, 3].
Notes:
- All-day events are ignored by default; cancelled meetings are dropped (Graph provider).
- Graph reports availability as words (
busy,oof, …); they are mapped onto the same Outlook status numbers above, soalert_statusesmeans one thing regardless of provider. - Each warning is one-shot per meeting, and the warnings own windows rather than deadlines: starting the watcher three minutes before a meeting gets you the five-minute warning only, never a ten-minute warning that is already false.
blink-light.bat statusreportsevent_countalongsidealertable_event_count, which is usually the answer to "why is my light still green?"
Credentials never live in the repo. Three layers enforce that:
| Layer | What it does |
|---|---|
.env (gitignored) |
Where ids and credentials actually live. .env.template documents the keys and is the only .env.* file that is committed. |
.gitignore |
Ignores .env, blink-light.json, *.local.json, *token-cache*.json, *.token. |
.githooks/pre-commit |
Blocks a commit that stages one of those files (even with git add -f) or that adds a credential-shaped value to any file. |
Install the hook once per clone:
git config core.hooksPath .githooksThe rules live in blink_light/secret_scan.py, and the test suite runs the
same scan over every tracked file, so a leak fails the build as well as the
commit. Run it by hand any time:
.venv\Scripts\python -m blink_light.secret_scan --trackedRecognised variables (all optional except the first two, and only for the Graph provider):
| Variable | Sets |
|---|---|
BLINK_LIGHT_GRAPH_CLIENT_ID |
calendar.graph.client_id |
BLINK_LIGHT_GRAPH_TENANT_ID |
calendar.graph.tenant_id |
BLINK_LIGHT_CALENDAR_PROVIDER |
calendar.provider |
BLINK_LIGHT_DEVICE_SERIAL |
device.serial |
Real environment variables win over .env, so a scheduled task can override
the file without editing it.
blink-light.bat light color "#00C853"
blink-light.bat light flash "#D50000" --count 4
blink-light.bat light pulse "#FFB300" --count 0
blink-light.bat light scene timer_done_scene
blink-light.bat preset list
blink-light.bat preset run focus
blink-light.bat override set --preset busy --expires-in 30m --reason "heads down"
blink-light.bat override clear
blink-light.bat override status
blink-light.bat timer start 25m
blink-light.bat timer start pomodoro
blink-light.bat timer pause
blink-light.bat timer resume
blink-light.bat timer stop
blink-light.bat watch once
blink-light.bat watch run
blink-light.bat watch start
blink-light.bat watch stop
blink-light.bat calendar login
blink-light.bat calendar status
blink-light.bat calendar upcoming --hours 24
blink-light.bat calendar logout
blink-light.bat github status
blink-light.bat github check --dry-run
blink-light.bat github check
blink-light.bat github test
blink-light.bat github test --event review_requested
blink-light.bat config init
blink-light.bat config validate
blink-light.bat config show
blink-light.bat autostart enable
blink-light.bat autostart status
blink-light.bat startup enable
blink-light.bat startup statusYour config file is blink-light.json in the repo root. It is machine-local:
.gitignore excludes it and the secret scan refuses to let it be committed, so
a device serial or a personal schedule cannot end up in a push. Start from
blink-light.example.json (exactly what
config init writes with no device attached), or leave the file out and run on
the built-in defaults.
You only need the keys you want to change. The file is merged over the defaults, so this is a complete config that moves the standup alerts and keeps the light on until 18:00:
{
"alarms": [
{ "name": "standup_warning", "at": "09:28", "action": { "scene": "standup_warning_scene" } },
{ "name": "standup_now", "at": "09:30", "action": { "scene": "standup_now_scene" } }
],
"settings": { "quiet_hours": { "start": "18:00" } }
}After editing, run blink-light.bat config validate, then
blink-light.bat autostart enable so the running loop picks it up.
Its top-level keys:
device, calendar, github, chime, show, alarms, notify, presets,
scenes, routines, rules, settings.
The watcher's precedence, highest first:
- manual override
- calendar (when
calendar.enabled) - active timer/routine phase
- first matching local rule
- quiet hours
settings.default_action
Supported local rule conditions: idle_seconds_gte, process_running_any,
file_exists, file_contains, time_between, battery_below_percent,
charging, timer_active, routine_phase.
The chime, show, alarms and notify sit outside that chain entirely: each is a one-shot played on top of whatever the light is already showing, and the watcher repaints the underlying state on its next tick.
| Path | Purpose |
|---|---|
blink-light.bat |
Launcher. Bootstraps .venv, installs requirements.txt, runs the CLI. |
blink-light-chime.bat |
Runner invoked by the hourly task; calls chime now. |
blink-light-chime.vbs |
Hides the console window for the hourly task. |
blink-light-autostart.bat |
Long-running runner started at logon; calls chime run. |
blink-light-autostart.vbs |
Hides the console and waits on the loop (single-instance guard). |
blink-light.example.json |
Config template: every key at its default. Committed. |
blink-light.json |
Your config. Gitignored; created by config init or copied from the template. |
blink_light/chime.py |
Hourly chime: slots, dedupe, scheduler loop, scheduled-task management. |
blink_light/show.py |
Daily light show, same slot/dedupe shape as the chime. |
blink_light/alarms.py |
Named daily alarms; shares slot logic with the show. |
blink_light/notify.py |
Named one-shot notifications for external tools. |
integrations/herdr/ |
Herdr plugin: manifest plus its event handler. |
blink_light/watcher.py |
Background loop, action precedence. |
blink_light/cli.py |
Argument parsing and command dispatch. |
docs/ |
Runbook, architecture, effects, the Herdr integration, and an example setup. |
tools/build_reference_card.py |
Builds the printable colour card from the defaults. Stdlib only. |
tests/ |
unittest suite, no hardware required. |
git config core.hooksPath .githooks
.venv\Scripts\python.exe -m unittest discover -s tests -vThe suite needs no hardware (device I/O is mocked) and runs in about five
seconds. It also checks the docs against the code: the schedule tables above
must match the defaults, and blink-light.example.json must equal
default_config(). Change a default and the suite tells you which docs to
update.
MIT.




