tools

Mirror Your Personal Calendars Into Outlook: A Local EventKit Sync for macOS

· May 7, 2026 · 8 min read
macOS calendar sync illustration: multiple colored calendar grids flowing into one Exchange calendar

The fastest macOS calendar sync that does not leak your data: a 250-line Python script using EventKit. If you live across five calendars — Google for one job, another Google for a side gig, iCloud for personal, a CalDAV for a board you sit on, and an Exchange for a fifth client — there is one slot you cannot win: the meeting your Exchange-using colleague is trying to book on your behalf.

They open Outlook, drop you into Scheduling Assistant, and see a green wall of “free.” You are, of course, not free. You are deep in a board meeting on a different calendar entirely. They book the slot. You curse. Repeat 30 times a year.

This is a calendar fragmentation problem with a deeply unsatisfying market. Every commercial fix sends your Outlook metadata to a US-hosted server, every IT department flags it, and you spend more energy on procurement than on the actual problem.

So I built a 250-line replacement that runs entirely on my Mac. No third party, no tokens, no IT review. The repo is at github.com/Sinfjell/busy-sync. This post explains what it does, why the obvious approaches all fail, and what surprised me along the way.

The OneCal problem

The clean commercial answer is OneCal: it logs into all your calendars, mirrors busy slots into the primary you care about, and your colleagues see you as actually busy. Beautiful. Until your client’s IT manager points out that OneCal is hosted in the US, your Outlook metadata flows there, and the GDPR-compliant data processing agreement involves a stack of paperwork no one wants to sign.

The alternatives have similar tradeoffs. Morgen.so is Swiss-hosted and cleaner on the privacy axis but still a third-party service at €8 a month. Microsoft Power Automate only sees Microsoft 365 data, which leaves out Google and CalDAV entirely. A custom Microsoft Graph API integration on your own server requires an Azure app registration in your client’s tenant — back to IT.

The pattern across all of these: every approach assumes the sync needs to live on a server. None of them notice that there’s already a perfectly good sync engine running on your laptop.

The real constraint no one talks about

I built the first version targeting a dedicated Busy Mirror calendar inside Exchange, hoping to keep the noise out of my main view. It worked beautifully on my own end. Then I checked Outlook’s Scheduling Assistant.

Empty.

Here is the lesson nobody documents until you trip over it: Exchange Free/Busy responses only include events from the primary Calendar folder. Secondary calendars in the same mailbox are excluded by design. From the Microsoft docs themselves: “members of your organization can see your Busy status only for events on your primary calendar, but not on your other calendars.”

This is the same reason why dragging shared calendars into your Outlook sidebar doesn’t actually block your time when colleagues invite you. Microsoft chose to keep Free/Busy aggregation strictly to one folder per mailbox. It is the reason every commercial sync tool writes to your main Calendar — they have no choice.

Once you accept the constraint, the architecture follows: write the busy mirrors directly to the primary Exchange Calendar, accept that they show up in your own view, and find another way to keep them visually unobtrusive. We’ll come back to that.

The macOS calendar sync architecture: EventKit, Python, launchd

The Mac is already syncing every calendar account in your life. macOS Calendar.app talks to Google over CalDAV, to iCloud over its own protocol, to Exchange over Exchange Web Services, and to anything else you can configure in System Settings → Internet Accounts. Apple’s EventKit framework gives you read and write access to every event behind that sync layer.

That means a local sync needs three things: a script that reads from any calendar EventKit can see, writes mirror events into the Exchange primary, and runs on a schedule. The whole stack:

  • Python 3.13 with pyobjc-framework-EventKit — Python bindings for the native macOS framework, mature, no shims.
  • EventKit for read and write — same API the system Calendar.app uses.
  • launchd for scheduling — macOS’s native cron replacement, runs every 15 minutes.
  • A JSON config file in ~/.config/busy-sync/ with calendar UUIDs.
  • A log file at ~/Library/Logs/busy-sync.log.

No database. No web server. No API tokens. No cloud anything. The entire dependency graph is one Python package and the macOS frameworks Apple ships.

Idempotency without a database

The script runs every 15 minutes. Each run, it has to figure out: which mirrors should exist, which need to be updated because the source moved, and which should be deleted because the source vanished. The naive way to track this is a SQLite file with a mapping from source event ID to mirror event ID.

The cleaner way is to put the state inside the data itself. Each mirror event gets a marker in its notes field:

[busy-sync:CB1F2D3E-4A5B-6C7D-8E9F-A0B1C2D3E4F5] kilde: sindre@nettsmed.no

The first bracketed token is the source event’s eventIdentifier. Diff is a single pass:

  1. Pull every event in the source calendars over the next 30 days.
  2. Pull every event in the target Exchange calendar that matches [busy-sync:*].
  3. For each source: if no marker matches, create a new mirror. If a marker matches but times differ, update. If a duplicate marker exists, delete the duplicate.
  4. For each existing mirror with no source match, delete the orphan.

Restart the laptop, lose the config, copy the script to a new machine — none of it matters. The next run reads state from the events themselves and converges. Idempotent by design, no recovery procedure needed.

The macOS calendar sync build, in five files

