Skip to content

200x Faster: Replacing AppleScript with Swift EventKit

SwiftmacOSPerformanceClaude CodeAppleScript

The SessionStart hook timed out on every single session start.

Belfry is my Claude Code plugin that integrates Apple Calendar and Reminders into my development workflow. Morning briefings pull today's schedule and due reminders. Commands like /cal-create and /remind-list let me manage my calendar without leaving the terminal.

At least, that was the idea. In practice, the two most important operations — fetching calendar events and listing reminders at session start — timed out every time. The fallback message appeared on every new session:

**Calendar:** Timed out fetching events. Use /cal-list to check manually.
**Reminders:** Timed out fetching reminders. Use /remind-list to check manually.

The entire point of the plugin — proactive calendar awareness — was non-functional. The morning briefing system, which depends on calendar and reminder data being available at session start, had never worked.

The fix wasn't a patch. It was replacing the entire backend.


Why AppleScript Is Slow

Belfry v0.1.0 shipped with 10 AppleScript files as its backend — one per command. The execution path for every call:

osascript process → AppleScript interpreter → Apple Events IPC
    → Calendar.app / Reminders.app → EventKit database → data

Three layers of indirection between the command and the data:

osascript startup. Each call spawns a new osascript process and loads the AppleScript interpreter. The interpreter parses the .applescript source file from scratch on every invocation.

Apple Events IPC. AppleScript doesn't access calendar data directly. It sends Apple Events — a macOS inter-process communication protocol dating back to 1993 — to Calendar.app or Reminders.app. These events are serialized, dispatched through the Mach port system, deserialized by the receiving app, processed, and the response follows the same path back.

App launch penalty. Calendar.app and Reminders.app must be running to receive Apple Events. If they're not (which is the typical cold-start case when a Claude Code session begins), macOS has to launch them first. This alone takes 5-15 seconds depending on system load and iCloud sync state.

The combination meant that the first osascript call in a session consistently took 20-30 seconds. The SessionStart hook had a 20-second timeout. The math didn't work.

Beyond performance, AppleScript had other problems. All 10 scripts returned pipe-delimited text (title | start | end | calendar). If an event title contained a literal | character, the parser silently corrupted the output. Date handling was done by substring extraction — text 1 thru 4 of dateStr for the year. And every piece of shared logic (date parsing, output formatting, error handling) was duplicated across all 10 files.


The Solution: Swift + EventKit

EventKit is the native macOS framework that Calendar.app and Reminders.app themselves use internally. It reads and writes directly to the system's calendar and reminder database, managed by the CalendarAgent daemon. The new execution path:

belfry binary → EventKit framework → CalendarAgent daemon → data

No app intermediaries. No Apple Events. No process spawning. The belfry binary links against EventKit at compile time and makes direct framework calls in-process.

AspectAppleScript (before)Swift CLI (after)
Process modelNew osascript process per callSingle compiled binary
Data accessApple Events → Calendar.app → EventKitDirect EventKit calls
App dependencyCalendar.app and Reminders.app must be runningNeither app needs to be running
Output formatPipe-delimited textJSON
Error handlingUnstructured stderr textJSON on stderr with exit code 1
Source files10 .applescript files (534 lines)1 .swift file (651 lines)
Build stepNone (interpreted)swiftc compile (~2 seconds)

Performance Results

Measured on the same machine, same session, same data:

