Skip to main content
Back to Blog

Understand Appium 3 Drivers, Plugins, and Boundaries

A global Appium install is not an environment: drivers, plugins, the device-side servers they ship and the platform toolchain all version separately. This guide covers what Appium 3 changed for setups (Node 20.19, mandatory insecure-feature prefixes, session discovery), why a project package.json with a lockfile beats APPIUM_HOME alone, why installed plugins stay inactive until named, a loopback-bound .appiumrc.json with scoped allow and deny lists, per-platform capability files, and a CI gate that parses appium driver list --installed --json and runs driver doctor.

Shashank Rawlani

Engineering Leader | Builder | Problem Solver | AI Engineer

Published Oct 13, 2026Updated Oct 14, 202615 min read
View AI-ready Markdown
Dark technical illustration read left to right. Near the left, five orange pills float at uneven heights, each linked by a faint dashed line to a dashed orange outline of itself sitting at a different height. An orange curve runs from each pill into one row of a tall green panel in the middle, where every row has a ringed green dot pierced by a short vertical green bar, followed by a green pill. Five green curves leave the panel and fan into a dashed green boundary on the right that holds a stack of five green layered panels, each a little narrower than the one above, each with a pill and a small dot. A dashed orange path climbs out of the top of the boundary and ends at a crossed-out orange ring; a green curve leaves the middle panel of the stack, crosses the boundary and ends in an arrowhead at a large ringed green dot. A grey rail enters from the left edge and stops at a small grey ringed dot. Grey rules and faint pills run along the top and bottom edges.
Quick answer: An Appium environment is five separately versioned things: the server, each driver, each plugin, the platform toolchain the drivers call (Xcode, the Android SDK, a JDK) and the capabilities your tests send. Pin the first three with exact versions in a project package.json and a lockfile, activate plugins by name, put the listen address and every insecure feature in .appiumrc.json with a driver prefix, keep capabilities per platform in version control, and make CI fail when appium driver list --installed --json disagrees with what the repository declares.

A mobile suite that passed on Friday fails on Monday on a CI agent that was rebuilt over the weekend. Nobody touched the tests. The agent's setup script ran npm install -g appium and appium driver install uiautomator2, both of which resolve to whatever is newest on the day they run, and the driver that came back carries a different on-device server from the one on every laptop in the team. Nothing in the repository records which versions produced the green run, so there is nothing to diff.

That is the default outcome of Appium's extension model, and it is a consequence of a design decision rather than a bug. The server knows how to speak WebDriver and how to load extensions. It does not know how to automate anything until a driver is installed, and the docs say so directly: without at least one driver, "Appium won't know how to automate anything." Everything that touches a device lives in packages the server does not bring with it.

Appium 3 is the current major. Versions checked on the npm registry on 2026-09-30: appium 3.8.0 (3.0.0 was published on 2025-08-18; the last Appium 2 release is 2.19.0), appium-uiautomator2-driver 8.7.0, appium-xcuitest-driver 12.13.3. Appium 3 requires Node.js in the range ^20.19.0 || ^22.12.0 || >=24.0.0 and npm 10 or newer. The registry's beta tag already points at a 4.0.0 pre-release, which is one more reason to install by exact version rather than by tag or range.

What a bare Appium install leaves undecided

The Appium docs describe what sits between a test and an iPhone when the XCUITest driver is in play: your test code, the Appium client, the Selenium client, the network, the Appium server, the XCUITest driver, WebDriverAgent, Xcode, XCUITest, iOS and macOS. Eleven layers, and the server is only one of them.

Two of those layers hide a pin you already have. The XCUITest driver is split in two: a Node.js half that runs inside Appium, and WebDriverAgent, an Objective-C WebDriver server that the driver "builds and manages" on the device. UiAutomator2 has the same shape, with most commands proxied to an on-device UiAutomator2 server. Neither device-side component is something you install yourself. Each driver release depends on an exact version of it:

$ npm view [email protected] dependencies.appium-uiautomator2-server
10.6.6
$ npm view [email protected] dependencies.appium-webdriveragent
16.12.11

So the driver version is also the version of the code running on the phone. On 2026-09-30 the newest appium-webdriveragent on npm was 16.13.5, yet XCUITest 12.13.3 ships 16.12.11. A team that "keeps WebDriverAgent current" separately from the driver is running a combination the driver was never released with.

The driver also decides which server major you can run. The UiAutomator2 README states that "since major version 5.0.0, this driver is only compatible with Appium 3", and the XCUITest compatibility table maps driver 10.0.0 and newer to Appium 3 and 4.0.0 through 9.10.5 to Appium 2. Unpinned, a driver install on an Appium 2 host is a request for a driver that no longer targets that host.

