Skip to main content
Glama
yagizdo
by yagizdo

Karagöz

Device automation for mobile apps. One tool for four targets: Android emulator, Android physical device, iOS simulator, iOS physical device. It is a CLI, and karagoz mcp serves the same commands to an AI agent as an MCP server.

Status: early development. Twelve commands work on the Android emulator, devices also works on a physical Android device, doctor reports the adb they use, and the MCP server offers all thirteen to an AI agent. The other three targets are not written yet. Nothing is published to npm. See Status.

Contents

Related MCP server: Android-MCP

Status

Command

Android emulator

Android device

iOS simulator

iOS device

devices

done

done

planned

planned

screenshot

done

planned

planned

planned

ui-tree

done

planned

planned

planned

tap

done

planned

planned

planned

swipe

done

planned

planned

planned

text

done

planned

planned

planned

key

done

planned

planned

planned

install

done

planned

planned

planned

launch

done

planned

planned

planned

terminate

done

planned

planned

planned

uninstall

done

planned

planned

planned

logs

done

planned

planned

planned

doctor

done

done

planned

planned

MCP server

done

"Done" means tested against a live emulator: macOS, an API 36 image (Android 16), 1080x2400 at 420 dpi. devices was tested on a physical Samsung phone (Android 14) over USB; no other command has been run on one yet. doctor touches no device; its row means tested on the same Mac with fake and real adb binaries. For the MCP server, done means smoke/M-mcp.sh passes against the live emulator, and Claude Code and Codex called its tools. Windows and Linux have not been run.

Requirements

  • Node 22 or newer.

  • adb from Android platform-tools, for Android targets. karagoz does not bundle it; it looks for it on every run, in this order:

    1. $ANDROID_HOME/platform-tools/adb

    2. $ANDROID_SDK_ROOT/platform-tools/adb

    3. adb on PATH

    4. Android Studio's default SDK: ~/Library/Android/sdk on macOS, ~/Android/Sdk on Linux, %LOCALAPPDATA%\Android\Sdk on Windows

    An empty variable is skipped. The first adb that starts is used for the rest of the run, so a broken adb under ANDROID_HOME does not fall back to PATH. The exception is an adb that fails to start with ENOENT (a missing interpreter or a broken link): it counts as not there, and the next one is tried. Under karagoz mcp the run is the whole server session, and if the adb in use disappears (ENOENT), the next call looks it up again. An adb that appears mid-session higher in the list is not picked up until the server restarts. karagoz doctor shows which one is used and why. install needs platform-tools 30.0.0 or newer. When none is found:

    {"error":{"code":"ADB_NOT_FOUND","message":"adb not found (tried ..., PATH). Install platform-tools (brew install --cask android-platform-tools, or download https://developer.android.com/tools/releases/platform-tools and add it to PATH) or set ANDROID_HOME to your Android SDK."}}
  • A running emulator (emulator -avd <name>), or for devices, a phone with USB debugging on, in state device. karagoz installs nothing on a device by itself; install installs only the APK you pass it. A phone that adb cannot see is missing from the list: on Windows without the phone maker's USB driver, on a Mac laptop where "Allow accessory to connect" was refused, or in fastboot mode.

  • For the smoke/1.5-app-lifecycle.sh script only: a JDK and Android SDK build-tools with one platform. The smoke builds its own test APK, dev.karagoz.smoke, and removes it at the end. See Development.

  • For ui-tree and tap --text / --id: the screen is on, and no other UiAutomation client is connected (Appium, Maestro, uiautomator events). For input to reach apps, the screen is also unlocked.

Install

Not on npm yet. From source:

git clone https://github.com/yagizdo/karagoz.git
cd karagoz
npm install
npm run build          # writes dist/: cli.js and two chunks
node dist/cli.js devices

npm link puts karagoz on your PATH. The examples below use karagoz.

Quick start

$ karagoz devices
{"devices":[{"id":"emulator-5554","platform":"android","kind":"emulator","state":"device","name":"Medium_Phone_API_36.1"}]}

$ karagoz tap --text Chrome
{"device":"emulator-5554","x":667.5,"y":2002.5,"element":{"class":"android.widget.TextView","text":"Chrome","contentDesc":"Chrome","clickable":true,"longClickable":true,"focusable":true,"bounds":[581,1905,754,2100]}}

$ karagoz key HOME
{"device":"emulator-5554","key":"KEYCODE_HOME","code":3}

$ karagoz screenshot --out home.png
{"path":"/Users/me/home.png","device":"emulator-5554","pixels":{"width":1080,"height":2400},"logical":{"width":411.42857142857144,"height":914.2857142857143},"scale":2.625,"safeArea":{"top":63,"right":0,"bottom":63,"left":0},"rotation":0}

karagoz ui-tree prints the screen's accessibility tree; tap --text above found its target in that tree.

Concepts

Device selection

Every command except devices works on one device, picked in this order:

  1. --device <id>

  2. the ANDROID_SERIAL environment variable (ignored when --device is given; empty counts as unset)

  3. the only listed device

The value is matched against adb serials first (emulator-5554), exactly. If no serial matches, it is matched against every name that devices prints: an emulator's AVD name (Medium_Phone_API_36.1) or a phone's model (SM-S908N), exactly and case-sensitively.

Situation

Error

A value is given and nothing matches

DEVICE_NOT_FOUND, listing what is connected

No value, nothing connected

NO_DEVICE

No value and two or more devices listed (in any state), or a name that matches two entries: the same AVD listed as emulator-5554 and 127.0.0.1:5555, or two phones of the same model

DEVICE_AMBIGUOUS

The picked device is not in state device (offline, unauthorized, ...)

DEVICE_NOT_READY

karagoz never guesses between devices. There is no config file and no other environment variable.

A phone connected over Wi-Fi as well as USB is listed twice, once per serial, and its model matches both: pick it by serial then. Quote a --device value that contains spaces, as some mDNS serials do.

Coordinates

screenshot pixels, ui-tree bounds and the tap / swipe coordinates share one space: physical pixels of the current screen orientation, origin top left. A node's bounds of [581,1905,754,2100] can be tapped at its center, (667.5, 2002.5), and that point is the same pixel in the screenshot. Decimals are allowed.

