dispatch
Health Warn
- License — License: MIT
- Description — Repository has a description
- Active repo — Last push 0 days ago
- Low visibility — Only 5 GitHub stars
Code Pass
- Code scan — Scanned 9 files during light audit, no dangerous patterns found
Permissions Pass
- Permissions — No dangerous permissions requested
No AI report is available for this listing yet.
iPhone app and Mac server for controlling Claude Code terminals using the phone's volume buttons and voice dictation.
dispatch.
⚠️ Before you start — what YOU (the human) must do
An AI agent can run every command in this README, but a few things require you, because macOS and Apple security won't let any script do them. Read this first.
You need installed first:
- Xcode (to build the iPhone app)
- Python 3 (server, stdlib-only — no install)
Steps only a human can do (your AI agent will pause and ask for these):
- Server side: just run
server/dispatch_server.py— and approve the macOS Automation prompt for iTerm2 on first run.- iPhone app: open the project in Xcode, set your own Apple Team ID (Signing & Capabilities), and build to a physical iPhone (volume events don't work in the Simulator). Trust the developer cert on the phone: Settings → General → VPN & Device Management.
- Grant Microphone + Speech Recognition on the phone the first time you use voice.
- Phone access is opt-in. The server only listens on this Mac until you opt in (
echo tailscale > ~/.config/dispatch/remote), and the app must be built with your token (Setup step 3). An agent must never commit that token.
Experimental software. This project is a personal research prototype — it is unstable, under active development, and likely has bugs. Use at your own risk.
A walkie-talkie for vibecoding with Claude.
Run multiple Claude Code sessions from your phone, hands-free. dispatch is an iPhone app that lets you control multiple terminal windows on your Mac using only the volume buttons and your voice. Kick your feet up, hold your phone, and project-manage four Claude Code sessions with two buttons and dictation.
It pairs an iOS app with a small Python server running on your Mac. The server snapshots iTerm2 windows; your phone cycles between them and pipes dictated speech in as keystrokes.
What It Does
- Volume buttons control your terminals. Single press cycles between iTerm2 windows. Long press starts dictation. The phone never has to be unlocked or focused on the app.
- Voice → keystrokes. Speak; on-device speech recognition transcribes and the server types your text into the focused terminal.
- Multi-window orchestration. Sit on the couch and bounce between four agents working on four different things.
- Visual highlight. The active iTerm2 window gets a colored tab so you can glance at the screen and know where input is going.
- Local by default. The server listens on this Mac only. Reaching it from your phone over your LAN or Tailscale tailnet is an explicit opt-in, and then every request from another device must carry a shared token.
Input matrix
| Input | Action |
|---|---|
| Volume Up — single press | Cycle to next terminal |
| Volume Down — single press | Cycle to previous terminal |
| Volume Up — long press | Start / stop dictation |
| Volume Down — long press | Send double-escape (clears Claude Code prompt) |
Requirements
- macOS with iTerm2 installed
- Python 3.10+ on the Mac
- iPhone running iOS 17+
- A Mac with Xcode 15+ to build the iOS app
- Either a free Apple ID (sideloading works — see "About signing" below) or a paid Apple Developer account
- Optional: Tailscale for secure phone↔Mac connection from anywhere
Privacy
- All processing is local. Speech transcription runs on-device on the iPhone (via Apple's
Speechframework). The server runs on your Mac. No third-party cloud services are involved. - No telemetry, no analytics, no crash reporting.
- Nothing reaches your terminal without your token. dispatch types into iTerm2, and whatever it types gets run, so see Security before turning on remote access.
About signing (free vs. paid Apple Developer)
You don't need a paid Apple Developer account to run dispatch on your own iPhone — a free Apple ID works. The tradeoffs:
| Free Apple ID | Paid Developer Program ($99/yr) | |
|---|---|---|
| Install on personal devices | ✅ | ✅ |
| Re-sign required every | 7 days | 1 year |
| Active app IDs at once | 3 | unlimited |
| TestFlight / App Store | ❌ | ✅ |
For just trying dispatch on your own iPhone, the free path is fine.
Setup
1. Clone
git clone https://github.com/brianharms/dispatch
cd dispatch
2. Start the Mac server
The server is a single Python file that runs on the Python standard library alone (+ AppleScript via osascript) — no install step required. Optional: pip install zeroconf enables Bonjour/mDNS auto-discovery so the iPhone app finds the Mac automatically; without it, the server still runs fine and you just enter the Mac's IP manually.
cd server
./dispatch_server.py
By default it listens on 127.0.0.1 only (this Mac). Your phone is another device, so to use the iPhone app you opt into remote access, either per run with a flag:
./dispatch_server.py --tailscale # bind to your Tailscale IP only (recommended)
./dispatch_server.py --lan # bind to 0.0.0.0 (LAN + tailnet)
./dispatch_server.py --bind 1.2.3.4 # bind to a specific IP
./dispatch_server.py --localhost # this Mac only (the default)
or once, with a one-word file that every later start (including talk's) picks up:
mkdir -p ~/.config/dispatch && echo tailscale > ~/.config/dispatch/remote # or: lan
The first remote start creates a random token in ~/.config/dispatch/token (mode 600). Other devices must sign every request with it (the token itself never crosses the network); the iOS app gets it baked in at build time (step 3). Run interactively with no flag and no remote file, the server asks where to listen and defaults to localhost.
The server runs on port 9879.
iTerm2 permissions: The first time the server tries to read your iTerm2 window list, macOS will prompt for AppleScript / Automation permission. Grant it.
3. Build the iOS app
open Dispatch.xcodeproj
Before building:
- Select the
Dispatchtarget → Signing & Capabilities → choose your team (sign in with your Apple ID if needed) - Change the Bundle Identifier to something unique (e.g.
com.YOURNAME.dispatch) — Apple won't let two developers share the same one - Give the app your token (only needed for remote access): create
Dispatch/Token.xcconfigcontainingDISPATCH_TOKEN = <the contents of ~/.config/dispatch/token>, then in Xcode open the project → Info → Configurations and set it as the configuration file for theDispatchtarget (or build from the command line withxcodebuild ... -xcconfig Dispatch/Token.xcconfig).*.xcconfigholding the token is git-ignored; never commit it. The token lands in the app's Info.plist asDispatchToken; the app signs every request with it. - Plug your iPhone in, select it as the target, and hit Run
4. Connect the phone to the server
On first launch, the iOS app asks for the server's IP address. Enter whatever the server told you it was binding to (e.g. 100.x.y.z:9879 if you used --tailscale).
Security
dispatch's job is to type into your terminal, and a terminal runs what it's given. So the server only takes orders from you:
- Localhost by default. Remote access (LAN / Tailscale) is opt-in: a flag or
~/.config/dispatch/remote. - Signed requests from other devices. In remote mode every request from another machine must carry
X-Dispatch-Auth: v1 <unix-seconds> <nonce> <hex>, wherehex = HMAC-SHA256(token, "METHOD\nPATH?QUERY\nSECONDS\nNONCE\n" + body). The token never goes over the wire, so sniffing plain HTTP on the LAN or posing as dispatch over Bonjour reveals nothing reusable. Requests more than 90 s off, with a nonce seen before, or with a changed path/body are refused (401). OnlyGET /pingis open. Prefer--tailscaleanyway: it encrypts the traffic too. - No web pages. Requests carrying browser headers (
Origin,Sec-Fetch-*) are refused, and no CORS headers are sent, so a website you visit can't reach dispatch throughlocalhost. - No DNS rebinding. Requests from the Mac itself need no token, but their
Hostmust belocalhostor an IP address. - JSON only.
POSTbodies must beapplication/json(/dictatetakesaudio/*), and bodies are size-capped. - Bonjour only in remote mode. A localhost-only server doesn't advertise itself.
Rotate the token by deleting ~/.config/dispatch/token, restarting the server, and rebuilding the app.
How it works
Volume button interception (iOS)
iOS doesn't expose volume buttons as a standard input. The technique:
- Hide the system volume HUD with a zero-frame
MPVolumeView - Observe
AVAudioSession.sharedInstance().outputVolumevia KVO - Detect direction (up/down) from the volume delta
- Reset volume to
0.5after each press (preserves both directions) - Long press = rapid repeated volume changes within a time window
- Single vs. long-press disambiguated with a short hold timer
The app keeps the audio session alive in the background so volume events keep firing even when the screen is off.
Server architecture
iPhone (SwiftUI) ──HTTP/WS──► Mac Server (Python) ──AppleScript──► iTerm2
│ │
├─ Volume button input ├─ Window snapshot
├─ Voice dictation ├─ Keystroke injection
└─ Status UI └─ Active-window highlight
The server:
- Snapshots iTerm2 windows (session IDs + names)
- Tracks the "active" window index
- Cycles next / prev on volume input
- Injects dictated text as keystrokes via AppleScript
- Optionally tints the active window's tab color via the iTerm2 Python API
File structure
dispatch/
├── Dispatch/ # iOS app (SwiftUI)
│ ├── DispatchApp.swift # Entry point
│ ├── ContentView.swift # Main UI
│ ├── VolumeButtonManager.swift # Volume button interception
│ ├── NetworkManager.swift # HTTP/WebSocket client
│ ├── AudioManager.swift # Recording + transcription
│ └── Assets.xcassets/
├── Dispatch Watch App/ # Optional Apple Watch companion
├── server/
│ ├── dispatch_server.py # HTTP server + AppleScript
│ └── transcribe.swift # Speech-to-text helper
├── iterm-scripts/
│ └── focus_tab_color.py # Optional iTerm2 tab-color highlight
└── Dispatch.xcodeproj/
Design
- Monochrome. Black, white, grays only.
- Low-poly. Geometric wireframe shapes as visual identity.
- Minimal UI. The screen shows status, not controls. You're not supposed to be looking at it.
- Typography. Bold lowercase with period (
dispatch.).
Troubleshooting
"Phone can't reach dispatch server" — First check whether your Mac's IP rotated. Run ipconfig getifaddr en0 on the Mac and compare to what the iOS app is configured with. DHCP leases drop and the IP can change without warning. Using --tailscale avoids this entirely.
Server log says refused ... bad signature (or missing or malformed X-Dispatch-Auth) — the phone's token doesn't match ~/.config/dispatch/token on the Mac it's talking to. Rebuild the app with the current token (Setup step 3). If you run dispatch on more than one Mac, copy the same token file to each. stale request means the phone's and Mac's clocks disagree by more than 90 s.
"One window is missing from the cycle" — The server filters out iTerm2 automation script windows. If a window's name happens to start with the substring "Script", it'll be excluded. Rename it.
Volume buttons don't do anything — On iOS, the app needs to be running (foreground or background — not killed). Audio session must be active. Open the app, play a sound, then re-test.
iTerm2 AppleScript permission denied — System Settings → Privacy & Security → Automation → make sure Terminal/your shell has permission to control iTerm2.
License
MIT — Ritual.Industries
For AI coding agents
This repo is a two-part system: an iOS+watchOS Swift app and a zero-dependency Python Mac server. They talk over plain HTTP on port 9879. The server obeys only this Mac unless remote access is opted into, and then only requests signed with a shared token (see Security). Read the files below before editing — don't infer behavior from names.
Repo layout (top level)
Dispatch/— the iOS app (SwiftUI, Swift 6.0). Volume-button interception, networking, audio capture, terminal-status UI.Dispatch Watch App/— optional watchOS companion (Swift 5.0); its ownAudioRecorder,NetworkManager,ContentView,SettingsView.server/— the Mac server.dispatch_server.py(stdlib-only HTTP server + AppleScript driver) plus two pre-compiled Swift helper binaries with their sources alongside:transcribe/transcribe.swift(on-deviceSpeechSTT) anddictation_hud/dictation_hud.swift(floating live-dictation panel)..gitkeepkeeps the dir tracked.iterm-scripts/—focus_tab_color.py(optional iTerm2 active-window tab tint) and aSKILL.md.project.yml— XcodeGen spec; theDispatch.xcodeprojis generated from it.Dispatch.xcodeproj/,README.md,LICENSE(MIT),.gitignore.
Key entry points (read first)
server/dispatch_server.py— the protocol's source of truth.DispatchHandlerdefines every route. Endpoints:GET /status,/watch,/ping,/layout,/notify/recent,/notify/ack;POST /cycle,/dictate,/stream-text,/type,/escape,/flush,/target,/notify,/recording-start,/recording-stop,/hud-position,/focus-push,/debug-log,/refresh.main()+choose_bind_ip()handle startup and binding;DispatchHandler._authorize()+verify_signature()gate every request.Dispatch/DispatchApp.swift(app entry),Dispatch/VolumeButtonManager.swift(the volume-HUD interception technique), andDispatch/NetworkManager.swift(the client side of every route above — keep its endpoint strings in lockstep with the server; every request goes throughDispatchAuth.signed()).
Build / run / test
- Server:
cd server && ./dispatch_server.py. No pip install — Python 3.10+ stdlib only, drives iTerm2 viaosascript. Localhost by default;--lan(0.0.0.0),--tailscale,--bind <ip>or a one-word~/.config/dispatch/remotefile opt into remote mode (signed requests only; token in~/.config/dispatch/token). Always listens on9879. First run triggers a macOS Automation permission prompt for iTerm2. - Swift helpers: rebuild from source when you touch the
.swiftfiles (the server prints the exact command), e.g.swiftc -o server/transcribe server/transcribe.swift -framework Speechand the analogous build fordictation_hud. Commit the rebuilt binary alongside the source. - iOS/watchOS: project is generated — run
xcodegen generateafter editingproject.yml, thenopen Dispatch.xcodeproj. Set your own signing team in Xcode and build to a device; for remote mode pass the token with-xcconfig(Setup step 3). There is no automated test suite; verify on a physical iPhone (volume events don't fire reliably in Simulator).
Invariants — do not break
DEVELOPMENT_TEAMmust stay""inproject.yml(both theDispatchandDispatch Watch Apptargets). It's intentionally blank so each contributor supplies their own team; never commit a real team ID.- Keep the server stdlib-only at its core. It must run with no install step. The one third-party import,
zeroconf, is optional and guarded bytry/except ImportError(Bonjour auto-discovery degrades gracefully to manual IP). Don't add required third-party imports or a mandatoryrequirements.txt; keep any new optional dep import-guarded the same way. - Don't hardcode paths. The server already resolves everything relative to itself:
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)), withTRANSCRIBE_BIN/DICTATION_HUD_BIN/DEBUG_LOG_FILEderived from it, and config underos.path.expanduser(...)(~/.config/dispatch/,~/.claude/...). Keep these parameterized — no absolute or user-specific paths. - Security model stays intact. The server types into a terminal, so: default bind stays
127.0.0.1(remote is opt-in only); every request from another machine must passverify_signature(); requests withOrigin/Sec-Fetch-*stay refused; no CORS headers, ever; same-Mac requests keep the Host check; POST stays JSON-only. Never bake in a specific LAN/tailnet IP. - The token never goes in git.
DispatchTokenin both Info.plists stays$(DISPATCH_TOKEN); the real value comes from a git-ignored*.xcconfigor~/.config/dispatch/token. Never send the raw token over the network — sign instead. - Keep the HTTP protocol in sync across all three clients. Route paths, JSON keys, and the cycle
seqtoken (last_cycle_seq/cycle_confirmed_seqordering guard that prevents stale/cycleresponses from snapping the active window backward) are a shared contract betweendispatch_server.py,Dispatch/NetworkManager.swift, and the watch app'sNetworkManager.swift. Change all sides together. - Respect
.gitignoresecrets/state..env*,certs/,*.pem/*.key/*.p12,credentials.json, logs (*.log,dictation_debug.log),*.pid,*.jsonl,state/, and internal notes (CLAUDE.md,SESSION_LOG.md,.claude/) must never be committed. - iTerm2 window filtering: the server excludes windows whose name starts with
Script. Preserve that filter (it hides automation/HUD windows from the cycle list).
Placeholdered / personal values to keep generic
- Bundle IDs in
project.ymluse the genericcom.ritualindustries.dispatch/.watchprefix — treat as a placeholder; contributors change it in Xcode. Don't substitute a real personal identifier. - All network addresses are runtime-supplied (server bind flags; the iOS app's
serverURLdefaults tohttp://localhost:9879in the Simulator and is set via Bonjour discovery or manual entry on device). No real LAN/Tailscale IPs are checked in — keep it that way. iterm-scripts/focus_tab_color.pyand the server's focus integration read from~/.claude/...toggle/state files; these are environment lookups, not committed config — leave them asexpanduserlookups.
Reviews (0)
Sign in to leave a review.
Leave a reviewNo results found