Set up your Mac & iPhone
The complete setup recipe, with commands for your host and private connection, plus native iPhone speech.
On this page
This guide sets up a real Claude host and a private HTTPS connection through Tailscale. The installed iPhone app transcribes speech and speaks replies on device; it needs no desktop Whisper model or speech server. The iPhone app or hosted browser companion supplies the interface. Neither runs the host for you. Earlier automated tests use disposable services; passing tests does not mean a host is still running.
Prefer a visual walkthrough? Connect your computer shows the desktop handoff, QR page and phone screens. After pairing, Your first conversation walks through work with screenshots.
Copy this request to your coding agent#
Set up this Handsfree checkout on my Mac so I can test the real app on my iPhone.
Read docs/LOCAL-SETUP.md, host/README.md and applicable repository instructions.
First inspect existing processes, ports, private host configuration, Claude login,
Tailscale status and Tailscale Serve routes. Reuse a healthy setup. Distinguish a
demo gateway from a real host; do not assume previous test fixtures are running.
I authorize installing the local dependencies, starting the real Claude host, and configuring Tailscale Serve for private HTTPS access.
Start in a disposable project. I understand that HF_ALLOW_UNATTENDED=true permits
Claude tools as my OS user; allowed project roots are not a filesystem sandbox.
Keep credentials, logs and host state outside Git in a private directory.
Use scripts/run-local.mjs with a private Node environment file. Configure separate
gateway/daemon state and exact browser origins. Install a user LaunchAgent so the
host starts at login; preserve any existing services and Tailscale routes.
Help me sign in to Tailscale on both devices using the same account. If an account
requires administrator approval, explain that blocker and help me choose between
approval and a personal account. Let me complete login, consent and OS prompts.
Use Tailscale Serve, never Funnel, and never disable certificate validation.
Verify demo:false health, authenticated WebSocket pairing, a real Claude response,
and confirm the host negotiates clientSpeech.v1 for native iPhone clients. Revoke temporary
test device tokens and rotate a fresh pairing code for me after verification.
Verify HTTPS/WSS through the actual Tailscale hostname once connected. A green
/health alone does not prove Claude works. Do not install host speech for iPhone use.
The phone uses Apple on-device English recognition and its native voice.
Configure named project choices with HF_PROJECTS. Open a fresh desktop QR code
using host/pair.mjs with this host environment and exact HTTPS URL. Give me the
pairing command to refresh it,
the selected project, private configuration/log paths, and start/stop/restart
commands. Walk me through the iPhone health check, pairing, a text request, then
voice. Keep Mac-only verification separate from physical-phone results. Finish
all independent setup work if my login or phone interaction is still pending.
Do not deploy the website or publish changes as part of this local setup.
1. Check what already exists#
Run these from the checkout root:
node --version
command -v node npm claude
claude auth status
lsof -nP -iTCP:8042 -iTCP:9878 -iTCP:9879 -sTCP:LISTEN
curl --fail --max-time 5 http://127.0.0.1:9879/health
Node 22+ is required. Port 8042 is the optional local web interface, 9878 the private daemon, and 9879 the gateway. A real gateway returns {"ok":true,"protocolVersion":2,"demo":false}. demo:true is simulated. Health intentionally does not report provider readiness. Inspect the process command and its configuration before replacing a listener; other checkouts may use different ports.
On macOS, the standalone Tailscale app includes a CLI even if tailscale is absent from PATH:
TS=/Applications/Tailscale.app/Contents/MacOS/Tailscale
"$TS" status
"$TS" serve status
If it is already configured, preserve other routes. Do not use serve reset to make room for this project.
2. Install dependencies and sign in#
npm ci
npm --prefix host ci
claude auth status
# If Claude is not already authenticated:
claude auth login
Install the standalone Tailscale Mac app, open it and complete the macOS network-extension/VPN prompts. Install Tailscale on the iPhone and sign in with the same account. A work email may belong to an administered tailnet. “User approval required” needs that tailnet's administrator; repeatedly logging in will not fix it. A personal account is an alternative for your own devices.
iPhone speech needs no Mac speech dependencies. Allow Microphone and Speech Recognition on the iPhone when you tap Start talking. The app requires on-device English recognition and offers typing when that is unavailable. Browser voice is a separate, optional setup described below.
3. Create private configuration#
The following block creates a new setup and refuses to overwrite an existing environment file. Run from the checkout root. If a setup already exists, inspect and update it deliberately instead.
python3 - <<'PY'
from pathlib import Path
base = Path.home() / '.handsfree-local'
base.mkdir(mode=0o700, exist_ok=True)
for name in ['project', 'gateway', 'agent', 'logs']:
(base / name).mkdir(mode=0o700, exist_ok=True)
values = {
'HF_ALLOW_UNATTENDED': 'true',
'HOST': '127.0.0.1',
'PORT': '9879',
'AGENTD_PORT': '9878',
'AGENTD_URL': 'ws://127.0.0.1:9878',
'WORKDIR': str(base / 'project'),
'HF_ALLOWED_ROOTS': str(base / 'project'),
'HF_DATA_DIR': str(base / 'gateway'),
'AGENTD_DATA_DIR': str(base / 'agent'),
'AGENTD_PID_FILE': str(base / 'agent.pid'),
'HF_ALLOWED_ORIGINS': 'http://localhost:8042,http://127.0.0.1:8042,https://handsfree.minima.nyc,capacitor://localhost',
'HF_DISABLE_TITLES': 'true',
}
config = base / 'host.env'
with config.open('x') as f:
config.chmod(0o600)
for key, value in values.items():
f.write(f'{key}="{value}"\n')
print(config)
PY
HF_ALLOW_UNATTENDED=true authorizes Claude's tools under your OS account. HF_ALLOWED_ROOTS restricts session directories, not everything a tool can access. This guide starts in a disposable directory. To work on another project, stop the host, change WORKDIR and HF_ALLOWED_ROOTS to the intended absolute paths, restart, and select a new session. Existing sessions retain their original directories. Multiple roots use : on macOS.
The supervisor generates a shared daemon token in memory on every launch. Nothing needs to be copied to the phone except the short-lived pairing code. Keep provider keys and durable device tokens out of Git and support tickets.
4. Start the host#
node --env-file="$HOME/.handsfree-local/host.env" scripts/run-local.mjs
This starts the gateway/daemon supervisor. Speech stays on the iPhone; the launcher starts a Mac speech helper only when HF_HOST_SPEECH=true is explicitly configured. It prints a random single-use pairing code valid for ten minutes. Ctrl-C stops all services. The launcher forces the gateway onto 127.0.0.1; it fails if the chosen gateway or daemon port is occupied. Set both AGENTD_PORT and AGENTD_URL consistently if changing ports.
For a quick Mac-only check, start npm run dev in another terminal (or reuse the interface already on 8042). Open localhost:8042/app/. The desktop page checks the standard gateway at localhost:9879 and connects directly; no QR code or pairing command is needed. This shortcut is accepted only when both the page and network connection are loopback. If the host uses a nonstandard port, choose Connect to a different host and enter its details. An iPhone cannot use the Mac's loopback address.
For deeper optional provider verification, node scripts/test-real-voice.mjs --run-real-claude exercises actual speech and Claude in isolated temporary services. That test does not verify the running host or your Tailscale route; also test those directly.
Keep it running as a macOS login service#
Stop the foreground launcher first. This creates a user LaunchAgent with the current Node executable and PATH. It starts at user login and runs after Terminal closes. LaunchAgent itself does not restart a failed launcher. The host supervisor may restart its own children; consult host/README.md for its current recovery behavior and review uncertain work before resubmitting it. No root service is installed.
python3 - <<'PY'
from pathlib import Path
import os, plistlib, shutil
root = Path.cwd()
base = Path.home() / '.handsfree-local'
node = shutil.which('node')
assert node and (root / 'scripts/run-local.mjs').is_file()
assert (base / 'host.env').is_file()
for name in ['host.log', 'host-error.log']:
p = base / 'logs' / name
p.touch(mode=0o600, exist_ok=True)
p.chmod(0o600)
config = {
'Label': 'com.handsfree.local',
'ProgramArguments': [node, '--env-file=' + str(base / 'host.env'),
str(root / 'scripts/run-local.mjs')],
'WorkingDirectory': str(root),
'EnvironmentVariables': {'PATH': os.environ['PATH']},
'RunAtLoad': True,
'KeepAlive': False,
'ExitTimeOut': 15,
'ProcessType': 'Background',
'StandardOutPath': str(base / 'logs/host.log'),
'StandardErrorPath': str(base / 'logs/host-error.log'),
}
p = Path.home() / 'Library/LaunchAgents/com.handsfree.local.plist'
p.parent.mkdir(parents=True, exist_ok=True)
with p.open('xb') as f:
plistlib.dump(config, f)
p.chmod(0o600)
print(p)
PY
launchctl bootstrap "gui/$(id -u)" "$HOME/Library/LaunchAgents/com.handsfree.local.plist"
Inspect status and read the current pairing code locally:
launchctl print "gui/$(id -u)/com.handsfree.local"
curl --fail http://127.0.0.1:9879/health
tail -20 "$HOME/.handsfree-local/logs/host.log"
tail -20 "$HOME/.handsfree-local/logs/host-error.log"
Rotate a fresh code without interrupting active work (then read the last printed pairing-code line):
launchctl kill SIGUSR1 "gui/$(id -u)/com.handsfree.local"
tail -f "$HOME/.handsfree-local/logs/host.log"
Ctrl-C exits log viewing; the service stays running. Logs contain conversation excerpts and fresh codes, so keep them private. Codes expire after ten minutes and work once. Browser reloads require pairing again; the native app retains its credential in Keychain.
Stop, then start again after reviewing active work:
launchctl bootout "gui/$(id -u)/com.handsfree.local"
# After shutdown completes:
launchctl bootstrap "gui/$(id -u)" "$HOME/Library/LaunchAgents/com.handsfree.local.plist"
Stopping interrupts any running agent turn. To start an installed but crashed service, use launchctl kickstart "gui/$(id -u)/com.handsfree.local". After moving the checkout or removing the pinned Node version, unload and update the LaunchAgent paths before loading it again. To remove automatic startup, unload it and remove only com.handsfree.local.plist; preserve the private state unless you intend to erase histories and device pairings.
5. Give the iPhone a private HTTPS host#
Once tailscale status shows the correct connected account, inspect existing routes and then add this gateway:
TS=/Applications/Tailscale.app/Contents/MacOS/Tailscale
"$TS" status
"$TS" serve status
"$TS" serve --bg --https=443 http://127.0.0.1:9879
"$TS" serve status
Tailscale Serve shares the gateway privately inside the tailnet and handles HTTPS. If it prints an enablement URL, complete the HTTPS approval there and rerun the command. Keep MagicDNS enabled. Do not use Funnel, which publishes a service to the internet. If HTTPS port 443 already has a route, preserve it and choose an unused Serve HTTPS port such as 8443; include :8443 in the app's host URL. See the Serve CLI reference for current options.
The enablement page may preselect optional Funnel: uncheck Funnel before enabling HTTPS. HTTPS certificates place the device hostname in public certificate-transparency logs; the service itself remains private. First certificate issuance can take a minute: if tailscale status says it is fetching a TLS certificate, wait and retry the health request without bypassing validation.
Serve prints a URL shaped like https://your-mac.your-tailnet.ts.net. Use the exact printed origin; do not invent a hostname or use the raw Tailscale IP with HTTPS. The certificate must match the hostname. Serve proxies the entire gateway including /pair, /health, and WebSocket upgrades at /ws. Leave the daemon and speech listeners private on loopback.
# Replace the example with the exact URL printed by Serve:
curl --fail --max-time 15 https://your-mac.your-tailnet.ts.net/health
--bg persists the Serve route independently of the host process. It does not keep the Mac awake or launch Handsfree. To remove only this setup's route, use "$TS" serve --https=443 off (or your chosen port), only after confirming that route still belongs to Handsfree.
Show a scannable connection code#
With the updated host running, run this from the repository on your Mac. Replace the example with the exact HTTPS origin from Serve:
node --env-file="$HOME/.handsfree-local/host.env" host/pair.mjs --url https://your-mac.your-tailnet.ts.net
A local connection page opens with a large QR code. On the phone, tap Scan my computer’s code. Both the long hostname and pairing code are supplied automatically. Scanning does not replace the private network connection: keep both devices connected to Tailscale.
The helper remembers the address privately. To refresh an expired or used code, run:
node --env-file="$HOME/.handsfree-local/host.env" host/pair.mjs
Each code works once and lasts ten minutes. Refreshing it preserves existing pairings and active work. The QR page is a private file in HF_DATA_DIR, not a public website. It contains no durable token or provider keys. Use the same environment file as the running host. --no-open generates the page without opening it. Alternatively, set HF_PUBLIC_URL in the host environment; that setting takes precedence over the remembered address.
Update both the iPhone app and host for native speech. After pulling the updated checkout, wait for active work to finish and restart the host service. The iPhone checks for clientSpeech.v1 and shows an update message if the host is too old. Pairing records and sessions are retained. Older running hosts also need an update for the QR helper. Wait for active work to finish before restarting. An older installed iPhone build can use the page's manual details; the new in-app scanner requires the updated native build. The current web companion supports camera scanning too.
Offer named projects on the phone#
Open Handsfree on the computer at http://localhost:8042/app/, open Projects in the sidebar (or Host connection → Manage projects), and select Browse…. Add, rename, remove, or reorder the folders that should appear on the phone. Changes are saved in the private host data directory and sent to connected phones immediately; the host does not need to restart. Removing a project only removes the choice—it never deletes files or conversations.
Only the local browser on that computer can manage the catalog. A phone or remotely paired browser can select approved projects but cannot add, rename, reorder, or remove them. On macOS, Browse… opens the native folder picker. An absolute path field remains available for headless or non-macOS setup.
For scripted first-run setup, HF_PROJECTS remains available as a bootstrap value. Add a JSON array to the private host.env, using real folders within HF_ALLOWED_ROOTS:
HF_PROJECTS='[{"name":"Website","path":"/Users/you/Projects/website"},{"name":"iPhone app","path":"/Users/you/Projects/mobile-app"}]'
Restart the host after changing its environment. On a new host data directory, these entries seed the persistent catalog. Without HF_PROJECTS, the catalog starts with the default folder plus accessible folders from saved sessions. Later edits should use the desktop Projects screen. One project skips the picker. Configuring a project does not clone files or create an isolated checkout.
6. Pair and test on the phone#
- Connect Tailscale on both devices using the same account. Keep the Mac awake and online; a powered sleeping Mac may still be unreachable. For a temporary test, run
caffeinate -iin a Mac terminal and leave it open, with the lid open. - In iPhone Safari, open the exact host HTTPS URL with
/healthappended. Expectdemo:falseand no certificate warning. Fix routing, VPN, DNS or TLS before trying codes if this fails. - Open Handsfree and tap Scan my computer’s code to scan the desktop QR. Manual entry is available under Enter details instead; use the host origin without
/health. Allow local-network access if prompted. - Choose New session, select the disposable project, then Type instead and send: “Reply with exactly phone connection confirmed. Do not use tools.” Check the actual response.
- Allow microphone and Speech Recognition access, try Record once → Finish recording, then explicitly tap Start talking. Confirm that your iPhone transcribes, the host receives text, and the iPhone speaks the reply. No recordings should reach the host.
- Keep Handsfree open. Its screen stays awake while talking or speaking. Test Done for now, Stop agent and returning after another app: listening pauses while away and resumes only when you tap Start talking. Recognition quality remains a physical-iPhone check.
The Handsfree website provides downloads and a desktop launch page. Use the installed Mac app for the workspace. The public website does not run the workspace or your coding agent.
Troubleshooting#
| Symptom | Next check |
|---|---|
| No listener / connection refused | Inspect the LaunchAgent and private error log; install dependencies and verify Node paths. |
| Address already in use | Identify the owner with lsof; reuse it or choose different ports, rather than killing arbitrary processes. |
| Health works but Claude never replies | Check claude auth status, daemon port/token configuration and private error log. Health checks the gateway only. |
| An existing store requires migration provenance | For a store known to come from the earlier real Claude host, add HF_LEGACY_STORE_MODE=claude to its private environment and restart. Use demo only for known demo state; never guess. The migration backs up the original store. |
| “User approval required” in Tailscale | Obtain tailnet admin approval or deliberately switch both devices to a personal account. |
| Safari cannot open host health | Confirm both Tailscale connections, correct hostname/Serve port, HTTPS enablement, tailnet access rules and Mac wakefulness. |
| Pairing rejected | Generate another code; use it within ten minutes, once. Never paste a demo code into the real host. |
| Web works locally but hosted companion fails | Use HTTPS/WSS and the exact origin allowlist; allow the browser's local-network permission. |
| Text works but transcription fails | On iPhone, check Microphone and Speech Recognition permissions and on-device English availability. For browser voice, check the optional host speech service. |
| Speech response is silent | On iPhone, check volume and the selected output route; replies use the iPhone voice. For browser voice, check host TTS and browser playback permissions. |
| Setup stopped after reboot/logout/sleep | A LaunchAgent starts at user login, and a sleeping Mac cannot reliably serve the phone. Check Tailscale also reconnects. |
Do not share .env files, pairing codes, tokens, full logs or private project contents in support reports. Report separately whether loopback health, Tailscale HTTPS health, authenticated agent execution and physical-phone voice passed.
Optional: host speech for the browser companion#
Skip this section for the installed iPhone app. The browser still sends recordings to the host for transcription and can receive host-generated voice audio. To use that path on a Mac:
brew install whisper-cpp ffmpeg
mkdir -p "$HOME/.handsfree-local/models"
curl --fail --location --retry 3 \
https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin \
-o "$HOME/.handsfree-local/models/ggml-base.en.bin"
Add these entries to your existing private host.env (use your actual absolute path):
HF_HOST_SPEECH=true
HF_WHISPER_MODEL=/Users/you/.handsfree-local/models/ggml-base.en.bin
Restart the service after active work finishes. The launcher now starts its loopback speech helper: whisper.cpp base.en for recognition and macOS say with Samantha for replies. This helper is for personal local use; expose only the authenticated gateway through Tailscale. Custom WHISPER_URL / TTS_URL services are described in the host reference. Native iPhone connections continue to use device speech even when browser speech is enabled.