
Photo by Jon Tyson on Unsplash
Kairos is out. It’s a front end for launchd’s user agents — the scheduler your Mac already has.
And I want to start by defending launchd, because Kairos exists out of respect rather than frustration. launchd is an excellent scheduler. It survives reboots. It handles missed runs. It doesn’t need a third-party daemon idling in your menu bar to do its job. Everything I run on a schedule, I run through it, and I have never once regretted that.
What I have regretted is every single interaction with it. Answering “is this job loaded, has it ever actually run, and did it fail?” means running a command like launchctl print gui/501/com.example.thing and parsing a hundred lines of output. Writing a new job means hand-authoring a property list and knowing, from experience or from a very bad afternoon, that Weekday counts Sunday as both 0 and 7. The engine is wonderful. The controls are a punishment.
Kairos shows every agent in ~/Library/LaunchAgents with its schedule in English — “18:30 on Monday, Wednesday, Friday”, not Minute => 30, Hour => 18 — along with how many times it has run, its last exit code, and its own output tailed from wherever it writes. Load, unload, enable, disable, run now, edit, reveal, delete.
Most tools in this space show one on/off switch, which hides the difference between off and off for a reason you forgot months ago. Kairos shows loaded, disabled, running and failed as four distinct states — and a job that has never once fired looks exactly like a healthy idle one until you can see that its run count is zero.
The disabled state is the one this app really exists to surface. launchctl disable writes to a database that is separate from the plist, and survives deleting the file. A job can be disabled by a command you ran months ago and forgot; nothing in ~/Library/LaunchAgents will tell you. You load it, launchd says nothing, and it silently never runs. I have lost real time to that landmine. Kairos reads the database separately and shows Disabled as its own state, with the explanation attached.
The feature that made me build this: pick an app, pick a launch time and a quit time, and Kairos writes both jobs and presents them as one thing. The launch and the quit have independent days, so a schedule can span them — launch Friday at 16:00, quit Monday at 09:00, and the app runs across the whole weekend.
A scheduled app has four possible states, built from two independent facts — whether it’s due to be running, and whether it is running. Only one of the four combinations means something is wrong. Telling you an app should be running is guesswork dressed up as information when the real answer is available for the asking; telling you it should be running and isn’t is the only version worth interrupting you for, so that’s the only one that does.
Two details here took longer than they should have, and I’d rather you know about them than assume they were free:
Launching uses open -a, not the binary inside the bundle. open hands the launch to the window server in your session, which is what actually makes a GUI app appear. Run the executable directly from launchd and you get a process with no session and, often, no window — technically running, usefully absent.
Quitting sends an AppleEvent, not a kill. The app gets to save and close cleanly — which also means it gets to refuse. An app holding an unsaved document will put up a dialog and stay open, and I think that’s correct: silently destroying someone’s work to keep to a schedule is the wrong trade. Kairos says so at the moment you set the schedule, and offers a force fallback as an explicit decision rather than a default.
launchd has a KeepAlive key, and the obvious design is to use it. The obvious design is wrong in a way that only bites once it’s live: put KeepAlive on the launch job and launchd faithfully keeps that process alive — which is open, which exits the instant the app is up. So launchd relaunches it. Forever. In a tight loop. “Keep this process alive” and “keep that application running” sound like the same request but they are not, and the second one is often what a person means.
So Kairos does it differently: the launch job opens a window — a marker that says this app is due to be running — and the quit job closes it. A small guard checks on an interval and restarts the app only if the marker is present and the app is not. No clock arithmetic, survives reboots, and multi-day schedules fall out of it for free.
While Kairos runs, its Dock icon is a working clock, redrawn on the minute and aligned to the minute boundary so it ticks over when the clock does rather than up to fifty-nine seconds late. It seemed rude to build a scheduling app whose own icon shows a painted-on time. (Only while it’s running — a quit app’s Dock tile belongs to Finder, which gets the static icon. Apple’s Clock behaves the same way, for the same reason.)
Scope, stated plainly: user agents only, not /Library/LaunchDaemons. Editing system daemons needs a privileged helper, which is a big security surface for a small utility — and more decisively, a daemon can’t launch a GUI app into your login session at all, so for the thing this app exists to make easy, daemons are the wrong tool anyway. And it needs no permissions: it reads and writes your own agents folder and runs /bin/launchctl, nothing more. macOS will raise its Login Items notification each time you create a schedule — that’s the system telling you a background item appeared, which is exactly what happened.
Kairos is free, like everything I make. macOS 14 or later, universal binary, signed and notarised installer or zip, source on GitHub, updates via the built-in updater. Or in one line:
brew install --cask perpetualbeta/jorvik/kairos
The Greeks had two words for time. Chronos is the kind that passes — the tick, the schedule, the calendar. Kairos is the opportune moment: the right thing happening at the right time. launchd has always been very good at chronos. This is my attempt to give it the other one.