Node.js API

This page is for developers who drive devices from their own Node.js code. Use createAgentDeviceClient() for typed, deterministic device automation instead of shelling out to the CLI.

Building an agent? Start with the dedicated AI SDK or Eve integration. Building a bridge, remote daemon, or device-cloud backend that owns device access or command dispatch? See Build an integration.

Runnable examples

The repository includes runnable, typechecked Node.js examples. They import the same published agent-device/* entry points your code uses:

ExampleDemonstrates
client-session.tsOpen, snapshot, interact, handle typed errors, and always close the session
contracts-result.tsConsume snapshot results with helpers from agent-device/contracts

Examples for custom transports and Metro are on Build an integration.

Each example typechecks against the SDK through its own tsconfig.json. Build the package with pnpm build, then run an example with Node:

node --experimental-strip-types examples/sdk/client-session.ts

API reference

Entry points for driving devices from Node.js:

  • agent-device
    • createAgentDeviceClient(options?)
    • startAgentDevice(options)
    • types: AgentDeviceHost, StartAgentDeviceOptions
    • ensureDaemon(options?)
    • createLocalArtifactAdapter(options?)
    • AppError, isAgentDeviceError(error), normalizeAgentDeviceError(error)
    • isUnreadableCaptureContentError(error): true when an Android helper capture found no readable app content yet (empty-helper-output, system-window-only, content-poor-app-window), for example while the app is still loading. Polling callers may keep polling. False for every other platform, backend, and mechanism failure.
    • centerOfRect(rect)
    • types: AgentDeviceClient, AgentDeviceClientConfig, AgentDeviceDevice
    • types: DaemonBinding, DaemonLifecycle, EnsureDaemonOptions, DaemonConnection, DaemonIdentity
    • types: every option and result type of the client's methods, among them AppOpenOptions, AppOpenResult, CaptureSnapshotOptions, CaptureSnapshotResult, CaptureScreenshotResult, PressOptions
    • types: SnapshotNode, RawSnapshotNode, SnapshotState, Rect, Point
    • types: NormalizedError, AppErrorCode, KnownAppErrorCode, AppErrorDetails, ErrorCause
  • agent-device/io
    • createLocalArtifactAdapter(options?)
    • types: ArtifactAdapter, ArtifactDescriptor, CreateTempFileOptions, FileInputRef, FileOutputRef, LocalArtifactAdapterOptions, OutputVisibility, ReserveOutputOptions, ReservedOutputFile, ResolveInputOptions, ResolvedInputFile, TemporaryFile
  • agent-device/ai-sdk
    • createAgentDeviceTools(options)
    • types: AgentDeviceToolSet, AgentDeviceTools, CreateAgentDeviceToolsOptions

Basic usage

This example opens an app, takes a snapshot, interacts, handles typed errors, and always closes the session. Run it from examples/sdk/client-session.ts.

/**
 * Root client session: create a client, open an app, capture a snapshot, tap
 * a node, then close the session — with typed error handling via the
 * exported error helpers.
 *
 * Demonstrates: `createAgentDeviceClient`, `AppError`, `isAgentDeviceError`,
 * and `normalizeAgentDeviceError` from the `agent-device` root export.
 *
 * Prerequisites: an `agent-device` daemon target (a booted iOS simulator).
 * This file typechecks without one; running it for real also requires
 * `pnpm build` first, so the package resolves at runtime.
 *
 * Run: node --experimental-strip-types examples/sdk/client-session.ts
 */
import {
  AppError,
  createAgentDeviceClient,
  isAgentDeviceError,
  normalizeAgentDeviceError,
} from 'agent-device';
import type { AgentDeviceClient, AgentDeviceDevice } from 'agent-device';

async function resolveSnapshotCapableIosDevice(
  client: AgentDeviceClient,
): Promise<AgentDeviceDevice> {
  const devices = await client.devices.list({ platform: 'ios' });
  const device = devices[0];
  if (!device) {
    throw new AppError('DEVICE_NOT_FOUND', 'No iOS device available');
  }

  const capabilities = await client.devices.capabilities({ platform: 'ios' });
  if (!capabilities.availableCommands.includes('snapshot')) {
    throw new AppError('UNSUPPORTED_OPERATION', 'Selected target does not support snapshots');
  }

  return device;
}

