docs/gateway/remote.md
OpenClaw runs one Gateway (the master) on a host and connects every client to it. The Gateway owns sessions, auth profiles, channels, and state; everything else is a client.
The Gateway WebSocket binds to loopback by default, on port 18789 (gateway.port). For remote use, either expose it through Tailscale Serve / a trusted LAN-Tailnet bind, or forward the loopback port over SSH.
| Setup | Where the Gateway runs | Best for |
|---|---|---|
| Always-on Gateway in your tailnet | Persistent host (VPS or home server), reached via Tailscale or SSH | Laptops that sleep often but need the agent always-on. See exe.dev (easy VM) or Hetzner (production VPS). |
| Home desktop | Desktop; laptop connects remotely via the macOS app's remote mode (Settings → Connection → OpenClaw runs) | Keeping the agent on hardware that stays powered on. Runbook: macOS remote access. |
| Laptop | Laptop, exposed safely via SSH tunnel or Tailscale Serve (keep gateway.bind: "loopback") | Single-machine setups. See Tailscale and Web. |
For the always-on and laptop setups, prefer keeping gateway.bind: "loopback" and using Tailscale Serve for the Control UI, or a trusted LAN/Tailnet bind with gateway.remote.transport: "direct". SSH tunnel is the fallback that works from any machine.
One Gateway owns state and channels; nodes are peripherals. Example (Telegram message routed to a node tool):
node.invoke RPC).Nodes do not run the Gateway service. Only one Gateway should run per host unless you intentionally run isolated profiles (see Multiple gateways). macOS app "node mode" is just a node client over the Gateway WebSocket.
ssh -N -L 18789:127.0.0.1:18789 user@gateway-host
With the tunnel up, openclaw health and openclaw status --deep reach the remote Gateway via ws://127.0.0.1:18789. openclaw gateway status, openclaw gateway health, openclaw gateway probe, and openclaw gateway call can also target a forwarded URL via --url.
Persist a remote target so CLI commands use it by default:
{
gateway: {
mode: "remote",
remote: {
url: "ws://127.0.0.1:18789",
token: "your-token",
},
},
}
When the Gateway is loopback-only, keep the URL at ws://127.0.0.1:18789 and open the SSH tunnel first. In the macOS app's SSH-tunnel transport, the discovered Gateway hostname goes in gateway.remote.sshTarget (user@host or user@host:port); gateway.remote.url stays the local tunnel URL. If the remote port differs from the local one, set gateway.remote.remotePort.
Host-key verification is strict by default (gateway.remote.sshHostKeyPolicy: "strict"). Set it to "openssh" to delegate to your effective OpenSSH config instead; review your user and system SSH settings before enabling it.
For a Gateway already reachable on a trusted LAN or Tailnet, use direct mode:
{
gateway: {
mode: "remote",
remote: {
transport: "direct",
url: "ws://192.168.0.202:18789",
token: "your-token",
},
},
}
Gateway credential resolution follows one shared contract across call/probe/status paths and Discord exec-approval monitoring. Node-host uses the same contract with one local-mode exception (it ignores gateway.remote.*).
--token, --password, or a tool's gatewayToken) always win on call paths that accept explicit auth.--url never reuses implicit config/env credentials.OPENCLAW_GATEWAY_URL may use env credentials only (OPENCLAW_GATEWAY_TOKEN / OPENCLAW_GATEWAY_PASSWORD).OPENCLAW_GATEWAY_TOKEN -> gateway.auth.token -> gateway.remote.token (remote fallback only when the local token is unset)OPENCLAW_GATEWAY_PASSWORD -> gateway.auth.password -> gateway.remote.password (remote fallback only when the local password is unset)gateway.remote.token -> OPENCLAW_GATEWAY_TOKEN -> gateway.auth.tokenOPENCLAW_GATEWAY_PASSWORD -> gateway.remote.password -> gateway.auth.passwordgateway.remote.token / gateway.remote.password are ignored.gateway.remote.token only (no local token fallback) when targeting remote mode.OPENCLAW_GATEWAY_* only.WebChat has no separate HTTP port; the SwiftUI chat UI connects directly to the Gateway WebSocket.
18789 over SSH (see above), then connect clients to ws://127.0.0.1:18789.ws:// or secure wss:// URL.The macOS menu bar app drives the same setup end-to-end: remote status checks, WebChat, and Voice Wake forwarding. Runbook: macOS remote access.
Keep the Gateway loopback-only unless you are sure you need a bind.
ws:// is accepted for loopback, private/LAN (RFC 1918), link-local, CGNAT, .local, and .ts.net hosts. Public remote hosts must use wss://.lan/tailnet/custom, or auto when loopback is unavailable) must use Gateway auth: token, password, or an identity-aware reverse proxy with gateway.auth.mode: "trusted-proxy".gateway.remote.token / .password are client credential sources; they do not configure server auth by themselves.gateway.remote.* as a fallback only when gateway.auth.* is unset.gateway.auth.token / gateway.auth.password is explicitly configured via SecretRef and unresolved, resolution fails closed (no remote fallback masking).gateway.remote.tlsFingerprint pins the remote TLS cert for wss://, including macOS direct mode. Without a stored pin, macOS only pins on first use after normal system trust passes; self-signed or private-CA Gateways need an explicit fingerprint or Remote over SSH.gateway.auth.allowTailscale: true. HTTP API endpoints do not use that header auth and instead follow the Gateway's normal HTTP auth mode. This tokenless flow assumes the Gateway host is trusted; set it to false for shared-secret auth everywhere.gateway.auth.trustedProxy.allowLoopback = true.Deep dive: Security.
For macOS clients, the easiest persistent setup uses an SSH LocalForward config entry plus a LaunchAgent that keeps the tunnel alive across reboots and crashes.
Edit ~/.ssh/config:
Host remote-gateway
HostName <REMOTE_IP>
User <REMOTE_USER>
LocalForward 18789 127.0.0.1:18789
IdentityFile ~/.ssh/id_rsa
Replace <REMOTE_IP> and <REMOTE_USER> with your values.
ssh-copy-id -i ~/.ssh/id_rsa <REMOTE_USER>@<REMOTE_IP>
openclaw config set gateway.remote.token "<your-token>"
Use gateway.remote.password instead if the remote Gateway uses password auth. OPENCLAW_GATEWAY_TOKEN is still valid as a shell-level override, but the durable remote-client setup is gateway.remote.token / gateway.remote.password.
Save as ~/Library/LaunchAgents/ai.openclaw.ssh-tunnel.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>ai.openclaw.ssh-tunnel</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/ssh</string>
<string>-N</string>
<string>remote-gateway</string>
</array>
<key>KeepAlive</key>
<true/>
<key>RunAtLoad</key>
<true/>
</dict>
</plist>
launchctl bootstrap gui/$UID ~/Library/LaunchAgents/ai.openclaw.ssh-tunnel.plist
The tunnel starts automatically at login, restarts on crash, and keeps the forwarded port live.
<Note> If you have a leftover `com.openclaw.ssh-tunnel` LaunchAgent from an older setup, unload and delete it. </Note># Check if the tunnel is running
ps aux | grep "ssh -N remote-gateway" | grep -v grep
lsof -i :18789
# Restart the tunnel
launchctl kickstart -k gui/$UID/ai.openclaw.ssh-tunnel
# Stop the tunnel
launchctl bootout gui/$UID/ai.openclaw.ssh-tunnel
| Config entry | What it does |
|---|---|
LocalForward 18789 127.0.0.1:18789 | Forwards local port 18789 to remote port 18789 |
ssh -N | SSH without executing remote commands (port forwarding only) |
KeepAlive | Restarts the tunnel automatically if it crashes |
RunAtLoad | Starts the tunnel when the LaunchAgent loads at login |