This guide builds a reusable Android 15 emulator image with the
Android SDK, an initialized virtual
device, Xvfb,
x11vnc, and
noVNC. A human can watch and interact with the
device in a browser while an agent controls the same Android session through
adb.
Do not take the Freestyle snapshot while the Android emulator is running. The
emulator is itself a KVM-backed virtual machine, and restoring that nested
process can leave adb stuck on an offline device. Instead, complete Android’s
first boot, stop the emulator cleanly, then snapshot the installed SDK and
initialized AVD on disk. Start the emulator after creating each runtime VM.
In the tested stack, both the first boot and a boot from the reusable snapshot
reached sys.boot_completed=1 in about a minute.
Install the SDK
pnpm add freestylebun add freestylenpm install freestyleyarn add freestyle export FREESTYLE_API_KEY="your-api-key"
Run Long Setup Steps Safely
The Linux packages, command-line tools, emulator, and Android system image take several minutes to download and unpack. Run each stage in a background systemd unit and poll marker files with short API calls:
async function runSetup(vm, name: string, script: string) {
const scriptPath = `/root/setup-${name}.sh`;
const donePath = `/tmp/setup-${name}.done`;
const failedPath = `/tmp/setup-${name}.failed`;
const unit = `setup-${name}`;
await vm.fs.writeTextFile(
scriptPath,
`#!/bin/bash
set -euxo pipefail
export DEBIAN_FRONTEND=noninteractive
rm -f ${donePath} ${failedPath}
trap 'touch ${failedPath}' ERR
${script}
touch ${donePath}
`,
);
await vm.exec(
`chmod +x ${scriptPath} && systemd-run --unit=${unit} --collect ${scriptPath}`,
);
for (let i = 0; i < 240; i++) {
const probe = await vm.exec(
`if test -f ${donePath}; then echo DONE; ` +
`elif test -f ${failedPath}; then echo FAILED; ` +
`journalctl -u ${unit} --no-pager -n 160; ` +
`else systemctl show ${unit} -p ActiveState -p Result --no-pager; fi`,
);
const output = `${probe.stdout ?? ""}${probe.stderr ?? ""}`;
if (output.includes("DONE")) return;
if (output.includes("FAILED")) throw new Error(output);
await new Promise((resolve) => setTimeout(resolve, 5_000));
}
throw new Error(`${unit} did not finish`);
}
This is especially important for the Android system image. It expands to
several gigabytes and can outlive a single buffered vm.exec() request.
Install the Android Runtime
Create a builder VM with no idle timeout. Install the JDK, Android emulator libraries, virtual display, VNC server, browser viewer, and X inspection tools:
import { freestyle } from "freestyle";
const { vm: builder } = await freestyle.vms.create({
slug: "android-builder",
idleTimeoutSeconds: null,
});
await runSetup(
builder,
"android-deps",
`mkdir -p /usr/share/man/man1
apt-get update -qq
apt-get install -y -qq --no-install-recommends \
ca-certificates curl default-jdk-headless unzip \
libpulse0 libgl1 libegl1 libnss3 \
libxcomposite1 libxcursor1 libxdamage1 libxi6 libxtst6 \
libxrandr2 libxkbcommon0 \
xvfb x11vnc novnc websockify x11-utils xdotool`,
);
The important dependencies are:
- Android command-line tools:
sdkmanagerandavdmanager. - Android Emulator: the KVM-backed device runtime.
- Android Debug Bridge: screenshots, input, package installation, shell commands, and device state.
default-jdk-headless: the Java runtime used by the SDK tools.- Xvfb: the virtual X display that owns the emulator window.
- x11vnc: the loopback VNC server attached to that display.
- noVNC and websockify: the browser client and WebSocket-to-VNC bridge.
x11-utils: readiness tools such asxdpyinfoandxwininfo.xdotool: resizes and positions the emulator window so noVNC does not stream an oversized empty desktop.
Install the SDK and Android 15 Image
The tested command-line tools download is 14742923. The official Android page
publishes its 40-character SHA-1 checksum as
48833c34b761c10cb20bcd16582129395d121b27. Check the
command-line tools page
before changing either value.
Accepting licenses with yes needs one small exception to pipefail: yes
normally receives a broken pipe when sdkmanager finishes. Capture
sdkmanager’s status rather than treating the expected yes exit as a failed
install.
await runSetup(
builder,
"android-sdk",
`curl -fsSL -o /tmp/cmdline-tools.zip \
https://dl.google.com/android/repository/commandlinetools-linux-14742923_latest.zip
echo '48833c34b761c10cb20bcd16582129395d121b27 /tmp/cmdline-tools.zip' | sha1sum -c -
mkdir -p /opt/android-sdk/cmdline-tools
unzip -q /tmp/cmdline-tools.zip -d /opt/android-sdk/cmdline-tools
mv /opt/android-sdk/cmdline-tools/cmdline-tools \
/opt/android-sdk/cmdline-tools/latest
set +o pipefail
yes | /opt/android-sdk/cmdline-tools/latest/bin/sdkmanager --licenses >/dev/null
license_status=\${PIPESTATUS[1]}
set -o pipefail
test "$license_status" -eq 0
/opt/android-sdk/cmdline-tools/latest/bin/sdkmanager --install \
'platform-tools' \
'emulator' \
'platforms;android-35' \
'system-images;android-35;google_apis;x86_64'
rm -f /tmp/cmdline-tools.zip`,
);
Confirm that nested KVM acceleration is available:
const accel = await builder.exec(
"/opt/android-sdk/emulator/emulator -accel-check",
);
if (accel.statusCode !== 0) {
throw new Error(`${accel.stdout}\n${accel.stderr}`);
}
console.log(accel.stdout);
Create a Pixel 7 AVD. The API 35 Google APIs image runs Android 15. Set HOME
because avdmanager stores the device under $HOME/.android/avd.
const avdmanager = "/opt/android-sdk/cmdline-tools/latest/bin/avdmanager";
const created = await builder.exec(
`export HOME=/root
echo no | ${avdmanager} create avd \
-n sandbox \
-k 'system-images;android-35;google_apis;x86_64' \
-d pixel_7
printf '%s\\n' \
'hw.ramSize=2048' \
'hw.gpu.enabled=yes' \
'hw.gpu.mode=swiftshader_indirect' \
>> /root/.android/avd/sandbox.avd/config.ini`,
);
if (created.statusCode !== 0) {
throw new Error(`${created.stdout}\n${created.stderr}`);
}
Add the Android Display Services
Four services run the visible device:
- Xvfb owns X display
:0. - The Android emulator opens its window on that display and starts
adbfirst. - A window-fitting service expands the emulator to
411x914, moves it to(0,0), and places the emulator toolbar beside it. - x11vnc exports only the loopback VNC port
5900. - noVNC serves the browser viewer on port
6080.
An android-desktop.target starts the stack as one unit:
await builder.fs.writeTextFile(
"/etc/systemd/system/xvfb.service",
`[Unit]
Description=Virtual X display for Android
[Service]
ExecStart=/usr/bin/Xvfb :0 -screen 0 465x914x24 -nolisten tcp
Restart=always
RestartSec=2
[Install]
WantedBy=multi-user.target
`,
);
await builder.fs.writeTextFile(
"/etc/systemd/system/android-emulator.service",
`[Unit]
Description=Android emulator
After=xvfb.service
Requires=xvfb.service
[Service]
Environment=HOME=/root
Environment=ANDROID_HOME=/opt/android-sdk
Environment=DISPLAY=:0
ExecStartPre=/bin/bash -lc 'for i in {1..30}; do xdpyinfo >/dev/null 2>&1 && exit 0; sleep 1; done; exit 1'
ExecStartPre=/opt/android-sdk/platform-tools/adb start-server
ExecStart=/opt/android-sdk/emulator/emulator -avd sandbox -port 5554 -no-audio -no-boot-anim -no-snapshot -gpu swiftshader_indirect
WorkingDirectory=/root
Restart=always
RestartSec=3
TimeoutStopSec=45
KillMode=control-group
[Install]
WantedBy=multi-user.target
`,
);
await builder.fs.writeTextFile(
"/usr/local/bin/fit-android-window",
`#!/bin/bash
set -euo pipefail
export DISPLAY=:0
adb=/opt/android-sdk/platform-tools/adb
main=
for _ in $(seq 1 90); do
main=$(xdotool search --name '^Android Emulator - sandbox:5554$' 2>/dev/null | head -1 || true)
state=$("$adb" get-state 2>/dev/null || true)
boot=$("$adb" shell getprop sys.boot_completed 2>/dev/null || true)
if [ -n "$main" ] && [ "$state" = device ] && [ "$boot" = 1 ]; then
break
fi
sleep 2
done
test -n "$main"
test "$state" = device
test "$boot" = 1
for _ in $(seq 1 10); do
xdotool windowsize "$main" 411 914
xdotool windowmove "$main" 0 0
for window in $(xdotool search --name '^Emulator$' 2>/dev/null || true); do
geometry=$(xdotool getwindowgeometry --shell "$window" 2>/dev/null || true)
width=$(printf '%s\\n' "$geometry" | sed -n 's/^WIDTH=//p')
height=$(printf '%s\\n' "$geometry" | sed -n 's/^HEIGHT=//p')
if [ "$width" = "54" ]; then
xdotool windowmove "$window" 411 0
elif [ "$width" = "300" ] && [ "$height" = "21" ]; then
xdotool windowmove "$window" 55 0
fi
done
sleep 1
done
warning=$(xdotool search --name '^Emulator Running in Nested Virtualization$' 2>/dev/null | head -1 || true)
if [ -n "$warning" ]; then
xdotool mousemove --window "$warning" 30 120 click 1
xdotool mousemove --window "$warning" 270 130 click 1
fi
`,
);
await builder.fs.writeTextFile(
"/etc/systemd/system/android-window-fit.service",
`[Unit]
Description=Fit the Android emulator to the VNC canvas
After=android-emulator.service
Requires=android-emulator.service
[Service]
Type=oneshot
Environment=DISPLAY=:0
ExecStart=/usr/local/bin/fit-android-window
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
`,
);
await builder.fs.writeTextFile(
"/etc/systemd/system/x11vnc.service",
`[Unit]
Description=VNC server for the Android display
After=xvfb.service
Requires=xvfb.service
[Service]
Environment=DISPLAY=:0
ExecStartPre=/bin/bash -lc 'for i in {1..30}; do xdpyinfo >/dev/null 2>&1 && exit 0; sleep 1; done; exit 1'
ExecStart=/usr/bin/x11vnc -display :0 -forever -shared -nopw -localhost -rfbport 5900
Restart=always
RestartSec=2
[Install]
WantedBy=multi-user.target
`,
);
await builder.fs.writeTextFile(
"/etc/systemd/system/novnc.service",
`[Unit]
Description=noVNC web client for Android
After=x11vnc.service
Requires=x11vnc.service
[Service]
ExecStart=/usr/bin/websockify --web /usr/share/novnc 6080 localhost:5900
Restart=always
RestartSec=2
[Install]
WantedBy=multi-user.target
`,
);
await builder.fs.writeTextFile(
"/etc/systemd/system/android-desktop.target",
`[Unit]
Description=Android emulator with browser viewer
Requires=xvfb.service android-emulator.service android-window-fit.service x11vnc.service novnc.service
After=xvfb.service android-emulator.service android-window-fit.service x11vnc.service novnc.service
[Install]
WantedBy=multi-user.target
`,
);
await builder.exec(
"chmod +x /usr/local/bin/fit-android-window && " +
"systemctl daemon-reload && systemctl enable android-desktop.target",
);
-localhost keeps the raw VNC server off the VM network. noVNC’s websockify
process is its only VNC client. The 465x914 X display closely matches the
emulator plus its side toolbar, so noVNC scales useful pixels instead of a
mostly empty 720x1280 desktop. Waiting for sys.boot_completed=1 matters:
Qt can resize the emulator late in startup, so the fitter repeats the geometry
for ten seconds after Android is ready.
Complete the First Boot
Start the whole target, then wait for the device, Android boot property, emulator window, and browser viewer:
async function waitForAndroid(vm) {
const started = Date.now();
for (let i = 0; i < 120; i++) {
const probe = await vm.exec(
`set +e
state=$(/opt/android-sdk/platform-tools/adb get-state 2>/dev/null)
boot=$(/opt/android-sdk/platform-tools/adb shell getprop sys.boot_completed 2>/dev/null)
window=$(DISPLAY=:0 xwininfo -root -tree 2>/dev/null | grep -E 'Android Emulator|sandbox' | head -1)
viewer=$(curl -fsS http://127.0.0.1:6080/vnc.html >/dev/null && echo ready)
printf 'state=%s boot=%s viewer=%s window=%s\\n' "$state" "$boot" "$viewer" "$window"`,
);
const output = `${probe.stdout ?? ""}${probe.stderr ?? ""}`;
if (
output.includes("state=device") &&
output.includes("boot=1") &&
output.includes("viewer=ready") &&
/Android Emulator|sandbox/.test(output)
) {
return Date.now() - started;
}
await new Promise((resolve) => setTimeout(resolve, 2_000));
}
const logs = await vm.exec(
"journalctl -u xvfb -u android-emulator -u x11vnc -u novnc --no-pager -n 240",
);
throw new Error(`${logs.stdout}\n${logs.stderr}`);
}
await builder.exec("systemctl start android-desktop.target");
const firstBootMs = await waitForAndroid(builder);
console.log(`Android first boot: ${Math.round(firstBootMs / 1000)}s`);
Disable Android’s UI animations if this image is primarily for automated testing:
const adbBin = "/opt/android-sdk/platform-tools/adb";
await builder.exec(
`${adbBin} shell settings put global window_animation_scale 0 && ` +
`${adbBin} shell settings put global transition_animation_scale 0 && ` +
`${adbBin} shell settings put global animator_duration_scale 0`,
);
Snapshot with the Emulator Stopped
Stop the nested emulator and display stack before taking the Freestyle snapshot. The initialized Android userdata remains on disk, but no KVM-backed emulator process is captured in memory:
const stopped = await builder.exec({
command: `systemctl stop android-desktop.target
systemctl stop novnc x11vnc android-emulator xvfb
/opt/android-sdk/platform-tools/adb kill-server || true
for i in $(seq 1 30); do
if ! pgrep -f '/opt/android-sdk/emulator/(emulator|qemu)' >/dev/null; then
exit 0
fi
sleep 1
done
pgrep -af '/opt/android-sdk/emulator/(emulator|qemu)'
exit 1`,
timeoutMs: 90_000,
});
if (stopped.statusCode !== 0) {
throw new Error(`${stopped.stdout}\n${stopped.stderr}`);
}
const { snapshotId } = await builder.snapshot({
name: "android-15-emulator",
});
await builder.delete();
This snapshot avoids repeating the package downloads and Android’s first-device initialization. It does not claim to freeze a live nested Android VM.
Start a Runtime Device
Create a VM from the snapshot, start the target explicitly, and wait for the device:
const { vm, vmId } = await freestyle.vms.create({
slug: "android-device",
snapshotId,
idleTimeoutSeconds: null,
});
await vm.exec("systemctl start android-desktop.target");
const bootMs = await waitForAndroid(vm);
console.log(`Android ready: ${Math.round(bootMs / 1000)}s`);
Because the target is enabled, a full VM stop and start also brings the Android
stack back automatically. A fresh VM created from the stopped snapshot needs
the explicit systemctl start above because it resumes the snapshot rather
than performing a Linux boot.
Optional: Require a VNC Password
The default x11vnc service uses -nopw. To make noVNC prompt for a password,
configure it on the runtime VM before mapping the public domain:
const vncPassword = process.env.VNC_PASSWORD;
if (!vncPassword || !/^[\x21-\x7e]{6,8}$/.test(vncPassword)) {
throw new Error("Set VNC_PASSWORD to 6-8 printable ASCII characters");
}
const passwordInput = "/root/vnc-password-input";
const passwordFile = "/root/.vnc/passwd";
const servicePath = "/etc/systemd/system/x11vnc.service";
await vm.fs.writeTextFile(
passwordInput,
`${vncPassword}\n${vncPassword}\ny\n`,
);
const stored = await vm.exec(
`set -eu
trap 'rm -f ${passwordInput}' EXIT
install -d -m 0700 /root/.vnc
chmod 0600 ${passwordInput}
x11vnc -storepasswd ${passwordFile} < ${passwordInput}
chmod 0600 ${passwordFile}`,
);
if (stored.statusCode !== 0) {
throw new Error(`${stored.stdout}\n${stored.stderr}`);
}
const service = await vm.fs.readTextFile(servicePath);
if (!service.includes("-nopw")) {
throw new Error("x11vnc is already authenticated or its service changed");
}
await vm.fs.writeTextFile(
servicePath,
service.replace("-nopw", `-rfbauth ${passwordFile}`),
);
await vm.exec(
"systemctl daemon-reload && systemctl restart x11vnc novnc",
);
VNC passwords use only the first eight characters, so this example accepts 6-to-8-character values. noVNC asks for the password with no username.
Turn Off the VNC Password
Restore -nopw, remove the password file, and restart the viewer services:
const passwordFile = "/root/.vnc/passwd";
const servicePath = "/etc/systemd/system/x11vnc.service";
const service = await vm.fs.readTextFile(servicePath);
if (!service.includes(`-rfbauth ${passwordFile}`)) {
throw new Error("x11vnc password authentication is not enabled");
}
await vm.fs.writeTextFile(
servicePath,
service.replace(`-rfbauth ${passwordFile}`, "-nopw"),
);
await vm.exec(
`rm -f ${passwordFile}
systemctl daemon-reload
systemctl restart x11vnc novnc`,
);
Restarting x11vnc disconnects current viewers. Reload the same noVNC URL after the service restarts.
Watch the Device in a Browser
Map a unique *.style.dev hostname to noVNC’s port:
const domain = `android-${crypto.randomUUID().slice(0, 8)}.style.dev`;
await freestyle.domains.mappings.create({
domain,
vmId,
vmPort: 6080,
});
const viewerUrl =
`https://${domain}/vnc.html?` +
"autoconnect=true&reconnect=true&reconnect_delay=2000&" +
"resize=scale&path=websockify";
console.log(viewerUrl);
The browser shows the emulator window on the X display. Mouse, keyboard, and
touch-style drags through noVNC affect the same Android session controlled by
adb.
Drive Android with adb
Keep VM lifecycle outside the helper and check every command result:
async function adb(vm, args: string) {
const result = await vm.exec(`/opt/android-sdk/platform-tools/adb ${args}`);
if (result.statusCode !== 0) {
throw new Error(`${result.stdout ?? ""}\n${result.stderr ?? ""}`);
}
return result;
}
const devices = await adb(vm, "devices");
console.log(devices.stdout); // emulator-5554 device
const release = await adb(vm, "shell getprop ro.build.version.release");
console.log(release.stdout.trim()); // 15
Send common input events:
await adb(vm, "shell input keyevent KEYCODE_HOME");
await adb(vm, "shell input tap 540 1200");
await adb(vm, "shell input swipe 200 900 600 300 350");
Take a screenshot and read the PNG directly through the VM filesystem API:
import { writeFile } from "node:fs/promises";
await adb(vm, "exec-out screencap -p > /tmp/android-screen.png");
const png = await vm.fs.readFile("/tmp/android-screen.png");
await writeFile("android-screen.png", png);
await vm.fs.remove("/tmp/android-screen.png");
Install and Launch an APK
Download an APK inside the VM, install it, then launch its package. This example uses the F-Droid client:
await vm.exec(
"curl -fsSL -o /tmp/fdroid.apk https://f-droid.org/F-Droid.apk",
);
const install = await adb(vm, "install /tmp/fdroid.apk");
console.log(install.stdout); // Success
await adb(vm, "shell monkey -p org.fdroid.fdroid 1");
const pid = await adb(vm, "shell pidof org.fdroid.fdroid");
console.log(pid.stdout);
The same pattern works for an APK built by an agent or CI job.
Give a Computer-Use Agent Android
Android computer use does not need desktop mouse automation. Give the agent the
device framebuffer as its observation and a narrow set of adb shell input
actions. Coordinates use the Android screenshot’s pixel space, not the scaled
noVNC browser window.
type AndroidAction =
| { type: "screenshot" }
| { type: "tap"; x: number; y: number }
| {
type: "swipe";
fromX: number;
fromY: number;
toX: number;
toY: number;
durationMs?: number;
}
| { type: "type"; text: string }
| { type: "key"; key: string };
function integer(value: unknown, name: string) {
if (!Number.isInteger(value) || (value as number) < 0) {
throw new Error(`${name} must be a non-negative integer`);
}
return value as number;
}
function shellArg(value: string) {
return `'${value.replaceAll("'", `'\\''`)}'`;
}
async function screenSize(vm) {
const result = await adb(vm, "shell wm size");
const match = result.stdout.match(/Physical size: (\d+)x(\d+)/);
if (!match) throw new Error(`Could not read screen size: ${result.stdout}`);
return { width: Number(match[1]), height: Number(match[2]) };
}
export async function runAndroidAction(vm, action: AndroidAction) {
if (action.type === "screenshot") {
const path = `/tmp/android-agent-${crypto.randomUUID()}.png`;
try {
await adb(vm, `exec-out screencap -p > ${path}`);
return await vm.fs.readFile(path);
} finally {
await vm.fs.remove(path).catch(() => {});
}
}
const { width, height } = await screenSize(vm);
const point = (x: unknown, y: unknown) => {
const checkedX = integer(x, "x");
const checkedY = integer(y, "y");
if (checkedX >= width || checkedY >= height) {
throw new Error("Coordinate is outside the Android screen");
}
return [checkedX, checkedY] as const;
};
if (action.type === "tap") {
const [x, y] = point(action.x, action.y);
await adb(vm, `shell input tap ${x} ${y}`);
} else if (action.type === "swipe") {
const [fromX, fromY] = point(action.fromX, action.fromY);
const [toX, toY] = point(action.toX, action.toY);
const duration = action.durationMs ?? 350;
if (!Number.isInteger(duration) || duration < 1 || duration > 5_000) {
throw new Error("Swipe duration must be between 1 and 5000 ms");
}
await adb(
vm,
`shell input swipe ${fromX} ${fromY} ${toX} ${toY} ${duration}`,
);
} else if (action.type === "type") {
const text = action.text.replaceAll(" ", "%s");
await adb(vm, `shell input text ${shellArg(text)}`);
} else if (action.type === "key") {
if (!/^KEYCODE_[A-Z0-9_]+$/.test(action.key)) {
throw new Error("Use an Android KEYCODE_* value");
}
await adb(vm, `shell input keyevent ${action.key}`);
} else {
throw new Error("Unsupported Android action");
}
return { ok: true };
}
Expose those five actions to the model: screenshot, tap, swipe, type, and key. After each action, request another screenshot and feed it back to the agent. noVNC remains the human observation and takeover channel.
adb shell input text is best for short ASCII input. Use an app-specific
clipboard or IME integration when the agent must enter arbitrary Unicode.
Stream logcat
vm.exec() buffers output until a command exits. Use a
PTY for live Android logs:
const session = await vm.pty.open({
cols: 120,
rows: 30,
onData: (bytes) => process.stdout.write(bytes),
});
session.write("/opt/android-sdk/platform-tools/adb logcat\n");
Detach the PTY when the observer disconnects. The Android emulator continues running under systemd.