screenshot reports scale (physical pixels per density-independent pixel) and logical size so a caller can convert to dp.

Accessibility tree first

To see the screen, read ui-tree before taking a screenshot. The tree is text a program can search, costs a few hundred to a few thousand tokens, and gives the bounds needed to tap. A screenshot is written to disk and returned as a path; the CLI never prints the image.

Output and errors

Every command prints exactly one line of JSON on stdout and exits, except mcp, which speaks JSON-RPC on stdout until stdin closes (MCP server).

  • Success: the command's result object, exit code 0.

  • Failure: an error object, exit code 1:

    {"error":{"code":"DEVICE_NOT_FOUND","message":"device 'nosuch' from --device matches no serial or device name. Listed: emulator-5554 (Medium_Phone_API_36.1)."}}

    The same message goes to stderr as karagoz: <message>. It can span more than one line when it quotes adb or Node output; the stdout line never does.

    INSTALL_FAILED and UNINSTALL_FAILED add a reason field with Android's own code for the failure:

    {"error":{"code":"INSTALL_FAILED","message":"adb: failed to install /Users/me/app-v1.apk: Failure [INSTALL_FAILED_VERSION_DOWNGRADE: Downgrade detected: Update version code 1 is older than current 2]","reason":"INSTALL_FAILED_VERSION_DOWNGRADE"}}

    No other code has it.

Branch on error.code, not on the message. Messages are for people and can change.

stderr also carries adb's own notices on success, such as * daemon started successfully when the adb server was not running. That first call takes about 3 s longer.

karagoz --version prints the version as plain text (0.0.0) and exits 0. It wins over any command: karagoz devices --version prints the version.

Arguments

  • Options can appear anywhere, before or after the command name. A repeated option keeps its last value.

  • There are no short flags. A value that starts with - is read as an option: --device=-x passes it as a value, and a positional that starts with - goes after --, with options before it: karagoz text --device emulator-5554 -- -5.

  • An empty value (--out=) is refused.

  • An option the command does not take is refused: 'devices' does not take the option '--device'.

All of these fail with INVALID_ARGS before any device is contacted.

Error codes

The set is closed. Any failure without a code of its own is reported as INTERNAL.

Code

Meaning

From

NO_COMMAND

No command given. The message lists the commands.

all

UNKNOWN_COMMAND

The command name is not known.

all

INVALID_ARGS

Missing, extra, malformed or unknown arguments, an unknown key name, an APK path that is not an existing .apk file, a screenshot --out that does not end in .png, a malformed package name, a --since that is not Unix time in seconds, or a --lines outside 1 to 999999999.

all

ADB_NOT_FOUND

No adb found. See Requirements.

all that reach adb, except doctor, which reports these in its output

ADB_TIMEOUT

adb did not answer in time: 10 s per call, 20 s for a ui-tree read, 10 s plus the duration for a long press or swipe, 10 s plus 1 s per started MB for install, 30 s for the start in launch. The message suggests adb kill-server; for ui-tree the cause is more often a stuck dump.

all that reach adb, except doctor, which reports these in its output

ADB_FAILED

adb exited with an error. The message is adb's stderr, or Node's error when adb printed nothing. For launch, terminate and uninstall it can also be the error the device command printed. For logs, logcat's own error text or output that is not whole log records. A device that disconnects mid-command ends here.

all that reach adb, except doctor, which reports these in its output

NO_DEVICE

No device connected and none named.

all but devices and doctor

DEVICE_NOT_FOUND

The named device is not connected.

all but devices and doctor

DEVICE_AMBIGUOUS

More than one device and none named, or a name that matches more than one.

all but devices and doctor

DEVICE_NOT_READY

The device is offline, unauthorized or similar.

all but devices and doctor

CAPTURE_FAILED

The screen could not be read: bad screenshot data, missing display info, or a uiautomator failure. The message says which.

screenshot, ui-tree, tap --text/--id

AUTOMATION_BUSY

Another UiAutomation client holds the device.

ui-tree, tap --text/--id

WRITE_FAILED

The screenshot file could not be written.

screenshot

TEXT_UNSUPPORTED

The text has a character Android's input text cannot type.

text

ELEMENT_NOT_FOUND

No node matches.

tap --text/--id

ELEMENT_AMBIGUOUS

More than one node matches.

tap --text/--id

ELEMENT_COVERED

The node's center is under the on-screen keyboard.

tap --text/--id

INSTALL_FAILED

Android refused the APK. reason is Android's code.

install

UNINSTALL_FAILED

Android refused to remove the app. reason is Android's code.

uninstall

APP_NOT_FOUND

The package is not installed.

launch, terminate, uninstall, logs

APP_NOT_LAUNCHABLE

The package is installed but has no activity a launcher can start.

launch

INTERNAL

A bug in karagoz. Please report it with the message.

all

Commands

Command

Does

devices

List connected devices

screenshot

Save a full-resolution PNG and return its path with scale metadata

ui-tree

Return the accessibility tree of the focused window

tap

Tap or long-press a point, or a node found by text or id

swipe

Swipe between two points

text

Type text into the focused field

key

Press a key

install

Install or replace an app from an APK

launch

Start an app as its launcher icon does

terminate

Stop every process of an app

uninstall

Remove an app

logs

Read the device log

doctor

Report which adb karagoz uses

mcp

Start the MCP server on stdio

devices

karagoz devices

Lists what adb devices lists. Takes no options.

Output

{"devices":[{"id":"emulator-5554","platform":"android","kind":"emulator","state":"device","name":"Medium_Phone_API_36.1"}]}

A phone on USB next to the emulator (serial masked):

{"devices":[{"id":"XXXXXXXXXXX","platform":"android","kind":"physical","state":"device","name":"SM-S908N"},{"id":"emulator-5554","platform":"android","kind":"emulator","state":"device","name":"Medium_Phone_API_36.1"}]}

Field

Type

Meaning

id

string

The adb serial. Pass it to --device.

platform

"android"

kind

"emulator" or "physical"

