My Mac Mini runs OpenClaw 24/7 as an AI agent server. I manage it remotely over SSH — no monitor, no keyboard attached. It’s been working fine for months. Then one morning my Telegram bot stopped responding. I SSHed in and found this:
12:13:21 [gateway] loading configuration…
12:13:21 [gateway] force: no listeners on port 18789
Force: Error: port 18789 still not bindable after 3000ms (TIME_WAIT or kernel hold)
What followed was a 45-minute diagnostic session that taught me exactly where OpenClaw’s launchd integration breaks when you’re working headless. Here’s everything I ran, every dead end I hit, and the command that actually worked.
The Starting Point: Port Stuck in TIME_WAIT
The first error was a port conflict. After a gateway crash, the kernel holds the port in TIME_WAIT state for up to 60 seconds. The process is gone but the port isn’t free yet.
lsof -i :18789
Nothing showed up — no PID holding it. The kernel just hadn’t released it yet. My first instinct was to restart the launchd service:
launchctl unload ~/Library/LaunchAgents/openclaw.gateway.plist
sleep 5
launchctl load ~/Library/LaunchAgents/openclaw.gateway.plist
Both commands failed with the same error:
Unload failed: 5: Input/output error
Try running `launchctl bootout` as root for richer errors.
Input/output error on a plist file that exists and has correct permissions. That’s when I realized the issue wasn’t the port — it was launchd itself.
Dead End #1: launchctl bootstrap Error 125
I tried the more modern launchctl commands:
sudo launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/ai.openclaw.gateway.plist
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/ai.openclaw.gateway.plist
This produced a clearer error message:
Gateway service install failed: launchctl bootstrap failed: Bootstrap failed: 125:
Domain does not support specified action
LaunchAgent openclaw gateway install --force requires a logged-in macOS GUI session
for this user (gui/501).
This usually means you are running from SSH/headless context or as the wrong user.
Fix: sign in to the macOS desktop as the target user and rerun
`openclaw gateway install --force`.
Error 125 means the operation requires a GUI session that doesn’t exist in the current context. LaunchAgent services run in the gui/$UID domain — a domain that macOS only activates after a user logs into the desktop. Over SSH, that domain exists but won’t accept bootstrap commands from a non-GUI session.
This is a known limitation. OpenClaw doesn’t ship a LaunchDaemon alternative (which would run at system level regardless of GUI), so the standard install path is blocked.
Dead End #2: nohup and the Missing PATH
If launchd won’t cooperate, the obvious workaround is to run the process directly in the background:
nohup openclaw gateway start > ~/openclaw-gateway.log 2>&1 &
This failed with exit code 127 — command not found. The openclaw binary lives at ~/.npm-global/bin/openclaw, and that path isn’t in the PATH inherited by SSH sessions on this machine.
find /usr/local /opt ~/.local -name "openclaw" 2>/dev/null
# → /Users/openclaw/.npm-global/bin/openclaw
Found it. Tried again with the full path:
nohup /Users/openclaw/.npm-global/bin/openclaw gateway start > ~/openclaw-gateway.log 2>&1 &
Exit 127 again — but this time from inside the openclaw binary itself trying to call gateway start, which internally calls launchctl kickstart. It still hits the same launchd wall. And then there was another problem:
nohup: can't detach from console: Inappropriate ioctl for device
nohup couldn’t detach from the TTY in this SSH context. Even if the gateway command had worked, the process would have died when I closed the session.
What Actually Worked: Running the Node Process Directly
The plist file told me everything I needed. Under ProgramArguments, it showed the real command that launchd normally runs:
<array>
<string>/Users/openclaw/.openclaw/service-env/ai.openclaw.gateway-env-wrapper.sh</string>
<string>/Users/openclaw/.openclaw/service-env/ai.openclaw.gateway.env</string>
<string>/usr/local/bin/node</string>
<string>/Users/openclaw/.npm-global/lib/node_modules/openclaw/dist/index.js</string>
<string>gateway</string>
<string>--port</string>
<string>18789</string>
</array>
The wrapper script sources an env file and then execs whatever follows. It’s a clean approach — all environment variables like API keys and config paths live in the .env file, not hardcoded in the plist.
I ran it directly in the foreground first to verify it worked:
/Users/openclaw/.openclaw/service-env/ai.openclaw.gateway-env-wrapper.sh \
/Users/openclaw/.openclaw/service-env/ai.openclaw.gateway.env \
/usr/local/bin/node \
/Users/openclaw/.npm-global/lib/node_modules/openclaw/dist/index.js \
gateway --port 18789
Gateway started. Then I launched it in a detached screen session so it would survive SSH disconnection:
screen -dmS openclaw-gateway \
/Users/openclaw/.openclaw/service-env/ai.openclaw.gateway-env-wrapper.sh \
/Users/openclaw/.openclaw/service-env/ai.openclaw.gateway.env \
/usr/local/bin/node \
/Users/openclaw/.npm-global/lib/node_modules/openclaw/dist/index.js \
gateway --port 18789
Verify it’s running:
screen -ls
lsof -i :18789
Why This Happens: The GUI Session Requirement
macOS LaunchAgent services load into the gui/$UID domain. This domain is only fully active after a user has logged into the desktop. When the Mac Mini reboots or the session gets into a weird state, and you’re connecting only via SSH, you’re operating in a context where:
- The
gui/$UIDdomain exists but won’t accept bootstrap commands launchctl load/unload(legacy API) fails with I/O errorlaunchctl bootstrap/bootout(modern API) fails with error 125
The OpenClaw team is aware of this — the error message itself says «Headless deployments should use a dedicated logged-in user session or a custom LaunchDaemon.» The LaunchDaemon path requires writing your own plist and isn’t officially supported yet.
The practical fix is to ensure the Mac Mini always has auto-login enabled for the openclaw user. When auto-login is on, the user session — and the gui/$UID launchd domain — activates immediately on boot without anyone touching a keyboard:
sudo defaults write /Library/Preferences/com.apple.loginwindow autoLoginUser openclaw
After a reboot with auto-login, openclaw gateway install from an SSH session works normally because the GUI session is already active.
The Full Recovery Sequence
For future reference, here’s the complete decision tree when the gateway goes down over SSH:
1. Check if it’s just a port conflict:
lsof -i :18789
If a PID shows up, kill it and wait 30 seconds for TIME_WAIT to clear.
2. Try launchd restart:
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/ai.openclaw.gateway.plist
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/ai.openclaw.gateway.plist
Works if auto-login is enabled and the GUI session is active.
3. If error 125, bypass launchd entirely:
screen -dmS openclaw-gateway \
/Users/openclaw/.openclaw/service-env/ai.openclaw.gateway-env-wrapper.sh \
/Users/openclaw/.openclaw/service-env/ai.openclaw.gateway.env \
/usr/local/bin/node \
/Users/openclaw/.npm-global/lib/node_modules/openclaw/dist/index.js \
gateway --port 18789
4. Fix PATH for future sessions:
echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> ~/.zshrc
5. Prevent recurrence — enable auto-login:
sudo defaults write /Library/Preferences/com.apple.loginwindow autoLoginUser openclaw
Note: requires FileVault to be disabled.
What I’m Changing Going Forward
This incident exposed two gaps in my setup.
First, PATH isn’t configured for SSH sessions. The openclaw binary is in ~/.npm-global/bin which isn’t in the default SSH PATH. Fixed by adding it to .zshrc, but I should also add it to .zprofile which SSH login shells actually read.
Second, I have no automated recovery. If the gateway crashes at 3am and launchd fails to restart it (which it can do in the same error 125 scenario after an unexpected reboot), nothing alerts me. I’m adding a cron job that pings the gateway health endpoint every 5 minutes and sends a Telegram alert if it’s down.
The screen session is a temporary workaround. The permanent fix is making sure auto-login is on so launchd can do its job properly after every boot.
FAQ
Why does launchctl work in Terminal but fail over SSH?
Terminal runs inside a GUI session (gui/$UID domain fully active). SSH sessions don’t have a window server, so certain launchd operations — specifically those that require the full GUI domain context — fail with error 125.
Can I use tmux instead of screen?
Yes, but tmux isn’t installed on macOS by default. Install via Homebrew (brew install tmux) and use tmux new-session -d -s openclaw-gateway instead.
What’s the difference between LaunchAgent and LaunchDaemon on macOS?
LaunchAgent runs per-user after login (~/Library/LaunchAgents). LaunchDaemon runs as root at system level (/Library/LaunchDaemons), before any user logs in. For a headless server that needs to survive reboots without GUI interaction, a LaunchDaemon would be the correct approach — but OpenClaw doesn’t ship one.
Will the screen session survive a Mac Mini reboot?
No. screen sessions don’t persist across reboots. After a reboot you’d need to SSH in and start it manually. This is why getting launchd to work correctly (via auto-login) is the real solution.
How do I check if auto-login is already enabled?
sudo defaults read /Library/Preferences/com.apple.loginwindow autoLoginUser