You are vibecoding from your phone, far from the Mac running Xcode — and the one screen you are holding is the one Xcode cannot reach. Wireless debugging is lovely, but it still needs the phone on the same local network as the Mac. Three cities away, on cellular, that is simply not the case: the build you just prompted into existence is the single build you cannot run. You are left reading diffs, guessing at what the app looks like.
vibe2phone closes that loop over Tailscale, the mesh VPN that links your own devices wherever they are. Your Mac builds and signs the app, then serves it over HTTPS on your private tailnet — and your iPhone installs it with one tap, on Wi-Fi or on cellular, from anywhere. No TestFlight, no Diawi, no public host, nothing ever leaving your own network.
cd ~/my-ios-project
vibe2phone
[8/8] End-to-end check
✓ manifest served as XML
✓ IPA downloadable over HTTPS (24M)
Done. On your iPhone, with Tailscale connected, open:
https://your-mac.your-tailnet.ts.net/ota/
The address never changes. Each run simply swaps the build sitting behind it: run the command, reopen the page on the phone, tap Install latest build.
One file, nothing to install, nothing to configure. The script works out the scheme, the bundle ID, the signing team and the Tailscale hostname on its own.
You are on the couch, or on a train. Claude Code — or whatever agent you drive — is running on the Mac back home. The loop goes like this:
./vibe2phone.There is nothing to force and no second command to remember. One run of
vibe2phone regenerates the project, archives, signs, exports, bumps the build
number and swaps what the URL serves. Running XcodeGen alone is not enough — it
only writes the .xcodeproj, it builds nothing.
Put this in your project's CLAUDE.md:
## Deploying to my phone
I test on my iPhone, away from this Mac. After any change I could see on screen,
run `./vibe2phone` so the new build reaches my phone.
That is usually the right amount of automation: the agent deploys when something visible changed, and skips it when it merely read a few files.
If you would rather it be unconditional, a Stop hook fires at the end of every
turn. In .claude/settings.json:
{
"hooks": {
"Stop": [
{
"hooks": [
{ "type": "command", "command": "cd \"$CLAUDE_PROJECT_DIR\" && ./vibe2phone" }
]
}
]
}
}
Be aware that this rebuilds even when the agent only answered a question, which costs a minute or two each time.
Installed from the App Store, and pointed at once:
sudo xcode-select -s /Applications/Xcode.app
xcodebuild -version # should print a version
python3 comes with the Xcode Command Line Tools, so there is nothing else to
install. XcodeGen is only needed if your project already uses a project.yml
(brew install xcodegen).
Free or paid, but the phone must be registered in the portal — its UDID has
to be in the provisioning profile, or iOS refuses the install with a blank
Unable to install. The easiest way to register it: plug the iPhone in once and
run the app from Xcode; Xcode adds the device for you. vibe2phone checks
this for you before publishing anything.
With a free account, apps stop launching after seven days. A paid account signs for a year.
tailscale commandThis is the one that trips people up. On macOS Tailscale comes in three flavours,
and they do not all put the tailscale CLI on your PATH — the script needs
it to serve the build.
Check what you have:
tailscale version
If that prints a version, you are done. If it prints command not found, pick
one of these:
| How you installed it | Where the CLI lives | What to do |
|---|---|---|
| Homebrew | /opt/homebrew/bin/tailscale | brew install tailscale && sudo brew services start tailscale |
| Standalone app (tailscale.com/download/mac) | /usr/local/bin/tailscale | already on your PATH |
| Mac App Store app | inside the app bundle only | add to ~/.zshrc:alias tailscale="/Applications/Tailscale.app/Contents/MacOS/Tailscale" |
vibe2phone looks in all three places on its own, so it will usually work even
when your shell cannot find the command — it prints which binary it picked:
[1/8] Preflight
✓ Xcode 26.6
✓ Tailscale 1.102.3 — /opt/homebrew/bin/tailscale
If none of them exists, it stops and tells you exactly what to install.
Then make sure the Mac is logged in and the iPhone is on the same tailnet:
tailscale up
tailscale status # the iPhone should be in the list
Install Tailscale on the iPhone from the App Store and sign in with the same account. It can stay connected all the time — that is what lets the install work over cellular, away from home.
The install page and the IPA are served over HTTPS with a real Let's Encrypt certificate, which iOS insists on. It is off by default:
Tailscale admin console → DNS → HTTPS Certificates → Enable
Without it, tailscale serve fails and the script says so.
The project itself can be a .xcworkspace, a .xcodeproj or an XcodeGen
project.yml — the script adapts.
For one project, drop the script at its root:
cd ~/my-ios-project
curl -fsSLO https://raw.githubusercontent.com/jystervinou/vibe2phone/main/vibe2phone
chmod +x vibe2phone
./vibe2phone
For every project, install it once:
sudo curl -fsSL https://raw.githubusercontent.com/jystervinou/vibe2phone/main/vibe2phone \
-o /usr/local/bin/vibe2phone
sudo chmod +x /usr/local/bin/vibe2phone
Then, in any iOS project: vibe2phone.
To check a project is ready without waiting for a build:
cd ~/my-ios-project && vibe2phone --check
[2/2] Detecting the configuration
✓ project MyApp.xcodeproj
✓ tailscale host your-mac.your-tailnet.ts.net
✓ scheme MyApp
✓ bundle com.example.myapp
✓ team ABCDE12345 (Automatic signing)
Every project gets its own local port and its own service, so they never collide. If you serve several at once, give them separate addresses:
OTA_PATH=/my-app vibe2phone
xcodegen generate, only when a project.yml is therexcodebuild archive in Releasemanifest.plist, install page, numbered IPAtailscale serveEverything lands in .vibe2phone/ at the project root, added to your .gitignore
on the first run.
The page holds one blue button. Tap it and it stops being mute: the server knows the moment iOS fetches the IPA, so the page waits for that and tells you the build is on its way to the Home Screen. No more wondering whether the tap did anything — the usual failure mode of over-the-air installs, since Safari itself shows nothing while iOS installs in the background.
Nothing is hardcoded. Every value is worked out at run time, and any of them can be forced through the environment.
The project. A .xcworkspace wins over a .xcodeproj — that is what
CocoaPods wants and the only one that knows every dependency. If a project.yml
is lying around, XcodeGen runs first.
The scheme. Among shared schemes, anything smelling of tests or extensions
(…Tests, …UITests, …Widget, …Watch) goes last, and the one named after
the project goes first. The script keeps the first that genuinely builds an iOS
application — checked in the resolved build settings, not guessed from the name.
The signing team. Two sources, crossed: non-expired Apple Development
certificates in the keychain (their OU field carries the team ID) and the
installed provisioning profiles. Profiles with no device — the App Store ones —
are dropped, since they can install nothing over the air. The team kept is the
one holding a valid certificate and the profile covering the most devices. If
the project already pins DEVELOPMENT_TEAM, that wins, and manual signing is
left alone: the script then reuses the profile the archive actually embedded.
The Tailscale host. Read from tailscale status --json, never cached — rename
the machine and the URL follows on the next deploy.
Your iPhone. After the export, the script opens the IPA's
embedded.mobileprovision, pulls the allowed UDIDs and compares them with the
iPhones Xcode knows about. If yours is missing it says so — rather than leaving
you with iOS's terse Unable to install.
Detected values are cached in .vibe2phone/config. vibe2phone --redetect
starts over.
vibe2phone # the normal case
vibe2phone --check # diagnose in seconds, no build
vibe2phone --redetect # forget the cache, detect everything again
vibe2phone --clean # start from an empty DerivedData
vibe2phone --no-serve # write the files, leave Tailscale alone
vibe2phone --open # open the install page on the Mac
vibe2phone --uninstall # stop serving this project
Overrides:
TEAM_ID=XXXXXXXXXX SCHEME=MyApp vibe2phone
EXPORT_METHOD=ad-hoc vibe2phone # instead of Development
OTA_PATH=/my-app OTA_PORT=9000 vibe2phone
iPhone ──HTTPS, Let's Encrypt cert for *.ts.net──> tailscaled (the Mac)
│ reverse proxy
▼
127.0.0.1:87xx
│
▼
.vibe2phone/public/
Two details make this necessary. tailscale serve pointed straight at a folder
returns 403 — the daemon cannot read the files. And python3 -m http.server
hands the manifest over as application/octet-stream, which iOS refuses. So the
script writes a small server of its own: loopback only, text/xml for the
manifest, no caching, and a /status.json the install page polls.
A LaunchAgent restarts it at login, and the tailscale serve configuration
survives reboots, so the URL keeps working with nothing to relaunch by hand.
Nothing is exposed to the public internet — only devices on your tailnet can
reach it.
Two independent locks, and you can check both yourself.
The network. tailscale serve publishes to your tailnet only — never to the
public internet, which is what its sibling tailscale funnel does. vibe2phone never
calls funnel. From a public resolver your hostname does not even exist:
tailscale serve status # says "(tailnet only)"
dig @8.8.8.8 your-mac.your-tailnet.ts.net # NXDOMAIN
The name only resolves through MagicDNS, inside the tailnet, and the address behind it sits in the CGNAT range — unroutable from the internet. There is no open port for anyone to find.
The signature. Even holding the IPA, a stranger cannot install it. The embedded provisioning profile names the UDIDs allowed, and iOS refuses every other device — the very check vibe2phone runs before it publishes anything.
So the trust boundary is your tailnet. If you share a node with someone, or add a user to the tailnet, they can open the page; they can still only install if their device is one of your registered UDIDs. Tailscale ACLs narrow it further if you need that.
"Unable to install". Nine times out of ten the iPhone's UDID is not in the
profile. Add the device on developer.apple.com, then vibe2phone --clean.
The page does not open on the iPhone. Is Tailscale connected there
(tailscale status should list the phone), and is the Mac awake? It is the one
hosting the IPA.
Safari downloads a file instead of installing. itms-services: links only
work in Safari on iOS — not Chrome, not an in-app browser.
The tap seems to do nothing. It probably worked. iOS installs in the background and puts the icon on your Home Screen, often on the last page or in the App Library, while Safari shows nothing. The status line under the button now confirms it.
tailscale serve failed. HTTPS is not enabled for the tailnet: admin console
→ DNS → HTTPS Certificates.
The app expires after seven days. That is the free developer account limit. A paid account signs for a year.
example/ holds a minimal SwiftUI app that displays its own version and build
number — enough to confirm at a glance which build is running on the phone.
cd example && ../vibe2phone
MIT
1 commits
Shell
95.7%
Swift
4.3%
You are vibecoding from your phone, far from the Mac running Xcode — and the one screen you are holding is the one Xcode cannot reach. Wireless debugging is lovely, but it still needs the phone on the same local network as the Mac. Three cities away, on cellular, that is simply not the case: the build you just prompted into existence is the single build you cannot run. You are left reading diffs, guessing at what the app looks like.
vibe2phone closes that loop over Tailscale, the mesh VPN that links your own devices wherever they are. Your Mac builds and signs the app, then serves it over HTTPS on your private tailnet — and your iPhone installs it with one tap, on Wi-Fi or on cellular, from anywhere. No TestFlight, no Diawi, no public host, nothing ever leaving your own network.
cd ~/my-ios-project
vibe2phone
[8/8] End-to-end check
✓ manifest served as XML
✓ IPA downloadable over HTTPS (24M)
Done. On your iPhone, with Tailscale connected, open:
https://your-mac.your-tailnet.ts.net/ota/
The address never changes. Each run simply swaps the build sitting behind it: run the command, reopen the page on the phone, tap Install latest build.
One file, nothing to install, nothing to configure. The script works out the scheme, the bundle ID, the signing team and the Tailscale hostname on its own.
You are on the couch, or on a train. Claude Code — or whatever agent you drive — is running on the Mac back home. The loop goes like this:
./vibe2phone.There is nothing to force and no second command to remember. One run of
vibe2phone regenerates the project, archives, signs, exports, bumps the build
number and swaps what the URL serves. Running XcodeGen alone is not enough — it
only writes the .xcodeproj, it builds nothing.
Put this in your project's CLAUDE.md:
## Deploying to my phone
I test on my iPhone, away from this Mac. After any change I could see on screen,
run `./vibe2phone` so the new build reaches my phone.
That is usually the right amount of automation: the agent deploys when something visible changed, and skips it when it merely read a few files.
If you would rather it be unconditional, a Stop hook fires at the end of every
turn. In .claude/settings.json:
{
"hooks": {
"Stop": [
{
"hooks": [
{ "type": "command", "command": "cd \"$CLAUDE_PROJECT_DIR\" && ./vibe2phone" }
]
}
]
}
}
Be aware that this rebuilds even when the agent only answered a question, which costs a minute or two each time.
Installed from the App Store, and pointed at once:
sudo xcode-select -s /Applications/Xcode.app
xcodebuild -version # should print a version
python3 comes with the Xcode Command Line Tools, so there is nothing else to
install. XcodeGen is only needed if your project already uses a project.yml
(brew install xcodegen).
Free or paid, but the phone must be registered in the portal — its UDID has
to be in the provisioning profile, or iOS refuses the install with a blank
Unable to install. The easiest way to register it: plug the iPhone in once and
run the app from Xcode; Xcode adds the device for you. vibe2phone checks
this for you before publishing anything.
With a free account, apps stop launching after seven days. A paid account signs for a year.
tailscale commandThis is the one that trips people up. On macOS Tailscale comes in three flavours,
and they do not all put the tailscale CLI on your PATH — the script needs
it to serve the build.
Check what you have:
tailscale version
If that prints a version, you are done. If it prints command not found, pick
one of these:
| How you installed it | Where the CLI lives | What to do |
|---|---|---|
| Homebrew | /opt/homebrew/bin/tailscale | brew install tailscale && sudo brew services start tailscale |
| Standalone app (tailscale.com/download/mac) | /usr/local/bin/tailscale | already on your PATH |
| Mac App Store app | inside the app bundle only | add to ~/.zshrc:alias tailscale="/Applications/Tailscale.app/Contents/MacOS/Tailscale" |
vibe2phone looks in all three places on its own, so it will usually work even
when your shell cannot find the command — it prints which binary it picked:
[1/8] Preflight
✓ Xcode 26.6
✓ Tailscale 1.102.3 — /opt/homebrew/bin/tailscale
If none of them exists, it stops and tells you exactly what to install.
Then make sure the Mac is logged in and the iPhone is on the same tailnet:
tailscale up
tailscale status # the iPhone should be in the list
Install Tailscale on the iPhone from the App Store and sign in with the same account. It can stay connected all the time — that is what lets the install work over cellular, away from home.
The install page and the IPA are served over HTTPS with a real Let's Encrypt certificate, which iOS insists on. It is off by default:
Tailscale admin console → DNS → HTTPS Certificates → Enable
Without it, tailscale serve fails and the script says so.
The project itself can be a .xcworkspace, a .xcodeproj or an XcodeGen
project.yml — the script adapts.
For one project, drop the script at its root:
cd ~/my-ios-project
curl -fsSLO https://raw.githubusercontent.com/jystervinou/vibe2phone/main/vibe2phone
chmod +x vibe2phone
./vibe2phone
For every project, install it once:
sudo curl -fsSL https://raw.githubusercontent.com/jystervinou/vibe2phone/main/vibe2phone \
-o /usr/local/bin/vibe2phone
sudo chmod +x /usr/local/bin/vibe2phone
Then, in any iOS project: vibe2phone.
To check a project is ready without waiting for a build:
cd ~/my-ios-project && vibe2phone --check
[2/2] Detecting the configuration
✓ project MyApp.xcodeproj
✓ tailscale host your-mac.your-tailnet.ts.net
✓ scheme MyApp
✓ bundle com.example.myapp
✓ team ABCDE12345 (Automatic signing)
Every project gets its own local port and its own service, so they never collide. If you serve several at once, give them separate addresses:
OTA_PATH=/my-app vibe2phone
xcodegen generate, only when a project.yml is therexcodebuild archive in Releasemanifest.plist, install page, numbered IPAtailscale serveEverything lands in .vibe2phone/ at the project root, added to your .gitignore
on the first run.
The page holds one blue button. Tap it and it stops being mute: the server knows the moment iOS fetches the IPA, so the page waits for that and tells you the build is on its way to the Home Screen. No more wondering whether the tap did anything — the usual failure mode of over-the-air installs, since Safari itself shows nothing while iOS installs in the background.
Nothing is hardcoded. Every value is worked out at run time, and any of them can be forced through the environment.
The project. A .xcworkspace wins over a .xcodeproj — that is what
CocoaPods wants and the only one that knows every dependency. If a project.yml
is lying around, XcodeGen runs first.
The scheme. Among shared schemes, anything smelling of tests or extensions
(…Tests, …UITests, …Widget, …Watch) goes last, and the one named after
the project goes first. The script keeps the first that genuinely builds an iOS
application — checked in the resolved build settings, not guessed from the name.
The signing team. Two sources, crossed: non-expired Apple Development
certificates in the keychain (their OU field carries the team ID) and the
installed provisioning profiles. Profiles with no device — the App Store ones —
are dropped, since they can install nothing over the air. The team kept is the
one holding a valid certificate and the profile covering the most devices. If
the project already pins DEVELOPMENT_TEAM, that wins, and manual signing is
left alone: the script then reuses the profile the archive actually embedded.
The Tailscale host. Read from tailscale status --json, never cached — rename
the machine and the URL follows on the next deploy.
Your iPhone. After the export, the script opens the IPA's
embedded.mobileprovision, pulls the allowed UDIDs and compares them with the
iPhones Xcode knows about. If yours is missing it says so — rather than leaving
you with iOS's terse Unable to install.
Detected values are cached in .vibe2phone/config. vibe2phone --redetect
starts over.
vibe2phone # the normal case
vibe2phone --check # diagnose in seconds, no build
vibe2phone --redetect # forget the cache, detect everything again
vibe2phone --clean # start from an empty DerivedData
vibe2phone --no-serve # write the files, leave Tailscale alone
vibe2phone --open # open the install page on the Mac
vibe2phone --uninstall # stop serving this project
Overrides:
TEAM_ID=XXXXXXXXXX SCHEME=MyApp vibe2phone
EXPORT_METHOD=ad-hoc vibe2phone # instead of Development
OTA_PATH=/my-app OTA_PORT=9000 vibe2phone
iPhone ──HTTPS, Let's Encrypt cert for *.ts.net──> tailscaled (the Mac)
│ reverse proxy
▼
127.0.0.1:87xx
│
▼
.vibe2phone/public/
Two details make this necessary. tailscale serve pointed straight at a folder
returns 403 — the daemon cannot read the files. And python3 -m http.server
hands the manifest over as application/octet-stream, which iOS refuses. So the
script writes a small server of its own: loopback only, text/xml for the
manifest, no caching, and a /status.json the install page polls.
A LaunchAgent restarts it at login, and the tailscale serve configuration
survives reboots, so the URL keeps working with nothing to relaunch by hand.
Nothing is exposed to the public internet — only devices on your tailnet can
reach it.
Two independent locks, and you can check both yourself.
The network. tailscale serve publishes to your tailnet only — never to the
public internet, which is what its sibling tailscale funnel does. vibe2phone never
calls funnel. From a public resolver your hostname does not even exist:
tailscale serve status # says "(tailnet only)"
dig @8.8.8.8 your-mac.your-tailnet.ts.net # NXDOMAIN
The name only resolves through MagicDNS, inside the tailnet, and the address behind it sits in the CGNAT range — unroutable from the internet. There is no open port for anyone to find.
The signature. Even holding the IPA, a stranger cannot install it. The embedded provisioning profile names the UDIDs allowed, and iOS refuses every other device — the very check vibe2phone runs before it publishes anything.
So the trust boundary is your tailnet. If you share a node with someone, or add a user to the tailnet, they can open the page; they can still only install if their device is one of your registered UDIDs. Tailscale ACLs narrow it further if you need that.
"Unable to install". Nine times out of ten the iPhone's UDID is not in the
profile. Add the device on developer.apple.com, then vibe2phone --clean.
The page does not open on the iPhone. Is Tailscale connected there
(tailscale status should list the phone), and is the Mac awake? It is the one
hosting the IPA.
Safari downloads a file instead of installing. itms-services: links only
work in Safari on iOS — not Chrome, not an in-app browser.
The tap seems to do nothing. It probably worked. iOS installs in the background and puts the icon on your Home Screen, often on the last page or in the App Library, while Safari shows nothing. The status line under the button now confirms it.
tailscale serve failed. HTTPS is not enabled for the tailnet: admin console
→ DNS → HTTPS Certificates.
The app expires after seven days. That is the free developer account limit. A paid account signs for a year.
example/ holds a minimal SwiftUI app that displays its own version and build
number — enough to confirm at a glance which build is running on the phone.
cd example && ../vibe2phone
MIT
1 commits
Shell
95.7%
Swift
4.3%