emulator when the id is emulator-<port>, or when the device reports ro.boot.qemu or ro.kernel.qemu as 1, or ro.hardware as ranchu or goldfish (an emulator attached with adb connect). Genymotion, and other ids not in state device, read as physical.

state

string

adb's state, unchanged: device, offline, unauthorized, ... Only device can be used.

name

string or null

The AVD name for emulators, the model (ro.product.model) for phones. null when the device does not answer, or when it is not in state device and its id is not emulator-<port>.

No devices is not an error: {"devices":[]}, exit 0. Devices keep adb's order.

Errors: INVALID_ARGS, ADB_NOT_FOUND, ADB_TIMEOUT, ADB_FAILED.

Notes

  • About 55 ms, plus about 50 ms per emulator for its name, asked in parallel. A device whose id is not emulator-<port> costs one more call in parallel, a chained getprop of about 135 ms. With a phone on USB and one emulator, the whole karagoz devices run took 257 to 289 ms (three runs, macOS).

  • A device listed as (no serial number), or two devices that share one serial, cannot be targeted: adb's -s cannot tell them apart.

  • karagoz does not pair or connect over Wi-Fi. Use adb pair and adb connect; a paired Android 11+ phone reconnects by itself.

  • If an emulator's name comes back null, check that HOME points to your home directory; the emulator console reads a token from there.

screenshot

karagoz screenshot [--out <path>] [--device <id>]

Saves the screen as a PNG at full device resolution, never scaled, and prints where it went with the metadata needed to measure against it.

Option

Default

Meaning

--out <path>

a new file under the temp directory

Where to write the PNG; must end in .png.

--device <id>

see Device selection

Output

{"path":"/Users/me/home.png","device":"emulator-5554","pixels":{"width":1080,"height":2400},"logical":{"width":411.42857142857144,"height":914.2857142857143},"scale":2.625,"safeArea":{"top":63,"right":0,"bottom":63,"left":0},"rotation":0}

Field

Type

Meaning

path

string

Absolute path of the PNG.

device

string

Serial of the device captured.

pixels

{width, height}

Size of the PNG, read from the file itself.

logical

{width, height}

pixels / scale, not rounded.

scale

number

Density / 160, using the override density if one is set (adb shell wm density).

safeArea

{top, right, bottom, left}

Pixels of this PNG covered by the status bar, navigation bar, caption bar or display cutout. A hidden bar counts as 0. The keyboard and gesture areas are not included.

rotation

0, 90, 180 or 270

Screen rotation in degrees.

File location

  • Without --out: <temp dir>/karagoz/<device id>-<UTC timestamp>.png, for example /var/folders/.../T/karagoz/emulator-5554-20260926T101530123Z.png. The temp directory follows TMPDIR. The karagoz directory is created private to your user (mode 0700) and must be a real directory you own; the file is created with mode 0600 and never overwrites. Characters other than letters, digits, ., _ and - in the device id become _.

  • With --out: the path must end in .png, in any case, or the command fails with INVALID_ARGS before any device call. Relative paths resolve against the current directory, missing parent directories are created, and an existing .png is overwritten.

  • karagoz never deletes screenshots. One 1080x2400 capture is about 1.4 MB.

Errors

  • CAPTURE_FAILED: screencap returned something other than a whole PNG (its message is included); the density, display size, rotation or insets could not be read (cannot read <value> from <command>); or the screen rotated or resized during the capture (display is WxH but the screenshot is WxH). With more than one display, screencap's warning comes back as CAPTURE_FAILED.

  • WRITE_FAILED: the file could not be written, or the default directory is not yours (pass --out). Two captures of one device in the same millisecond without --out: the second fails.

  • INVALID_ARGS: '<absolute path>' is not a .png file for an --out that does not end in .png, before any device call.

  • Plus the device selection errors, the other INVALID_ARGS cases and the adb errors.

Notes

  • About 1 s.

  • A screen that is off, or an app that sets FLAG_SECURE, gives a black PNG and exit 0.

  • If the density changes during a capture, scale and safeArea can disagree for that one capture.

ui-tree

karagoz ui-tree [--device <id>]

Returns the accessibility tree of the focused window, read with uiautomator dump.

Output (children cut short here; the real tree nests the Chrome icon several levels deep)

{"device":"emulator-5554","rotation":0,"root":{"class":"android.widget.FrameLayout","package":"com.google.android.apps.nexuslauncher","bounds":[0,0,1080,2400],"children":[...]}}

A node from the same tree:

{"class":"android.widget.TextView","text":"Chrome","contentDesc":"Chrome","clickable":true,"longClickable":true,"focusable":true,"bounds":[581,1905,754,2100]}

Field

Present

Meaning

device

always

Serial.

rotation

always

0, 90, 180 or 270.

root

always

The top node.

Node fields, in this order:

Field

Present

Meaning

class

always

Android class, e.g. android.widget.Button. Can be empty.

package

on the root, and on any node whose package differs from its parent's

App package.

text, contentDesc, resourceId, hint

when not empty

resourceId is the full form, com.example:id/save. hint exists only from API 36.

checkable, checked, clickable, longClickable, focusable, focused, scrollable, selected, password

only when true

enabled

only when false

bounds

always

[left, top, right, bottom] in screen pixels. Can be negative for nodes partly off screen.

children

when the node has any

Nodes, same shape.

Other attributes that uiautomator prints (index, drawing-order, NAF) are dropped. Every node uiautomator returns is kept; there is no filtering.

What the tree covers is what uiautomator covers: the focused window, and nodes visible to the user. Where a framework puts its labels differs: Jetpack Compose puts text in text, Flutter puts it in contentDesc.

WebView: a WebView's content often arrives only on the second read. When the tree has a WebView with no children, karagoz reads once more and returns the second tree, or the first if the second read fails.

Errors

  • CAPTURE_FAILED, with the reason in the message:

    • the screen kept changing for uiautomator's 10 s idle wait (an animation or live content);

    • no focused window (the screen is off, or an app is still starting);

    • uiautomator was killed (the message names adb -s <id> logcat -b crash); a lone UTF-16 surrogate in on-screen text does this;

    • the output could not be parsed.

  • AUTOMATION_BUSY: another UiAutomation client is connected. Only one can be at a time.

  • ADB_TIMEOUT after 20 s.

  • Plus the device selection errors, INVALID_ARGS and the adb errors.

