---
title: "How to Run Android in a Sandbox"
description: "Build a reusable Android 15 emulator image, watch it through noVNC, and give a computer-use agent control through adb."
url: "/docs/guides/run-android-in-a-sandbox"
---

This guide builds a reusable Android 15 emulator image with the
[Android SDK](https://developer.android.com/tools), an initialized virtual
device, [Xvfb](https://www.x.org/archive/X11R6.8.2/doc/Xvfb.1.html),
[x11vnc](https://github.com/LibVNC/x11vnc), and
[noVNC](https://novnc.com/info.html). A human can watch and interact with the
device in a browser while an agent controls the same Android session through
[`adb`](https://developer.android.com/tools/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

```bash pnpm
pnpm add freestyle
```

```bash bun
bun add freestyle
```

```bash npm
npm install freestyle
```

```bash yarn
yarn add freestyle
```

```bash
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:

```ts
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:

```ts
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](https://developer.android.com/studio#command-line-tools-only):
  `sdkmanager` and `avdmanager`.
- [Android Emulator](https://developer.android.com/studio/run/emulator-commandline):
  the KVM-backed device runtime.
- [Android Debug Bridge](https://developer.android.com/tools/adb): screenshots,
  input, package installation, shell commands, and device state.
- [`default-jdk-headless`](https://packages.debian.org/stable/default-jdk-headless):
  the Java runtime used by the SDK tools.
- [Xvfb](https://www.x.org/archive/X11R6.8.2/doc/Xvfb.1.html): the virtual X
  display that owns the emulator window.
- [x11vnc](https://github.com/LibVNC/x11vnc): the loopback VNC server attached
  to that display.
- [noVNC](https://github.com/novnc/noVNC) and
  [websockify](https://github.com/novnc/websockify): the browser client and
  WebSocket-to-VNC bridge.
- [`x11-utils`](https://packages.debian.org/stable/x11-utils): readiness tools
  such as `xdpyinfo` and `xwininfo`.
- [`xdotool`](https://packages.debian.org/stable/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](https://developer.android.com/studio#command-line-tools-only)
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.

```ts
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:

```ts
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`.

```ts
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 `adb` first.
- 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:

```ts
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:

```ts
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:

```ts
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:

```ts
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:

```ts
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:

```ts
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:

```ts
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:

```ts
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:

```ts
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:

```ts
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:

```ts
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](https://f-droid.org/) client:

```ts
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.

```ts
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](https://www.freestyle.sh/docs/vms/pty) for live Android logs:

```ts
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.
