MacVisor

VM hooks

Run your own commands on this Mac after a VM starts or stops, and give an appliance a shutdown command of its own. The three hooks, the environment and JSON a hook gets, limits, hooks.log, VM Settings and mvz hooks, mvz events, and examples.

A hook is a command of yours that MacVisor runs on this Mac for a VM. Use hooks to be notified, to log, to tell other systems, and to shut down an appliance its own way.

  • Hooks run on this Mac, as you, not in the VM. MacVisor's background service runs them, whether or not the app is open.
  • They're kept on this Mac, not in the VM.
  • A VM without hooks behaves exactly as before: nothing runs, and no file is written.

The three hooks

HookIn VM SettingsSet asWhat it does
Shutdown commandShutdown CommandshutdownRuns instead of MacVisor's own shutdown, for every Shut Down.
After the VM startsAfter the VM StartsstartedRuns once the VM is running: when MacVisor starts it, not when the guest restarts itself (a Linux guest's reboot, an appliance's update), which MacVisor doesn't see as a start.
After the VM stopsAfter the VM StopsstoppedRuns once the VM has stopped. MVZ_REASON says how (below).

A VM's health check can also run a command when it has failed: its When It Fails, Run field, set as check-failed. It runs the same way as After the VM starts and After the VM stops.

These run once the event has happened, one at a time per VM, in the order the events came. MacVisor doesn't wait for them: they never delay or change the VM, and their exit status is logged and changes nothing.

stopped's MVZ_REASON:

ReasonMeaning
shutdownA Shut Down, or the guest shut itself down
poweroffPower Off, or a stop MacVisor forced
crashedThe VM's process ended while the VM ran
errorVirtualization stopped the VM with an error
restartThe stop half of a restart
suspendThe session was saved to disk
unknownMacVisor can't tell

The shutdown command

A VM's shutdown command shuts it down in place of MacVisor's own way, which is the MacVisor Agent, or the virtual power button for a guest without one. It's for appliances, such as pfSense-based or OPNsense-based firewalls or a NAS, which have no MacVisor Agent and may not act on the power button, but shut down cleanly over SSH or their own API.

It's used for every Shut Down of the VM: the app, the menu bar, the VM's window, mvz stop, the stop half of Restart (the app's, the window's and mvz restart), the Mac logging out, and the restarts of a health check and VM Watchdog. Power Off and Suspend never use it.

  1. The command runs, for at most 60 seconds, with MVZ_EVENT=stopping and MVZ_HOOK=shutdown. MVZ_IP is the address the Mac last saw the VM use.
  2. If it exits 0, the VM has 30 seconds to stop. If it's still running then, MacVisor shuts it down its own way, once, and Tasks says so: The VM hadn't stopped 30 s after its shutdown command; pressed the power button.
  3. If it fails (any other exit status, or 60 seconds without finishing), the VM has 10 seconds to stop. A VM that stops in that time needs nothing more, and no failure is reported: ssh host sudo poweroff usually exits 255 as the guest drops the SSH session under it. A guest whose agent closes its connection in that time is going down, and gets the 30 seconds. Otherwise MacVisor shuts the VM down its own way at once, and Tasks says so: The shutdown command failed (exit 255: Connection refused); pressed the power button instead.
  4. From there the VM gets MacVisor's usual wait, and the usual "still shutting down" handling. See Lifecycle actions.
  • A second Shut Down while the command runs joins it rather than running it again. A mvz stop of the same VM from inside the command is told "web" is already shutting down: its shutdown command is running. and sends nothing, so a command that ends with mvz stop doesn't cancel the Restart it's part of.
  • Power Off while the command runs ends it at once and sends nothing more.
  • At logout the command has the wait chosen in Settings → General → Logout and Shutdown less 10 seconds kept for MacVisor's own way (50 seconds of the default minute), or 8 of the 12 seconds that sudo shutdown leaves. The VM is still powered off at the end, as without a command. See When you log out or restart the Mac.
  • A guest that's already shutting down from a Shut Down sent MacVisor's way is sent that way again, as without a command.

What a hook gets

MacVisor runs the command with /bin/zsh -c "<command>", from your home folder, in a process group of its own. It isn't a login shell: ~/.zshenv is read, ~/.zshrc and ~/.zprofile aren't, so aliases and functions from them aren't there.