function reportAgentDeviceError(error: unknown): void {
  const normalized = normalizeAgentDeviceError(error);
  console.error(`agent-device error [${normalized.code}]: ${normalized.message}`);
  if (normalized.hint) {
    console.error(`hint: ${normalized.hint}`);
  }
  process.exitCode = 1;
}

async function main(): Promise<void> {
  const client = createAgentDeviceClient({
    session: 'sdk-example',
    lockPolicy: 'reject',
    lockPlatform: 'ios',
  });

  try {
    const device = await resolveSnapshotCapableIosDevice(client);

    await client.apps.open({
      app: 'com.apple.Preferences',
      platform: 'ios',
      udid: device.id,
    });

    const snapshot = await client.capture.snapshot({ interactiveOnly: true });
    const target = snapshot.nodes.find((node) => node.role === 'button');
    // A node carries a ref only when something can address it, and press needs that address.
    if (target?.ref !== undefined) {
      await client.interactions.press({ ref: target.ref });
    }
  } catch (error) {
    if (!isAgentDeviceError(error)) throw error;
    reportAgentDeviceError(error);
  } finally {
    await client.sessions.close();
  }
}

await main();

client.devices.capabilities() returns { device, availableCommands }, using the same capability matrix as the CLI. Use it to check which command names the selected target supports.

For direct iOS simulator app launches, client.apps.open({ app, platform: 'ios', launchConsole: './artifacts/app.console.log' }) captures launch-time stdout/stderr. The option mirrors open --launch-console and is not valid for URL opens or non-simulator targets.

client.sessions.stateDir() mirrors session state-dir and returns the resolved daemon state directory without starting or contacting the daemon. Pass { stateDir } to resolve an explicit override the same way the CLI resolves --state-dir.

client.sessions.artifacts({ provider, providerSessionId }) mirrors artifacts --provider ... --provider-session ... and returns provider-hosted cloudArtifacts. Use it for BrowserStack, AWS Device Farm, or TestMu AI session videos/logs after a cloud session has stopped; client.sessions.close() returns the ID as closed.provider?.providerSessionId. Omit providerSessionId when an embedding host has registered a provider runtime that can infer the active lease. Limrun does not expose provider artifacts through this command.

const result = await client.sessions.artifacts({
  provider: 'aws-device-farm',
  providerSessionId: 'arn:aws:devicefarm:us-west-2:123:session/project/session/00000',
});

if ('cloudArtifacts' in result) {
  for (const artifact of result.cloudArtifacts) {
    console.log(artifact.kind, artifact.name, artifact.url);
  }
}

Daemon lifecycle

A client talks to one local daemon per state directory. daemonLifecycle says who owns that daemon.

Close an embedded runtime in its host process with the handle returned by startAgentDevice(). agent-device daemon stop --state-dir <path> refuses a live runtime with error.details.reason: 'daemon-owner-embedded' and a hint naming the host PID. It sends no request or signal. If the recorded host lifetime has provably exited, daemon stop retires its abandoned registration. A record with live unrecognized ownership is also refused, with daemon-owner-unrecognized. Without --clean, an unreadable daemon.json fails with daemon-record-unreadable.

A 'managed' client reuses a matching embedded runtime. Takeover, drain, and timeout recovery refuse a live embedded or unrecognized owner without a signal. A provably exited owner is retired so you can reuse the state directory.

ModeWhat the client does with the daemon
'managed' (default)Starts a missing daemon, replaces one that does not match the client, and retires an unresponsive one after a timeout.
'attach'Connects to the running daemon. Never starts, replaces or stops one, even after a timeout.

'managed' stays the default until 0.22. Use 'attach' when your code, or another process, owns the daemon, and call ensureDaemon() to start or replace it deliberately:

import { createAgentDeviceClient, ensureDaemon } from 'agent-device';

const stateDir = './.my-app/agent-device-state';

await ensureDaemon({
  stateDir,
  onStart: (reason, previous) => console.log(`started a daemon: ${reason}`, previous),
});
const client = createAgentDeviceClient({ stateDir, daemonLifecycle: 'attach' });