The Appium 3 changes your setup will hit

The migration guide calls Appium 3 "a smaller upgrade with fewer breaking changes" than Appium 2 was. Three of them land on environment definition rather than test code.

The Node floor moved from 14.17.0 to 20.19.0. A CI image that has been quietly working on Node 18 stops starting the server.

Insecure feature names now need a scope prefix. Appium 2.13 introduced the prefix as optional; Appium 3 "makes the scope prefix mandatory, and will throw an error if features are specified without a scope." Started with an Appium 2 style flag, Appium 3.8.0 refuses to boot:

$ npx appium --allow-insecure=adb_shell
Error: The full feature name must include both the destination automation name or
the '*' wildcard to apply the feature to all installed drivers, and the feature name
split by a colon. Got 'adb_shell' instead

Session discovery moved behind a flag. GET /sessions is gone; its replacement, GET /appium/sessions, only answers when the server was started with the session_discovery feature, and server-scope features like that one "also require the wildcard prefix". Appium Inspector's attach-to-session needs version 2025.3.1 or later for the new endpoint.

The fourth change bites old clients rather than old setups: POST /session no longer accepts desiredCapabilities or requiredCapabilities, only the W3C capabilities object. If a homegrown HTTP helper still builds the JSON Wire payload, it fails at session creation.

A project manifest beats a global install

The shape that produced the Monday failure is the one most setup guides still show, including Appium's own migration page:

# Every line resolves "newest" on the day it runs, into state outside the repo.
npm install -g appium
appium driver install uiautomator2
appium driver install xcuitest
appium plugin install images

Appium supports two ways out. The first is the extension CLI with exact versions and a dedicated APPIUM_HOME, the directory where "Appium manages extensions"; it defaults to .appium in your home directory, which is shared by every project on the machine.

export APPIUM_HOME="$PWD/.appium-home"
npx --yes [email protected] driver install [email protected]
npx --yes [email protected] driver install [email protected]
npx --yes [email protected] plugin install [email protected]

That fixes the top-level versions and the location. It does not fix the tree beneath them. Install a plugin into an empty APPIUM_HOME with Appium 3.8.0 and list the directory: you get package.json and node_modules, and no package-lock.json. The drivers pin their direct dependencies exactly, but those dependencies use caret ranges of their own (appium-adb 16.0.5 depends on @appium/support ^7.2.6, for instance), so two installs a month apart can resolve different transitive code.

The second way closes that gap. When APPIUM_HOME is not set, Appium checks whether the current directory is inside an npm package that lists appium as a dependency, and if so loads drivers and plugins from that package.json. The extensions become ordinary dependencies, and npm ci reproduces the whole tree from the lockfile:

{
  "private": true,
  "engines": { "node": ">=22.12.0" },
  "devDependencies": {
    "appium": "3.8.0",
    "appium-uiautomator2-driver": "8.7.0",
    "appium-xcuitest-driver": "12.13.3",
    "@appium/images-plugin": "5.0.1"
  },
  "scripts": {
    "appium": "appium",
    "appium:check": "node ci/check-appium-stack.mjs"
  }
}

Start the server with npx appium from that directory and the log says where it looked: The autodetected Appium home path: /path/to/your/repo. If that line shows ~/.appium instead, something set APPIUM_HOME in the environment and your manifest is being ignored.

The cost is that a test repository written in Java or Python gains a package.json, and Appium's docs recommend the project route "only if you are already using npm for your project". Take the trade anyway when the suite runs in CI. A lockfile you did not otherwise need is cheaper than a stack you cannot rebuild. If the repository really cannot carry one, use the pinned APPIUM_HOME form and accept that the drift gate further down is your only defence.

One more CLI behaviour matters here: appium driver update only moves minor and patch versions unless you pass --unsafe. A setup script that "updates drivers" on every run is still drift, just bounded drift.

Installed plugins stay inactive until named

Drivers and plugins have opposite defaults, and the asymmetry catches people in both directions. The server reference says of --use-drivers: "By default, all installed drivers are activated." Of --use-plugins: "By default, no plugins are activated." An installed images plugin does nothing until the server is told to load it, and an installed driver you forgot about is live whether you wanted it or not.

Name both explicitly. The server log confirms the result, marking loaded plugins with (ACTIVE) under Available plugins, which is worth keeping in the CI artefacts next to the test report.

The official plugin list includes relaxed-caps, which exists to "relax Appium's requirement for vendor prefixes on capabilities". Installing it to make an old capability file work hides exactly the thing you want visible in review: which keys are W3C standard and which belong to a driver.

Security settings that live in the repository