Notes

  • One read takes 2.4 to 3.3 s; a screen with a fresh WebView about 5.3 s. The idle failure arrives after about 12 s.

  • Output size on real screens was 560 to 3,900 tokens.

  • While a read runs, accessibility services such as TalkBack are unbound, and apps see accessibility as enabled.

tap

karagoz tap <x> <y> [--duration <ms>] [--device <id>]
karagoz tap --text <label> [--timeout <ms>] [--duration <ms>] [--device <id>]
karagoz tap --id <resource-id> [--timeout <ms>] [--duration <ms>] [--device <id>]

Taps a point, or finds one node in the tree and taps its center. Give exactly one of <x> <y>, --text or --id.

Option

Default

Meaning

<x> <y>

A point in screen pixels. Non-negative, decimals allowed (12, 12.5).

--text <label>

A node whose text or contentDesc equals the label: whole string, case-sensitive, no trimming.

--id <resource-id>

A node whose resourceId equals the value, or ends with :id/<value>. --id save matches com.example:id/save.

--duration <ms>

none

Hold for this long (a long press). Whole milliseconds, 0 to 999999999.

--timeout <ms>

0

Keep reading the tree until a node matches or this much time has passed. Only with --text or --id.

--device <id>

see Device selection

Output

Point:

{"device":"emulator-5554","x":540,"y":1200}

With --duration 10 a "duration":10 field follows y. Node:

{"device":"emulator-5554","x":667.5,"y":2002.5,"element":{"class":"android.widget.TextView","text":"Chrome","contentDesc":"Chrome","clickable":true,"longClickable":true,"focusable":true,"bounds":[581,1905,754,2100]}}

element is the matched node as ui-tree returns it, without children. x and y are its center.

How a node is found

  • The tap goes to the center of the node that matched, not to a clickable parent. Measured on View, Compose and Flutter test apps, the center of the matched node receives the click.

  • A parent and a child that both match are two matches: ELEMENT_AMBIGUOUS. Pick one and tap its coordinates.

  • Nodes with zero-size bounds never match.

  • If the keyboard is visible and the node's center is inside it, the tap is refused with ELEMENT_COVERED. Close the keyboard with karagoz key BACK. Only the keyboard is checked; bubbles and picture-in-picture windows are not.

  • With --timeout, only "no match" triggers another read, and it starts right away. The last read can end up to one read (about 3 s) past the timeout. The tap itself is never repeated.

  • The screen can change between the read and the tap; the tap then lands on the old point.

  • A long press with --duration is sent as a swipe that does not move. Long-press thresholds measured: 400 ms on View and Compose, 500 ms on Flutter.

Errors

  • INVALID_ARGS: no target or more than one ('tap' takes <x> <y>, --text or --id), a single coordinate ('tap' needs <x> <y>), --timeout with a point, or a malformed number.

  • ELEMENT_NOT_FOUND: no node with text or contentDesc 'Save' in com.example (1 read in 2.6 s).

  • ELEMENT_AMBIGUOUS: lists up to five matches with class and bounds.

  • ELEMENT_COVERED: the node is under the keyboard.

  • Node taps also get every ui-tree error. A point tap does not read the tree, so it works while another UiAutomation client is connected.

  • Plus the device selection errors and the adb errors.

Notes

  • A point tap takes about 0.2 s. A node tap adds one tree read, 2.5 to 3.3 s.

  • Points are not checked against the screen size.

  • Exit 0 means Android accepted the event, not that the app reacted to it. Read the tree again to check.

swipe

karagoz swipe <x1> <y1> <x2> <y2> [--duration <ms>] [--device <id>]

Moves one finger from (x1, y1) to (x2, y2).

Option

Default

Meaning

<x1> <y1> <x2> <y2>

Screen pixels, same rules as tap.

--duration <ms>

300

Time from start to end. Whole milliseconds, 0 to 999999999.

--device <id>

see Device selection

Output

{"device":"emulator-5554","x1":540,"y1":1800,"x2":540,"y2":600,"duration":300}

duration is always present, including the default.

Duration matters. A fast swipe flings a list and a slow one drags it. Measured on a 1200 px drag: 300 ms scrolled a further 1059 px after the finger lifted, 1000 ms scrolled 131 px further, 3000 ms 19 px.

Errors: INVALID_ARGS, the device selection errors and the adb errors.

text

karagoz text <text> [--device <id>]
karagoz text [--device <id>] -- <text starting with ->

Types into whatever has focus, as Android's input text does.

Output

{"device":"emulator-5554","text":"hello"}

Characters. Printable ASCII, newline (sent as Enter), tab (sent as Tab), ç, Ç and ß. Anything else fails before anything is typed:

{"error":{"code":"TEXT_UNSUPPORTED","message":"cannot type 'ü' (U+00FC): Android's input text types only printable ASCII, newline, tab, ç, Ç and ß; nothing was typed"}}

Quotes, spaces and %s are typed as they are; karagoz handles the escaping.

Errors

  • INVALID_ARGS: empty text.

  • TEXT_UNSUPPORTED: see above.

  • Long text is sent in pieces of up to 100 characters. If a piece fails, the error keeps its code and the message ends with ; <n> of <total> characters were typed before this.

  • Plus the device selection errors and the adb errors.

Notes

  • Characters typed right after a field appears can be lost. Waiting about 2 s after the field shows up fixed it in testing.

  • 100 characters take about 0.9 s.

key

karagoz key <key> [--device <id>]

Presses one key.

<key> is an Android KeyEvent name or code:

  • A name, case-insensitive, with or without KEYCODE_: HOME, back, KEYCODE_ENTER, VOLUME_UP.

  • A number from 1 to 340 is a key code: key 3 is HOME. To press the digit 7, use KEYCODE_7, because key 7 is code 7, which is KEYCODE_0.

Output

{"device":"emulator-5554","key":"KEYCODE_HOME","code":3}

key is the resolved name and code its number.

Errors

  • INVALID_ARGS: unknown key 'FOO'; use a KeyEvent name such as HOME, BACK or ENTER, or a code from 1 to 340. Checked before any device call.

  • Plus the device selection errors and the adb errors.

