MeetAlert
Unmissable meeting alerts for your Mac.
Desktop popup + phone push + urgent escalation — so "I didn't see the calendar reminder"
stops being an excuse.
brew install --cask roypadina/tap/meetalert
Table of Contents
Features
- Multiple alerts per meeting at whatever offsets you configure — minutes before, at start, or after (a late nag) — not just one.
- Desktop popup that stays on screen until you act on it — no auto-dismiss, no silently missed alert — shown on whichever display your mouse is on.
- Live countdown that changes colour as it gets close — a large ticking numeral (minutes, then seconds, then how late you are) with the whole card shifting cyan → amber → red, and a pulsing rim once escalation has pinged your phone. State is always spelled out in words and icons too, never colour alone.
- A colour per calendar — each alert shows its calendar's name and colour dot, starting from the colour macOS already uses in Calendar.app and overridable per calendar in Settings → Calendars.
- Keyboard shortcuts on the popup — click the card once (this never steals focus from the app you're typing in), then
↩/J join · 5 snooze 5m · 1 snooze 1m · T until start · esc snooze. The two final actions need a modifier on purpose, so ordinary typing can never kill an alert: ⌘D dismiss, ⌘⇧I ignore.
- One-tap Join — a prominent Join button (and a matching push action) opens the Zoom/Meet/Teams/Webex/Whereby link straight from the alert, and every upcoming meeting with a link is joinable from the menu bar too.
- Phone push via ntfy at the same moment, with tappable ACK, Join, and Snooze 5m actions.
- Away-aware escalation — idle past a threshold or screen-locked, and the first push already goes out at urgent priority instead of waiting on a grace window nobody at the desk would see.
- Repeating urgent escalation — up to
escalationRepeats re-pushes (3 by default, never past 15 minutes after the meeting starts) until you ack or snooze, not just one.
- Morning agenda push — an optional daily rundown of today's meetings and your largest free gap.
- Reads every calendar macOS syncs — Google, iCloud, Exchange, CalDAV — with zero API setup, and skips meetings you've declined. See below.
- Travel-lead alerts for meetings with a physical address — one extra early alert to account for getting there.
Install
Homebrew
brew install --cask roypadina/tap/meetalert
Requirements
macOS 14 Sonoma or later · Apple Silicon and Intel (universal binary)
First launch
MeetAlert is ad-hoc signed (not notarized). If macOS blocks it, open System Settings →
Privacy & Security, scroll down and click Open Anyway next to MeetAlert (right-click → Open
no longer works on macOS 15+). Or clear quarantine once:
xattr -dr com.apple.quarantine /Applications/MeetAlert.app
On first launch macOS will prompt for Calendar access — MeetAlert can't see your meetings
without it. A Welcome window then offers to open MeetAlert at login (you can change it
any time in Settings → Alerts, or in System Settings → General → Login Items & Extensions);
MeetAlert never adds itself silently. Reopen the Welcome window any time from Welcome Guide… in the
status menu. See the
Installation wiki page.
Build from source
git clone https://github.com/roypadina/MeetAlert.git
cd MeetAlert
./build.sh
open build/MeetAlert.app
build.sh runs swift build -c release --arch arm64 --arch x86_64 (universal) and assembles/ad-hoc-signs build/MeetAlert.app.
No Xcode project — it's a plain Swift Package executable.
ntfy setup
This is the part that makes escalation actually reach your phone — don't skip it.
- Install the ntfy app: App Store (iOS) or
Play Store (Android).
- Pick a private, random topic name — a long random string, e.g.
meetalert-9f3a1c7b2e.
A topic name is effectively a password: on the public ntfy.sh server, anyone who knows the
name can publish to it or read it. Don't use anything guessable.
- In the ntfy app, subscribe to that topic (and leave the server as
ntfy.sh, unless
you're self-hosting).
- Put the same topic (and server, if not
ntfy.sh) into MeetAlert: menu bar icon →
Settings… → ntfy section, or edit ~/.config/meetalert/config.json directly.
ntfyTopic is empty by default — phone push, and escalation with it, stays completely
off until you set one. This step is required for anything beyond the desktop popup.
- In the ntfy app, make sure urgent (priority 5) notifications are set up to bypass
silence/Do Not Disturb — that's the whole point of the escalation. The exact steps differ
between Android and iOS and are more fiddly than you'd hope; see the
ntfy Setup wiki page for the
per-platform walkthrough (short version: Android needs a per-channel "override Do Not
Disturb" toggle; iOS's Focus-mode bypass is a known ntfy limitation, so also allow the ntfy
app explicitly under Focus mode settings as a backstop).
- On Android, leave Settings → Notifications → "Keep alerting for highest priority" off
(that's the default) and let MeetAlert do the repeating instead — see
Why not ntfy's insistent ring? below. If you turn it on
anyway, tapping ACK will not silence it.
Self-hosted ntfy servers work the same way — just set ntfyServer to your own instance. If it
requires a login, paste an ntfy access token in Settings → ntfy (stored in the macOS Keychain,
sent only to that server, including inside the ACK / Snooze buttons); an optional fallback topic
URL gets the alert (without buttons) if the main server is unreachable. Details in the
ntfy Setup wiki page. Keep
message caching enabled on the server (the default) — with cache-duration: 0, there's no
message history left to poll, so MeetAlert can never see an ACK/snooze reply and escalates the
full escalationRepeats times regardless of whether you actually acked.
Why not ntfy's insistent ring?
The ntfy Android app has a "Keep alerting for highest priority" setting (off by default) that
loops an alarm sound for every priority-5 notification until it's stopped. It sounds like exactly
what MeetAlert wants. It isn't — tapping ACK cannot stop it.
In the app, that loop is one shared MediaPlayer, and only two things ever stop it:
- swiping the notification away (its delete intent), or
- opening the topic's detail screen in the app.
An action button with clear: true — which is what MeetAlert's ACK and Snooze 5m are —
cancels the notification programmatically. A programmatic cancel does not fire the delete
intent, so the loop keeps playing over a notification that is already gone and already marked
read. Same for a view action with clear: true. There is no publish-side way to stop it either:
nothing you can send to the topic silences a ring that's already started.
The result, if you enable it: ACK reaches MeetAlert and stops the escalation correctly, and the
phone keeps ringing anyway until you separately swipe the (vanished) notification or open the
topic. Swiping first doesn't help — ntfy has no dismiss callback, so a swipe tells MeetAlert
nothing, isn't an ack, and the next escalation re-push starts the ring again.
So leave that setting off and let MeetAlert own the repetition: set a short escalationSeconds
and a higher escalationRepeats (e.g. 45 and 8 ≈ six minutes of pings). Every ping is a
normal priority-5 notification you can silence, and a single ACK genuinely ends all of them.
The popup
One instrument, read from across the room: a big monospaced-digit countdown, and a colour that
changes with urgency rather than decoration.
| State | When | Colour | Countdown |
|---|
| Starting in | more than 60 s to go | cyan | minutes (3 / min) |
| Starting in | 60 s or less | amber | seconds (35 / sec) |
| Started | at or after the start | coral | now, then +2 / min late |
| Not acknowledged — phone alerted | escalation has pushed to your phone | keeps the state colour | unchanged, rim thickens and pulses |
Urgency is never colour alone — the state word, the icon, the rim width and the countdown's unit
all change together, so it survives colour-blindness, Increase Contrast (which drops the
translucency for an opaque background and a heavier border) and Reduce Motion (which keeps the
fades and drops the movement).
The row of actions is Join (the one filled button, ↩), Snooze 5m,
Dismiss (⌘D), Ignore/Ignore series (⌘⇧I), and More ▾ holding the two
remaining snoozes. With no meeting link, Dismiss becomes the filled button — there's always
exactly one. Nothing about the layout changes on hover; only the buttons themselves light up.
How it works
| When | What happens |
|---|
Each offset in alertMinutesBefore (before, at, or after start), plus one travelLeadMinutes offset for meetings with a physical address | Desktop popup appears on whichever screen your mouse is on, and an ntfy push goes out with ACK, Join (if there's a meeting link), and Snooze 5m actions. Tapping the push body itself opens the meeting link. |
You're away from the Mac (idle past awayIdleSeconds, or the screen is locked) | That first push skips the high-priority grace window and goes straight out at urgent priority — nobody's at the desk to see the popup anyway. |
| Several offsets of the same meeting come due in one scan (e.g. the event synced in late) | They collapse into one alert — the latest due one — instead of stacking popups. |
| Anything up to escalation | Clicking Dismiss, Join, Snooze, Till start, or Ignore forever on the popup — or tapping ACK on the push — counts as acknowledged; escalation stops. Tapping Snooze 5m on the push snoozes it (clamped to the meeting's start) and also stops escalation. Tapping ACK or Snooze 5m on the push also clears that notification from the phone and marks it read — but it does not stop ntfy's insistent ring if you enabled that; see Why not ntfy's insistent ring?. Tapping Join on the push only opens the meeting — it does not ack (that action never reports back to MeetAlert); the desktop popup's Join button does both. |
| Dismiss/ACK/Join on any of a meeting's alerts | That whole meeting occurrence is done: every remaining offset (including a not-yet-fired travel-lead or at-start alert) is suppressed, and any pending snooze for it is dropped. Snoozing, by contrast, only quiets that one alert until the snooze comes due. |
escalationSeconds after an unacked alert, repeating | The push resends at urgent priority (rotating_light tag, titled and numbered ) and the desktop popup switches to a pulsing state — up to times ( by default), and never past after the meeting's start. If an alert itself first fires later than that (e.g. a very late offset), there's no escalation window left at all — just the one initial push. |
Each offset of a meeting occurrence alerts once (tracked in state.json); a snooze that comes
back due re-shows the popup without re-sending a push or re-arming escalation. MeetAlert also
rescans immediately on system wake, not just every 30 seconds.
Configuration
Everything lives in ~/.config/meetalert/config.json, written with defaults on first launch,
and re-read every 30 seconds — so a hand edit (or a Settings-window change) applies without
restarting the app. Menu bar icon → Settings… gives you a native GUI for all of this except
raw file editing.
| Field | Default | Meaning |
|---|
alertMinutesBefore | [3, 0] | Minutes before start to fire an alert — one entry per alert. 0 = at start, negative = that many minutes after start (a late alert). Deduplicated and sorted (descending) on load. |
lateAlertMinutes | 10 | Still fire a missed alert up to this many minutes after its scheduled time (sync-lag cushion). |
escalationSeconds | 120 | Seconds between each escalation re-push (never past 15 minutes after the meeting's start). |
escalationRepeats | 3 | How many urgent re-pushes before MeetAlert gives up. Raise it (with a shorter escalationSeconds) to replace ntfy's insistent ring with repetition MeetAlert controls — an ACK stops these, insistent can't be stopped. |
awayIdleSeconds | 120 | Idle time (or an immediate screen lock) before MeetAlert treats you as away and sends the first push at urgent priority instead of high. |
travelLeadMinutes | 30 | Extra early alert for meetings with a physical (non-video-call) location. |
agendaTime | null | Minutes since midnight (local time) to push today's agenda — e.g. 619 = 10:19; use the Settings time picker instead of computing this by hand. null = off. A legacy agendaHour value migrates automatically. |
ignoreAllDay | true | Skip all-day events entirely. |
ignoreKeywords | [] | Case-insensitive substrings — any event title containing one is skipped. |
ntfyServer | "https://ntfy.sh" | Base URL of your ntfy server. |
ntfyTopic | "" (empty) | Your private ntfy topic. Empty means phone push is off — desktop popups still work, nothing goes to your phone. See ntfy setup. |
Declined invites are always skipped — there's no config knob for it.
state.json in the same folder tracks which occurrences already alerted (alertedKeys, pruned
after 24h) and which are permanently ignored (ignoredKeys) — see the
Configuration wiki page for the
exact key format if you ever need to hand-edit it (e.g. to un-ignore something).
Filtering / ignoring events
- All-day events — skipped by default (
ignoreAllDay).
- Keywords — add comma-separated substrings (Settings → Ignore, or
ignoreKeywords) to
skip any event whose title matches, e.g. focus time, lunch.
- Whole calendars — untick a calendar in Settings → Calendars to stop watching it entirely.
- Ignore forever — the popup/menu ignore button. On a recurring meeting it ignores the
whole series (the button says "Ignore series"); on a one-off it ignores that occurrence.
- Pre-ignore — Settings → Ignore lists the next 7 days of meetings with per-row Ignore
buttons (recurring ones offer "This time only" / "Whole series"), plus everything currently
ignored with one-click un-ignore. Ignored meetings are excluded everywhere — alerts, menu bar,
and the morning agenda.
- Declined invites — always skipped, no config needed.
Joining a meeting
If MeetAlert finds a Zoom, Google Meet, Microsoft Teams, Webex, Whereby, GoToMeeting, Jitsi,
Chime, BlueJeans, or RingCentral link in the event's URL, location, or notes (checked in that
order), the desktop popup, the ntfy push, and the menu bar all get a Join action — but they
behave slightly differently. On the desktop popup, Join opens the link and acks in one
step, same as Dismiss. On the phone push, Join only opens the link — it can't ack, since
that kind of action never reports back to MeetAlert; use the ACK button on the push if you
want to ack from your phone. In the menu bar, any upcoming meeting with a link is a
clickable Join row — clicking it only opens the link, so the alerts for that meeting still
fire as scheduled.
Morning agenda
- Morning agenda (
agendaTime, off by default) — pick a time (to the minute) in Settings →
Phone and MeetAlert pushes a single ntfy summary once a day: how many meetings, the first one,
up to 6 upcoming, and your largest free gap before 7pm.
Testing
Set MEETALERT_TEST=1 to run a fully headless self-test: no calendar access is requested (no
TCC prompt, no EventKit touched at all), and 10 seconds after launch it fires a synthetic
[TEST] MeetAlert pipeline meeting 3 minutes out, with the escalation window forced to 20
seconds. State transitions print to stdout so you can watch the whole pipeline:
MEETALERT_TEST=1 build/MeetAlert.app/Contents/MacOS/MeetAlert
# fired a1b2c3d4
# acked a1b2c3d4 (if you click a button in the popup, or ACK the push)
# escalated a1b2c3d4 (if nothing acks it within 20s)
The token is a short hash of the alert's own key (meeting + offset), not a timestamp — it's just
an opaque id shared between the "fired"/"acked"/"escalated" lines for the same alert.
Run the executable inside the built .app directly (not .build/release/MeetAlert, and not
via open) so the bundle's Info.plist/LSUIElement context is intact and stdout stays
attached to your terminal.
Test mode forces a few things for determinism: you're always treated as "present" (never away),
the escalation loop caps at a single urgent resend instead of escalationRepeats, and the morning-agenda push
is skipped entirely.
Google Calendar (and any other calendar)
MeetAlert doesn't talk to any calendar API — it reads whatever macOS's own EventKit already
has, which is every calendar synced through System Settings → Internet Accounts: Google,
iCloud, Exchange, CalDAV, whatever. Zero setup on MeetAlert's side.
The tradeoff is that you're at the mercy of macOS's own sync interval for that account. If
last-minute meetings are showing up late, open Calendar.app → Settings → General and set
"Refresh calendars" to Every minute. lateAlertMinutes exists specifically to cushion
whatever lag remains.
Screenshots
| Alerts settings | Phone settings |
|---|
| |
Feature requests and contributions
Have an idea for MeetAlert? Request a feature and say what you're trying to do; that matters more than the exact feature. Found a bug? Report it.
Code is welcome too: small, focused pull requests, and an issue first for anything bigger. See CONTRIBUTING.md.
Tipped on Ko-fi? Add your Ko-fi name to the request and it goes to the front of my queue. It's a thank-you, not a purchase: no promise on what ships or when.
More Mac apps by Roy
Free, open-source Mac menu-bar apps and terminal tools, all on one site: apps.roypadina.synology.me.
- NtfyBar — ntfy notifications on your Mac: popups that stay, rules, commands, plugins.
- Obsidian Vault Git Watcher — Never leave a vault uncommitted again.
- Window Organizer — Clear the desk in one keystroke.
- LanGuard — Wi-Fi off when you're wired. Back on when you're not.
- PullPing — Know when your pull requests need you, across every GitHub account.
Support
If MeetAlert saves you from walking into a meeting 10 minutes late, you can support its development — it's optional and always appreciated.
A ⭐ on the repo helps just as much.
License
MIT © 2026 Roy Padina