This is the setting most often got wrong in CI, usually with good intentions: a test needs one shell command, so the job starts the server with --relaxed-security, which "turns on all insecure features". The server reference adds that it should be used only "if all clients are in a trusted network and could not potentially break out of the session sandbox." A shared CI agent does not meet that bar.

The server has a second default worth changing at the same time. --address defaults to 0.0.0.0, every interface on the host, while Appium's security guide assumes you "don't expose Appium's port(s) to the wider internet." A server that only needs to talk to test processes on the same agent should bind to loopback.

Appium reads a config file from the working directory upward, and .appiumrc.json is the recommended name. Every server argument becomes a kebab-case property under server, with list arguments written as arrays:

{
  "server": {
    "address": "127.0.0.1",
    "port": 4723,
    "use-drivers": ["uiautomator2", "xcuitest"],
    "use-plugins": ["images"],
    "relaxed-security": false,
    "allow-insecure": [
      "uiautomator2:chromedriver_autodownload",
      "xcuitest:shutdown_other_sims"
    ],
    "deny-insecure": [
      "*:adb_shell",
      "*:session_discovery"
    ]
  }
}

Each allowed feature names the driver it applies to, using the driver's automation name as the prefix, so uiautomator2:chromedriver_autodownload lets UiAutomator2 fetch a matching Chromedriver without granting anything to XCUITest. The deny list is a tripwire rather than a restatement of defaults: --deny-insecure is "applied after both" the allow list and relaxed security, so if someone later adds --relaxed-security to a CI command line, shell access through mobile: shell still stays off. The server echoes the result at startup under Explicitly disabling insecure features.

Command-line arguments take precedence over the config file, so the file is only the policy if nothing in the CI job overrides it. Grep the pipeline definitions for --relaxed-security and --allow-insecure as part of review, and take feature names from each driver's own security page rather than guessing them. If the same values also live in environment files for a device farm, the Environment Variable Parser is a quick way to spot a duplicated or conflicting key before it reaches the job.

Capabilities per platform, under review

Capabilities "cannot be changed during the lifecycle of the session", which makes them part of the environment definition, not a detail of the test. Keep one file per platform next to the manifest, and write the appium: vendor prefix explicitly even when your client would add it.

{
  "platformName": "Android",
  "appium:automationName": "UiAutomator2",
  "appium:avd": "ci_api_35",
  "appium:platformVersion": "15",
  "appium:app": "build/outputs/apk/debug/app-debug.apk",
  "appium:systemPort": 8210,
  "appium:uiautomator2ServerLaunchTimeout": 60000,
  "appium:newCommandTimeout": 120
}
{
  "platformName": "iOS",
  "appium:automationName": "XCUITest",
  "appium:deviceName": "iPhone 16",
  "appium:platformVersion": "18.4",
  "appium:app": "build/Debug-iphonesimulator/App.app",
  "appium:usePreinstalledWDA": true,
  "appium:prebuiltWDAPath": "wda/WebDriverAgentRunner-Runner.app",
  "appium:wdaLaunchTimeout": 120000
}

A few of these are chosen for reproducibility rather than convenience. UiAutomator2 treats appium:deviceName as "not used to select a device under test"; appium:avd for an emulator or appium:udid for hardware is what actually picks one, and the README asks for udid and an explicit systemPort whenever sessions run in parallel. On iOS, the prebuilt WebDriverAgent pair skips the xcodebuild step at session start. The driver can fetch the matching runner itself with appium driver run xcuitest download-wda -- --kind=sim --platform=ios --outdir=wda, and because the driver pins its WebDriverAgent version, the download matches the installed driver. That route requires iOS 17 or later with WebDriverAgent 13 or newer.

A drift gate that fails the build

A lockfile says what should be installed. CI should prove what is. Declare the expected stack in one small file:

{
  "server": "3.8.0",
  "drivers": { "uiautomator2": "8.7.0", "xcuitest": "12.13.3" },
  "plugins": { "images": "5.0.1" }
}

Then compare it with what the server reports. appium driver list --installed --json returns an object keyed by driver name, with version, installSpec and installPath for each; appium plugin list takes the same flags.

// ci/check-appium-stack.mjs
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";

const expected = JSON.parse(readFileSync("appium-stack.json", "utf8"));
const appium = (...args) =>
  execFileSync("node_modules/.bin/appium", args, {
    encoding: "utf8",
    stdio: ["ignore", "pipe", "ignore"],
  });

const problems = [];

const server = appium("--version").trim();
if (server !== expected.server) {
  problems.push(`appium server ${server}, expected ${expected.server}`);
}

