til

launchd PATH whack-a-mole

macOS Launch Agents have a minimal PATH. Here’s how to stop playing whack-a-mole every time you add a new tool.

While setting up Caddy and Tailscale on reboot, I kept hitting “command not found” errors from my Launch Agent. This is the story of fixing them one at a time until I found a better approach.

The problem #

macOS Launch Agents run with a minimal PATH—basically /usr/bin:/bin:/usr/sbin:/sbin. Anything installed through Homebrew, ASDF, or into ~/.local/bin doesn’t exist as far as launchd is concerned.

My Argus build script needs npm (Homebrew), go (ASDF), and the Node server it spawns needs claude (~/.local/bin). None of those are on launchd’s default PATH.

Round 1: hardcode it in the plist #

My first fix was adding PATH to the plist’s EnvironmentVariables:

1
2
3
4
5
<key>EnvironmentVariables</key>
<dict>
    <key>PATH</key>
    <string>~/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
</dict>

That got Homebrew tools and claude working. Ship it.

Round 2: ASDF enters the chat #

Then the build failed because it couldn’t find go. I manage Go through ASDF, and the shims live in ~/.asdf/shims—which wasn’t in my hardcoded PATH. I could have added it, but I was starting to see the pattern: every time I add a new tool or switch version managers, I’d need to remember to update the plist too.

That’s whack-a-mole.

The fix: let the script own its environment #

Instead of cramming PATH entries into the plist, I moved the environment setup into the build script itself and removed EnvironmentVariables from the plist entirely.

argus-build.sh

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
#!/bin/bash
set -e

# Set up PATH so launchd services find homebrew, ASDF, etc.
eval "$(/opt/homebrew/bin/brew shellenv)"
export PATH="/opt/homebrew/opt/libpq/bin:$PATH"
export PATH="$HOME/.local/bin:$PATH"
export PATH="$HOME/.asdf/shims:$PATH"

cd /Users/bitsbyme/projects/code-orchestrator

# Build the client (tsc + vite)
npm run build -w client

# Start the API server (port 5400)
exec /opt/homebrew/bin/npx tsx server/src/index.ts

The script sources brew shellenv and adds the paths it needs. If I switch from ASDF to mise next month, I update one script—not a plist buried in ~/Library/LaunchAgents.

The plist stays clean:

com.user.argus.plist

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.user.argus</string>
    <key>ProgramArguments</key>
    <array>
        <string>/bin/bash</string>
        <string>/Users/bitsbyme/tools/argus-build.sh</string>
    </array>
    <key>WorkingDirectory</key>
    <string>/Users/bitsbyme/projects/code-orchestrator</string>
    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</key>
    <true/>
    <key>StandardOutPath</key>
    <string>/tmp/argus.log</string>
    <key>StandardErrorPath</key>
    <string>/tmp/argus.log</string>
</dict>
</plist>

No EnvironmentVariables. The plist’s only job is telling launchd what to run and when.

Why not source .zshrc? #

You could have the script run source ~/.zshrc to get the full shell environment. But .zshrc tends to accumulate interactive-only stuff—prompt themes, key bindings, completion setup—that either fails or prints warnings when sourced non-interactively. Sourcing just what you need is more predictable.

Reloading #

If you change a plist, launchd doesn’t notice until you unload and reload it:

1
2
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/com.user.argus.plist
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.user.argus.plist

But since the PATH setup now lives in the script, most changes don’t require touching the plist at all. Just edit the script and restart the service:

1
launchctl kickstart -k gui/$(id -u)/com.user.argus

See Also

View page source