The npx serve
of Android Emulators.
Host 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.
npx serve-avd
serve-emu.mov #
serve-avd
captures the emulator's screen via adb screenrecord
, 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.
It is a faithful Android port of serve-sim by Evan Bacon: same interface, same streaming design, same wire protocol β rebuilt on what Android and adb provide.
- Smooth H.264 video stream in the browser (WebCodecs), with instant paint on connect β no waiting for the next frame.
- Full interaction: tap, drag, and fling with the mouse; scroll with the wheel.
- Android navigation from the browser: Back, Home, Recents, power, volume, theme toggle, screenshot.
- Keyboard forwarding β type into the emulator directly, Escape acts as Back, ββ§H goes Home.
- logcat is forwarded to the browser (and mirrored into the browser console for browser-use MCP tools to read).
- Recent actions are available in the browser Tools panel and
serve-avd event-log
. - UI hierarchy dumps for agents:
serve-avd ax
(uiautomator β JSON). - Multiple emulators at once β boot and attach AVDs straight from the Devices panel.
- Works headless over the same HTTP API when no browser is attached.
Hosted emulators can be hard to test. serve-avd
lets 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.
It'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.
Requires the Android SDK platform-tools (adb
; the emulator
binary is needed to boot AVDs by name) and a maintained Node.js LTS release (Node 18.17+). serve-avd finds your SDK via $ANDROID_HOME
, $ANDROID_SDK_ROOT
, or the default SDK locations on macOS and Linux.
The H.264 stream needs screenrecord --output-format=h264
(present on every emulator image from the last decade). If it's unavailable, serve-avd automatically falls back to an MJPEG screenshot stream.
serve-avd [device...] Start preview server (default: localhost:3200)
device = adb serial or AVD name (boots it if needed)
serve-avd --no-preview [device...] Stream in foreground without a preview server
serve-avd gesture '<json>' [-d serial]
Send a touch gesture
serve-avd tap <x> <y> [-d serial] Tap at normalized 0..1 coords
serve-avd button [name] [-d serial] Send a button press (default: home)
home|back|app-switch|power|lock|wake|
volume-up|volume-down|mute|menu|camera|
notifications|quick-settings|dpad-*|β¦
serve-avd type <text> [-d serial] Type text via the emulator keyboard
(ASCII only; also --stdin / --file <path>)
serve-avd rotate <orientation> [-d serial]
portrait | portrait_upside_down |
landscape_left | landscape_right
serve-avd debug <option> <on|off> [-d serial]
Toggle an Android render/debug flag
(overdraw|gpu-profile|layout-bounds|
show-taps|pointer-location|slow-animations)
serve-avd memory-warning [-d serial] Ask the foreground app to trim memory
serve-avd event-log [-d serial] Show recent emulator events
serve-avd screenshot [path] [-d serial]
Save a screenshot (PNG/JPEG)
serve-avd ax [-d serial] Dump the UI hierarchy as JSON (uiautomator)
serve-avd foreground [-d serial] Print the foreground app
Options:
-p, --port <port> Starting port (preview default: 3200; --no-preview default: 3100)
--host <host> Host to bind (default: 127.0.0.1; use 0.0.0.0 for LAN)
-d, --detach Spawn a background server and exit (daemon mode)
-q, --quiet JSON-only output
--no-preview Skip the web UI; stream in foreground only
--panes <panes> Initially open preview panes: devices, tools, logs, or none
--fit Initially size the emulator to fit the preview viewport
--theme <theme> Set device appearance before opening the preview:
light or dark
--codec <codec> Stream codec for the preview UI: 'auto' (H.264 when the
browser can decode it) or 'mjpeg' (force screenshot
streaming β e.g. in browsers without WebCodecs)
--bit-rate <mbps> H.264 bitrate in Mbps (default: 8)
--size <WxH> Capture at a fixed size (default: display size, capped
at 1920 on the long side)
--list [device] List running streams
--kill [device] Kill running stream(s)
serve-avd # attach every online device, open preview
serve-avd Pixel_9_Pro_XL # target an AVD by name β boots it if needed
serve-avd emulator-5554 emulator-5556 # two emulators side by side
serve-avd --detach # start a background server, return JSON
serve-avd --list # show running streams
serve-avd --kill # stop all servers
serve-avd --panes devices,tools --fit # start with panes open and fit the emulator
serve-avd --theme dark # start the device in Dark Mode
serve-avd type "Hello, world!"
echo "from stdin" | serve-avd type --stdin
serve-avd type --file ./snippet.txt
serve-avd tap 0.5 0.9 # near bottom-center
serve-avd gesture '{"type":"begin","x":0.5,"y":0.8}'
serve-avd gesture '{"type":"move","x":0.5,"y":0.4}'
serve-avd gesture '{"type":"end","x":0.5,"y":0.3}' # swipe up
serve-avd screenshot ./now.png
serve-avd ax | jq '.root.children[0]'
serve-avd foreground
serve-avd event-log --json
Multiple devices are supported β pass several serials or AVD names, or leave it empty to attach to every online device.
The 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:
emulator -avd Pixel_9_Pro_XL -camera-back virtualscene -camera-front webcam0
Inside 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.)
Everything the preview UI does goes through a small same-origin API you can drive yourself:
GET /api server + device state
GET /api/event-log?device=&limit= recent events JSON
GET /api/event-log/events SSE: snapshot + live entries
GET /grid/api connected devices + configured AVDs
POST /grid/api/start {"device": "..."} attach a serial / boot an AVD
GET /helper/<serial>/stream.mjpeg multipart image stream (?raw=1 β bare frames)
GET /helper/<serial>/stream.avcc H.264 AVCC envelopes (see below)
GET /helper/<serial>/config { width, height, orientation, rotation }
GET /helper/<serial>/health { status: "ok" }
GET /helper/<serial>/screenshot.png one-shot screenshot
GET /helper/<serial>/logs SSE of logcat lines
GET /helper/<serial>/ax UI hierarchy JSON
GET /helper/<serial>/foreground { packageName, activity, pid }
WS /helper/<serial>/ws binary input protocol
The /stream.avcc
wire format matches serve-sim byte-for-byte: each chunk is [len:u32-be][tag:u8][payload]
where len
covers the tag + payload. Tags: 0x01
avcC decoder config, 0x02
keyframe, 0x03
delta frame, 0x04
seed 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.
The input WebSocket accepts binary [tag][JSON]
frames (all coordinates normalized 0..1 in the rotated display space):
0x03 touch {"type":"begin|move|end","x":β¦,"y":β¦}
0x04 button {"button":"home"}
0x05 pinch {"type":"begin|move|end","x1":β¦,"y1":β¦,"x2":β¦,"y2":β¦}
0x06 key {"type":"down|up","code":"Enter"} (browser KeyboardEvent.code)
0x07 rotate {"orientation":"landscape_left"}
0x08 debug {"option":"overdraw","enabled":true}
0x09 memory-warning
0x0b scroll {"dx":β¦,"dy":β¦,"x":β¦,"y":β¦}
0x0c toggle software keyboard
0x0d text {"text":"hello"}
0x0e theme {"theme":"dark"}
The server pushes 0x82
- JSON screen config whenever dimensions or orientation change.
Create a .claude/launch.json
and define a server:
{
"version": "0.0.1",
"configurations": [
{
"name": "Android",
"runtimeExecutable": "npx",
"runtimeArgs": ["serve-avd"],
"port": 3200
}
]
}
Automatically start serve-avd with npx expo start
and access the URL at http://localhost:8081/.emu
.
First, customize the metro.config.js
file (bunx expo customize
):
// Learn more https://docs.expo.io/guides/customizing-metro
const { getDefaultConfig } = require("expo/metro-config");
const connect = require("connect");
const { emuMiddleware } = require("serve-avd/middleware");
/** @type {import('expo/metro-config').MetroConfig} */
const config = getDefaultConfig(__dirname);
config.server = config.server || {};
const originalEnhanceMiddleware = config.server.enhanceMiddleware;
config.server.enhanceMiddleware = (metroMiddleware, server) => {
const middleware = originalEnhanceMiddleware
? originalEnhanceMiddleware(metroMiddleware, server)
: metroMiddleware;
const app = connect();
app.use(emuMiddleware({ basePath: "/.emu" }));
app.use(middleware);
return app;
};
module.exports = config;
serve-avd/middleware
is a Connect-style middleware that mounts the same preview UI inside your existing dev server (Metro, Vite, Next, plain Express, etc.):
import { emuMiddleware } from "serve-avd/middleware";
const middleware = emuMiddleware({ basePath: "/.emu" });
app.use(middleware);
// β preview HTML at /.emu
// β state JSON at /.emu/api
const server = app.listen(3000);
server.on("upgrade", (req, socket, head) => middleware.handleUpgrade(req, socket, head));
Device 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
event as above so input and live streams work. When terminating TLS at a reverse proxy, the page uses wss:
automatically based on the page origin.
On shutdown, call closeAllDeviceSessions()
(exported from serve-avd/middleware
) to stop the adb capture processes.
ββββββββββββββββββββ adb screenrecord ββββββββββββββββββββ AVCC / MJPEG / WS βββββββββββ
β Android Emulator β ββββββββββββββββββΊ β serve-avd server β βββββββββββββββββββΊ β Browser β
β (or adb device) β ββββββββββββββββββ β (Node, β βββββββββββ
ββββββββββββββββββββ adb shell input β in-process) β
ββββββββββββββββββββ
β²
state files in
$TMPDIR/serve-avd/
β²
ββββββββββββββββββββ
β serve-avd CLI / β
β middleware β
ββββββββββββββββββββ
Video:adb exec-out screenrecord --output-format=h264
produces 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 WebCodecsVideoDecoder
onto a canvas.Stills:adb exec-out screencap
(JPEG on modern Android, PNG otherwise) backs the MJPEG fallback stream, seed frames, and screenshots.Input: a single persistentadb shell
per device multiplexesinput motionevent/tap/swipe/keyevent/text
commands, skipping the per-command adb handshake for low latency. Devices withoutinput motionevent
(pre-Android 11) get gestures replayed astap
/swipe
on release.Pinch: synthesized as raw multi-touchsendevent
s where/dev/input
is writable (non-Play images afteradb root
); unavailable on Play-store images β the UI tells you.
No native code, no device daemons: the npm package is plain Node + adb
.
type
supports ASCII only (Android'sinput text
limitation) β matching serve-sim's "US keyboard only".- Apps that lock their orientation (launchers do) won't visibly rotate, exactly like hardware.
- Pinch requires a rootable (non-Play) emulator image.
- Physical devices work for everything except AVD-specific features (boot-by-name, emulator camera flags); enable USB debugging and expect
screenrecord
limits to vary by OEM.
npm install
npm run build # bundle CLI + middleware + browser client into dist/
npm test # unit tests (H.264 parser, XML/rotation/keymap parsers)
npm run typecheck
Apache-2.0