Run OpenCode on Your Mac and Reach It From Your Phone With Tailscale
You can drive an OpenCode session from your phone while the work runs on your Mac. This guide gets a server running that survives reboots, reaches it over a private network, and connects two ways:
- OpenCode’s built-in web UI, opened in Safari. No third-party app.
- KKCode, a third-party iPhone app with a native touch interface.
OpenCode has no official mobile app. KKCode is an independent client. I tested this on OpenCode 2.2.6, Tailscale 1.104.5 and KKCode 1.0.9: the auth checks, the built-in service, Tailscale Serve, signing in from an iPhone with opencode pair, and connecting KKCode over Tailscale and loading sessions. I tested the launchd wrapper from Option B on OpenCode 2.0.27. I did not test the Option A login agent, a reboot, or KKCode’s permission prompts. Each of those is flagged where it comes up.
An OpenCode server can run commands and edit files as your user. Read the risk section before opening any path to it.
What We’re Building
flowchart TD
Phone["iPhone: Safari web UI or KKCode"] -->|HTTPS| Tailscale["Tailscale Serve (your tailnet only)"]
Tailscale -->|localhost| Server["OpenCode server on the Mac"]
Server --> Repo["Your projects"]
Server --> LLM["LLM provider API"]
Launchd["launchd"] -->|starts at login| Server
You need a Mac that stays awake, opencode installed (opencode --version), a configured provider (opencode auth), and a free Tailscale account on the Mac and the phone. The Personal plan is free for non-commercial use. Sign in with a personal account (Google, GitHub or Apple). A work email on your own domain creates a business tailnet that starts on a paid trial, and once the trial ends the Mac won’t connect.
This guide is for OpenCode 2.x. Run opencode --version, and update if you’re on 1.x. KKCode added OpenCode 2 support in version 1.0.7, so both phone clients work with the same server.
Step 1: Run the Server
Pick one way to run it:
- Option A, the built-in service: the simplest, and the one I tested end to end. It doesn’t restart itself if it crashes.
- Option B,
opencode serveunder launchd: more setup, but launchd restarts the server whenever it exits.
Option A: the built-in service
export OPENCODE_PW="$(openssl rand -base64 32)"
printf '%s' "$OPENCODE_PW" | pbcopy # save it in your password manager now
opencode service set hostname 127.0.0.1
opencode service set port 49374
opencode service set password "$OPENCODE_PW"
opencode service start
opencode service status
Generating the password into an exported variable means you can save it and reuse it in this terminal. You need it for the smoke test below and for any app that asks for it. In a new terminal, opencode service get password prints it again. Changing a setting stops the service, so run opencode service restart afterwards. The default is 127.0.0.1:49374. The password is also stored in ~/.config/opencode/service.json in plain text. The file is readable only by you (600), but never paste, screenshot or commit it.
The docs don’t say whether the service comes back after a reboot. I haven’t tested a reboot, and the login agent below is untested. Here is what I did see: on my machine the running service is opencode serve --service, with parent PID 1 and its own process group. In other words opencode service start launches a daemon that detaches itself and exits. That means launchd will show the job as finished, and it will not watch or restart the daemon.
Treat the agent as a best-effort login starter. After a reboot and login, run opencode service status. If it says the service isn’t running, this approach doesn’t work on your version.
Save this as ~/Library/LaunchAgents/com.example.opencode-login-start.plist, replacing YOURUSER and the path to your opencode (command -v opencode):
<?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.opencode-login-start</string>
<key>ProgramArguments</key>
<array>
<string>/Users/YOURUSER/.opencode/bin/opencode</string>
<string>service</string>
<string>start</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>AbandonProcessGroup</key>
<true/>
<key>StandardOutPath</key>
<string>/Users/YOURUSER/Library/Logs/opencode-login-start.log</string>
<key>StandardErrorPath</key>
<string>/Users/YOURUSER/Library/Logs/opencode-login-start.log</string>
</dict>
</plist>
plutil -lint ~/Library/LaunchAgents/com.example.opencode-login-start.plist
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.opencode-login-start.plist
AbandonProcessGroup tells launchd not to kill leftover child processes when the job exits, which protects the daemon if it ever stays in the job’s process group. The agent never restarts a crashed service, so check opencode service status if the phone stops connecting. The two agents in this post have different labels (opencode-login-start for Option A, opencode-server for Option B), and you should load only the one for your option.
Option B: opencode serve supervised by launchd
Testing status: I ran this exact wrapper and plist on a spare port against OpenCode 2.0.27. Without the password file the wrapper refused to start, never listened, and launchd retried about every 10 seconds. Once the file existed the server came up and answered 401, 401, 200. Killing the server made launchd start a new one. On 2.2.6 I checked that opencode serve still reads the password from OPENCODE_SERVER_PASSWORD, but I didn’t repeat the launchd test.
opencode serve runs the server in the foreground, which is what launchd wants. It reads OPENCODE_SERVER_PASSWORD for basic auth. The wrapper below sets the address and port explicitly, so it doesn’t depend on defaults.
Store the password in a file only you can read:
mkdir -p ~/.config/opencode-server
openssl rand -base64 32 > ~/.config/opencode-server/password
chmod 600 ~/.config/opencode-server/password
Create a wrapper that refuses to start without a password, because a server with an empty password is the worst failure here:
mkdir -p ~/bin
cat > ~/bin/opencode-server <<'EOF'
#!/bin/zsh
set -eu
OPENCODE_BIN="${OPENCODE_BIN:-$(command -v opencode || true)}"
[ -x "$OPENCODE_BIN" ] || { echo "opencode not found; set OPENCODE_BIN" >&2; exit 1; }
PASSWORD_FILE="$HOME/.config/opencode-server/password"
[ -s "$PASSWORD_FILE" ] || { echo "missing or empty $PASSWORD_FILE" >&2; exit 1; }
export OPENCODE_SERVER_PASSWORD="$(cat "$PASSWORD_FILE")"
exec "$OPENCODE_BIN" serve --hostname 127.0.0.1 --port 4096
EOF
chmod 700 ~/bin/opencode-server
Save the LaunchAgent as ~/Library/LaunchAgents/com.example.opencode-server.plist, replacing YOURUSER. launchd does not expand ~ or $HOME.
<?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.opencode-server</string>
<key>ProgramArguments</key>
<array>
<string>/Users/YOURUSER/bin/opencode-server</string>
</array>
<key>WorkingDirectory</key>
<string>/Users/YOURUSER</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>ThrottleInterval</key>
<integer>10</integer>
<key>StandardOutPath</key>
<string>/Users/YOURUSER/Library/Logs/opencode-server.log</string>
<key>StandardErrorPath</key>
<string>/Users/YOURUSER/Library/Logs/opencode-server.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/Users/YOURUSER/.opencode/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
</dict>
</dict>
</plist>
KeepAliverestarts the server when it exits.- The password sits in the server process’s environment for as long as it runs. That’s acceptable on a single-user Mac, since anything running as you can read the password file anyway, but it is no stronger than that.
ThrottleIntervalis a minimum 10-second delay between launches. It slows a crash loop, it doesn’t stop one. If the wrapper exits immediately, launchd retries every 10 seconds forever, so watch the log.PATHis set because launchd’s default is minimal and OpenCode shells out togitand friends.
plutil -lint ~/Library/LaunchAgents/com.example.opencode-server.plist
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.opencode-server.plist
launchctl print gui/$(id -u)/com.example.opencode-server | head -20
tail -f ~/Library/Logs/opencode-server.log
After editing the wrapper or plist, run launchctl kickstart -k gui/$(id -u)/com.example.opencode-server.
Keep the Mac awake
A sleeping Mac can’t serve requests, and a LaunchAgent only runs while you’re logged in. Stop automatic sleep in System Settings → Energy (or Battery), or use caffeinate -s on AC power. A closed laptop lid sleeps the machine unless an external display is attached. After an unattended reboot nothing starts until you log in.
Step 2: Smoke-Test Auth
Run this before exposing anything. Use the port and password from Step 1 (49374 for Option A, 4096 for Option B).
PORT=49374 # 4096 for Option B
PW="$(opencode service get password)" # Option B: PW="$(cat ~/.config/opencode-server/password)"
URL="http://127.0.0.1:${PORT}"
curl -s -o /dev/null -w 'no credentials: %{http_code}\n' "${URL}/api/session"
curl -s -o /dev/null -w 'wrong password: %{http_code}\n' -u opencode:wrong "${URL}/api/session"
curl -s -o /dev/null -w 'right password: %{http_code}\n' -u "opencode:${PW}" "${URL}/api/session"
You want 401, 401, 200. If you get 200 without credentials, stop and don’t expose the server. Note ${PW} is in braces on purpose, because zsh treats $PW: followed by a letter as a modifier. Use /api/session for this check. Other paths, such as /doc or /global/health, return the web UI page with no credentials on 2.2.6, so they always look like a success. When you’ve finished setting up your phone, run unset OPENCODE_PW PW so the password doesn’t stay in your shell.
On 2.2.6 the basic-auth username is always opencode. I set OPENCODE_SERVER_USERNAME=tester and only opencode was accepted. When an app asks for a username, enter opencode.
Read This Before You Expose Anything
- Whoever authenticates has your shell. They can run commands and edit files as you.
- Keep the server bound to
127.0.0.1. Binding to0.0.0.0exposes it to everyone on the network. - Use a long random password in a
chmod 600file. Never commit the password file,service.json, or pairing links. - A pairing link is a login. Each one works once and expires in 5 minutes, but treat it like a password until it expires, and never post a screenshot of the QR code.
- Prefer a private path (Tailscale). Avoid
opencode pair --remote. Its help text says it pairs “through the OpenTunnel remote address, enabling remote access if needed (exposes the server to the internet)”, and it requires--yeswhen there is no interactive terminal. Once you’ve accepted it, later runs skip the prompt, even after you turn remote access off withopencode service set remote false. With remote access on, the password is your only protection. - Don’t use auto-approve flags on a server you drive from a phone, and review OpenCode’s permission settings.
- Rotate the password if you ever shared a log, screenshot or link. See Rollback.
Step 3: Give the Phone a Private Path With Tailscale
Install Tailscale on the Mac and on the phone, signed in to the same account. On the Mac, use the Standalone app from tailscale.com/download/mac, which Tailscale recommends over the App Store version.
The Mac app doesn’t add the tailscale command to your shell, so tailscale serve fails with command not found until you set it up:
-
Standalone app: open Tailscale’s Settings, go to CLI integration, choose Show me how, then Install Now. This installs a launcher at
/usr/local/bin/tailscale. -
App Store app: the command is inside the app bundle. Add an alias to
~/.zshrc, then open a new terminal:alias tailscale="/Applications/Tailscale.app/Contents/MacOS/Tailscale"
Check it with tailscale version.
Before tailscale serve can give you an https:// address, enable MagicDNS and HTTPS Certificates for your tailnet in the Tailscale admin console (the DNS page). Without them you won’t get an HTTPS address, so enable both first. Enabling HTTPS publishes your machine names and tailnet DNS name in a public certificate transparency log, so don’t give the Mac a name you’d mind being public.
Then publish the local port inside your tailnet:
tailscale serve --bg 49374 # 4096 for Option B
tailscale serve status
status prints an address like https://machine-name.tailnet-name.ts.net. Only devices on your tailnet can reach it. Traffic from the phone to the Mac is HTTPS, but Tailscale Serve forwards it to OpenCode over plain HTTP on 127.0.0.1. That last hop never leaves the Mac, so nothing on the network sees it. Tailscale Serve is not Tailscale Funnel: Serve stays inside your tailnet, Funnel publishes to the internet. Don’t run Funnel.
From another device on the tailnet, repeat the smoke test against that HTTPS address, or open it in the phone’s browser. Don’t test from the Mac itself: on my Mac (Tailscale 1.104.5, App Store version), requests to its own Serve address were dropped, while the phone loaded the page.
Step 4: Connect the Phone
Use either client, or both. They talk to the same server.
OpenCode’s web UI in Safari
-
On the Mac, print a one-time link that points at your tailnet address:
opencode pair --url https://machine-name.tailnet-name.ts.netIt prints a link of the form
https://<your-address>/auth/connect/<token>and a QR code. Without--url, the link points at localhost. -
On the iPhone, with Tailscale connected, scan the QR code with the Camera app or open the link in Safari. It signs you in with a session cookie that lasts 30 days.
-
Optional: in Safari tap Share → Add to Home Screen so it opens like an app.
Notes:
- The link works once and expires in 5 minutes. Run
opencode pairagain for another device. - Changing the server password signs out every session.
- I paired an iPhone against the built-in service (Option A). I haven’t tried
opencode pairagainst a foregroundopencode serveprocess (Option B).
KKCode
-
Install KKCode from the App Store (iOS or iPadOS 16.4 or later). It is a proprietary, independent client. You need version 1.0.7 or later for OpenCode 2.x.
-
Open the app, go to Servers, and add a server:
- Protocol:
https:// - Host: your Tailscale name without
https://or a path, for examplemachine-name.tailnet-name.ts.net. Check that it ends in.ts.net, since autocorrect can change it. - Port:
443 - Username:
opencode - Password: the server password
The Add Server screen suggests running
opencode service set hostname 0.0.0.0and port4096. Don’t: that exposes the server to your whole network. Keep the setup from Step 1, because Tailscale Serve already reaches it. - Protocol:
-
Avoid the app’s QR pairing option. It routes through KKCode’s relay, and its privacy policy doesn’t say whether the relay can read traffic or is end-to-end encrypted. Direct Tailscale avoids the relay.
Privacy notes from the app’s policy: it collects crash reports and usage analytics through Firebase, and keeps server URLs and credentials in the iOS Keychain. It describes the app as a remote control, with the repository, model keys and command execution staying on your Mac.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| KKCode: “A server with the specified hostname could not be found” | Typo in Host, or phone’s Tailscale is off | Host must end in .ts.net; turn Tailscale on |
| KKCode shows JSON parse errors or 404 | KKCode older than 1.0.7 | Update KKCode |
401 with the right password |
Wrong username | The username is opencode |
launchctl print shows exit 78 or 127 |
Bad path in the plist or wrapper | Run the command by hand |
| Log repeats “missing or empty password file” | Password file absent | Recreate it, then kickstart -k |
| Pair link says expired | Over 5 minutes old or already used | Run opencode pair again |
| Works at home, fails on cellular | Phone’s Tailscale is off | Turn Tailscale on |
| Tailscale says “your trial has ended” | Signed in with a work email on a custom domain | tailscale logout, then sign in with a personal account |
zsh: command not found: tailscale |
The Mac app doesn’t add the command to your shell | Install the CLI integration or add the alias (Step 3) |
tailscale serve status empty |
Serve not running | Re-run tailscale serve --bg <port> |
| Phone gets “server unexpectedly dropped the connection” | Tailscale not yet serving the new HTTPS certificate | Quit and reopen the Tailscale app on the Mac, wait a minute, retry |
| Fails from the Mac but works from the phone | The Mac can’t reach its own Serve address | Test from another tailnet device |
| Everything stops overnight | Mac slept | Fix energy settings or use caffeinate |
Rollback
# 1. close the network path
tailscale serve reset
# 2. stop the server
opencode service stop # Option A
launchctl bootout gui/$(id -u)/com.example.opencode-login-start # Option A login agent
launchctl bootout gui/$(id -u)/com.example.opencode-server # Option B
# 3. remove the launch agents and wrapper
rm -f ~/Library/LaunchAgents/com.example.opencode-login-start.plist
rm -f ~/Library/LaunchAgents/com.example.opencode-server.plist
rm -f ~/bin/opencode-server
# 4. remove the stored password
opencode service unset password # Option A
rm -f ~/.config/opencode-server/password # Option B
To rotate the password, set a new one (opencode service set password ... for Option A, or rewrite ~/.config/opencode-server/password for Option B) and restart the server. On 2.x this signs out every browser session. Then remove the server entry from KKCode and add it again. If a password or link leaked, rotate first and check the logs afterwards.
Wrap-Up
A localhost-only server, a login-time starter, and a Tailscale path give you a private, always-available OpenCode host. From the phone, the built-in web UI needs nothing extra installed, and KKCode gives you a native app against the same server. Either way, the password and the tailnet are what keep it private, so treat both with care.