Open source · MIT · MCP
Let your AI agent debug the WebView on your user’s phone.
Add one script to your H5 page or app WebView. Your user reads out a short device code, and Claude Code, Cursor or any MCP client can see the page, read console and network, and click through the bug — while the user watches and can take over at any time.
- No USB cable
- No remote-debugging port
- No app rebuild beyond one script
K7Q2-9M4X01Live demo
A real bug, fixed on a “real” phone
The phone in this demo runs the actual page SDK inside a demo shop. Its gateway is mocked in your browser, so nothing leaves this tab — but every snapshot, click and console line below is real.
AI agent
connected to the rwd MCP server
Watch an agent connect to the phone, reproduce a checkout bug and find the root cause.
Customer’s phoneK7Q2-9M4X
Runs entirely in your browser: the real SDK (sdk.js) in an iframe, a WebSocket shim that tunnels to an in-page mock gateway, and a scripted agent issuing real RPCs.
02How it works
Three steps from “it’s broken on my phone” to a root cause
-
Add the page SDK
One script in
<head>, as early as possible, so console and network are recorded from the first line. In an app, inject the same script at document start. -
The user shares a device code
Every phone gets a permanent code like
K7Q2-9M4Xin a small pill. Users can tap Ask for help, and the device jumps to the top of the agent’s list. -
Your agent connects over MCP
Add the gateway’s hosted MCP endpoint with an API key. Then just describe the problem — the agent finds the device, asks for approval and starts working.
<!-- In <head>, before your own scripts -->
<script src="https://rwd.example.com/v1/sdk.js"></script>
<script>
RemoteWebviewDebugger.run("https://rwd.example.com/v1", "rwd_pk_…", {
userId: currentUser?.id ?? null, // optional: find devices by user
helpButton: true, // show the “Ask for help” button
})
</script>claude mcp add --transport http rwd https://rwd.example.com/v1/mcp \
--header "Authorization: Bearer rwd_sk_live_…"{
"mcpServers": {
"rwd": {
"url": "https://rwd.example.com/v1/mcp",
"headers": {
"Authorization": "Bearer rwd_sk_live_…"
}
}
}
}# stdio-only clients: a local bridge to the same hosted endpoint
claude mcp add rwd -e RWD_API_KEY=rwd_sk_live_… \
-- rwd mcp --gateway https://rwd.example.com/v103What the agent gets
The DevTools workflow, through MCP, on someone else’s phone
-
Accessibility snapshots
Playwright-style YAML with element refs. The agent reads the page as structure, not pixels — and acts on refs.
-
Real interactions
click, fill, type, press, select, hover, scroll, drag — with actionability checks and events React and Vue accept.
-
Console and network
Recorded from page load in bounded buffers, so errors from before the agent joined are still there. Bodies on request.
-
Multi-page sessions
List → detail → checkout, or a second WebView: all pages of a device form one session, with a cross-page timeline.
-
Session changes, pushed
Every tool result ends with what changed — new page, navigation, an error, the user taking over. No polling.
-
Task card on the phone
The agent shows its plan and progress on the device, so the user always knows what it is doing and why.
-
Screenshots
Native pixels when the app provides a bridge, DOM rendering otherwise. Masking applies to both.
-
Any MCP client
Streamable HTTP at
/v1/mcpfor Claude Code, Cursor and friends; a tiny stdio bridge for everything else.
All tools 32
list_devicesconnect_devicewait_for_devicelist_pagesselect_pagewait_for_pagetake_snapshotfind_elementstake_screenshotclickfilltype_textpress_keyselect_optionhoverscrolldragbatch_actionsnavigate_pagego_backreload_pagewait_forlist_console_messageslist_network_requestsget_network_requestget_storageevaluate_scriptset_taskget_session_timelinewait_for_eventwait_for_controldisconnect_device
04Privacy & control
The user’s phone stays the user’s phone
Remote debugging only works if people trust it. Every session is visible, approved and interruptible.
-
Approval first
An agent asks to attach; the user sees who is asking and picks a level on the phone. Only real taps count — a script can’t approve itself.
-
Take over or end, any time
While the agent works, the page is locked behind a glowing edge. One tap on Take over pauses the agent; End disconnects it.
-
Masked by default
Passwords, and anything marked
data-rwd-mask, show as••••in snapshots, screenshots, console and network data. Add selectors and regex patterns per project. -
Sensitive clicks need a yes
Mark buttons with
data-rwd-confirm(“Submit order”, “Delete account”) and the user confirms each agent click on them. -
Permission levels
observe reads, interact operates, advanced adds scripts, request bodies and storage. Capped per key and per project.
-
Audit log
Every attach, approval, RPC and takeover is recorded with who, what and when — never page content or parameter values.
05Native apps
In your app, it’s one user script
Download the SDK once and inject it at document start — WKUserScript on iOS, addDocumentStartJavaScript on Android. Your web pages don’t change at all.
One device code everywhere
Pass a stable
deviceId(and keep thedeviceToken) so the code survives across WebViews and domains.Native controls
Show your own take over / hand back / end buttons with
nativeControl(); page scripts can’t call it.Native approval dialogs
nativeApproval: truehands attach requests and confirmations to your app’s own UI.Real-pixel screenshots
A small bridge (WKWebView snapshot, Android bitmap) gives the agent true pixels instead of a DOM rendering.
import WebKit
/// Call before webView.load(…): fetch the SDK once, inject it at document start.
func installRemoteDebugger(into controller: WKUserContentController,
userId: String?, done: @escaping () -> Void) {
let gateway = "https://rwd.example.com/v1"
URLSession.shared.dataTask(with: URL(string: gateway + "/sdk.js")!) { data, _, _ in
DispatchQueue.main.async {
defer { done() }
guard let data, let sdk = String(data: data, encoding: .utf8) else { return }
let options: [String: Any] = [
"deviceId": stableDeviceId, // a UUID kept in UserDefaults
"userId": userId ?? NSNull(),
"helpButton": true,
]
let json = String(data: try! JSONSerialization.data(withJSONObject: options), encoding: .utf8)!
let boot = sdk + "\n;RemoteWebviewDebugger.run(\"\(gateway)\", \"rwd_pk_…\", \(json));"
controller.addUserScript(WKUserScript(source: boot,
injectionTime: .atDocumentStart,
forMainFrameOnly: true))
}
}.resume()
}// needs androidx.webkit:webkit:1.12.0, javaScriptEnabled and domStorageEnabled
// call before webView.loadUrl(…)
fun installRemoteDebugger(webView: WebView, userId: String?, onReady: () -> Unit) = thread {
val gateway = "https://rwd.example.com/v1"
val sdk = runCatching { URL("$gateway/sdk.js").readText() }.getOrNull()
webView.post {
if (sdk != null && WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) {
val options = JSONObject()
.put("deviceId", stableDeviceId) // a UUID kept in SharedPreferences
.put("userId", userId ?: JSONObject.NULL)
.put("helpButton", true)
val boot = "$sdk\n;RemoteWebviewDebugger.run(${JSONObject.quote(gateway)}, \"rwd_pk_…\", $options);"
WebViewCompat.addDocumentStartJavaScript(webView, boot, setOf("https://m.example.com"))
}
onReady()
}
}<!-- In <head>, before your own scripts -->
<script src="https://rwd.example.com/v1/sdk.js"></script>
<script>
RemoteWebviewDebugger.run("https://rwd.example.com/v1", "rwd_pk_…", {
userId: currentUser?.id ?? null, // optional: find devices by user
helpButton: true, // show the “Ask for help” button
})
</script>06Self-hosted
One Go binary. Your servers. Your data.
The gateway embeds the web console and the page SDK. SQLite out of the box, Postgres when you need it, Docker Compose and Caddy for HTTPS.
Console
Devices, API keys, members and roles, QR invites, policies and an audit log — plus an integration guide filled with your own keys.
Storage
SQLite file by default; set
RWD_DATABASE_URL=postgres://…to use Postgres.Ops
Prometheus metrics at
/v1/metrics, versioned SDK URLs with SRI, health check at/v1/healthz.
git clone https://github.com/ZaneL1u/remote-webview-debugger
cd remote-webview-debugger
# build the binary (console and SDK embedded), ship it, start it
RWD_DOMAIN=rwd.example.com \
deploy/platform/deploy.sh you@your-server amd64
# → console https://rwd.example.com
# → gateway https://rwd.example.com/v1 (MCP at /v1/mcp)
# or run it locally
make -j16
RWD_ADMIN_PASSWORD=change-me \
gateway/bin/rwd-darwin-arm64 serve --addr :8080Building needs Go and Node; the server only needs Docker.
07FAQ
Questions people ask
Does the user have to install anything?
No. For H5 pages the SDK is part of your page. In an app you inject it from native code. The user only sees a small pill with their device code and, when an agent attaches, an approval dialog.
What can the agent see and do?
Only what the user approved: the WebView pages of that device, at the granted level. It never controls anything outside the WebView. Masked fields stay masked, and page content is passed to the model as untrusted data.
Which agents work with it?
Any MCP client. Claude Code and Cursor use the hosted Streamable HTTP endpoint directly; clients that only speak stdio use rwd mcp, a small bridge to the same endpoint.
Which platforms are supported?
Any modern mobile WebView or browser: WKWebView on iOS, Android System WebView, in-app browsers, and plain mobile Safari or Chrome. Desktop browsers work too.
What about bad networks or blocked WebSockets?
The page keeps a light heartbeat WebSocket with exponential back-off. If it can’t connect three times in a row it falls back to a once-a-minute presence poll, and the gateway wakes it up when an agent wants to connect.
Does it slow my page down?
It’s one script. Console and network records live in bounded ring buffers (500 entries each, response bodies capped at 5 MB), and an idle connection only exchanges heartbeats.
Does it work alongside eruda, vConsole or PageSpy?
Yes — their nodes are skipped by snapshots and hit-testing, our own traffic stays out of their network logs, and it can even live as a tab inside eruda.
Is the demo above real?
The SDK is real and unmodified; only the gateway is simulated in your browser. A WebSocket shim tunnels the SDK’s connection to it with postMessage, and a scripted agent sends the same RPCs a real gateway would.