Notes

  • Codes 338 to 340 exist in the name table but Android 16 sends them as KEYCODE_UNKNOWN, still with exit 0.

  • Some keys act on the whole device: code 312 opens Recents, 318 saves a screenshot.

  • Exit 0 means Android accepted the key, not that the app reacted to it.

install

karagoz install <apk> [--device <id>]

Installs an app from one APK file, or replaces the installed version of the same app.

Option

Default

Meaning

<apk>

Path to an .apk file. A relative path is resolved against the current directory.

--device <id>

see Device selection

Output

{"device":"emulator-5554","path":"/Users/me/app/build/outputs/apk/debug/app-debug.apk"}

path is the absolute path of the APK, as given (symlinks are not followed). The package name is not reported.

Errors

  • INVALID_ARGS: '<path>' is not an .apk file, no file at '<path>' or '<path>' is not a file. Checked before any device call.

  • INSTALL_FAILED: Android refused the APK. The message is adb's, and reason is Android's code, as in the example above. Codes seen on the emulator: INSTALL_FAILED_VERSION_DOWNGRADE (a lower versionCode than the installed app), INSTALL_FAILED_UPDATE_INCOMPATIBLE (signed with another key), INSTALL_PARSE_FAILED_NOT_APK, INSTALL_FAILED_DEPRECATED_SDK_VERSION (targetSdkVersion below 24 on Android 16).

  • A failure without an Android code, such as Error: device is still booting., stays ADB_FAILED.

  • ADB_TIMEOUT after 10 s plus 1 s for every started MB of the APK.

  • Plus the device selection errors and the adb errors.

Notes

  • Runs adb install -r --no-incremental. Since Android 9 a reinstall replaces the app without -r; it stays for older devices. --no-incremental matters when an .idsig file sits next to the APK (apksigner writes one by default): adb would then install incrementally, through a background adb inc-server process.

  • One .apk only. Split APKs, .apks and .aab are not supported. There is no way to pass -g (grant runtime permissions), -d (allow a downgrade) or -t: an APK marked testOnly, which Android Studio's Run button can produce, fails with INSTALL_FAILED_TEST_ONLY.

  • An 8.5 KB APK took 0.7 s, a 100 MB one 2.5 to 3.4 s.

launch

karagoz launch <package> [--device <id>]

Starts an app the way tapping its launcher icon does.

Option

Default

Meaning

<package>

The package name, such as com.android.settings.

--device <id>

see Device selection

Output

{"device":"emulator-5554","package":"com.android.settings","activity":"com.android.settings/.homepage.SettingsHomepageActivity"}

activity is the activity Android reports as started, in its package/.Class short form, or null when Android names none. It is not always the launcher activity:

  • Settings' launcher activity .Settings hands off to .homepage.SettingsHomepageActivity, and that one is reported.

  • If the app's task is already running, it comes to the front as it is, and its top activity is reported, even one from another package. With the Settings search open, launch com.android.settings reports com.google.android.settings.intelligence/.modules.search.activity.SearchActivity.

Which activity is started. Android's own rule for launch intents: the first activity with the MAIN action and the INFO category, otherwise the first with MAIN and LAUNCHER. With two launcher activities the first one Android lists is started; no chooser appears.

Errors

  • INVALID_ARGS: '<value>' is not a package name. A package name is letters, digits and _, in dot-separated parts that each start with a letter. Checked before any device call.

  • APP_NOT_FOUND: package 'dev.karagoz.nope' is not installed on emulator-5554.

  • APP_NOT_LAUNCHABLE: package 'com.android.shell' has no launcher activity.

  • ADB_FAILED: Android refused the start. The message is Android's Error: line, such as Error: Activity class {com.example/com.example.Main} does not exist.

  • ADB_TIMEOUT after 30 s. Android itself gives up after 10 s for a process to start and 10 s for the activity to settle.

  • Plus the device selection errors and the adb errors.

Notes

  • Exit 0 means Android started the activity, not that the app is up. An app that crashes at start, and a start with the screen off, also exit 0. Read the tree to check.

  • Nothing is restarted or cleared. For a fresh start, run terminate first.

  • A cold start of a small app took 1.7 to 2.0 s.

terminate

karagoz terminate <package> [--device <id>]

Stops an app with am force-stop: every process of the package is killed before the command returns, and Android removes its alarms, scheduled jobs and notifications.

Option

Default

Meaning

<package>

The package name.

--device <id>

see Device selection

Output

{"device":"emulator-5554","package":"com.android.settings"}

An app that is installed but not running gives the same output.

Errors

  • INVALID_ARGS: '<value>' is not a package name, as for launch.

  • APP_NOT_FOUND: the package is not installed. am force-stop alone says nothing in that case, so karagoz checks first.

  • ADB_FAILED: am force-stop printed an error; the message is that error.

  • Plus the device selection errors and the adb errors.

Notes

  • Only the package's own processes stop. An activity from another package in the same task stays on screen: after terminate com.android.settings with the Settings search open, the search is still in front.

  • Took 0.8 to 1.3 s.

uninstall

karagoz uninstall <package> [--device <id>]

Removes an app and its data.

Option

Default

Meaning

<package>

The package name.

--device <id>

see Device selection

Output

{"device":"emulator-5554","package":"dev.karagoz.smoke"}

Errors

  • INVALID_ARGS: '<value>' is not a package name, as for launch.

  • APP_NOT_FOUND: the package is not installed. Checked first, because Android answers a missing package with DELETE_FAILED_INTERNAL_ERROR, the same code it gives for a package it will not remove.

  • UNINSTALL_FAILED: Android refused. The message is Android's Failure [...] line and reason its code, such as DELETE_FAILED_INTERNAL_ERROR for a system app with no updates.

  • Plus the device selection errors and the adb errors.

Notes

  • A system app with installed updates goes back to its factory version. Android reports that as success, and so does karagoz.

  • The app's data is always removed; adb uninstall -k (keep data) is not offered.

  • Took 0.6 to 0.8 s.

logs

karagoz logs [--package <package>] [--since <seconds>] [--lines <n>] [--device <id>]

