My coding agents live on a headless Mac, supervised by Leo. They run in launchd background sessions: no GUI, no logged-in desktop, nobody at the keyboard. For most work that’s fine. An agent can write Swift, run tests, even boot a simulator headlessly and screenshot it. What it can’t do is put the app in front of me.

The apps I want to watch run on the Mac I’m sitting at, in a simulator there or on the iPhone paired to it. So for a while the loop closed through me: the agent builds, I copy the .app over, launch it, squint at Console, and paste crash reports back into the conversation. That got old fast.

So I built xcode-remote, a small Python CLI that closes the loop without me in it. One command builds the project on the build host, copies the .app over SSH to the machine I’m watching, launches it there (open for macOS, simctl for the simulator, devicectl for a real iPhone), and hands everything back as a single JSON object: build errors, launch pid, recent logs, and a crash report if there is one.

It’s open source under MIT: github.com/blackpaw-studio/xcode-remote.

Built for an agent to read

Remote builds over SSH are nothing new. What’s different here is who the output is for.

Every Xcode tool assumes a person is reading it. xcodebuild prints thousands of lines because a human can skim them. Crash reports open in a viewer. Console is an app. That all breaks down when the thing consuming the output is an LLM on another machine. An agent doesn’t need a firehose of build logs. It needs to know: did the build succeed, did the app launch, did it crash, and where.

So every run produces one JSON result the agent can branch on:

  • If build.errors is present, fix the compile errors and run again.
  • If build.diagnostic is present, the failure wasn’t a compile error (missing project, linker, toolchain, signing); it’s the raw xcodebuild output tail.
  • If crash is present, use signal and thread0 to find the fault.
  • If launch failed, run xcode-remote doctor; the target machine may be asleep or missing the simulator runtime.
  • If everything worked, build is "ok", launch.pid is set, and crash is null.

A clean run looks like this:

{
  "build": "ok",
  "launch": { "pid": 41213, "device": "iPhone 17 Pro", "platform": "ios" },
  "logs": ["2026-07-08 12:00:01.123 MyApp[41213] starting up"],
  "crash": null
}

Configuration is split in two. Connection info and the simulator device live in a global config at ~/.config/xcode-remote/config.toml, and the per-project settings live in a .xcode-remote.toml at the repo root:

[project]
scheme   = "MyApp"
platform = "auto"     # ios | macos | ios-device | auto

Then xcode-remote run from inside the project does the rest.

There’s also a bundled agent skill that tells an agent when to reach for the tool and what to do with the result. You can install it into Claude Code, Codex, Cursor, and others with the skills CLI:

npx skills add blackpaw-studio/xcode-remote

Most of this was straightforward to build. Two things were not.

Code signing without a GUI

Code signing was the first problem. Xcode’s automatic signing doesn’t work from a launchd background session: -allowProvisioningUpdates fails with “No Accounts,” because Apple account enumeration is tied to the GUI login session. The account is signed in on the same machine as the same user, but a background session can’t see it.

That didn’t bother me much. I use fastlane match, so I default to manual signing anyway, and the certificates and provisioning profiles are already on the build host. The question was just how to get xcodebuild to use them.

Seems simple enough. I passed the manual signing settings to xcodebuild as command-line build settings: CODE_SIGN_STYLE=Manual, CODE_SIGN_IDENTITY, and DEVELOPMENT_TEAM.

That failed, and this failure is the interesting one. Command-line build settings apply to every target in the build graph, including Swift package dependencies and macro targets. Those targets use automatic signing, and forcing CODE_SIGN_STYLE=Manual onto them fails the build with “conflicting provisioning settings.” There’s no way to scope a command-line setting to a single target.

It turns out DEVELOPMENT_TEAM is the only signing setting that auto-signed targets will tolerate as a global override. So that’s the only setting xcode-remote injects. Everything else gets declared on the app target itself: CODE_SIGN_STYLE = Manual, the provisioning profile, and the CODE_SIGN_IDENTITY. One more gotcha there: the identity needs to be the SHA-1 fingerprint of the signing certificate, not the display name. "Apple Development" matches more than one certificate once a machine has been provisioned a few times, and the build fails as ambiguous. xcode-remote can scan your .mobileprovision files, decode the embedded certificate, and print the exact SHA-1 to use.

On the xcode-remote side, device builds need one thing:

[device]
team = "ABCDE12345"

Finding the iPhone

With signing sorted out, the next step was installing and launching on the phone, which means first figuring out which phone. devicectl list devices reports a pairingState and a tunnelState for each device. My first implementation picked the device whose tunnel was connected, which seemed like the obvious way to find a usable one.

It didn’t work. My iPhone is paired to my laptop over Wi-Fi and works fine with Xcode, but devicectl reports its tunnel as disconnected. As far as I can tell, the tunnel only comes up when something is actively talking to the device, so at rest a healthy Wi-Fi-paired phone always looks disconnected. If you filter on tunnelState, you reject devices that work.

So the selection logic checks pairingState == "paired" and ignores tunnelState completely. I saved the real devicectl output as a test fixture and added a regression test, since ignoring tunnelState looks like a bug if you don’t know why it’s there.

Wireless installs also fail intermittently with NWError 54 (connection reset by peer). It’s transient, so xcode-remote retries the install up to three times. If all three attempts fail, the result includes a hint that the device may be locked or on flaky Wi-Fi and that USB is more reliable. The agent can’t do anything about my Wi-Fi, but it passes the hint along to me.

What it doesn’t do

Device mode has some real gaps. Crash reports for physical devices live on the phone, and pulling them off requires Xcode or idevicecrashreport, so crash is always null in device mode. Device logs are best-effort: xcode-remote tails a console log file during launch, and sometimes it’s empty. And stop --device can’t reliably kill the app, because devicectl needs a PID that isn’t saved between CLI runs. In practice the app just gets terminated by the next run --device.

None of this applies to simulator and macOS runs. Those give you full logs and a full crash report, including the signal and the faulting thread.

Final Thoughts

Now when an agent works on one of my apps, it builds, launches the app on the simulator on my desk, reads the result, and either fixes what broke or moves on. Build failures and crashes never reach me. The work I used to do by hand, copying builds around and pasting crash logs into the chat, doesn’t exist anymore.

There’s still a gap here. The agent can’t see the app running on my machine, so screenshots and UI verification are still on me for now. But knowing that the build compiled, the app launched, what it logged, and whether it crashed covers more than I expected.

xcode-remote is on GitHub under MIT. To try it:

uv tool install git+https://github.com/blackpaw-studio/xcode-remote
xcode-remote doctor

If you end up using it, I’d love to hear about it — reach out on Mastodon at @[email protected] or BlueSky at @edc.me.