claude code on a scheduleLaunchAgent, not LaunchDaemonStartCalendarIntervaleventSourceStateID

Claude Code on a launchd schedule, the input arbitration problem every other guide skips

The advice for putting Claude Code on a schedule is everywhere: write a plist, drop it in ~/Library/LaunchAgents, bootstrap it. Fine. The part nobody writes is what happens when the scheduled run needs to actually drive your Mac apps, and what happens to your keyboard when the agent fires while you are using the same machine. That is what this page is.

M
Matthew Diakonov
11 min read

Direct answer · verified 2026-05-07

Drop a LaunchAgent plist at ~/Library/LaunchAgents/com.you.claude-code.plist with StartCalendarInterval, run claude in non-interactive -p mode, and load it as your user with launchctl bootstrap gui/$UID. If the prompt needs to drive Mac apps, add macos-use as an MCP server so AX-tree automation is available to the scheduled run.

launchctl bootstrap gui/$UID \
  ~/Library/LaunchAgents/com.you.claude-code.plist

Anchor fact · the bit that makes 3am safe

If you add macos-use, every disruptive tool call calls InputGuard.shared.engage() at main.swift:1835. That installs a CGEventTap that drops every hardware event (eventSourceStateID == 0, InputGuard.swift:329) except plain Esc (keycode 53, lines 340-351). A 30-second watchdog at InputGuard.swift:24 force-disengages the tap, so a stuck job can never lock you out longer than that.

The four pieces, in order

  1. 1

    Write the LaunchAgent plist

    ~/Library/LaunchAgents/com.you.claude-code.plist with StartCalendarInterval and a working stdout/stderr path.

  2. 2

    Run claude in -p mode

    Non-interactive: claude -p "<prompt>" prints once to stdout and exits.

  3. 3

    Pre-grant Accessibility

    TCC inherits down the spawn tree. Grant it to the parent the LaunchAgent runs.

  4. 4

    Add macos-use if you need apps

    claude mcp add macos-use -- npx -y mcp-server-macos-use. Now the scheduled run can drive any Mac GUI.

Step 1. LaunchAgent, not LaunchDaemon. Not negotiable.

Every guide that tells you to use a LaunchDaemon for this is wrong. A LaunchDaemon runs as root, before login, with no connection to the WindowServer. It cannot open a window, it cannot read the AX tree, it cannot send a synthetic click. The symptom you get when you try is silent: the daemon fires, claude runs, the first MCP tool call returns errAXAPIDisabled, the agent reports back “I tried but it failed”, and you go back and forth wondering why the same prompt works from your Terminal.

A LaunchAgent in ~/Library/LaunchAgents runs in your user’s GUI session as you. Same WindowServer connection. Same TCC posture. Same access to the AX tree of every running app.

FeatureLaunchDaemon (/Library/LaunchDaemons)LaunchAgent (~/Library/LaunchAgents)
Filesystem location/Library/LaunchDaemons (system) or /Library/LaunchAgents (privileged)~/Library/LaunchAgents (your user only, no sudo)
When it loadsLaunchDaemons: at boot, before login, as rootLaunchAgents: at login or per-user bootstrap, as you
GUI session accessLaunchDaemons: none. Cannot open a window, cannot use Accessibility APIs.LaunchAgents: full WindowServer + AX access in your session
TCC permission scopeLaunchDaemons: separate root TCC db, can never claim user-level AccessibilityLaunchAgents: inherits your user's TCC, including Accessibility you already granted
Bootstrap commandsudo launchctl bootstrap system /Library/LaunchDaemons/<name>.plistlaunchctl bootstrap gui/$UID ~/Library/LaunchAgents/<name>.plist
Right call for Claude Code on a scheduleWrong. Headless root context, no app it spawns can drive your GUI.Right. Same context as if you opened Terminal and ran the command.

Step 2. The plist itself, with the bits that actually matter

Three things the snippets you find online get wrong. They use StartInterval (every N seconds, ignores the wall clock) when you almost always want StartCalendarInterval. They forget to wrap the command in /bin/zsh -lc so claude is not on launchd’s minimal PATH. And they forget the StandardOutPath / StandardErrorPath, so when the run silently fails there is no record of what claude actually said.

cron the way the internet still says vs LaunchAgent the way that works