Reads the device log once and prints the newest records as JSON. Each record is whole: a stack trace is one record with newlines in its message, not one entry per line.

Option

Default

Meaning

--package <package>

all records

Keep only the records written under this package's Linux user id (uid).

--since <seconds>

the whole log

Unix time in seconds on the device clock, up to 9 decimals and at most 4294967295. Only records stamped later are read.

--lines <n>

30

How many of the newest matching records to return, 1 to 999999999.

--device <id>

see Device selection

Output

{"device":"emulator-5554","package":"com.android.shell","uid":2000,"records":[{"time":1790521762.474251,"pid":24931,"tid":24931,"level":"I","tag":"KaragozManual","message":"hello from karagoz"}],"omitted":0}

That record was written with adb shell log -t KaragozManual "hello from karagoz"; adb shell runs as com.android.shell.

Field

Type

Meaning

device

string

Serial of the device read.

package

string

Only with --package: the package as given.

uid

number

Only with --package: the uid the records were matched on. com.android.settings has 1000, which it shares with the system server.

records

array

The matching records, at most --lines, oldest first in the order the device's log daemon received them.

records[].time

number

When the record was written: seconds since the Unix epoch on the device clock, to the microsecond, rounded up.

records[].pid, records[].tid

number

Process and thread that wrote it.

records[].level

string

V, D, I, W, E or F; ? for any other priority.

records[].tag

string

The log tag.

records[].message

string

The message, newlines kept.

omitted

number

How many older matching records --lines left out. 0 when records holds every match.

An empty records with omitted 0 means nothing matched. It is not an error.

Reading since your last call. Pass the largest time of one result as the next --since to get only newer records. The rounding up makes this exact: no record comes back in the next call. After the call above:

karagoz logs --package com.android.shell --since 1790521762.474251
{"device":"emulator-5554","package":"com.android.shell","uid":2000,"records":[],"omitted":0}

Take the largest time, not the last record's: records are in arrival order, and times can step back by a few milliseconds.

Errors

  • INVALID_ARGS: '<value>' is not a package name, as for launch; --since must be Unix time in seconds (got '<value>'); --lines must be a whole number from 1 to 999999999 (got '<value>'). Checked before any device call.

  • APP_NOT_FOUND: package 'dev.karagoz.missing' is not installed on emulator-5554.

  • ADB_FAILED: logcat printed its own error instead of records, such as Failed to wait for logd.ready to become true. logd not running?; the output was not whole log records; or pm list packages printed an error while karagoz looked up the uid. The message is that output, cut at 300 characters.

  • ADB_TIMEOUT after 10 s.

  • Plus the device selection errors and the adb errors.

Notes

  • Logs read: main, system and crash, plus kernel from Android 11, logcat's defaults. The events log is not read.

  • The log is never cleared, resized or reconfigured, so other tools and the user keep their history. A call reads what is there and exits; nothing streams. To wait for a line, call again with --since.

  • The whole window is read from the device and filtered on the host, because logcat counts records before it filters by uid. A full log on the test emulator was 26 MB, about 133,000 records: logs --lines 1 took 0.7 s, 0.8 s with --package. The default 30 records come to about 7 KB of JSON on average; over every 30-record window of a 141,000-record log, the largest was 47 KB.

  • --package matches the uid, not a process: every process of the app, every restart, and its Java and native crash lines. Lines the system server writes about the app, such as Start proc and ANR in, have uid 1000 and are not included. A package that shares a system uid, such as Settings, gets the other processes of that uid as well.

  • A record stamped at or before --since that the log daemon receives after the previous read is returned by neither call. Records arrived up to 8.4 ms late on the test emulator.

  • --since is on the device clock. The emulator keeps it in step with the host (within 55 ms here), so a host timestamp works there too.

  • Android cuts a record's tag and message at 4068 bytes together when it is written. Bytes that are not valid UTF-8 become U+FFFD.

  • From Android 15 the device ends a read after 5 s without data and still exits 0, so a read cut short looks complete.

doctor

karagoz doctor

Reports which adb karagoz uses, its version, every other adb it could use, and how to install platform-tools when adb is missing or too old. It changes nothing. No options.

Output

{"adb":{"status":"ok","source":"PATH","path":"/opt/homebrew/bin/adb","version":"37.0.0-14910828","candidates":[{"source":"ANDROID_HOME","status":"unset"},{"source":"ANDROID_SDK_ROOT","status":"unset"},{"source":"PATH","status":"ok","path":"/opt/homebrew/bin/adb","version":"37.0.0-14910828"},{"source":"default","status":"ok","path":"/Users/me/Library/Android/sdk/platform-tools/adb","version":"36.0.2-14143358"}]}}

Field

Type

Meaning

adb.status

string

ok, outdated or failed: the status of the candidate karagoz uses. missing: no candidate can be used.

adb.source, adb.path, adb.version, adb.message

string

Copied from the candidate karagoz uses, when it has them.

adb.install

string

Only when status is not ok: how to install platform-tools on this OS, the same text ADB_NOT_FOUND prints.

adb.candidates

array

The four places karagoz looks, in lookup order (see Requirements).

candidates[].source

string

ANDROID_HOME, ANDROID_SDK_ROOT, PATH, or default for Android Studio's default SDK.

candidates[].status

string

See below.

candidates[].path

string

When known: the file karagoz would run. For PATH, the path adb prints for itself in adb version, so it is absent when adb printed none; on Windows, the adb.exe found on PATH.

candidates[].version

string

With ok and outdated: the Version line of adb version, as printed.

candidates[].message

string

With failed and outdated: why, cut at 300 characters.

Status

Means

ok

adb version exited 0 and reported platform-tools 30.0.0 or newer.

outdated

adb version exited 0 and reported a version below 30.0.0.

failed

Something is there but cannot be used: it did not start, exited non-zero, did not answer within 10 s, or printed no Version line. The message says which.

missing

Nothing at that location. For PATH: no adb on PATH, or on macOS and Linux only ones that fail to start with ENOENT (see Notes).

unset

No location to check: the variable is unset or empty. For default: Windows without LOCALAPPDATA.