Environment. PATH is /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin, so mvz, Homebrew's tools and the system's are found. HOME, USER, LOGNAME, SHELL, TMPDIR and your language settings come from your session; nothing else does. In particular, there's no SSH agent (SSH_AUTH_SOCK): give ssh a key file that has no passphrase.

VariableValue
MVZ_EVENTstarted, stopped or check-failed. stopping for the shutdown command, check for a health check.
MVZ_HOOKThe kind of hook: event, shutdown, or check for a health check
MVZ_VM_IDThe VM's id
MVZ_VM_NAMEThe VM's name at the time of the event
MVZ_OSmacos, linux, windows or freebsd
MVZ_REASONstopped's reason, else empty
MVZ_IPThe best IPv4 address MacVisor knows for the VM, else empty
MVZ_EVENT_JSON_VERSION1

A variable without a value is set and empty, so a set -u script can test it.

MacVisor learns a VM's address when it asks the guest for its details: while the app is open, and for mvz show, mvz ip and similar requests. For a running VM with a hook, a health check or VM Watchdog on, the background service also asks by itself, about 30 seconds after the VM starts and then every 90 seconds. So on a Mac where the app is closed, such as a headless host, MVZ_IP still has the address.

Standard input has the event as one line of JSON, then ends. Keys are sorted, and a key without a value is left out:

{"event":"stopped","ip":"192.168.64.7","os":"linux","reason":"shutdown","time":"2026-11-02T13:12:05.123Z","version":1,"vmID":"3F2A9C1E-7B4D-4E8A-9C61-2D5B8E0F4A17","vmName":"web"}
Key
version1. Version 1 only ever gains keys.
eventThe event's name
timeWhen it happened, ISO 8601 in UTC, with milliseconds
vmID, vmName, osAs the variables
reason, ipWhen the event has one
detailA short sentence for people, when there is one

A hook doesn't have to read it. mvz events --json prints events in the same form, with snapshot for the snapshot events.

Quote the name

A VM's name can be any text, including a name an AI agent chose. Always put "$MVZ_VM_NAME" in double quotes, and never into code: not into eval, sh -c or osascript -e. Pass it as an argument instead. The notification example shows how with osascript.

When you set a hook from Terminal, put the command in single quotes, so $MVZ_VM_NAME is left for the hook to expand rather than your shell.

Time limits and order

HookTime limit
After the VM starts, After the VM stops, When It Fails, Run60 seconds
Shutdown command60 seconds (less at logout)
Health check30 seconds, or the interval less 5 seconds if that's shorter
  • At the limit, the hook's process group gets SIGTERM, and SIGKILL 5 seconds later.
  • When the shell exits, anything it left running in its group (a command &) is ended with it. A process that leaves the group (setsid, launchctl, open) isn't MacVisor's to end.
  • A VM's started, stopped and check-failed hooks run one at a time, in the order of the events. At most 8 hooks run at once across all VMs.
  • A VM with 50 hooks waiting has newer events dropped, with a line in the service log, until its queue empties.
  • Hooks still running when the background service quits (an update, a logout) get SIGTERM.
  • A hook may run mvz, on its own VM too. MacVisor never waits on a hook while holding the VM, so it doesn't deadlock.

hooks.log

Each run is written to the VM's hooks log, beside its runner log: ~/Library/Application Support/MacVisor/logs/<VM id>-hooks.log. An entry has the time, the event, the kind of hook, how it ended and how long it took, the command, and its output (standard output and error together, the first 64 KB):

2026-11-02T13:12:05.123Z stopped (event hook): exit 0 in 84 ms
  command: curl -fsS -m 10 --data-binary @- https://hooks.example.com/macvisor
  output:
    ok

The log holds what the command printed, never its environment. It's rolled over to .1 at 1 MB, and deleted with the VM. A VM whose hooks never ran has no hooks log.

mvz show web --json | jq -r .id        # the VM's id
tail -f ~/Library/Application\ Support/MacVisor/logs/<VM id>-hooks.log

Where hooks are kept

All hooks and health checks are in one file on this Mac: ~/Library/Application Support/MacVisor/hooks.json, readable only by you, keyed by VM id.

  • They're never written into a VM's folder, so they don't travel with copies, clones, exports, imports, snapshots, moves or backups of the VM. A copy of a VM has none.
  • Renaming a VM keeps its hooks. Deleting a VM deletes them.
  • There's no file until you set a hook.
  • A hooks file MacVisor can't read means no hook runs for any VM. MacVisor says why, and refuses changes until it's fixed: MacVisor's hooks file (~/Library/Application Support/MacVisor/hooks.json) can't be used: it isn't valid JSON. No hooks run until it's fixed; fix it, or move it to the Trash to start again with no hooks.