ensureDaemon() starts a daemon when none runs. It replaces a daemon that does not match the client only if that daemon holds no work: no request in flight, no session, no repair session and no live lease. A resumable upload still streaming and a host human-control hold that names no lease are not counted, so a replacement can end them. Otherwise it fails and leaves the daemon alone. Two cases retire a daemon without that proof, because it cannot be asked: a registered daemon that answers nothing on any transport, and one that times out on the quiesce request and then fails repeated liveness probes. A daemon that accepted the quiesce request is never signaled while it drains: the call waits up to 60 s for it to exit and clear its registration. One still running after that fails repeated liveness probes and is force-retired when its process is provably a daemon. A daemon that still answers, or whose process cannot be proven to be a daemon, fails the call with daemon-drain-timeout; a force retirement that cannot be confirmed fails it with daemon_retirement_unconfirmed. It returns { stateDir, daemon, started }; daemon holds the pid, version and code identity, and no credentials. onStart runs when the call started a daemon: 'incompatible' when it replaced a daemon that agreed to leave, with that daemon as previous, and 'missing' otherwise. A 'missing' start that also has a previous replaced a registered daemon that answered nothing, or one that accepted the request to leave and was force-retired after outlasting its drain.

These failures carry a typed error.details.reason:

ReasonMeaning
daemon-not-runningAn 'attach' client found no daemon to connect to. Call ensureDaemon().
daemon-incompatibleThe running daemon does not match the client. details.daemon and details.client hold both identities. For an embedded runtime, close it in its host process. For a detached daemon, use agent-device daemon stop --state-dir <path>. Then call ensureDaemon(). A daemon from a release that predates quiescing cannot be asked to leave, so ensureDaemon() reports it the same way.
daemon-busyensureDaemon() found an incompatible daemon that still holds work. details counts inFlightRequests, sessions, repairSessions and leases. Wait for them to end and retry.
daemon-drain-timeoutA daemon accepted the request to leave but was still running 60 s later and could not be force-retired, either because it still answers or because its process cannot be proven to be a detached daemon. It was not signaled. Retry once it has exited, or stop it with agent-device daemon stop --state-dir <path>.
daemon-owner-embeddedA live host process owns the daemon on stateDir, so this client neither replaced it nor retired it, and signaled nothing. A daemon that matches the client is still usable. Close the runtime in its host process with its close() handle.
daemon-owner-unrecognizedThe record on stateDir states an owner this version cannot read, so this client replaced nothing and signaled nothing. Update agent-device if the record comes from a newer build. Otherwise check what owns that process: a host that started it closes it with its own close, and otherwise remove daemon.json once that process is gone.
daemon-binding-mismatchA client pinned to one daemon instance reached another one instead. details.mismatch says which: replaced, absent, or unaddressable. Nothing was dispatched, started, or signaled. Take a fresh config from the host that owns the daemon, or start a host of your own.

ensureDaemon() only ever manages the local daemon on stateDir. A client with daemonBaseUrl never starts or stops a local daemon in either mode, and daemonLifecycle does not change daemonTransport or daemonServerMode.

Running the daemon inside your process

startAgentDevice() starts the daemon inside your own process and returns a client for it. Use it when you want the daemon's lifetime to be exactly your process's, or when you import plugin objects you already have.

import { startAgentDevice } from 'agent-device';

const host = await startAgentDevice({
  stateDir: './.my-app/agent-device-state',
});

const devices = await host.client.devices.list();
await host.close();

stateDir is required. If another daemon owns it, startup refuses with daemon_registration_refused; choose a separate directory or close its owner first. Pass imported plugin objects in plugins. They register after the built-ins without reading the plugin store, so plugin add does not change a running host. Plugin failures appear in agent-device daemon status --state-dir <path>. close() reports unfinished teardown. Repeated calls share one close and never exit your process.

The runtime stays alive until you call close() in its host process; idle reaping is disabled. daemon stop refuses live embedded runtimes with daemon-owner-embedded. daemon stop --clean is refused because cleanup must stay within this runtime. If the shutdown report lists abandoned requests or unfinished cleanup, inspect those outcomes before reusing the state directory: the runtime may retain its registration to protect work still in progress.