# /etc/crontab the way the internet still tells you to
# DEPRECATED on macOS. cron is a wrapper around launchd now and
# does not inherit your user GUI session by default.

0 9 * * 1-5 /usr/local/bin/claude -p "summarise unread mail"

# Symptoms when you try this and it needs to drive an app:
# - claude runs, MCP server starts
# - first AX call returns errAXAPIDisabled
# - System Settings > Privacy & Security > Accessibility shows
#   nothing to toggle on for "cron" because cron is not the
#   binary the OS sees as the caller
# - you grant /usr/sbin/cron access, restart, still fails
-433% more lines, but reliable

RunAtLoad is set to false on purpose. If you set it to true, the job fires the first time you bootstrap the plist, which is rarely what you want while you are still iterating on the prompt. Trigger manual runs with launchctl kickstart -k gui/$UID/com.you.claude-code.morning instead.

Step 3. The TCC permission inheritance problem

This is the gotcha that eats two evenings if you skip it. macOS attaches Accessibility permission to a code-signing identity, not to a process tree. When launchd spawns your shell, which spawns claude, which spawns mcp-server-macos-use, the AX call comes from mcp-server-macos-use, but TCC walks up the responsible-process chain and asks: which user-approved binary is responsible for this request?

The answer is the long-lived parent the user actually has on file: claude. So you grant Accessibility to the claude binary (find it with which claude, drag the resolved path into System Settings > Privacy & Security > Accessibility, toggle it on). Granting Accessibility to Terminal does not help here, because launchd does not spawn through Terminal.

how the spawn tree maps to TCC posture

⚙️

launchd (PID 1) fires the calendar trigger

↪️

spawns /bin/zsh -lc (your login shell, your $UID)

TCC sees the launchd-created process as you. Same posture as Terminal.

🔒

zsh execs claude (the Node binary in /usr/local/bin)

Accessibility grant on Terminal does NOT cover this. claude is a different binary signature.

claude spawns mcp-server-macos-use over stdio

AX calls flow up to claude's TCC posture. Grant Accessibility to the claude binary itself.

Step 4. Add macos-use, or the scheduled run cannot touch your apps

Claude Code by itself can read files, run shell commands, and hit HTTP APIs. It cannot, by itself, click the “send” button in Mail, scroll your Calendar, paste text into Slack, or navigate the Notes sidebar. For those, the scheduled run needs an MCP server that exposes macOS apps to the agent.

claude mcp add macos-use -- npx -y mcp-server-macos-use

The npm postinstall hook (package.json line 17) runs xcrun swift build -c release, so the only host requirement is the Xcode Command Line Tools. Six tools land in claude’s MCP inventory: open_application_and_traverse, click_and_traverse, type_and_traverse, press_key_and_traverse, scroll_and_traverse, refresh_traversal. Each one returns a path to a flat AX-tree file in /tmp/macos-use/ so the agent can grep instead of holding 800 elements in context.

One thing to do once before scheduling: run claude -p "open Calculator" from your Terminal, manually, while you are at the keyboard. macOS will prompt for Accessibility the first time the binary tries to call AX APIs. Satisfy it now, awake, with the dialog visible. The 3am scheduled run cannot click an OK button on a permission prompt.

The 3am case and the 9am case

The interesting question is not whether the trigger fires. It is what happens to your keyboard while the run is mid-click. Two scenarios.

3am: nobody is home

Mac is on, screen locked, you are asleep. LaunchAgent fires. claude starts, MCP server starts, first tool call lands. macos-use installs the CGEventTap. Zero hardware events arrive (your hand is not on the keyboard), so the tap is a pure no-op. Action runs, tap disengages, next tool call, repeat. At the end, the tree-diff is in /tmp/macos-use, the response is in your stdout file, the cursor is back where it was. Note: a LaunchAgent does not wake the Mac. If the machine is asleep at the trigger time, the job runs the next time you log in. If you actually want a wake, schedule a power-management event with pmset repeat wake MTWRF 8:55:00 five minutes before the trigger.

9am: you are typing into a different app and the trigger fires

This is the case the rest of the internet does not describe. macos-use does not let your keystrokes race the agent’s into the same field. The CGEventTap engages, your typing stops, the agent does its thing (typically 700ms for one click_and_traverse), the tap disengages, your typing resumes. If you want to abort, plain Esc kills the run immediately.