You can edit the file by hand. Only shutdown, started, stopped, check-failed and check are used: an entry for any other event, as earlier test versions allowed, is ignored and dropped at the next change. A health check in it must be valid, with an interval of 30 to 3600 seconds, or the whole file counts as damaged.

{
  "3F2A9C1E-7B4D-4E8A-9C61-2D5B8E0F4A17": {
    "check": { "command": "curl -fsS -m 10 http://192.168.64.7/health", "interval": 60, "restart": true, "where": "mac" },
    "shutdown": "ssh -i ~/.ssh/appliance -o BatchMode=yes admin@192.168.64.7 'shutdown -p now'",
    "stopped": "~/bin/vm-stopped"
  }
}

A command is a command line, or the path of your own script with its arguments. MacVisor keeps the line, never a copy of the script. It's trimmed, can be at most 4096 bytes (put a longer one in a script), and can't contain a NUL character.

In VM Settings

VM Settings → Advanced → Hooks has a field for each hook: Shutdown Command, After the VM Starts and After the VM Stops, each with Test.

  • Changes save by themselves, like the rest of VM Settings, and apply once saved. Emptying a field removes that hook.
  • Test runs the saved command once now, on this Mac, with a sample event, and shows its exit status, how long it took and the last lines of its output. It warns when the command's first word isn't found or can't be run. It waits while a change is being saved. MacVisor doesn't change the VM for a test.
  • Test of the Shutdown Command runs it for real: if it works, the VM shuts down. While the VM runs, MacVisor asks first: Test the shutdown command of "web"? It runs now, and shuts "web" down if it works. Choose Run It to go ahead. A test whose ssh exits 255 adds If the VM shut down, this is how SSH ends: the guest closed the session.

The health check, with its When It Fails, Run command, is in its own section above Hooks: VM Settings → Advanced → Health Check. See Health checks.

Templates and AI sandboxes have no hooks, and the page says why: Templates never run, so they have no hooks. Hooks don't run for AI sandboxes. Hooks don't run while a VM's system is being installed either.

With mvz hooks

mvz hooks <vm> [--json]                           # list
mvz hooks <vm> set <hook> '<command>'             # set or replace
mvz hooks <vm> remove <hook>
mvz hooks <vm> test <hook> [--yes] [--json]       # run once now with a sample event
mvz hooks <vm> check [--json]                     # the health check
mvz hooks <vm> check set ['<command>'] [--in-guest|--on-mac] [--interval N] [--restart|--no-restart] [--on-fail '<command>'|--no-on-fail]
mvz hooks <vm> check remove
mvz hooks <vm> check test [--json]
  • <hook> is shutdown, started, stopped or check-failed. Any other name is refused, with exit status 2 and the list of names.
  • set takes the command as one quoted argument, run by zsh as you typed it. Several words are quoted one by one and joined. Everything after the hook's name is the command's, options and all.
  • set refuses an empty command and points at remove, so an unset variable in a script can't delete a hook by accident.
  • check set on a VM that has a check changes only what you give (check set --interval 120 keeps the command and the restart). With no check yet, it needs the command. --on-fail sets the check-failed hook, and --no-on-fail removes it. See Set it with mvz hooks.
  • test prints the exit status, the time and the output, and exits 1 when the hook failed. test shutdown runs the shutdown command for real, so while the VM runs it needs --yes.
  • --json of the list has vmID, name, hooks (each hook to its command), check and checkStatus, and problem when the hooks file can't be used.
  • mvz show <vm> has a Hooks row naming the VM's hooks, and a Health check row. Its --json has hooks, hookCheck and hookCheckStatus.
mvz hooks web set started 'curl -fsS -m 10 -d "$MVZ_VM_NAME is up" https://ntfy.sh/my-vms'
mvz hooks web test started
mvz hooks web
mvz hooks web remove started

AI agents over MCP can't see or change hooks.

mvz events