host.clientConfig is serializable JSON: pass it to another process or worker and build an attach client from it. The client must pass normal version and code compatibility checks, then match the pinned instance. An incompatible or replacement daemon is refused:

import { createAgentDeviceClient, startAgentDevice } from 'agent-device';

const host = await startAgentDevice({ stateDir: './.my-app/agent-device-state' });
// Hand the config to a worker or another process: it is plain JSON, no build step.
const client = createAgentDeviceClient(host.clientConfig);

A client can also pin one instance on its own with daemonBinding: { token, pid, processStartTime }. Every request then goes to that instance and authenticates with its token. The client never starts, replaces, retires or redirects a daemon to satisfy the pin, so a state directory that registers any other instance is refused with details.reason: 'daemon-binding-mismatch', and so is one that registers none or holds a record it cannot read as an address. The pin holds the daemon's token, so treat it as a credential: share it only with trusted processes that should control this runtime.

Device cloud sessions

You drive Limrun, BrowserStack, AWS Device Farm, and TestMu AI devices with the same typed client methods. Use the CLI connect flow to persist connection state locally. Pass provider settings in the client config when your Node.js integration already owns credentials and provider selectors.

import { createAgentDeviceClient } from 'agent-device';

const client = createAgentDeviceClient({
  leaseProvider: 'browserstack',
  providerOsVersion: '14.0',
  providerApp: 'bs://app-id',
  // Optional hosted device features, applied when the session is created.
  providerDeviceOrientation: 'portrait',
  providerTimezone: 'New_York',
});

await client.apps.open({ app: 'com.example.app', platform: 'android', device: 'Google Pixel 8' });
await client.capture.snapshot({ interactiveOnly: true });
const closed = await client.sessions.close();

apps.open also returns a response-level selection record describing whether the target came from an explicit selector, an existing session, one local booted/bootable candidate, the one booted simulator with the app installed, or one provider-owned candidate. Ambiguous requests fail with structured retry selectors instead of silently retargeting.

To fetch provider-hosted video and log URLs after close, pass closed.provider?.providerSessionId to client.sessions.artifacts(). See the BrowserStack, AWS Device Farm, TestMu AI, and Limrun guides for provider-specific setup.

Web sessions

Pass platform: 'web' to drive a browser session with the same command methods. Web automation requires Node 24+. No client method sets up the managed web backend: run agent-device web setup before first use, in the same state directory the client uses. Run agent-device web doctor to check backend health.

await client.apps.open({ url: 'https://example.com', platform: 'web' });
await client.capture.snapshot({ platform: 'web', interactiveOnly: true });
await client.interactions.fill({ platform: 'web', ref: '@e12', text: 'test@example.com' });
await client.command.wait({ platform: 'web', text: 'Welcome' });
await client.observability.network({ platform: 'web', include: 'headers' });
await client.observability.audio({
  platform: 'web',
  action: 'probe',
  probeAction: 'start',
  durationMs: 10_000,
  bucketMs: 1_000,
});
await client.sessions.close();

MCP tools use the same command contracts, so they can also target platform: 'web' after setup; setup and doctor stay CLI-only. Web network inspection returns the standard network result shape without request or response bodies.

Command methods

Use client.command.<method>() for command-level device actions. These calls go through the same daemon transport as the higher-level client methods, so they carry session metadata and tenant/run/lease fields, return normalized daemon errors, and handle remote artifacts.

Results are daemon-shaped objects with typed known fields and follow the CLI's command semantics.

A failed interaction rejects with the same error the CLI prints. Read error.details.dispatched before you retry; Retry after a failed command explains the values.

Every client call that dispatches to the daemon accepts signal?: AbortSignal to cancel that one call:

const controller = new AbortController();
await client.interactions.press({ ref: '@e12', signal: controller.signal });

