Android Agent
1.6K
A Docker image that runs a VLM-powered Android device automation agent, exposed via A2A and OpenAI-compatible APIs. Live device view streams to the browser through an on-pod scrcpy bridge: the FastAPI process spawns scrcpy-server.jar on the device over ADB, pipes the H.264 elementary stream to a WebSocket client, and renders it with the browser's native WebCodecs VideoDecoder — no on-device APK, no MediaProjection consent, no WebRTC.
Implements an agent using the ReAct (Reasoning + Acting) pattern for intelligent Android device automation via ADB.
The container bundles:
app/server/scrcpy_bridge.py pushes scrcpy-server-v2.4.jar (baked at /opt/scrcpy-server.jar) to /data/local/tmp, runs it via app_process, opens a TCP tunnel through adb reverse, and forwards the H.264 packets to the browser. Browser-side input events come back over the same WebSocket and dispatch through the shared ADBClient (same code path as the VLM tools) — see PLAN_SCRCPY_STREAMING.md/stream + static /stream.html — JWT-authed WebSocket endpoint for the bridge plus a self-contained viewer page using the browser's WebCodecs APIThe agent observes the device through screenshots, reasons about the next action, and executes it — repeating until the task is complete or the step budget is exhausted. The streaming path is independent of the VLM control loop; it adds a real-time human-watchable view + a click-to-tap path on top.
| Feature | Details |
|---|---|
| Android automation | Full ADB control — tap, double-tap, swipe, long-press, scroll, key events, text input |
| VLM-driven agent | Any OpenAI-compatible VLM (GPT-4o, Claude, Gemini, local models) |
| UI Locator model | Optional secondary model (UI-TARS) resolves natural-language element descriptions to pixel coordinates |
| Loop detection | Warns the VLM after 2 consecutive identical actions |
| SSE progress updates | Per-step status messages streamed to A2A clients |
| A2A API | Google Agent-to-Agent JSON-RPC 2.0 protocol |
| OpenAI-compatible API | /v1/chat/completions endpoint — drop-in for OpenAI clients |
| HTTPS | Self-signed TLS at :443 for local dev (LOCAL_TLS=true); plain HTTP at :8080 in production with TLS terminated at the per-pod nginx Ingress |
| Trajectory recording | Saves per-step screenshots, VLM requests/responses, and locator logs |
| Live device view | scrcpy server piped over WebSocket → browser WebCodecs decoder. Hardware-encoded H.264 from any modern Android (API 21+) — no APK install, no consent dialog |
| Click-to-tap | Browser's pointer / keyboard events travel as JSON over the same WebSocket and dispatch through ADBClient.tap / swipe / key_press — same pixel coords as apps/android_agent/app/agent/tools.py |
┌──────────────────────────┐
Android device (USB or network ADB) │ User's browser (Chrome) │
┌──────────────────────────────┐ │ stream.html?token=… │
│ scrcpy-server-v2.4.jar │ H.264 │ • WebCodecs VideoDecoder│
│ (pushed via adb push + │ Annex-B │ → <canvas> │
│ spawned via app_process) │◄──────────────┤ • pointer/key events │
└──────────────┬───────────────┘ TCP via │ → JSON over same WS │
│ adb reverse └──────────┬───────────────┘
│ │
│ │ WS /stream
│ │ ?token=<viewer_jwt>
▼ ▼
┌──────────────────────────────────────────────────────┐
│ Docker container │
│ │
│ ADB ─► ADBClient ─► LangGraph (graph.py) │
│ screenshot → VLM → │
│ execute_action (tap/swipe/…) │
│ │
│ FastAPI on :8080 plain (LOCAL_TLS=false, prod) │
│ or :443 self-signed (LOCAL_TLS=true, dev) │
│ ├── A2A (POST /) │
│ ├── OpenAI (/v1/…) │
│ ├── /stream (JWT-authed WS — scrcpy bridge) │
│ └── /stream.html (static WebCodecs viewer) │
└──────────────────────────────────────────────────────┘
The bridge spawns a fresh scrcpy server per WebSocket connection and binds an ephemeral local TCP port, so multiple viewer sessions on one pod don't collide.
cd apps/android_agent
./scripts/run-local.sh ADB_SERIAL [VLM_API_KEY]
Builds the image, mints the device + viewer JWT pair, runs the container with LOCAL_TLS=false, and prints the viewer URL: http://localhost:8765/stream.html?token=<viewer_jwt>. Open it in Chrome — no consent dialog, no APK install, video starts immediately.
ADB_SERIAL accepts any of:
7a4e6acb — USB device (the Mac's adb daemon is proxied into the container)192.168.1.10:5555 — network ADBemulator-5554 — local emulatorFor USB / emulator serials, run this once on the host before starting the container so the container's adb can reach your daemon:
adb kill-server && adb -a -P 5037 nodaemon server &
Optional env (read by the script before invoking docker):
| Env var | Default | Purpose |
|---|---|---|
VLM_URL | https://api.openai.com/v1 | Override for non-OpenAI VLMs |
VLM_MODEL | gpt-4o | Model name |
API_KEY | — | Sets the agent's bearer token |
HOST_PORT | 8765 | Host port mapped to container :8080 |
TRAJ_HOST_DIR | — | Host dir bind-mounted at /data/traj; the script also sets TRAJECTORY_DIR=/data/traj so per-step screenshots + VLM IO land on the host |
REBUILD | 0 | Force docker build even if the image is already cached |
Example:
VLM_URL=https://my-vlm.example.com/v1 VLM_MODEL=gpt-4o-mini \
TRAJ_HOST_DIR=$PWD/agent-logs \
./scripts/run-local.sh 192.168.1.10:5555 sk-...
The Android device must have ADB debugging enabled:
adb connect HOST:PORTThe on-device side is just scrcpy-server-v2.4.jar running under app_process — no APK install, no system-app signing, no Device Owner enrollment.
cd apps/android_agent
docker build \
--build-context a2a_pkg=../../packages/a2a_agent \
-f Dockerfile.ubuntu \
-t android_agent:ubuntu .
The image bakes vendor/scrcpy-server-v2.4.jar to /opt/scrcpy-server.jar. Single-stage build — no Android SDK needed at build time.
docker run -d \
--name android_agent \
-p 8765:8080 \
-e ADB_SERIAL=192.168.1.10:5555 \
-e VLM_URL=https://api.openai.com/v1 \
-e VLM_API_KEY=sk-... \
-e VLM_MODEL=gpt-4o \
-e LOCAL_TLS=false \
-e STREAM_HMAC_SECRET=$(openssl rand -hex 32) \
-e RUN_ID=run-local-1 \
-e POD_PUBLIC_HOST=localhost:8765 \
android_agent:ubuntu --wait
When the device is connected on the host (adb devices shows it), forward the host ADB server into the container instead of using network ADB.
Step 1 — restart host ADB server to listen on all interfaces:
adb kill-server
adb -a -P 5037 nodaemon server &
adb devices # verify your device is still listed
Step 2 — run the container (macOS / Windows Docker Desktop):
docker run -d \
--name android_agent \
-p 8765:8080 \
-e ADB_SERIAL=emulator-5554 \
-e ANDROID_ADB_SERVER_ADDRESS=host.docker.internal \
-e ANDROID_ADB_SERVER_PORT=5037 \
-e VLM_API_KEY=sk-... \
-e LOCAL_TLS=false \
-e STREAM_HMAC_SECRET=$(openssl rand -hex 32) \
-e RUN_ID=run-local-1 \
-e POD_PUBLIC_HOST=localhost:8765 \
android_agent:ubuntu --wait
Linux — use --network host instead:
docker run -d \
--network host \
-e ADB_SERIAL=emulator-5554 \
-e VLM_API_KEY=sk-... \
-e LOCAL_TLS=false \
-e STREAM_HMAC_SECRET=$(openssl rand -hex 32) \
-e RUN_ID=run-local-1 \
-e POD_PUBLIC_HOST=localhost:8080 \
android_agent:ubuntu --wait
ANDROID_ADB_SERVER_ADDRESS and ANDROID_ADB_SERVER_PORT are standard ADB client environment variables. The adb binary inside the container reads them automatically and connects to the host's ADB server, which proxies all communication to the device.
Mint a viewer-role JWT (same STREAM_HMAC_SECRET, same RUN_ID, role:"viewer") and open http://localhost:8765/stream.html?token=<viewer_jwt> in Chrome.
A minimal token-minting helper (Python ≥ 3.8, no deps):
python3 - <<'PY'
import jwt, time, os
print(jwt.encode(
{"run_id": "run-local-1", "role": "viewer", "ice": [],
"iat": int(time.time()), "exp": int(time.time()) + 14400},
os.environ["STREAM_HMAC_SECRET"], algorithm="HS256",
))
PY
The bridge validates token signature, role, run_id, and expiry on connect. Close codes: 4401 (auth), 4503 (streaming not configured on this pod). See tests/test_stream.py and tests/test_stream_auth.py for boundary coverage.
For testing ADB connectivity manually with a noVNC viewer (no agent, no scrcpy bridge):
docker build \
--build-context a2a_pkg=../../packages/a2a_agent \
-f Dockerfile.dev \
-t android_agent:dev .
docker run -d \
--name android_agent_dev \
-p 5901:5901 \
-p 6901:6901 \
-e ADB_SERIAL=192.168.1.10:5555 \
android_agent:dev --wait
Open http://localhost:6901/?password=vncpassword.
LOCAL_TLS=true only)When LOCAL_TLS=false, the pod serves plain HTTP and TLS terminates at the per-pod Ingress with a real (Let's Encrypt or wildcard) cert — no manual trust step needed. The block below applies only to local dev.
macOS:
curl -k https://localhost/ca.crt -o android-agent-ca.crt
sudo security add-trusted-cert -d -r trustRoot \
-k /Library/Keychains/System.keychain android-agent-ca.crt
Linux (Debian/Ubuntu):
curl -k https://localhost/ca.crt -o /usr/local/share/ca-certificates/android-agent-ca.crt
sudo update-ca-certificates
Windows:
curl -k https://localhost/ca.crt -o android-agent-ca.crt
certutil -addstore Root android-agent-ca.crt
curl -sk -X POST https://localhost/ \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": "1",
"method": "message/send",
"params": {
"message": {
"role": "user",
"messageId": "m1",
"parts": [{"kind": "text", "text": "Open Settings and show me the Android version"}]
}
}
}'
curl -sk -X POST https://localhost/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "android-agent",
"messages": [{"role": "user", "content": "Open the YouTube app and search for cats"}]
}'
curl -sk https://localhost/.well-known/agent-card.json
curl -sk -N -X POST https://localhost/ \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": "1",
"method": "message/stream",
"params": {
"message": {
"role": "user",
"messageId": "m1",
"parts": [{"kind": "text", "text": "Take a screenshot and describe what you see"}]
}
}
}'
| Variable | Default | Description |
|---|---|---|
VLM_URL | https://api.openai.com/v1 | Base URL of the VLM API |
VLM_API_KEY | — | API key for the VLM |
VLM_MODEL | gpt-4o | Model name |
ADB_SERIAL | — | Device serial or HOST:PORT (e.g. 192.168.1.10:5555). If unset, uses the first connected device |
ADB_PATH | adb | Path to the adb binary |
API_KEY | — | Bearer token to protect the agent API (optional) |
PORT | 443 | HTTPS port for the agent server |
AGENT_URL | — | Public URL published in the AgentCard (e.g. https://my-agent.example.com/). Defaults to https://localhost:{PORT}/ |
VIEWPORT_WIDTH | 1080 | Fallback screen width — overwritten at startup by adb shell wm size |
VIEWPORT_HEIGHT | 1920 | Fallback screen height — overwritten at startup by adb shell wm size |
SEND_IMAGE | true | Set to false for text-only LLMs (disables screenshot in VLM prompt) |
TRAJECTORY_DIR | — | Directory to save trajectory data; each run creates a YYYYMMDD_HHMMSS/ subdirectory |
The locator is a secondary vision model (e.g. UI-TARS) specialised in resolving natural-language element descriptions to exact pixel coordinates. When configured, the agent describes elements by name (query) instead of estimating coordinates, and the locator finds the exact tap target.
| Variable | Default | Description |
|---|---|---|
LOCATOR_URL | — | OpenAI-compatible API base URL for the locator model |
LOCATOR_API_KEY | — | Bearer token for locator API auth (optional) |
LOCATOR_MODEL | ui-tars | Model name passed to the locator API |
Used when the agent runs as part of a multi-agent pipeline. Agents read/write shared artifacts and post their status to a blackboard visible to the orchestrator.
| Variable | Default | Description |
|---|---|---|
WORKSPACE_ID | — | Workspace identifier (e.g. project-abc). Required to enable workspace features |
WORKSPACE_MOUNT_PATH | /workspace | Path where the S3 bucket is FUSE-mounted (used when S3_BUCKET is not set) |
S3_BUCKET | — | S3 bucket name for direct S3 access (skips FUSE mount) |
S3_ENDPOINT_URL | — | S3-compatible endpoint URL (omit for AWS S3) |
S3_ACCESS_KEY_ID | — | S3 access key |
S3_SECRET_ACCESS_KEY | — | S3 secret key |
S3_REGION | us-east-1 | S3 region |
S3_PREFIX | — | Optional prefix before WORKSPACE_ID in the S3 key path |
AGENT_ID | android_agent | Unique name written to the blackboard for this agent instance |
| Variable | Default | Description |
|---|---|---|
AGENT_NAME | computer.android.gui | Name published in the AgentCard |
AGENT_VERSION | 0.1.0 | Version string published in the AgentCard |
AGENT_DESCRIPTION | (built-in) | Description published in the AgentCard |
These wire the JWT-authed scrcpy bridge on /stream and the static viewer page on /stream.html. See PLAN_SCRCPY_STREAMING.md for the full design.
| Variable | Default | Description |
|---|---|---|
LOCAL_TLS | true | When true, pod generates self-signed cert and listens HTTPS on :443 (local docker-run flow). When false (Core injects this for streaming pods), pod listens plain HTTP on :8080 and TLS terminates at the per-pod nginx Ingress |
STREAM_HMAC_SECRET | — | Shared secret with Core for HMAC-validating stream JWTs. Empty disables /stream (close code 4503) |
RUN_ID | — | Per-run identifier; must match the run_id claim in stream JWTs |
POD_PUBLIC_HOST | — | Externally reachable hostname (e.g. <run_id>.agents.forfetch.ai). Used in token minting + AgentCard stream_url |
STREAM_DEVICE_TOKEN | — | Reserved for symmetry with the legacy WebRTC plan; the scrcpy bridge has no on-device side that consumes it |
SCRCPY_SERVER_JAR | /opt/scrcpy-server.jar | Path to scrcpy server JAR. The bridge adb push'es this to /data/local/tmp per session |
STREAM_BITRATE_KBPS | 8000 | Currently informational — scrcpy_bridge.py hard-codes video_bit_rate=8000000 when spawning the server. Surface here so a future config refactor flows it through |
STREAM_FPS | 30 | Informational — see above |
STREAM_RESOLUTION | 1080x1920 | Informational — bridge currently passes max_size=0 to scrcpy (= native resolution) |
| Tool | Description |
|---|---|
screenshot | JPEG screenshot of the Android device (base64) — taken automatically at every step |
tap | Single tap at (x, y) |
double_tap | Double-tap at (x, y) |
swipe | Swipe from (x1,y1) to (x2,y2) over duration_ms ms — for transitions and drag-and-drop |
long_press | Long-press at (x, y) for duration_ms ms — opens context menus, selects text |
scroll | Scroll at (x, y) in direction up/down/left/right by distance pixels |
key_press | Press an Android key: back, home, app_switch, enter, delete, volume_up, etc. |
type_text | Type text into the currently focused input field (tap to focus first) |
get_current_app | Return the currently focused app and window from dumpsys window |
run_adb_shell | Run an arbitrary shell command on the device via adb shell |
When TRAJECTORY_DIR is set, each agent run creates a timestamped subdirectory:
$TRAJECTORY_DIR/
20260317_141523/ # run timestamp (YYYYMMDD_HHMMSS)
summary.md # task, result, and final plan summary
turn_0000/
screenshot.png # device state at start of step
llm_0_request.json # messages sent to the VLM
llm_0_response.json # raw VLM reply + parsed action/args/reasoning
locator.json # locator model request + response (when LOCATOR_URL is set)
turn_0001/
...
Example:
docker run -d \
-e VLM_URL=... -e VLM_API_KEY=... \
-e ADB_SERIAL=192.168.1.10:5555 \
-e TRAJECTORY_DIR=/data/trajectory \
-v /tmp/agent-logs:/data/trajectory \
android_agent:ubuntu --wait
MIT
The agent image exposes an MCP (Model Context Protocol) server on the same port as A2A at /mcp. Any MCP client — Claude Desktop, Cursor, Windsurf, ChatGPT Connectors, OpenAI Agents SDK — can list and invoke these tools using the pod's API_KEY as Bearer.
In production, Core proxies https://android-agent.agents.forfetch.ai/mcp → the worker pod's /mcp.
| Tool (skill_id) | Description |
|---|---|
tap | Tap on coordinates or an element |
swipe | Swipe in a direction |
type | Type text using the keyboard |
screenshot | Take a screenshot of the device screen |
back | Press the Android back button |
home | Press the Android home button |
launch_app | Launch an application by package name |
scroll | Scroll the screen |
Input schemas are authored in apps/agents_mcp/app/seed.py. Skills without an explicit schema advertise a single {task: str} freeform parameter.
Content type
Image
Digest
sha256:dda977d47…
Size
558.5 MB
Last updated
5 months ago
docker pull superbizon007/android-agent