mvz events prints what happened to VMs lately: the last 200 events the background service remembers since it started, oldest first, whether or not a VM has hooks. They're more than the hooks run on: start, started, guest-ready, stopping, stopped, paused, resumed, suspended, restored, snapshot-taken, snapshot-reverted, snapshot-deleted, ha-restart, ha-gave-up, ip-changed, check-failed, check-recovered, guest-not-responding and guest-responding.

mvz events                       # all VMs
mvz events web                   # one VM
mvz events web --follow          # new events as they happen, until Control-C
mvz events --follow --json       # one JSON object per line, as hooks read it
2026-11-02 14:42:05  web  stopping
2026-11-02 14:42:11  web  stopped (shutdown)
2026-11-02 14:43:02  web  started  192.168.64.7
  • --follow carries on across a service restart, and follows a VM by its id, so a rename or delete doesn't end it.
  • It only reads. A follower costs nothing when it disconnects.
  • To keep a record of every event, run mvz events --follow --json >> <file> in the background, or as a launchd agent of yours.

Privacy prompts

A hook runs as part of MacVisor, so macOS asks whether MacVisor may use your Desktop, Documents or Downloads folder the first time a hook touches one, and may ask the same for network or removable volumes. Until you answer, the hook waits or fails. Keep the files hooks use elsewhere, such as ~/bin, ~/Library/Logs or ~/.local, or answer the prompt once at the Mac.

Examples

Shut down an appliance over SSH

pfSense-based and OPNsense-based appliances don't run the MacVisor Agent and can ignore the power button, but shut down cleanly over SSH.

  1. In the appliance, turn on SSH and add a public key of yours to its admin account. Use a key without a passphrase, kept for this: hooks have no SSH agent.

  2. Connect once from Terminal, so the appliance's host key is in ~/.ssh/known_hosts:

    ssh -i ~/.ssh/appliance admin@192.168.64.20
  3. Set the shutdown command:

    mvz hooks pfsense set shutdown 'ssh -i ~/.ssh/appliance -o BatchMode=yes -o ConnectTimeout=10 admin@192.168.64.20 "shutdown -p now"'
  4. Try it: mvz hooks pfsense test shutdown --yes shuts the VM down.

Use the address you SSH to; "$MVZ_IP" works in its place when MacVisor knows the VM's address. BatchMode=yes makes ssh fail at once rather than wait for a password or a host key question it can't answer.

ssh exits 255 when the appliance drops the session as it powers off. That's fine: MacVisor gives the VM 10 seconds to stop before it takes the command as failed, and a VM that stops needs nothing more. A Test, which doesn't wait, shows exit 255 and the note that this is how SSH ends.

Notify when a VM stops

A macOS notification, with the name passed as an argument, never as code:

mvz hooks web set stopped 'osascript -e "on run argv" -e "display notification (item 1 of argv) with title \"MacVisor\"" -e "end run" "$MVZ_VM_NAME stopped ($MVZ_REASON)"'

Or a webhook, with the event's JSON as the body:

mvz hooks web set stopped 'curl -fsS -m 10 -H "Content-Type: application/json" --data-binary @- https://hooks.example.com/macvisor'

--data-binary @- sends standard input, which is the event. -m 10 keeps a slow server from holding the VM's other hooks back.

Log starts and stops to a file

Each event's JSON is one line, so appending it gives a JSON Lines file:

mvz hooks web set started 'cat >> ~/Library/Logs/macvisor-web-events.jsonl'
mvz hooks web set stopped 'cat >> ~/Library/Logs/macvisor-web-events.jsonl'

To log every event of every VM, not just starts and stops, follow them instead: mvz events --follow --json >> ~/Library/Logs/macvisor-events.jsonl.

When a hook doesn't do what you expect

  • It works in Terminal but not as a hook. Hooks have their own PATH, no ~/.zshrc and no SSH agent. Use full paths, and a key file for ssh. Run Test, or mvz hooks <vm> test <hook>, and read the output.
  • Nothing happens, or it stops half way. Read the VM's hooks.log. A hook that hit its time limit says timed out after 60 s. One waiting on a privacy prompt is stuck until you answer it at the Mac.
  • Shut Down pressed the power button. Tasks says why: The shutdown command failed (exit 255: Connection refused); pressed the power button instead. Run the command in Terminal to see what ssh says.
  • No hook runs for any VM. The hooks file can't be read. VM Settings and mvz hooks say why. Fix it, or move it to the Trash.

For a VM that restarted by itself, see Troubleshooting on the High availability page.