# I Vibe-Coded an iOS Debug Tool That Lets Claude Code Drive My App

> Source: <https://dev.to/foxgem/i-vibe-coded-an-ios-debug-tool-that-lets-claude-code-drive-my-app-2295>
> Published: 2026-07-29 03:11:34+00:00

An offhand "why not?" became a Playwright for native iOS — written almost entirely by Claude Code.

I was building an iOS app with Claude Code, and the workflow came with a friction I'd just gotten used to: Claude changes a screen, then I pick up my phone, tap through the app, look at the result, and try to describe what I saw back to Claude. Claude was coding blind. It could write Swift all day, but it had no eyes on the running app and no hands to touch it.

The idea came from watching Claude work on the web. With Playwright connected, it drives a browser by itself — open a page, click, screenshot, read the page, fix, repeat — and checks the result automatically, with no human in the loop. So the "why not?" was simple: **let Claude do the same thing to my iOS app.** Take a screenshot, see the screen, tap a button, read the internal state. Do that, and the whole loop closes. No more me passing messages between phone and model.

So I brought the idea to Claude Code and we talked it through. The web has Playwright; iOS has XCUITest, but that's a heavy, separate test runner — not something an agent can reach for in the middle of a chat.

That's the key point: **what I wanted was Playwright without the test framework.** No test target, no XCTest setup, no build-and-run-the-suite cycle — just a live bridge into the debug build

It also quietly fixed the other half of the problem: **collecting debug data.** Screenshots, a live state snapshot, even a recorded video of a flow — Claude grabs them itself, on demand, instead of me capturing each one and pasting it into the chat. The data it needs to reason about a bug now comes straight from the source.

The tool is a chain of four small parts. Claude Code calls MCP tools; an MCP server on the Mac turns each call into an HTTP request; `iproxy`

carries it over USB to the phone; and a tiny HTTP server inside the app does the actual work and answers back.

Each link is deliberately simple — a standard protocol with nothing custom in between:

| Part | Where | Job |
|---|---|---|
Claude Code |
the chat | Decides what to do; calls MCP tools |
`app-debug-mcp.py` |
on the Mac | A `FastMCP` server; wraps each HTTP route as an MCP tool |
`iproxy` |
terminal | Forwards Mac port 9876 → device port 9876 over USB |
`AppDebugServer` |
in the app (`#if DEBUG` ) |
HTTP server that renders screenshots, runs actions, reports state, records video |

Why a USB tunnel at all? On iOS an `NWListener`

can only bind to loopback (`127.0.0.1`

), so the app's server isn't reachable over the network. `iproxy`

bridges that gap — a Mac-side port mapped straight to the device.

`AppDebugServer`

is the only piece that matters on the device, and it stays small. An `NWListener`

accepts a connection, the request is parsed by hand, a router dispatches on method and path, and each route reaches one capability.

Six routes, one MCP tool each:

| MCP tool | Route | Returns |
|---|---|---|
`screenshot` |
`GET /screenshot` |
PNG of the current screen |
`list_actions` |
`GET /actions` |
Identifiers registered on the current screen |
`activate` |
`POST /activate` |
Runs a registered closure by identifier |
`app_state` |
`GET /state` |
JSON snapshot of the app's live state |
`record_start` |
`POST /record/start` |
Starts a ReplayKit recording |
`record_stop` |
`POST /record/stop` |
Finishes and returns the MP4 |

A few notes on the harder corners:

`\r\n\r\n`

, pull out method, path, and `Content-Length`

, then read the body. No HTTP library, just `Network.framework`

. It all runs on `@MainActor`

, because screenshots and SwiftUI state reads both need the main thread.`UIGraphicsImageRenderer`

; the Mac bridge base64-encodes the PNG so Claude can actually see it. `Codable`

types into clean JSON.`@MainActor`

. If those closures inherited main-actor isolation, the runtime would crash. So the handler factories are `nonisolated`

, each frame rides back to the main actor inside an `@unchecked Sendable`

box, and an `AVAssetWriter`

stitches the frames into H.264.The split is the point: this in-app code is **app-agnostic** — it imports only system frameworks (`Network`

, `UIKit`

, `AVFoundation`

, `ReplayKit`

). The only app-specific pieces are the action closures your views register and a small state provider that returns an `Encodable`

snapshot. So the whole thing drops into any iOS app.

How does `activate`

actually tap something? The obvious first idea was tapping at `(x, y)`

— `window.hitTest`

plus `accessibilityActivate()`

— and it's kept as a fallback. But it's flaky: SwiftUI gesture views without accessibility traits don't respond, and coordinates shift with device and layout.

The **registry** is what holds up. Each screen calls `.debugAction("header.settings") { ... }`

when it appears and removes it when it disappears. `activate`

looks up the identifier and calls the closure directly — no hit-testing, no localization, no fragile geometry. Because registration follows the view lifecycle, the available set changes as screens come and go — which is why the rhythm is always ** list_actions → activate**: ask what's there now, then act. Those identifiers are the contract for automation; user-facing labels are localized and never used as keys.

This is the whole loop in one step:

None of these parts is large on its own — a hand-rolled HTTP loop, a dictionary-backed registry, a view modifier, a recording pipeline, a Python bridge. That's by design. Once the architecture was settled, each piece was small and well-scoped enough to hand to Claude and build end to end, one at a time. What that handoff actually looked like — and what stayed my job — is the subject of the last section.

Every line on the iOS side sits inside `#if DEBUG`

— the server, the provider, and the `start()`

call in the app's entry point:

``` js
#if DEBUG
let provider = MyDebugStateProvider(/* your coordinators */)
let server = AppDebugServer(port: 9876, stateProvider: provider)
Task { @MainActor in server.start() }
#endif
```

Release builds compile **none** of it: no server, no open port, nothing left behind. (`NWListener`

on iOS can only bind to loopback — which is also why the USB tunnel isn't optional.)

Project-scoped, in `.mcp.json`

:

```
{
  "mcpServers": {
    "app-debug": {
      "type": "stdio",
      "command": "uv",
      "args": ["run", "--script", "tools/app-debug-mcp.py"],
      "env": { "APP_DEBUG_OUTPUT": "debug-output" }
    }
  }
}
```

Dependencies are declared inline, so `uv`

pulls `mcp`

and `requests`

into an isolated venv on first run — no `pip install`

. Output lands in `debug-output/`

.

`iproxy 9876 9876`

in a terminal (from `libimobiledevice`

).`list_actions`

→ `activate`

, then `screenshot`

or `app_state`

to see the result. Wrap flows worth keeping in `record_start`

… `record_stop`

(wait for `{"ok": true}`

— iOS shows the recording consent prompt first).A breakpoint in Xcode pauses the process, so in-flight calls time out until you resume. Expected and harmless.

This whole tool came from one "why not?", and Claude wrote almost all of it — the HTTP server, the registry, the ReplayKit pipeline, concurrency traps and all — while I mostly read diffs. A working debugging tool, in an afternoon. So with the same idea, **you can build your own — for your app, in your stack — without much effort.**

But don't mistake "Claude wrote it" for "Claude did it alone." It wrote the code; I did the rest, and the rest is what made it good:

That's the shape of vibe coding when it works: not handing off the thinking, but doing more of it — about what to build, which way to go, and whether the result is actually right. Claude handles the code now. The judgment is still yours, and it matters more than ever.

Original Link: [https://www.mymona.xyz/blogs/2026-06/vibe-coding-an-ios-debug-tool-for-claude-codemd](https://www.mymona.xyz/blogs/2026-06/vibe-coding-an-ios-debug-tool-for-claude-codemd)
