{"slug": "show-hn-stream-android-emulators-to-a-browser-window", "title": "Show HN: Stream Android Emulators to a Browser Window", "summary": "A new open-source tool called serve-avd lets developers stream Android emulators to a browser window for use with AI agent tools like Codex, Cursor, or Claude Desktop, capturing the emulator's screen via adb screenrecord and exposing it as an H.264 WebCodecs stream with full interaction support. Created by an unnamed developer, serve-avd is a port of Evan Bacon's serve-sim for Android, requiring the Android SDK platform-tools and Node.js 18.17+, and works with any running Android emulator or physical device over adb without root or plugins.", "body_md": "The `npx serve`\n\nof Android Emulators.\n\nHost your emulator for use with Agent tools like Codex, Cursor, or Claude Desktop — locally, over your LAN, or host on a remote machine and tunnel anywhere.\n\n```\nnpx serve-avd\n# → Preview at http://localhost:3200\n```\n\n## serve-emu.mov\n\n`serve-avd`\n\ncaptures the emulator's screen via `adb screenrecord`\n\n, exposes it as an H.264 WebCodecs stream (with an MJPEG fallback) plus a WebSocket control channel, and serves a browser preview UI on top. It works with any running Android Emulator (and most physical devices over adb) — no root, no plugin, no instrumentation in your app.\n\nIt is a faithful Android port of [serve-sim](https://github.com/EvanBacon/serve-sim) by Evan Bacon: same interface, same streaming design, same wire protocol — rebuilt on what Android and adb provide.\n\n- Smooth H.264 video stream in the browser (WebCodecs), with instant paint on connect — no waiting for the next frame.\n- Full interaction: tap, drag, and fling with the mouse; scroll with the wheel.\n- Android navigation from the browser: Back, Home, Recents, power, volume, theme toggle, screenshot.\n- Keyboard forwarding — type into the emulator directly, Escape acts as Back, ⌘⇧H goes Home.\n- logcat is forwarded to the browser (and mirrored into the browser console for browser-use MCP tools to read).\n- Recent actions are available in the browser Tools panel and\n`serve-avd event-log`\n\n. - UI hierarchy dumps for agents:\n`serve-avd ax`\n\n(uiautomator → JSON). - Multiple emulators at once — boot and attach AVDs straight from the Devices panel.\n- Works headless over the same HTTP API when no browser is attached.\n\nHosted emulators can be hard to test. `serve-avd`\n\nlets you test the hosted infra locally first for faster iteration. When you're ready to host an emulator remotely, simply tunnel the served URL and users can interact with the emulator as if it were running locally on their device.\n\nIt's also a great way to hand an emulator to an AI agent: everything is driveable over plain HTTP + WebSocket, screenshots and UI dumps are one command away, and the event log tells you what the agent did.\n\nRequires the Android SDK platform-tools (`adb`\n\n; the `emulator`\n\nbinary is needed to boot AVDs by name) and a [maintained Node.js LTS release](https://nodejs.org/en/about/previous-releases) (Node 18.17+). serve-avd finds your SDK via `$ANDROID_HOME`\n\n, `$ANDROID_SDK_ROOT`\n\n, or the default SDK locations on macOS and Linux.\n\nThe H.264 stream needs `screenrecord --output-format=h264`\n\n(present on every emulator image from the last decade). If it's unavailable, serve-avd automatically falls back to an MJPEG screenshot stream.\n\n```\nserve-avd [device...]                 Start preview server (default: localhost:3200)\n                                      device = adb serial or AVD name (boots it if needed)\nserve-avd --no-preview [device...]    Stream in foreground without a preview server\nserve-avd gesture '<json>' [-d serial]\n                                      Send a touch gesture\nserve-avd tap <x> <y> [-d serial]     Tap at normalized 0..1 coords\nserve-avd button [name] [-d serial]   Send a button press (default: home)\n                                      home|back|app-switch|power|lock|wake|\n                                      volume-up|volume-down|mute|menu|camera|\n                                      notifications|quick-settings|dpad-*|…\nserve-avd type <text> [-d serial]     Type text via the emulator keyboard\n                                      (ASCII only; also --stdin / --file <path>)\nserve-avd rotate <orientation> [-d serial]\n                                      portrait | portrait_upside_down |\n                                      landscape_left | landscape_right\nserve-avd debug <option> <on|off> [-d serial]\n                                      Toggle an Android render/debug flag\n                                      (overdraw|gpu-profile|layout-bounds|\n                                       show-taps|pointer-location|slow-animations)\nserve-avd memory-warning [-d serial]  Ask the foreground app to trim memory\nserve-avd event-log [-d serial]       Show recent emulator events\nserve-avd screenshot [path] [-d serial]\n                                      Save a screenshot (PNG/JPEG)\nserve-avd ax [-d serial]              Dump the UI hierarchy as JSON (uiautomator)\nserve-avd foreground [-d serial]      Print the foreground app\n\nOptions:\n  -p, --port <port>   Starting port (preview default: 3200; --no-preview default: 3100)\n      --host <host>   Host to bind (default: 127.0.0.1; use 0.0.0.0 for LAN)\n  -d, --detach        Spawn a background server and exit (daemon mode)\n  -q, --quiet         JSON-only output\n      --no-preview    Skip the web UI; stream in foreground only\n      --panes <panes> Initially open preview panes: devices, tools, logs, or none\n      --fit           Initially size the emulator to fit the preview viewport\n      --theme <theme> Set device appearance before opening the preview:\n                      light or dark\n      --codec <codec> Stream codec for the preview UI: 'auto' (H.264 when the\n                      browser can decode it) or 'mjpeg' (force screenshot\n                      streaming — e.g. in browsers without WebCodecs)\n      --bit-rate <mbps>  H.264 bitrate in Mbps (default: 8)\n      --size <WxH>    Capture at a fixed size (default: display size, capped\n                      at 1920 on the long side)\n      --list [device] List running streams\n      --kill [device] Kill running stream(s)\nserve-avd                              # attach every online device, open preview\n                                       # (boots your first AVD when none are running)\nserve-avd Pixel_9_Pro_XL               # target an AVD by name — boots it if needed\nserve-avd emulator-5554 emulator-5556  # two emulators side by side\nserve-avd --detach                     # start a background server, return JSON\nserve-avd --list                       # show running streams\nserve-avd --kill                       # stop all servers\nserve-avd --panes devices,tools --fit  # start with panes open and fit the emulator\nserve-avd --theme dark                 # start the device in Dark Mode\n\n# Type text into the focused field\nserve-avd type \"Hello, world!\"\necho \"from stdin\" | serve-avd type --stdin\nserve-avd type --file ./snippet.txt\n\n# Touch\nserve-avd tap 0.5 0.9                  # near bottom-center\nserve-avd gesture '{\"type\":\"begin\",\"x\":0.5,\"y\":0.8}'\nserve-avd gesture '{\"type\":\"move\",\"x\":0.5,\"y\":0.4}'\nserve-avd gesture '{\"type\":\"end\",\"x\":0.5,\"y\":0.3}'   # swipe up\n\n# Agent helpers\nserve-avd screenshot ./now.png\nserve-avd ax | jq '.root.children[0]'\nserve-avd foreground\nserve-avd event-log --json\n```\n\nMultiple devices are supported — pass several serials or AVD names, or leave it empty to attach to every online device.\n\nThe Android Emulator has camera injection built in — no helper needed. Point the AVD's camera at your host webcam or the animated virtual scene when you boot it:\n\n```\nemulator -avd Pixel_9_Pro_XL -camera-back virtualscene -camera-front webcam0\n```\n\nInside the virtual scene, custom posters can be placed via the emulator's extended controls. (This replaces serve-sim's dylib-injection camera feature — Android provides the equivalent natively.)\n\nEverything the preview UI does goes through a small same-origin API you can drive yourself:\n\n```\nGET  /api                                  server + device state\nGET  /api/event-log?device=&limit=         recent events JSON\nGET  /api/event-log/events                 SSE: snapshot + live entries\nGET  /grid/api                             connected devices + configured AVDs\nPOST /grid/api/start {\"device\": \"...\"}     attach a serial / boot an AVD\n\nGET  /helper/<serial>/stream.mjpeg         multipart image stream (?raw=1 → bare frames)\nGET  /helper/<serial>/stream.avcc          H.264 AVCC envelopes (see below)\nGET  /helper/<serial>/config               { width, height, orientation, rotation }\nGET  /helper/<serial>/health               { status: \"ok\" }\nGET  /helper/<serial>/screenshot.png       one-shot screenshot\nGET  /helper/<serial>/logs                 SSE of logcat lines\nGET  /helper/<serial>/ax                   UI hierarchy JSON\nGET  /helper/<serial>/foreground           { packageName, activity, pid }\nWS   /helper/<serial>/ws                   binary input protocol\n```\n\nThe `/stream.avcc`\n\nwire format matches serve-sim byte-for-byte: each chunk is `[len:u32-be][tag:u8][payload]`\n\nwhere `len`\n\ncovers the tag + payload. Tags: `0x01`\n\navcC decoder config, `0x02`\n\nkeyframe, `0x03`\n\ndelta frame, `0x04`\n\nseed image (painted before the first keyframe decodes). The decoder config and the current GOP are replayed to late joiners, so viewers paint instantly even when the screen is static.\n\nThe input WebSocket accepts binary `[tag][JSON]`\n\nframes (all coordinates normalized 0..1 in the rotated display space):\n\n```\n0x03 touch     {\"type\":\"begin|move|end\",\"x\":…,\"y\":…}\n0x04 button    {\"button\":\"home\"}\n0x05 pinch     {\"type\":\"begin|move|end\",\"x1\":…,\"y1\":…,\"x2\":…,\"y2\":…}\n0x06 key       {\"type\":\"down|up\",\"code\":\"Enter\"}          (browser KeyboardEvent.code)\n0x07 rotate    {\"orientation\":\"landscape_left\"}\n0x08 debug     {\"option\":\"overdraw\",\"enabled\":true}\n0x09 memory-warning\n0x0b scroll    {\"dx\":…,\"dy\":…,\"x\":…,\"y\":…}\n0x0c toggle software keyboard\n0x0d text      {\"text\":\"hello\"}\n0x0e theme     {\"theme\":\"dark\"}\n```\n\nThe server pushes `0x82`\n\n+ JSON screen config whenever dimensions or orientation change.\n\nCreate a `.claude/launch.json`\n\nand define a server:\n\n```\n{\n  \"version\": \"0.0.1\",\n  \"configurations\": [\n    {\n      \"name\": \"Android\",\n      \"runtimeExecutable\": \"npx\",\n      \"runtimeArgs\": [\"serve-avd\"],\n      \"port\": 3200\n    }\n  ]\n}\n```\n\nAutomatically start serve-avd with `npx expo start`\n\nand access the URL at `http://localhost:8081/.emu`\n\n.\n\nFirst, customize the `metro.config.js`\n\nfile (`bunx expo customize`\n\n):\n\n```\n// Learn more https://docs.expo.io/guides/customizing-metro\nconst { getDefaultConfig } = require(\"expo/metro-config\");\nconst connect = require(\"connect\");\nconst { emuMiddleware } = require(\"serve-avd/middleware\");\n\n/** @type {import('expo/metro-config').MetroConfig} */\nconst config = getDefaultConfig(__dirname);\n\nconfig.server = config.server || {};\nconst originalEnhanceMiddleware = config.server.enhanceMiddleware;\nconfig.server.enhanceMiddleware = (metroMiddleware, server) => {\n  const middleware = originalEnhanceMiddleware\n    ? originalEnhanceMiddleware(metroMiddleware, server)\n    : metroMiddleware;\n  const app = connect();\n  app.use(emuMiddleware({ basePath: \"/.emu\" }));\n  app.use(middleware);\n  return app;\n};\n\nmodule.exports = config;\n```\n\n`serve-avd/middleware`\n\nis a Connect-style middleware that mounts the same preview UI inside your existing dev server (Metro, Vite, Next, plain Express, etc.):\n\n``` js\nimport { emuMiddleware } from \"serve-avd/middleware\";\n\nconst middleware = emuMiddleware({ basePath: \"/.emu\" });\napp.use(middleware);\n// → preview HTML at /.emu\n// → state JSON  at /.emu/api\n\nconst server = app.listen(3000);\nserver.on(\"upgrade\", (req, socket, head) => middleware.handleUpgrade(req, socket, head));\n```\n\nDevice sessions are created in-process and everything (video, input socket, logs) is same-origin behind your one port, so remote proxying/tunnelling needs no extra configuration — just forward the `upgrade`\n\nevent as above so input and live streams work. When terminating TLS at a reverse proxy, the page uses `wss:`\n\nautomatically based on the page origin.\n\nOn shutdown, call `closeAllDeviceSessions()`\n\n(exported from `serve-avd/middleware`\n\n) to stop the adb capture processes.\n\n```\n┌──────────────────┐  adb screenrecord  ┌──────────────────┐  AVCC / MJPEG / WS  ┌─────────┐\n│ Android Emulator │ ─────────────────► │ serve-avd server │ ──────────────────► │ Browser │\n│ (or adb device)  │ ◄───────────────── │ (Node,           │                     └─────────┘\n└──────────────────┘   adb shell input  │  in-process)     │\n                                        └──────────────────┘\n                                                ▲\n                                          state files in\n                                        $TMPDIR/serve-avd/\n                                                ▲\n                                        ┌──────────────────┐\n                                        │ serve-avd CLI /  │\n                                        │ middleware       │\n                                        └──────────────────┘\n```\n\n**Video**:`adb exec-out screenrecord --output-format=h264`\n\nproduces an Annex B elementary stream. serve-avd re-frames it into length-prefixed AVCC envelopes, extracts SPS/PPS into a decoder config, and caches the current GOP so new viewers decode instantly. screenrecord's 3-minute cap and its inability to follow rotation are handled by transparent restarts (viewers just see a new decoder config). The browser decodes with WebCodecs`VideoDecoder`\n\nonto a canvas.**Stills**:`adb exec-out screencap`\n\n(JPEG on modern Android, PNG otherwise) backs the MJPEG fallback stream, seed frames, and screenshots.**Input**: a single persistent`adb shell`\n\nper device multiplexes`input motionevent/tap/swipe/keyevent/text`\n\ncommands, skipping the per-command adb handshake for low latency. Devices without`input motionevent`\n\n(pre-Android 11) get gestures replayed as`tap`\n\n/`swipe`\n\non release.**Pinch**: synthesized as raw multi-touch`sendevent`\n\ns where`/dev/input`\n\nis writable (non-Play images after`adb root`\n\n); unavailable on Play-store images — the UI tells you.\n\nNo native code, no device daemons: the npm package is plain Node + `adb`\n\n.\n\n`type`\n\nsupports ASCII only (Android's`input text`\n\nlimitation) — matching serve-sim's \"US keyboard only\".- Apps that lock their orientation (launchers do) won't visibly rotate, exactly like hardware.\n- Pinch requires a rootable (non-Play) emulator image.\n- Physical devices work for everything except AVD-specific features (boot-by-name, emulator camera flags); enable USB debugging and expect\n`screenrecord`\n\nlimits to vary by OEM.\n\n```\nnpm install\nnpm run build       # bundle CLI + middleware + browser client into dist/\nnpm test            # unit tests (H.264 parser, XML/rotation/keymap parsers)\nnpm run typecheck\n```\n\nApache-2.0", "url": "https://wpnews.pro/news/show-hn-stream-android-emulators-to-a-browser-window", "canonical_source": "https://github.com/hsandhu/serve-avd", "published_at": "2026-07-22 21:30:29+00:00", "updated_at": "2026-07-22 21:53:10.201002+00:00", "lang": "en", "topics": ["developer-tools", "artificial-intelligence", "ai-agents"], "entities": ["serve-avd", "Codex", "Cursor", "Claude Desktop", "Evan Bacon", "serve-sim", "Android SDK", "Node.js"], "alternates": {"html": "https://wpnews.pro/news/show-hn-stream-android-emulators-to-a-browser-window", "markdown": "https://wpnews.pro/news/show-hn-stream-android-emulators-to-a-browser-window.md", "text": "https://wpnews.pro/news/show-hn-stream-android-emulators-to-a-browser-window.txt", "jsonld": "https://wpnews.pro/news/show-hn-stream-android-emulators-to-a-browser-window.jsonld"}}