Services (or Daemons or Agents) are normal programs that run in the background and are managed by the OS. I needed to make a python script run under Mac OS using launchd, which is a bit more involved than a similar process under linux.

I had the following requirements:

  • Run a python program as a background service that runs continuously
  • Use a conda environment
  • Install the service on multiple macs as automatically as possible


Here is how I achieved it:

1. Create an plist (template) file to define the launchd service

This plist file is defined in XML and is not at all user friendly and hard to debug. There is some good info at and on the apple dev website. The KeepAlive and WorkingDirectory keys are straightforward to understand, but I had to experiment a lot for my use case for the other aspects:

Firstly, you’ll see that the main entry point tag is ProgramArguments, and each argument, normally separated by a space on the command line must be a different string within an array of ProgramArguments, similar to python’s subprocess.Popen. However, I could not properly work out how to activate the conda environment first, so instead I ran the python script direct from the conda environment python instead (the first string). I assume this would be similar for a pip venv.

Secondly, the plist requires full absolute paths, and since I wanted to reuse this plist file on multiple macs, I needed to use variables for the path (obviously different for each user), and for the conda environment path (because some were using a conda environment for a M1 mac, some for x86 mac). However, variables are not possible in an XML plist file, and we can’t use normal shell subtituions like $HOME, so you’ll see I added my own tags and which will get replaced later…

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "">
<plist version="1.0">
                <!-- MUST USE FULL PATHS SPECIFIC TO USER-->

2. Create a zsh script to copy the plist file and substitute the variables

To install a service, all this is required is to create the plist file in the correct folder, for a per user launch agent / service this should be in user’s individual folder Library/LaunchAgents, so line 2 copies the plist template file above to this location.

Next, lines 3-10 check on the sysmte whether the mac uses the apple silicon or x86, and choses the conda environment to match.

Finally, the clever bit is to replace the (which we can get using $HOME in this zch script) and in the plist file with the actual values we have, using the sed command.

cp com.myprogram.start.plist ~/Library/LaunchAgents
if [[ $(uname -m) = 'arm64' ]]
elif [[ $(uname -m) = 'x86_64' ]]
# replace <HOME-VARIABLE>
sed -i '' -e 's|<HOME-VARIABLE>|'"$HOME"'|g' ~/Library/LaunchAgents/com.myprogram.start.plist
sed -i '' -e 's|<CONDAENV-VARIABLE>|'"$CONDAENV"'|g' ~/Library/LaunchAgents/com.myprogram.start.plist

3. Set environment variables
Anything run by launchd as a service is run in a new shell environment, and in addition to running python in the conda environment, I also needed to pass environment variables – specifically the path to the Android sdk as this is what the script was dealing with, which can be done with launchctl setenv VARIABLE ‘content_of_variable’. This command itself could be run as a service on startup, however, I also needed to regularly run this script alongside a git pull to retrieve the latest code, so this script can be modified to do that to, unloading the service, git pull (command not shown here), setting the environment, and then reloading the service.

launchctl setenv PATH "$PATH:$ANDROID_HOME/emulator:$ANDROID_HOME/tools:$ANDROID_HOME/platform-tools:$HOME/Library/Android/sdk/platform-tools"
launchctl unload ~/Library/LaunchAgents/com.myprogram.start.plist
launchctl load ~/Library/LaunchAgents/com.myprogram.start.plist