for (const kind of ["driver", "plugin"]) {
  const installed = JSON.parse(appium(kind, "list", "--installed", "--json"));
  const declared = expected[`${kind}s`];

  for (const [name, version] of Object.entries(declared)) {
    const actual = installed[name]?.version ?? "missing";
    if (actual !== version) problems.push(`${kind} ${name}: ${actual}, expected ${version}`);
  }
  for (const name of Object.keys(installed)) {
    if (!(name in declared)) problems.push(`${kind} ${name}: installed but not declared`);
  }
}

if (problems.length > 0) {
  console.error(`Appium stack drift:\n  ${problems.join("\n  ")}`);
  process.exit(1);
}
console.log("Appium stack matches appium-stack.json");

Run against a declared driver version that differs from the installed one, the script prints driver uiautomator2: 8.7.0, expected 8.6.0 and exits 1. The script calls the project's own binary rather than npx appium on purpose. When the package is absent locally, npx offers to fetch it from the registry, and a CI job answering yes would check the newest Appium instead of the one the lockfile installed. A missing local binary should fail the job, and here it does, with ENOENT.

The extensions are half the environment. The other half is the toolchain each driver calls, which no npm file captures. Each official driver ships doctor checks that "validate whether the extension has its prerequisites configured correctly", and appium driver doctor uiautomator2 separates required fixes from optional ones. With Appium 3.8.0 and UiAutomator2 8.7.0 it exits non-zero when a required check fails, such as an unset ANDROID_HOME or JAVA_HOME, so it works as a gate without parsing anything. Put it first in the job and record the toolchain beside it:

#!/usr/bin/env bash
set -euo pipefail

npm ci
npm run appium:check

mkdir -p artifacts
{
  node --version
  npx appium --version
  java -version 2>&1 | head -n 1
  "$ANDROID_HOME/platform-tools/adb" version | head -n 1
  if [[ "$(uname)" == "Darwin" ]]; then
    xcodebuild -version
    xcrun simctl list runtimes
  fi
} > artifacts/toolchain.txt

npx appium driver doctor uiautomator2
if [[ "$(uname)" == "Darwin" ]]; then
  npx appium driver doctor xcuitest
fi

The Xcode line is not decoration. XCUITest aims to support "the latest two (2) major Xcode/iOS/iPadOS/tvOS/watchOS versions", and its compatibility table lists full support for Xcode 16.0 and newer from driver 11.1.2, with Xcode 14.0 as the oldest likely working version. When a hosted macOS image changes its default Xcode, toolchain.txt from the last green run is the evidence that tells you what moved.

What pinning costs

Every upgrade becomes a pull request that changes the lockfile, appium-stack.json and possibly the Xcode image together. Someone now has to watch the driver changelogs, because fixes no longer arrive by accident. appium driver list --installed --updates reports what is available for npm-installed extensions and is a reasonable weekly job.

The pin also does not reach the device. An emulator image, a simulator runtime or a physical phone's OS version is still chosen elsewhere; which of those combinations are worth paying for is its own decision. And none of this applies to exploratory sessions through the Appium MCP server, which is a different tool with a different job.

Pin the stack before the next CI run

On a machine where the suite passes today, run appium --version, appium driver list --installed --json and appium plugin list --installed --json. Those outputs are the versions your green run actually used. Write them into a project package.json with exact versions, commit the lockfile, and add appium-stack.json with the same numbers.

Then move every server flag out of the CI script and into .appiumrc.json: loopback address, named drivers, named plugins, prefixed insecure features, and a deny list for shell access. Delete --relaxed-security wherever it appears and see which test fails; that test tells you the one feature you need to allow by name.

Evidence to keep with each CI run: the drift check output, the doctor output for each driver, toolchain.txt, and the server startup log showing the active plugins and disabled insecure features.

Questions about pinning Appium extensions

Does appium setup give a reproducible environment?

No. appium setup mobile installs a preset: UiAutomator2, XCUITest (on macOS hosts only) and Espresso, plus the images and inspector plugins, with no versions. It is a quick start for a laptop. appium setup reset is the useful one in this context: it uninstalls every extension and its manifest from the Appium home, which clears a home left inconsistent by a failed upgrade.

Where does Appium record what is installed?

In extensions.yaml under $APPIUM_HOME/node_modules/.cache/appium/. Each entry keeps the resolved version and the installSpec you typed, so an entry reading installSpec: universal-xml beside version: 3.0.1 tells you the version was whatever was newest that day. Treat it as a cache to inspect, not a file to commit.

Do Java or Python test projects really need a package.json?

Only the process that starts the server does. The client talks HTTP to whatever server is running, so the manifest can live in a small appium/ directory in the repository, with the CI job starting the server from there before the Maven, Gradle or pytest step runs.

References