The exit code is 0 whenever the report is printed, with adb missing or broken as well. Check adb.status.

Errors: INVALID_ARGS for any argument or option ('doctor' does not take the option '--device'), INTERNAL.

Notes

  • Only adb version runs, on every candidate at once. It never connects to the adb server, so server trouble does not show up here; the other commands report it as ADB_TIMEOUT.

  • Nothing is installed.

  • outdated means below platform-tools 30.0.0, where install fails: it passes --no-incremental, which older versions forward to the device, and the device rejects it. Debian 12 ships 29.0.6. The other commands still work.

  • version keeps the builder's suffix: digits from Google, -debian, -android-tools. Debian's 8.1.0 package prints its package version, 1:8.1.0+r23-8, and is outdated. 1.0.41 in Android Debug Bridge version 1.0.41 is adb's protocol number and is not shown.

  • For PATH, path is the symlink as invoked on macOS (/opt/homebrew/bin/adb) and the resolved file on Linux.

  • A candidate that is there but fails to start with ENOENT (a script whose interpreter is missing, a broken link) is shown failed, and karagoz moves on to the next one, as every command does. Any other failure stops the lookup at that candidate. The exception is PATH on macOS and Linux: adb runs by name there, the error does not say which file failed, and the candidate is shown missing.

  • An invalid ANDROID_ADB_SERVER_PORT makes adb version itself fail, so every adb found is failed with adb's message: adb: $ANDROID_ADB_SERVER_PORT must be a positive number less than 65535: got "abc".

  • Took 0.1 s with two adbs; 1.4 s on a cold first run. A candidate that hangs costs 10 s, and the others run meanwhile.

  • Windows and Linux were not run.

MCP server

karagoz mcp

Starts an MCP server for an AI agent on stdin and stdout. It opens no port: the client starts the process and talks to it over stdio. The 13 commands are its tools, and each tool returns the JSON line the CLI prints. The server code loads only when mcp runs, so the other commands start as fast as before.

mcp takes no arguments or options. It exits when stdin closes, or on SIGINT or SIGTERM.

Registration

karagoz is not on npm yet, so every example runs the built file by its absolute path. After the first release, npx -y karagoz mcp replaces node /abs/path/karagoz/dist/cli.js mcp.

Claude Code and Codex were run against this server. The Claude Desktop, Cursor and VS Code entries follow their documentation and were not run.

Claude Code

claude mcp add karagoz -- node /abs/path/karagoz/dist/cli.js mcp

The server starts in the directory claude started in, with the shell's environment. A call still running after 2 minutes becomes a background task.

Claude Desktop, in ~/Library/Application Support/Claude/claude_desktop_config.json on macOS or %APPDATA%\Claude\claude_desktop_config.json on Windows; quit and restart Desktop after editing:

{"mcpServers":{"karagoz":{"command":"node","args":["/abs/path/karagoz/dist/cli.js","mcp"],"env":{"ANDROID_HOME":"/Users/me/Library/Android/sdk"}}}}

Desktop starts servers with part of your environment and possibly / as the working directory, so set ANDROID_HOME in env when adb is not on the PATH it passes, and use absolute paths.

Cursor, in .cursor/mcp.json in the project or ~/.cursor/mcp.json:

{"mcpServers":{"karagoz":{"type":"stdio","command":"node","args":["/abs/path/karagoz/dist/cli.js","mcp"]}}}

VS Code, in .vscode/mcp.json in the workspace:

{"servers":{"karagoz":{"type":"stdio","command":"node","args":["/abs/path/karagoz/dist/cli.js","mcp"]}}}

or from the command line: code --add-mcp '{"name":"karagoz","command":"node","args":["/abs/path/karagoz/dist/cli.js","mcp"]}'.

Codex, in ~/.codex/config.toml:

[mcp_servers.karagoz]
command = "node"
args = ["/abs/path/karagoz/dist/cli.js", "mcp"]
env_vars = ["ANDROID_HOME", "ANDROID_SDK_ROOT", "ANDROID_SERIAL"]
tool_timeout_sec = 600

Codex passes a server only a short list of environment variables; env_vars forwards the ones karagoz reads. tool_timeout_sec covers long tap waits and big installs. install and uninstall ask for approval; codex exec, which never asks, refuses them with MCP tool call requires approval, but approval policy is never.

Tools

Tool

Command

Arguments

Marked

devices

devices

none

read-only

screenshot

screenshot

out, inline, device

ui_tree

ui-tree

device

read-only

tap

tap

x, y, text, id, duration, timeout, device

swipe

swipe

x1, y1, x2, y2 (required), duration, device

text

text

text (required), device

key

key

key (required), device

install

install

apk (required), device

destructive

launch

launch

package (required), device

terminate

terminate

package (required), device

uninstall

uninstall

package (required), device

destructive

logs

logs

package, since, lines, device

read-only

doctor

doctor

none

read-only

"Marked" is the tool's annotation: readOnlyHint or destructiveHint. The other seven carry destructiveHint: false, and all 13 carry openWorldHint: false and a title. Clients use these to decide what needs approval and what may run in parallel.

Arguments

  • The names are the CLI's option and positional names: tap {"x": 540, "y": 1200} is karagoz tap 540 1200, and logs {"lines": 5} is karagoz logs --lines 5. ui_tree is the one renamed tool, because Codex turns - into _.

  • Values go through the CLI's own checks, as the text of the value (12.5 is 12.5), and the messages keep CLI syntax: tap {"x": 1, "y": 2, "duration": 1.5} fails with --duration must be a whole number of milliseconds (got '1.5').

  • null counts as not given. An argument the tool does not take is refused: 'devices' does not take the option '--foo'.

  • inline exists only here: true or false, else INVALID_ARGS inline must be true or false (got 'yes').

  • out and apk should be absolute. A relative path resolves against the server's working directory, which the client picks.

  • device picks one of several devices. Without it the server uses ANDROID_SERIAL from its own environment, then the only device (Device selection). Codex passes ANDROID_SERIAL only through env_vars.