With the built-in transport, a signal that is already aborted rejects the call without sending anything (error.details.dispatched: 'no'). Aborting while the request is in flight closes that request's connection, the daemon marks the request canceled, and the promise rejects with the typed canceled-request error (error.details.reason: 'request_canceled', error.details.dispatched: 'unknown'). A custom transport receives the signal on its context and may cancel differently. The daemon and the session stay alive for other requests. On iOS, a command that already reached the device keeps running there, so the daemon holds the session while the device reports it running. Your promise still rejects at once. The hold ends when the device reports the command finished, when the command's own timeout runs out, or when the device cannot confirm the command (it never received it, or the status check fails). Only in the first case is the canceled work known to be over before the session's next request starts. An abort is not a timeout: it never triggers the runner cleanup or daemon reset that a timeout does. Cancellation covers only the daemon request, so a response-artifact download already underway is not stopped, and a canceled one-shot replay still runs the existing cleanup that can tear down a daemon this client started.

await client.command.wait({
  text: 'Continue',
  timeoutMs: 5_000,
});

await client.command.keyboard({
  action: 'dismiss',
});

await client.command.clipboard({
  action: 'write',
  text: 'hello from Node',
});

await client.command.back({
  mode: 'system',
});

await client.command.tvRemote({
  platform: 'android',
  target: 'tv',
  button: 'volume-down',
});

await client.command.tvRemote({
  platform: 'ios',
  target: 'tv',
  button: 'select',
});

await client.command.tvRemote({
  platform: 'vega',
  target: 'tv',
  serial: 'VirtualDevice',
  button: 'select',
  durationMs: 900,
});

await client.command.appSwitcher();
await client.command.actionButton();
await client.command.hardwareButton({ button: 'volume-up' });
await client.command.hardwareButton({ button: 'volume-down' });
await client.command.hardwareButton({ button: 'power', platform: 'android' });
await client.command.fold({ pose: 'open' });
await client.command.telephony({ action: 'call', number: '5551234' });
await client.command.telephony({ action: 'sms', number: '5551234', text: 'Your code is 1234' });
await client.command.fold({
  keyframes: [
    { atMs: 0, angle: 0 },
    { atMs: 1667, angle: 160 },
    { atMs: 3333, angle: 100 },
    { atMs: 5000, angle: 180 },
  ],
});

fold accepts either pose or keyframes. Keyframes use linear interpolation at roughly 60 updates per second; repeat an angle to hold it. Timestamps must start at zero and increase strictly, with 2–64 frames and a final timestamp no greater than 60,000ms. Angles must be finite and between 0° and 180°. The final timestamp bounds motion, excluding helper preparation and final hinge verification. A custom final angle is verified within 0.5°; interior angles must also settle. Cancellation stops the motion at its current angle. Re-snapshot afterwards, including after interrupted motion. Android emulators accept pose only.

hardwareButton presses one phone hardware key once: button is 'volume-up', 'volume-down', 'power', 'mute', 'sleep', 'wake', 'media-play-pause', 'media-next', 'media-previous', 'media-rewind', or 'media-fast-forward'. It takes no hold duration and activates no app; the result reports the dispatched press, not the OS reaction. Android presses all eleven on phones and tablets; a physical iPhone or iPad presses volume only; iOS Simulators, Android TV, and tvOS refuse (use tvRemote on TV targets). sleep and wake are the explicit Android events that turn the screen off or on; power toggles it.

press, click, and longpress do not wait for a missing target to appear. When a screen is still loading, wait for its landmark before interacting:

await client.command.wait({ text: 'Continue', timeoutMs: 5_000 });
await client.interactions.press({ selector: 'label="Continue"' });

If the wait times out, check the selector and app state with a fresh snapshot. A covered, off-screen, or ambiguous target still fails the interaction.

tvRemote is TV-only. Android TV accepts the navigation buttons, named volume, power, media, channel, and color keys, and android-keycode:N. tvOS and Vega OS accept only up, down, left, right, select, menu, home, and back; other buttons fail with UNSUPPORTED_OPERATION. Phones and WebDriver sessions are unsupported. See TV targets for the full button list and hold behavior.

Vega OS client support is VVD-only and covers device discovery, app open/close, back, home, and tvRemote. Physical Vega OS devices, capture, selector, install, logging, and performance methods report unsupported for Vega targets. Fire TV devices that run Fire OS use the Android client methods as Android TV devices.