OperationAppleScriptSwift CLISpeedup
remind-list (37 items)20+ seconds (timeout)0.096s~200x
cal-list (2 events)20+ seconds (timeout)0.077s~260x
session-context.sh (both + formatting)timeout → fallback1.13s∞ (didn't work before)

The SessionStart hook went from "always times out" to "completes in about a second." The morning briefing integration — calendar events, due reminders, project state, suggested focus — works for the first time.


Single-File Swift CLIs Are Underrated

Swift is often associated with Xcode projects, SPM packages, and iOS development. But for small CLI tools that only need Foundation and system frameworks, there's a sweet spot:

swiftc belfry.swift -O -o belfry -framework EventKit

No Xcode project. No Package.swift. No module structure. One file in, one binary out. The 651-line belfry.swift compiles in about 2 seconds on Apple Silicon and produces a ~150KB binary.

The distribution model is compile-from-source: the repo ships the .swift file, and a build script compiles it on first run. This keeps the repository clean (no committed binaries) while adding negligible overhead — 2 seconds of compile time on the first invocation, then the binary is cached.

For tools that need Foundation, EventKit, CoreLocation, or other system frameworks, this is the right level of complexity. More capable than a shell script, simpler than a "real project," and the resulting binary is fast, small, and dependency-free.


The Command Architecture

The Swift CLI replaced 10 AppleScript files with a single binary using flat subcommands — belfry cal-list, belfry remind-create, belfry cal-free. Each subcommand maps directly to a plugin command that Claude can invoke via slash commands:

CommandWhat It Does
/cal-listList events in a date range
/cal-createCreate a calendar event
/cal-editEdit an existing event
/cal-deleteDelete an event
/cal-freeFind free time slots in a date range
/remind-listList reminders (overdue, due today, upcoming)
/remind-createCreate a reminder with due date and list
/remind-completeMark a reminder as done
/remind-deleteDelete a reminder

The calling convention stayed the same — each command's markdown file defines the invocation, arguments, and output format. The plugin's skill files and SessionStart hook call the belfry binary instead of osascript. From the user's perspective, nothing changed. From the system's perspective, everything changed.

All output is JSON. Errors are JSON on stderr with exit code 1. The calling code — shell scripts, hook scripts, command markdown — parses structured data instead of splitting on pipe characters. No more silent corruption.


Calendar-Aware Development

With the performance problem solved, the real value of Belfry emerged: calendar and reminder integration that actually works in the flow of development.

The morning briefing is the most visible piece. Every day, the first session that starts after 9am pulls today's schedule, surfaces due and overdue reminders, checks project state across the workspace, and presents a focused summary. It knows when my first commitment is, how many free hours I have, and what's due this week. I read it, decide what to work on, and go.

But the deeper integration is time blocking. Belfry's /cal-free command finds open slots in a given date range. Combined with DCM's project tracker — which knows active sprints, priority to-dos, and carry-forward items — Claude can propose time blocks for planned work sessions. At the end of the day, we review what's pending, find tomorrow's free time, and create calendar events for each block. The schedule isn't aspirational — it's built from actual availability and actual project state.

/remind-create captures commitments as they surface in conversation. A deadline mentioned in passing, a follow-up I promised someone, a task that needs to happen by a specific date — these get routed to Apple Reminders immediately, not written on a mental sticky note that falls off by morning. /remind-complete clears them as they're done. The reminder queue is always current because it's maintained in the same environment where the work happens.

The result is that my days are structured without being rigid. The calendar reflects real priorities. The reminders catch what I'd otherwise forget. And none of it requires switching to a separate app, opening a browser, or breaking focus. It all happens in the terminal, in the flow of the work.


Lessons

AppleScript is not a viable backend for latency-sensitive operations. For interactive commands where a user can wait 3-5 seconds, it's adequate. For a hook that runs on every session start with a tight timeout, it's fundamentally unsuitable. The Apple Events IPC layer and app-launch penalty make cold-start performance unpredictable.

Pipe-delimited output is a false economy. It feels simpler than JSON — just string concatenation. But the downstream cost is higher: fragile splitting, silent corruption from special characters, and ad-hoc error conventions. JSON is a few more lines at the source and eliminates an entire class of bugs.

Test the actual bottleneck early. We spent time investigating hook configuration, shell backgrounding, and script errors before identifying that the core problem was simply "AppleScript is slow on cold start." If we'd timed osascript remind-list.applescript in the first minute, we'd have had the diagnosis immediately. When something times out, measure the thing that's timing out before investigating the timeout machinery.


Sometimes the right fix isn't a patch — it's replacing the substrate. AppleScript served its purpose as a prototype. When it became the bottleneck, we replaced it with something purpose-built. The plugin went from "broken on every startup" to "works every time" in a single afternoon.

A calendar query that timed out at 20+ seconds now completes in 96 milliseconds.


View Belfry on GitHub