The whole repository is small enough to read in ten minutes:

  • busy_sync.py — the entire sync logic, around 250 lines.
  • setup.sh — bootstraps a Python 3.13 venv (Homebrew’s 3.14 has a known pyobjc issue, so 3.13 is explicit).
  • run.sh — three-line wrapper that activates the venv and runs the script.
  • com.sindre.busy-sync.plist — launchd configuration, 15-minute interval, RunAtLoad enabled.
  • README.md — install + verification checklist.

The first run prompts macOS for Calendar access via TCC (Transparency, Consent, and Control). After approval, ./run.sh --list-calendars prints every calendar EventKit can see, with UUIDs and source identifiers. You drop the UUIDs you want into the config:

{
  "source_calendars": [
    { "calendar_identifier": "2AED2B2E-91D1-4DB0-B630-74D19DA21B84" },
    { "calendar_identifier": "359429DE-8E9F-45DC-B69B-7CD8E009DD90" },
    { "calendar_identifier": "1AC05215-6098-4BAE-94F3-3E656CDA7FC4" }
  ],
  "target_calendar": {
    "calendar_identifier": "FB81C0BD-43C6-499E-86F6-BF982F6EA615"
  },
  "horizon_days": 30,
  "min_duration_minutes": 15,
  "skip_all_day": true
}

Run ./run.sh --dry-run -v to preview the diff. Run it without the flag to actually write. Drop the launchd plist into ~/Library/LaunchAgents/, load it, and the loop runs forever.

Gotchas and trade-offs

All Google calendars share one source identifier

If you have multiple Google accounts in System Settings → Internet Accounts, EventKit collapses every Google calendar into a single source titled “Google” with one shared sourceIdentifier. You cannot disambiguate one Gmail account from another by source attributes. The fix is to match calendars by calendarIdentifier — the per-calendar UUID — instead of by account name. The --list-calendars flag exposes them.

Calendar.app must be running for upstream sync

EventKit writes to the local store. Calendar.app pushes that store up to Exchange over EWS. If Calendar.app is closed, your mirrors sit on disk and never reach Outlook. Keep it running in the background — it costs nothing.

Mirrors duplicate visually in Fantastical

Because the mirrors live in your primary Exchange Calendar (the only place Free/Busy reads from), they appear alongside the originals in any calendar app that shows both Exchange and your source calendars. Fantastical has no built-in event-title filter, so you cannot hide events whose title is just 🔒. The two practical workarounds: keep the title to a single character to minimize visual weight, or build a Fantastical Calendar Set that excludes the Exchange calendar entirely. Neither is perfect.

TCC permissions are tied to the binary

macOS grants Calendar access to a specific binary path. If you delete and rebuild the venv, the prompt fires again. Re-approve and you’re back. If you ever see the script silently doing nothing, check System Settings → Privacy & Security → Calendars first.

When this is the wrong tool

This solution is squarely for individuals on macOS. It does not generalize. If you need:

  • Cross-device sync (your phone needs to write the mirrors too) — you need a server, and a server means tokens and IT review.
  • Team-wide rollout — every team member would need their own venv and TCC approval. Pick a hosted product.
  • Bidirectional sync — this is a one-way mirror by design. Two-way sync introduces conflict resolution and you should not write that on a Wednesday.
  • Anything beyond busy/free — full meeting details require attendee management, RSVPs, and meeting room logic. EventKit can do it, but you’ve left the 250-line range.

For a single person who wants to stop losing slots to colleagues looking at the wrong calendar, though, the math works out. The build took an afternoon. The maintenance cost is zero. The data never leaves the laptop.

The repo is open at github.com/Sinfjell/busy-sync. Clone it, point it at your calendars, and reclaim your Wednesdays.

Why not just use OneCal or Reclaim?

They work, but they relay your calendar metadata through a third-party server. For Exchange accounts under client IT policy, that often triggers a GDPR review and a data processing agreement. A local-only sync sidesteps the entire conversation.

Will this work with Outlook for Mac instead of the system Calendar.app?

No. EventKit reads from the local CalendarAgent store that Calendar.app maintains. Outlook for Mac maintains a separate cache. You need at least one Exchange account configured in System Settings → Internet Accounts so it appears in EventKit.

Why every 15 minutes and not real-time?

EventKit supports change notifications, but launching a long-running daemon adds operational complexity (crashes, restarts, log rotation). A 15-minute polling loop covers 99% of meeting-booking scenarios with five lines of plist configuration.

Can I extend this to mirror events between two Google calendars?

Yes. The source and target are just calendar UUIDs in the config. Point both at Google calendars and the same diff logic applies. The only constraint is that the target calendar must allow content modifications — some shared calendars do not.

Does it handle recurring events?

Yes. EventKit’s predicate-based event fetch returns one EKEvent per occurrence, each with its own eventIdentifier. The diff loop treats them independently, so a 50-week recurring meeting becomes 50 mirrors with 50 distinct markers.

SF
Sindre Fjellestad

Indie maker and developer. Building productivity tools and writing about systems, automation, and the craft of focused work.

Want a custom Notion template?

Browse my ready-made tools or get in touch for a custom build.

Browse Products Get in touch