Supported command methods:

  • wait
  • alert
  • appState
  • back
  • home
  • orientation
  • appSwitcher
  • actionButton
  • hardwareButton
  • fold
  • telephony
  • keyboard
  • clipboard
  • tvRemote
  • reactNative
  • doctor
  • prepare
  • viewport

rotate() is a deprecated alias for orientation(); use orientation().

Domain client methods:

  • client.devices.list(), capabilities(), boot(), shutdown()
  • client.sessions.list(), stateDir(), close(), saveScript(), artifacts()
  • client.apps.install(), reinstall(), uninstall(), installFromSource(), list(), open(), close(), push(), triggerEvent()
  • client.materializations.release()
  • client.leases.allocate(), heartbeat(), release(). Pass retainOnClose: true to allocate() when you release the lease yourself; session close then leaves it active until release(), expiry, or daemon shutdown. The returned lease has retainOnClose: true only when the daemon honored it. Keep heartbeating a retained lease between sessions: it expires after its ttlMs like any other lease. Pass leaseId (16 to 128 hex characters) to allocate() to name a new lease with an id the daemon has never issued, or to get back your live lease with that id when you retry with the same tenant, run, client, lease backend, and provider (and no device, or the leased one), then use the returned leaseId: a run that already holds a lease gets that one back, and an older daemon mints its own. allocate() with a leaseProvider the daemon has no runtime for fails with UNSUPPORTED_OPERATION, reason provider-not-registered, and details.registeredProviders; no lease is recorded. Leases with no provider, proxy, or cloud need no runtime.
  • client.metro.prepare(), reload()
  • client.capture.snapshot(), screenshot(), diff()
  • client.interactions.gesture(), click(), press(), longPress(), swipe(), pan(), drag(), fling(), swipeGesture(), focus(), type(), fill(), scroll(), pinch(), rotateGesture(), transformGesture(), tapGesture(), get(), is(), find()
  • client.replay.run() and client.replay.test()
  • client.batch.run()
  • client.observability.perf(options), logs(), events(), network(), and audio()
  • client.debug.symbols()
  • client.recording.record() and client.recording.trace()
  • client.settings.update()

Use client.interactions.gesture() with a kind and the corresponding gesture inputs:

await client.interactions.gesture({ kind: 'pan', origin: { x: 100, y: 200 }, delta: { x: 40, y: -20 } });

If the daemon returns a missing or non-object lease, or a lease without a non-empty string for leaseId, tenantId, runId, or backend, client.leases.allocate() and client.leases.heartbeat() reject with COMMAND_FAILED and details.reason: "INVALID_LEASE_RESPONSE". The original daemon response is available in error.details.response.

client.devices.list() returns AgentDeviceDevice entries. Their optional model and osVersion fields describe the hardware and OS when discovery reports them; see Device discovery for the sources.

client.capture.snapshot() carries an optional viewport: { width, height } beside nodes: the box those rects are measured in, in the same coordinate space and orientation. Scale and clip against it instead of inferring the screen from the largest rect. When no box was measured, the field is absent rather than zero; see Coordinates and viewport for what each producer answers with.

It also carries an optional presentedSurface: 'presented' | 'absent' | 'unknown' on iOS captures: whether the capture saw a modal presentation over your app's content. 'absent' is a reading that was earned, so it is safe to act on; 'unknown' says the capture could not tell and is not 'absent'. The field is absent on other platforms. See Presented surface.

client.observability.events({ cursor, limit }) reads the session event timeline as paged JSON entries. Use nextCursor from the previous page to continue from the daemon-owned events.ndjson file without replaying already uploaded/displayed events. Cursors are absolute and survive the file's size rotation; a cursor older than the retained window rejects with COMMAND_FAILED, details.reason: "EVENT_LOG_CURSOR_EXPIRED", and details.earliestCursor to resume from. The timeline leaves out user-entered content such as typed text, selector values, and payloads; see Sessions for what it keeps.

client.observability.audio() mirrors audio probe start|status|stop. Use it to collect compact RMS/peak dBFS buckets while other session actions continue:

await client.observability.audio({
  platform: 'web',
  action: 'probe',
  probeAction: 'start',
  durationMs: 10_000,
  bucketMs: 1_000,
});
await client.interactions.click({ platform: 'web', ref: '@e4' });
const audio = await client.observability.audio({
  platform: 'web',
  action: 'probe',
  probeAction: 'status',
});
await client.observability.audio({ platform: 'web', action: 'probe', probeAction: 'stop' });

Pass platform: 'web', 'macos', 'ios' (simulators), or 'android' (emulators on macOS hosts). See Audio probes for what each platform samples, permissions, and unsupported targets.

client.observability.perf() requires an options object with an explicit area; a call without one fails with an error that names the replacement. The option shapes mirror the perf CLI forms:

  • Frame health: { area: 'frames' }
  • Memory: { area: 'memory', action: 'sample' }, or { area: 'memory', action: 'snapshot', kind: 'android-hprof' | 'memgraph', out } to write an artifact to disk
  • CPU profile: { area: 'cpu', subject: 'profile', action: 'start' | 'stop' | 'report', kind: 'xctrace' | 'simpleperf', out }
  • Trace: { area: 'trace', action: 'start' | 'stop', kind: 'perfetto' | 'xctrace', out }

To start an xctrace capture, also pass a template such as 'Time Profiler' or 'Animation Hitches'.

On Android, each successful perf({ area: 'frames' }) read and each open of an app resets the frame window, so call it, perform a transition or gesture, then call it again to inspect that window. See Performance snapshots for result fields and platform support.

client.recording.record({ action: 'start', path, quality: 'medium' }) starts a recording with medium output quality.

client.capture.screenshot({ path, scale: 0.3 }) captures a screenshot at 30% of its original width and height. scale accepts 0.01 through 1.

client.batch.run({ steps }) accepts structured steps: { command: 'open', input: { app: 'settings' } }. Step input uses the same fields as the matching client command. Daemon-shaped positionals/flags steps are internal to the daemon and not part of the client API. To run daemon-shaped batches through your own dispatcher, see Batch orchestration for custom transports.

Android installFromSource()

import { createAgentDeviceClient } from 'agent-device';

const androidClient = createAgentDeviceClient({ session: 'qa-android' });

const installed = await androidClient.apps.installFromSource({
  platform: 'android',
  retainPaths: true,
  retentionMs: 60_000,
  source: { kind: 'url', url: 'https://example.com/app.apk' },
});

await androidClient.apps.open({
  platform: 'android',
  app: installed.launchTarget,
});

console.log(installed.packageName, installed.launchTarget);

if (installed.materializationId) {
  await androidClient.materializations.release({
    materializationId: installed.materializationId,
  });
}

await androidClient.sessions.close();

On Android, a successful installFromSource() response includes the identity you need to relaunch the app:

  • packageName
  • launchTarget

If the daemon cannot determine the installed app's identity, the request fails instead of returning an empty success payload.

URL source rules

installFromSource() limits URL sources:

  • Private and loopback hosts are blocked by default.
  • URL sources from any public host may point directly to an installable, including a bare iOS .ipa, or to a .zip, .tar, .tar.gz, or .tgz archive containing exactly one.
  • For existing reachable artifact URLs, use source: { kind: 'url', url: ... }.
  • For local artifacts, use source: { kind: 'path', path: ... } or the CLI install/reinstall commands.
  • When a remote daemon resolves CI artifacts server-side, pass a GitHub Actions artifact source:
await client.apps.installFromSource({
  platform: 'android',
  source: {
    kind: 'github-actions-artifact',
    owner: 'acme',
    repo: 'mobile',
    artifactId: 1234567890,
  },
});

Remote daemons may also support { kind: 'github-actions-artifact', owner, repo, artifactName } or { kind: 'github-actions-artifact', owner, repo, runId, artifactName }. The local client passes these payloads through unchanged; it does not authenticate with GitHub or download the artifact.

Android .apk and .aab URL sources resolve package identity from the downloaded install artifact. Archive URLs may contain one installable .apk, .aab, .ipa, or iOS .app, including inside nested archives.

Need React or React Native expertise you can count on?