Skip to content

Your first Mac shell script (zsh), and running it automatically with launchd

Write a script in Terminal, make it executable, put it on your PATH, and schedule it with a LaunchAgent, the Mac's built-in alternative to cron.

beginnerUpdated 2026-11-02

Apple’s support page says Macs have used zsh as the default login and interactive shell since macOS 10.15 Catalina; bash was the default in 10.14 and earlier. Most simple scripts work in either.

1. Write it

In Terminal:

mkdir -p ~/Scripts
nano ~/Scripts/hello.sh

Paste this, then press Ctrl-O, Return, Ctrl-X to save and quit:

#!/bin/zsh
# hello.sh - greet, show free disk space, list the 5 newest Downloads
echo "Hello from zsh! It's $(date '+%A %d %B, %H:%M')."
df -h / | tail -1 | awk '{print "Free space on the startup disk: " $4}'
ls -t ~/Downloads | head -5

The first line, #!/bin/zsh, is the shebang. It tells macOS which program should run the file.

2. Make it executable and run it

chmod +x ~/Scripts/hello.sh
~/Scripts/hello.sh

chmod +x marks the file as runnable. Without it you’ll get permission denied.

To run it by name from anywhere, add the folder to your PATH:

echo 'export PATH="$HOME/Scripts:$PATH"' >> ~/.zshrc
source ~/.zshrc
hello.sh

3. Double-clickable version

Rename a copy to hello.command. Double-clicking a .command file in Finder opens Terminal and runs it. It still needs chmod +x.

4. Run it automatically with launchd

macOS uses launchd for scheduled and background jobs. Apple’s guide describes where job files go:

FolderWhat goes there
~/Library/LaunchAgentsAgents for your account only. Apple describes a user agent as specific to one logged-in user, running only while that user is logged in. This is what you usually want.
/Library/LaunchAgentsAgents loaded for every user who logs in. Files here must be owned by root, so you need admin rights to install them.
/Library/LaunchDaemonsSystem-wide daemons, loaded when the Mac starts up rather than at login. Also root-owned.

Create ~/Library/LaunchAgents/com.example.hello.plist:

<?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.example.hello</string>
  <key>ProgramArguments</key>
  <array><string>/Users/YOUR-USER/Scripts/hello.sh</string></array>
  <key>StartCalendarInterval</key>
  <dict><key>Hour</key><integer>9</integer><key>Minute</key><integer>0</integer></dict>
  <key>StandardOutPath</key><string>/tmp/hello.log</string>
  <key>StandardErrorPath</key><string>/tmp/hello.err</string>
</dict>
</plist>

Then load it and test it:

plutil -lint ~/Library/LaunchAgents/com.example.hello.plist      # checks the file for typos
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.hello.plist
launchctl kickstart gui/$(id -u)/com.example.hello             # run it right now
cat /tmp/hello.log

To unload it: launchctl bootout gui/$(id -u)/com.example.hello.

Other triggers (the first three are covered in Apple’s guide; all four are in man launchd.plist):

  • StartInterval: every N seconds.
  • StartCalendarInterval: a cron-style time. Any key you leave out means “every”.
  • WatchPaths: run whenever a file or folder changes.
  • RunAtLoad: run when the job is loaded, i.e. at login for an agent.

Gotcha: launchd doesn’t load your ~/.zshrc, so your PATH and aliases aren’t there. Use full paths inside scheduled scripts (/usr/bin/rsync, /opt/homebrew/bin/...). If a script needs files in protected folders like Desktop or Documents, macOS may block it until you grant access under System Settings → Privacy & Security.

Next steps

Sources