An agent-drivable Android device, exposed to Claude as native MCP tools. The mobile sibling of veil (stealth browser): instead of a browser or an NLE, the agent gets a real Android 13 screen it can see (screenshot), read (a numbered element list), and act on (tap / swipe / type / launch / install).
A headless redroid container is
the device. ADB is the wire. A small FastMCP
stdio server (droid) is the tool surface Claude Code talks to.
Claude Code ──stdio(MCP)──► server.py (droid) ──adb──► redroid (Android 13)
127.0.0.1:5555
./droidctl.sh up # reload binder, start the container, wait for boot
# once "BOOTED (localhost:5555)" prints, the droid tools are live in Claude CodeThe MCP server is already registered at user scope (claude mcp get droid →
✔ Connected), so the 11 tools are available in every Claude Code session on
this box. Ask Claude to "screenshot the phone" or "open Settings and turn on
airplane mode" and it drives the device.
Standalone smoke test (acts as an MCP client against the live device):
./.venv/bin/python smoke_mcp.py| Tool | What it does |
|---|---|
droid_status |
Device health: connection, boot state, screen size, foreground app. Call first if anything looks off. |
droid_screenshot |
Current screen as PNG. How the agent sees the phone. Retries once on an empty capture. |
droid_ui |
On-screen elements as a numbered list with tap coords: #i [Class] "label" clickable @(x,y). The accessibility-tree trick — grounds taps without pixel-guessing. |
droid_tap |
Tap (x, y). Coords come from droid_ui. |
droid_swipe |
Swipe (x1,y1)→(x2,y2) over a duration. Scrolling/dragging. |
droid_type |
Type text into the focused field (submit=True presses Enter; settle_ms waits before typing). |
droid_key |
Press a nav/hardware key: BACK, HOME, RECENTS, ENTER, DEL, MENU, POWER, VOLUME_UP/DOWN, or a raw keycode. |
droid_launch |
Launch an app by package or name substring. Resolves the launcher activity and am starts it. |
droid_list_apps |
List installed packages (third_party_only=False for system apps). |
droid_install |
Install/reinstall a local .apk. |
droid_shell |
Arbitrary adb shell command. Escape hatch / power tool. |
Typical loop: droid_screenshot + droid_ui to see and locate → droid_tap /
droid_type to act → screenshot again to confirm.
- redroid (Android 13) — a full Android userspace in a
--privilegedDocker container. Software-rendered (redroid_gpu_mode=guest), 720x1280, no video/GPU. - binderfs — Android needs the binder IPC drivers. On this kernel (6.8) they
come from the
binder_linuxmodule surfaced via a binderfs mount at/dev/binderfs/{binder,hwbinder,vndbinder}— not the olderdevices=module param.droidctl.sh uphandlesmodprobe+ mount idempotently. - ADB — bound to
127.0.0.1:5555only (see Security). Every tool call goes through_adb()→adb -s localhost:5555 …, with an idempotent_ensure()reconnect so a dropped connection self-heals. - FastMCP stdio server (
server.py, namedroid) — 11@mcp.tool()functions. Launched byrun-mcp.sh, which Claude Code's MCP registration runs.
./droidctl.sh up # ensure binder + start/create container + wait boot
./droidctl.sh down # stop the container (userdata persists in ./data)
./droidctl.sh restart # docker restart + reconnect adb
./droidctl.sh rm # remove the container (keeps ./data volume)
./droidctl.sh status # container + boot_completed + screen size
./droidctl.sh shell <cmd> # adb shell passthrough
./droidctl.sh logs # container logs (tail)
./droidctl.sh screenshot <f> # dump a PNG to a file
The container's restart policy is no, and this is deliberate: redroid needs
/dev/binderfs mounted before it starts, so an auto-restart on boot would race
the mount and come up with broken binder IPC. After a host reboot, bring it back
with the one command that does the ordering correctly:
./droidctl.sh upup reloads the binder_linux module, mounts binderfs, starts the existing
container (userdata in ./data survives), and blocks until sys.boot_completed=1.
Verified: ./droidctl.sh down && ./droidctl.sh up cleanly cycles the device and
ADB reconnects.
(Optional, not installed: a --restart unless-stopped policy or a user systemd
unit would auto-start it, but only if you also guarantee binderfs is mounted first
— not worth the fragility here. droidctl.sh up is the supported path.)
- tap → type pacing. Right after a screen transition the target field may not
have focus yet, so the first characters of
droid_typecan drop. Tap the field, let it settle, then type — or passdroid_type(..., settle_ms=300). Typing into an already-focused field is reliable (spaces preserved via%sencoding). droid_typeis plain-text only. Shell metacharacters (& ; | < > ( ) " '…) are passed throughadb shell input textunescaped and can misbehave. Fine for words, queries, URLs; don't feed it arbitrary payloads.monkeyis unreliable on redroid (it frequently no-ops), sodroid_launchresolves the launcher activity and usesam start -ninstead, falling back tomonkeyonly when no launcher activity exists.- ADB is localhost-only by design. Bound to
127.0.0.1:5555; there is no auth on ADB, so it must never be exposed off-box. Reach it over an SSH/tailnet tunnel if remote access is ever needed. - No GPU / no video. Software rendering only (
redroid_gpu_mode=guest). Games and video playback will be slow or black; this is a UI-automation device, not a media device. - Privileged container. redroid requires
--privilegedfor binder/ashmem. Run it only on a trusted host (it is, here).
server.py— the FastMCPdroidserver (11 tools).droidctl.sh— container + device lifecycle.run-mcp.sh— launch wrapper Claude Code's MCP registration invokes.smoke_mcp.py— end-to-end MCP client test against the live device..venv/— hasmcpinstalled (recreate:python3 -m venv .venv && .venv/bin/pip install mcp).data/— redroid userdata volume (gitignored).