what InputGuard does for one tool call

claudemcp-serverCGEventTapmacOS appyour handtools/call: click_and_traverseInputGuard.engage() at main.swift:1835CGEventTap installed (head-insert)CGEvent.post(left-mouse-down)you press a hardware key (rare, you are asleep)drop event (sourceStateID == 0, line 329)30s watchdog auto-disengages (line 24)tool result + AX tree diff

The eventSourceStateID check is the actual mechanism: every CGEvent the server posts uses .hidSystemState which has a non-zero stateID. Your keyboard’s events have stateID 0. The tap callback at InputGuard.swift:329-332 lets non-zero through and drops zero. That is how the tap can be permissive about the agent’s own clicks while blocking everything else.

Verify the whole pipeline before you commit to a calendar

Run through this list once and you will catch every common failure (TCC, PATH, AX, watchdog, missing wake). Skip it and you find out at 3am that nothing is happening.

end-to-end smoke test

  • launchctl print gui/$UID/com.you.claude-code.morning shows state = waiting
  • Manual test: launchctl kickstart -k gui/$UID/com.you.claude-code.morning
  • tail -f /tmp/claude-morning.err while it runs to see Claude Code output
  • ls /tmp/macos-use/*_open_application_and_traverse.txt to confirm AX traversal landed
  • grep 'log: InputGuard' /tmp/claude-morning.err to confirm the tap engaged + disengaged
  • If AX errors: System Settings > Privacy & Security > Accessibility, toggle the claude binary off and on
  • If trigger never fires: launchctl bootstrap returned 0, but Mac was asleep at scheduled time and there is no wake provision (LaunchAgent will not wake the system, you need pmset for that)

Building scheduled agents that drive Mac apps?

Half an hour with us, walk through your scheduled-Claude-Code-on-launchd plan, leave with the gotchas already mapped.

Frequently asked questions

Does cron still work on macOS for this?

Technically yes. cron is a thin shim over launchd on modern macOS, and the binary still ships. The reason every guide nudges you toward launchd is that cron jobs run under a stripped environment that does not inherit your user's GUI session, so any Accessibility-API call from a job kicked off by cron returns errAXAPIDisabled. You would have to grant Accessibility to /usr/sbin/cron itself, which is brittle and a system binary the OS may replace on a point upgrade. A LaunchAgent in ~/Library/LaunchAgents loaded with `launchctl bootstrap gui/$UID` runs in the exact same context as if you had typed the command in Terminal, which is what you actually want.

What is the right shape for the Claude Code command in a LaunchAgent?

claude -p "<prompt>" exits after one response, prints the answer to stdout, and accepts no follow-up. That is the non-interactive shape you want for a scheduled job. Your ProgramArguments should be /bin/zsh -lc "/usr/local/bin/claude -p '<prompt>'" so the login shell sets your PATH (otherwise claude often is not on launchd's default PATH) and so node version managers like nvm or fnm get a chance to expose the right node binary. Capture stdout to a file via StandardOutPath and stderr via StandardErrorPath; without those, output goes to /dev/null and you cannot debug a silent failure.

If the scheduled prompt needs to drive a Mac app, what is the minimum extra setup?

One command: `claude mcp add macos-use -- npx -y mcp-server-macos-use`. The npm postinstall hook (package.json line 17) runs `xcrun swift build -c release`, so the only host requirement is the Xcode Command Line Tools. After install, the scheduled run can call open_application_and_traverse, click_and_traverse, type_and_traverse, press_key_and_traverse, scroll_and_traverse, and refresh_traversal. The first call from the scheduled context will fire a system Accessibility prompt the first time it executes, which is a problem if you are asleep at 3am, so manually run `claude -p "open Calculator"` in your Terminal once before scheduling so the prompt is satisfied while you are at the keyboard.

What happens to my keyboard if a scheduled run starts while I am typing?

macos-use installs a head-insert CGEventTap at InputGuard.swift:113 around every disruptive call. The tap separates programmatic events from hardware events by eventSourceStateID (programmatic events have a non-zero stateID, hardware events have stateID == 0, checked at InputGuard.swift:329-332) and drops everything coming from your hardware except plain Esc with no modifiers (keycode 53, lines 340-351), which is the one-key abort. The tap auto-disengages after a 30-second watchdog (InputGuard.swift:24) so the worst case is a 30-second freeze if the agent hangs mid-call. For scheduled jobs, this means: at 3am you are asleep, no hardware events, the tap is a no-op; at 9am you are at the keyboard, the tap blocks your typing for the duration of one tool call (typically 700ms), and you can hit Esc to kill the run.

Why does TCC Accessibility grant follow the binary, not the parent process?

TCC (Transparency, Consent, Control) keys permissions on the code-signing identity of the executable that calls a protected API. When launchd spawns /bin/zsh which spawns claude which spawns mcp-server-macos-use, the AX call originates from mcp-server-macos-use, but TCC walks up the responsible-process chain and attributes the request to the responsible parent that the user has approved. In practice on macOS 13+, that ends up being the claude binary (because claude is the long-lived process that owns the MCP child). Symptom of getting this wrong: the first AX call returns errAXAPIDisabled even though you remember granting Terminal access. Fix: grant Accessibility to the actual claude binary path (find it with `which claude`, drag that file into System Settings > Privacy & Security > Accessibility, toggle on).

Will a LaunchAgent wake my Mac to run the job?

No. A LaunchAgent only fires while the user session is active. If your Mac is asleep at the scheduled trigger time, the job runs the next time the user logs in (StartCalendarInterval coalesces missed firings into one). To actually wake the machine, schedule a power-management event with `pmset repeat wake MTWRF 8:55:00` (cron-style, system-level, requires sudo). Pair the pmset wake five minutes before the LaunchAgent trigger and the run is reliable. This is also why a LaunchDaemon is tempting and wrong: a LaunchDaemon will fire while the screen is locked or the user is logged out, but it has no GUI session and cannot drive AX, so all you get is a daemon that wakes the machine and then fails.

How do I see what the scheduled run actually did?

Three places to look. First, the StandardOutPath and StandardErrorPath you set in the plist; that is where Claude Code's response and any MCP tool errors land. Second, /tmp/macos-use/ contains a timestamped .txt file per AX traversal (one line per element, `[Role] "text" x:N y:N w:W h:H visible`) and a screenshot per disruptive call, written by the macos-use server regardless of the host process; this is the cheapest way to confirm the agent actually saw the UI you expected. Third, `launchctl print gui/$UID/<label>` reports the last exit status and how many times the job has been spawned, which is the easiest tell for whether your trigger is even firing.

Can I trigger a scheduled run on demand to test it without changing the calendar?

Yes. `launchctl kickstart -k gui/$UID/com.you.claude-code.morning` fires the job immediately and (with -k) kills any in-flight instance first. This is the right loop while you are dialing in the prompt: edit the plist, `launchctl bootout gui/$UID/<label>` and `bootstrap` again to reload, then `kickstart -k`. Do not edit the plist while the job is bootstrapped; launchd reads the plist at bootstrap time and ignores changes until you reload.

Are there things macos-use cannot do that I should know about before I schedule it?

Two real ones. First, anything that requires the user to actually look at a screen, like solving a CAPTCHA in a web app, is going to fail; the agent can navigate to it but not solve it. Second, apps that run in secure-input mode (system password fields, 1Password unlock, sudo prompts in Terminal) drop synthesized key events; the macos-use type_and_traverse uses CGEvent and will not type into those fields. The escalation primitive set_value (writes via kAXValueAttribute directly on the AX element) sometimes works on these but not always. Schedule the safe stuff first: read-only summaries, app navigation, drafting Notes/Mail/Slack messages, scrolling Calendar.

Where does the macos-use MCP server live and what license is it under?

Open source at github.com/mediar-ai/mcp-server-macos-use, MIT licensed. Written in Swift, ~2400 lines across Sources/MCPServer/main.swift (2056 lines, the JSON-RPC handler plus six tools) and Sources/MCPServer/InputGuard.swift (355 lines, the CGEventTap input arbiter). Distributed via npm so the install is one command and the postinstall hook builds the Swift release binary against your local Xcode toolchain.

macos-useMCP server for native macOS control
© 2026 macos-use. All rights reserved.

How did this page land for you?

React to reveal totals

Comments ()

Leave a comment to see what others are saying.

Public and anonymous. No signup.