{"slug": "i-vibe-coded-an-ios-debug-tool-that-lets-claude-code-drive-my-app", "title": "I Vibe-Coded an iOS Debug Tool That Lets Claude Code Drive My App", "summary": "A developer built an iOS debug tool, powered almost entirely by Claude Code, that lets the AI agent drive a native iOS app autonomously. The tool, called AppDebugServer, creates a live bridge via USB tunnel so Claude can take screenshots, tap buttons, read app state, and record video without human intervention, closing the feedback loop between coding and testing.", "body_md": "An offhand \"why not?\" became a Playwright for native iOS — written almost entirely by Claude Code.\n\nI 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.\n\nThe 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.\n\nSo 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.\n\nThat'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\n\nIt 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.\n\nThe 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`\n\ncarries it over USB to the phone; and a tiny HTTP server inside the app does the actual work and answers back.\n\nEach link is deliberately simple — a standard protocol with nothing custom in between:\n\n| Part | Where | Job |\n|---|---|---|\nClaude Code |\nthe chat | Decides what to do; calls MCP tools |\n`app-debug-mcp.py` |\non the Mac | A `FastMCP` server; wraps each HTTP route as an MCP tool |\n`iproxy` |\nterminal | Forwards Mac port 9876 → device port 9876 over USB |\n`AppDebugServer` |\nin the app (`#if DEBUG` ) |\nHTTP server that renders screenshots, runs actions, reports state, records video |\n\nWhy a USB tunnel at all? On iOS an `NWListener`\n\ncan only bind to loopback (`127.0.0.1`\n\n), so the app's server isn't reachable over the network. `iproxy`\n\nbridges that gap — a Mac-side port mapped straight to the device.\n\n`AppDebugServer`\n\nis the only piece that matters on the device, and it stays small. An `NWListener`\n\naccepts a connection, the request is parsed by hand, a router dispatches on method and path, and each route reaches one capability.\n\nSix routes, one MCP tool each:\n\n| MCP tool | Route | Returns |\n|---|---|---|\n`screenshot` |\n`GET /screenshot` |\nPNG of the current screen |\n`list_actions` |\n`GET /actions` |\nIdentifiers registered on the current screen |\n`activate` |\n`POST /activate` |\nRuns a registered closure by identifier |\n`app_state` |\n`GET /state` |\nJSON snapshot of the app's live state |\n`record_start` |\n`POST /record/start` |\nStarts a ReplayKit recording |\n`record_stop` |\n`POST /record/stop` |\nFinishes and returns the MP4 |\n\nA few notes on the harder corners:\n\n`\\r\\n\\r\\n`\n\n, pull out method, path, and `Content-Length`\n\n, then read the body. No HTTP library, just `Network.framework`\n\n. It all runs on `@MainActor`\n\n, because screenshots and SwiftUI state reads both need the main thread.`UIGraphicsImageRenderer`\n\n; the Mac bridge base64-encodes the PNG so Claude can actually see it. `Codable`\n\ntypes into clean JSON.`@MainActor`\n\n. If those closures inherited main-actor isolation, the runtime would crash. So the handler factories are `nonisolated`\n\n, each frame rides back to the main actor inside an `@unchecked Sendable`\n\nbox, and an `AVAssetWriter`\n\nstitches the frames into H.264.The split is the point: this in-app code is **app-agnostic** — it imports only system frameworks (`Network`\n\n, `UIKit`\n\n, `AVFoundation`\n\n, `ReplayKit`\n\n). The only app-specific pieces are the action closures your views register and a small state provider that returns an `Encodable`\n\nsnapshot. So the whole thing drops into any iOS app.\n\nHow does `activate`\n\nactually tap something? The obvious first idea was tapping at `(x, y)`\n\n— `window.hitTest`\n\nplus `accessibilityActivate()`\n\n— 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.\n\nThe **registry** is what holds up. Each screen calls `.debugAction(\"header.settings\") { ... }`\n\nwhen it appears and removes it when it disappears. `activate`\n\nlooks 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.\n\nThis is the whole loop in one step:\n\nNone 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.\n\nEvery line on the iOS side sits inside `#if DEBUG`\n\n— the server, the provider, and the `start()`\n\ncall in the app's entry point:\n\n``` js\n#if DEBUG\nlet provider = MyDebugStateProvider(/* your coordinators */)\nlet server = AppDebugServer(port: 9876, stateProvider: provider)\nTask { @MainActor in server.start() }\n#endif\n```\n\nRelease builds compile **none** of it: no server, no open port, nothing left behind. (`NWListener`\n\non iOS can only bind to loopback — which is also why the USB tunnel isn't optional.)\n\nProject-scoped, in `.mcp.json`\n\n:\n\n```\n{\n  \"mcpServers\": {\n    \"app-debug\": {\n      \"type\": \"stdio\",\n      \"command\": \"uv\",\n      \"args\": [\"run\", \"--script\", \"tools/app-debug-mcp.py\"],\n      \"env\": { \"APP_DEBUG_OUTPUT\": \"debug-output\" }\n    }\n  }\n}\n```\n\nDependencies are declared inline, so `uv`\n\npulls `mcp`\n\nand `requests`\n\ninto an isolated venv on first run — no `pip install`\n\n. Output lands in `debug-output/`\n\n.\n\n`iproxy 9876 9876`\n\nin a terminal (from `libimobiledevice`\n\n).`list_actions`\n\n→ `activate`\n\n, then `screenshot`\n\nor `app_state`\n\nto see the result. Wrap flows worth keeping in `record_start`\n\n… `record_stop`\n\n(wait for `{\"ok\": true}`\n\n— 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.\n\nThis 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.**\n\nBut 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:\n\nThat'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.\n\nOriginal 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)", "url": "https://wpnews.pro/news/i-vibe-coded-an-ios-debug-tool-that-lets-claude-code-drive-my-app", "canonical_source": "https://dev.to/foxgem/i-vibe-coded-an-ios-debug-tool-that-lets-claude-code-drive-my-app-2295", "published_at": "2026-07-29 03:11:34+00:00", "updated_at": "2026-07-29 03:30:25.435460+00:00", "lang": "en", "topics": ["artificial-intelligence", "developer-tools", "ai-agents"], "entities": ["Claude Code", "Playwright", "XCUITest", "FastMCP", "iproxy", "AppDebugServer", "ReplayKit", "Network.framework"], "alternates": {"html": "https://wpnews.pro/news/i-vibe-coded-an-ios-debug-tool-that-lets-claude-code-drive-my-app", "markdown": "https://wpnews.pro/news/i-vibe-coded-an-ios-debug-tool-that-lets-claude-code-drive-my-app.md", "text": "https://wpnews.pro/news/i-vibe-coded-an-ios-debug-tool-that-lets-claude-code-drive-my-app.txt", "jsonld": "https://wpnews.pro/news/i-vibe-coded-an-ios-debug-tool-that-lets-claude-code-drive-my-app.jsonld"}}