Approve Codex and Claude Code commands from your Apple Watch.
Your AI agent runs on a remote Linux server inside tmux. When it needs approval, the request reaches your Apple Watch instantly through the native app stack (bridge → iPhone app → watchOS app). If you configure Pushover, it can also notify as a fallback path. A single tap approves or denies, and your agent continues.
Supports Codex and Claude Code side-by-side, with two delivery paths:
- Native iPhone + Apple Watch app — bridge approvals relayed to iPhone/watch, with local notifications when the app is backgrounded
- Pushover notifications — optional fallback when native delivery isn't enough for your setup
┌────────────────────┐
│ Codex / Claude │ running in tmux on your Linux server
└─────────┬──────────┘
│ needs approval
▼
┌────────────────────┐ ┌─────────────────┐
│ codex_watch.sh │────▶│ approve_webhook │ injects keystroke into tmux
│ (tmux poller) │ │ .py │
└─────────┬──────────┘ └─────────────────┘
│
│ POST /hooks/codex (or /hooks/permission for Claude Code)
▼
┌────────────────────┐ ┌─────────────────┐ ┌────────────────┐
│ bridge/server.js │◀───▶│ iPhone app │◀───▶│ Apple Watch │
│ (Node.js + SSE) │ SSE │ (relay + UI) │ WC │ native app │
└────────────────────┘ └─────────────────┘ └────────────────┘
│
│ (optional fallback, no iPhone needed)
▼
┌────────────────────┐
│ Pushover │ notification with action button
└────────────────────┘
The bridge talks to the iPhone app over SSE on your Tailscale tailnet. The iPhone app pairs with the bridge once (6-digit code), then keeps a long-lived event stream open and forwards approval requests to the Apple Watch over WCSession. Approve or deny on either device — the response flows back through the iPhone to the bridge, which unblocks the agent.
Codex flow — codex_watch.sh polls tmux every 2s, detects the approval prompt, POSTs to the bridge, blocks until your Watch (or iPhone) responds, then injects the correct keystroke (y) into tmux.
Claude Code flow — Claude Code's native hook system POSTs directly to the bridge on PermissionRequest. The bridge blocks Claude until your Watch (or iPhone) responds, then returns the decision so Claude resumes.
watch-control/
├── approve_webhook.py # HTTP webhook — validates secret, injects tmux keystrokes
├── codex_watch.sh # Watcher — polls tmux, detects prompts, posts to bridge
├── restart.sh # Start/restart both services
├── bridge/ # Node.js bridge server for Apple Watch app
│ ├── server.js # SSE bridge — handles pairing, hooks, watch responses
│ └── package.json
├── ios/ # Native watchOS + iOS Xcode project
│ └── ClaudeWatch/ # SwiftUI app — pairs with bridge, shows approvals
├── .env.example # Environment variable template
└── web/ # Landing page source (Next.js, static export)
Prerequisites: Linux server with tmux + Python 3 + Node.js 18+, Tailscale, and an Apple Watch with the native app sideloaded. Pushover is optional.
git clone https://github.com/CryptoPilot16/watch-control.git watch-control
cd watch-control
cp .env.example .env
# edit .env — fill in APPROVE_SECRET (and PUSHOVER tokens if using fallback)
bash ./restart.shStart the bridge server (for the native Apple Watch app):
cd bridge && node server.jsThe bridge prints a 6-digit pairing code at startup. Enter your Tailscale IP and that code into the Apple Watch app to pair.
A two-target SwiftUI app: an iPhone app that holds the bridge connection and a watchOS app that displays approvals on your wrist.
- The iPhone app does the actual Tailscale → bridge HTTP/SSE work and shows the prompt directly when the watch isn't paired or reachable.
- The watchOS app receives state and approval requests from the iPhone over
WCSession(no internet on the watch required), and lets you tap Approve/Deny on your wrist.
- If the iPhone app is in the background/minimized (including screen sleeping), approval prompts are still surfaced via local notifications.
- If the iPhone app is force-quit by the user, iOS may suspend delivery until you open it again.
- Optional: configure Pushover as an additional fallback path so Claude/Codex approvals can still ping you when the native app process is not active.
Source lives in ios/ — open ios/ClaudeWatch/ClaudeWatch.xcodeproj in Xcode.
Setup:
- Open the Xcode project on a Mac. A paid Apple Developer account ($99/yr) is recommended — free accounts can sideload but watchOS sideloads are notoriously fragile.
- Set your signing team in ClaudeWatch iOS and ClaudeWatchWatch targets (Signing & Capabilities → Team).
- Install Tailscale on your iPhone — same tailnet as your VPS.
- Plug your iPhone into the Mac, build and run the
ClaudeWatchscheme targeting your iPhone. The watch app embeds and auto-installs on the paired Apple Watch. - On the iPhone, open the watch-control app. On the pairing screen, type your VPS's Tailscale IP into the Bridge IP field (e.g.
100.x.x.x). - Enter the 6-digit pairing code shown by the bridge server's terminal.
- Done — the iPhone shows approval prompts directly, and forwards them to the watch when you're wearing it.
Note on the Apple Watch app: for sideloads on watchOS 10+ the Apple Watch needs Developer Mode turned on (Settings → Privacy & Security → Developer Mode → restart → confirm). Xcode will only "see" the watch as a build destination after both Developer Mode is enabled and the watch is registered via Window → Devices and Simulators.
Attribution: the iOS/watchOS source was originally derived from shobhit99/claude-watch. Adapted to use Tailscale instead of Bonjour, to integrate with this bridge server, and rebranded to watch-control.
| Variable | Required | Default | Description |
|---|---|---|---|
APPROVE_SECRET |
yes | — | Shared secret for webhook auth |
BRIDGE_URL |
no | http://100.x.x.x:7860 |
Bridge server URL (Tailscale IP) |
PUSHOVER_APP_TOKEN |
no | — | Pushover app token (optional fallback notifications from bridge/watcher) |
PUSHOVER_USER_KEY |
no | — | Pushover user key (optional fallback notifications from bridge/watcher) |
PUSHOVER_DEVICE |
no | — | Optional Pushover device name |
TMUX_SESSION |
no | codex:0.0 |
Default tmux target pane |
TMUX_TARGETS |
no | $TMUX_SESSION |
Space-separated list of panes to watch |
WATCHCONTROL_TMUX_TARGET |
no | auto-detected | tmux pane that typed/spoken watch commands are sent to |
APPROVE_PORT |
no | 8787 |
Webhook listening port |
COOLDOWN_SECONDS |
no | 30 |
Min seconds between push notifications |
WATCHCONTROL_NOTIFY_COOLDOWN_MS |
no | 30000 |
Bridge push cooldown for approval notifications |
| Endpoint | Method | Description |
|---|---|---|
/pair |
POST | Pair the watch with a 6-digit code, returns session token |
/events |
GET | SSE stream — watch listens here for approval requests |
/command |
POST | Watch posts approval/deny decisions or typed/spoken commands |
/hooks/permission |
POST | Claude Code PermissionRequest hook |
/hooks/tool-output |
POST | Claude Code PostToolUse hook |
/hooks/stop |
POST | Claude Code Stop hook |
/hooks/codex |
POST | Codex approval — codex_watch.sh posts here |
/status |
GET | Bridge health and session info |
| Endpoint | Method | Description |
|---|---|---|
/approve |
GET/POST | Injects the queued approval keystroke into tmux |
/deny |
GET/POST | Sends Escape to the queued tmux pane |
/status |
GET/POST | Returns current queue depth |
Auth via ?secret=<APPROVE_SECRET> query param or X-Secret header.
watch-control ships with a ready-to-use PreToolUse hook script at hooks/claude-code-permission.sh. It reads the tool-use payload from stdin, POSTs it to the bridge, blocks until you tap Approve/Deny on your watch (or iPhone), and returns the decision in the format Claude Code expects.
Add the following to ~/.claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "/opt/watchcontrol/hooks/claude-code-permission.sh",
"timeout": 600
}
]
}
]
}
}The matcher is a regex of tool names to gate behind the watch. The default matches the destructive tools (Bash, Edit, Write, MultiEdit) and lets read-only ones (Read, Glob, Grep) pass through without prompting. Adjust to taste — ".*" gates everything, "Bash" gates only shell commands.
The hook reads WATCHCONTROL_BRIDGE_URL from the environment if you need to point at a different bridge instance (defaults to http://127.0.0.1:7860). If the bridge is unreachable, the hook returns permissionDecision: "ask" so you fall back to Claude Code's interactive prompt instead of being silently blocked.
You can also opt in to streaming tool output and stop notifications to the watch by adding PostToolUse and Stop hooks that POST to /hooks/tool-output and /hooks/stop respectively (see the bridge endpoints table above).
The webhook listens on 127.0.0.1 only and is exposed exclusively over your Tailscale tailnet. The bridge server binds to 0.0.0.0:7860 but is reachable only via your tailnet. Pairing requires a 6-digit code shown in the server logs, and all subsequent watch requests use a session token (Bearer auth). Rate-limiting prevents brute-force pairing attempts.
Live at cryptopilot.dev/watchcontrol
cd web
npm install
npm run dev # dev server
npm run build # static export → out/Built by CryptoPilot16 · cryptopilot.dev