Results

  • Success: one text block with the command's JSON line.

  • Failure: isError: true and one text block with the CLI's error envelope. karagoz: <message> goes to stderr, as in the CLI.

  • An unknown tool name is a JSON-RPC error, -32602 Unknown tool: <name>.

  • screenshot with inline: true adds the full-resolution PNG as an image block. Clients scale it before the model sees it (Claude Code sent a 1080x2400 capture as a JPEG), so take coordinates from ui_tree bounds, or from pixels and scale, never from the image.

A real exchange, one line per message, --> sent and <-- received. The data string is cut.

--> {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"devices","arguments":{}}}
<-- {"result":{"content":[{"type":"text","text":"{\"devices\":[{\"id\":\"emulator-5554\",\"platform\":\"android\",\"kind\":\"emulator\",\"state\":\"device\",\"name\":\"Medium_Phone_API_36.1\"}]}"}]},"jsonrpc":"2.0","id":2}
--> {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"tap","arguments":{"x":1}}}
<-- {"result":{"content":[{"type":"text","text":"{\"error\":{\"code\":\"INVALID_ARGS\",\"message\":\"'tap' needs <x> <y>\"}}"}],"isError":true},"jsonrpc":"2.0","id":3}
--> {"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"screenshot","arguments":{"out":"/Users/me/home.png","inline":true}}}
<-- {"result":{"content":[{"type":"text","text":"{\"path\":\"/Users/me/home.png\",\"device\":\"emulator-5554\",\"pixels\":{\"width\":1080,\"height\":2400},\"logical\":{\"width\":411.42857142857144,\"height\":914.2857142857143},\"scale\":2.625,\"safeArea\":{\"top\":63,\"right\":0,\"bottom\":63,\"left\":0},\"rotation\":0}"},{"type":"image","data":"iVBORw0KGgoAAAANSUhEUgAABDgAAAlgCAYAAABt...","mimeType":"image/png"}]},"jsonrpc":"2.0","id":4}
--> {"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"ui-tree","arguments":{}}}
<-- {"jsonrpc":"2.0","id":5,"error":{"code":-32602,"message":"Unknown tool: ui-tree"}}

Behavior

  • Calls run in parallel, except UiAutomation reads (ui_tree, and tap with text or id), which take turns per device inside one server. Another process that holds UiAutomation still causes AUTOMATION_BUSY.

  • A cancelled call kills its adb child at once and gets no response. The device does not stop with it: it keeps its UiAutomation slot for about 1-2 s, so a ui_tree right after can get AUTOMATION_BUSY; a gesture already sent finishes on the device; and a cancelled install may leave the app installed. Esc in Claude Code cancels the running call: the adb dump was gone within 0.3 s and the server kept running.

  • stdin closing, SIGINT and SIGTERM cancel every call the same way; the process exits with 0, 130 and 143.

  • The server keeps the adb it found for the whole session. If that file disappears (ENOENT), the next call looks it up again.

Token cost

What the server adds to a Claude Code session, measured on this build on 2026-09-29 with Claude Code 2.1.284 and claude-opus-5-5: input tokens of the first API call with karagoz registered, minus the same call with no MCP server.

Case

Tokens

Every session, tool search on (the default): the 13 tool names and instructions

331

All 13 definitions, loaded by one ToolSearch call

2,247

Every session, tool search off (ENABLE_TOOL_SEARCH=false): every definition and instructions

2,129

The tools/list result is 5,682 characters and instructions 383. One ui_tree of the launcher home screen is 5,383 characters.

Scope

karagoz is the primitive layer. Each command does one thing and exits.

  • Not a test framework. No assertions, no test runner, no recorded flows, no retry policy, no reports. tap --timeout waits for a node to appear; it never repeats an action.

  • Not a design comparison tool. Figma diffing, measurement and reporting belong a layer above. karagoz produces the raw input, and the metadata to measure it with, but does not interpret it.

Development

npm run build       # empty dist/, then bundle to dist/cli.js and two chunks
npm run typecheck
npm run lint        # oxlint and the Prettier check; npm run format fixes formatting

Each step has one smoke script in smoke/. Each builds first and needs a running emulator, smoke/M-mcp.sh included, except smoke/0b-version.sh and smoke/1.7-doctor.sh: 1.7 uses fake adb scripts only and never runs the real adb. The header of each script lists its preconditions:

sh smoke/1.4-input.sh

smoke/1.5-app-lifecycle.sh also builds a test APK on every run, from the manifest in smoke/fixtures/app-lifecycle/, and installs and removes it as dev.karagoz.smoke. No APK is kept in the repository. It needs a JDK (java and keytool on PATH) and, from the Android SDK, build-tools with aapt2 and apksigner plus one platform. The SDK is the first of $ANDROID_HOME, $ANDROID_SDK_ROOT, ~/Library/Android/sdk and ~/Android/Sdk that has both build-tools/ and platforms/. When something is missing the smoke fails and says what, rather than skipping.

The full check, with the emulator running:

npm run typecheck && npm run lint && for s in smoke/*.sh; do sh "$s" || exit 1; done

Name

Karagöz is Turkish shadow puppet theatre. Figures are driven with rods from behind a lit screen while the audience watches the movement on the screen. That is what this tool does.

License

MIT

Related MCP Connectors

Related MCP Servers

  • F
    license
    A
    quality
    D
    maintenance
    Enables AI agents to interact with Android devices through UI manipulation, screen capture, touch gestures, text input, and app management via ADB. Provides comprehensive mobile automation capabilities including element detection, navigation, and application control for Android device testing and interaction.
    9
    4
    -
  • A
    license
    B
    quality
    B
    maintenance
    Enables AI agents to control Android devices and emulators through direct UI interaction, allowing app navigation, automated testing, and real-world task execution via ADB without computer vision or scripts.
    18
    2
    MIT
  • A
    license
    A
    quality
    B
    maintenance
    Enables AI assistants to control mobile and desktop devices with natural language, including running automation tasks, taking screenshots, and managing devices.
    6
    5 npm
    MIT
  • A
    license
    A
    quality
    C
    maintenance
    Enables AI agents to control, inspect, and automate Android devices, Waydroid containers, and AVD emulators over ADB. Provides tools for screenshots, UI-hierarchy text-based tapping, gestures, key presses, text input, app management, and raw shell commands.
    12
    MIT