Batching

Use batch to run several commands in one daemon request. It saves round trips when you already know the next sequence of actions.

The whole batch has a 60-second wall-clock budget within a 90-second client timeout. At the budget, the running step receives the same cancellation as a canceled request. When that step settles, a failed batch returns COMMAND_FAILED with details.reason: daemon_request_timeout. The daemon and other sessions stay available if it responds to the client. A close step may already have ended its session. A failed batch keeps details.executed, details.partialResults, and its dispatch status, so you can continue from the failing step. A batch that completes every step returns success even if it settles shortly after the deadline. Cancellation does not prove that native device work has stopped. If a step ignores cancellation beyond the remaining 30 seconds, the client may report a transport timeout. For a daemon that does not respond, the client's existing liveness check can reset it. Split longer sequences into separate requests.

Some resource operations cannot run as bounded steps. The failed step returns details.reason: bounded_composite_resource_unsupported, details.shape, details.dispatched: no, and details.standaloneCommandName for a known public command. The name is not a complete command. Affected commands include perf, audio, record, trace, and some logs operations. A close that would dispose an owned resource or release a provider lease, and acquisition that would recover another session's durable resources, can also be refused. Opening another app in the same session keeps its owned resource handles. An Android test IME alone is restored by ordinary close, including a bounded step. Restoration may settle after the remaining batch time. If it fails, the session retires and the device claim stays for recovery; inspect the device and claim before acting. Run a refused step separately with its original arguments and options, the same --session value, and the same private CLI context. Inspect details.partialResults and details.dispatchedSteps before retrying the batch: earlier steps may already have changed the app. The batch still authenticates and renews its own lease while it runs.

Run a batch from the CLI

From a file:

agent-device batch \
  --session sim \
  --platform ios \
  --udid 00008150-001849640CF8401C \
  --steps-file /tmp/batch-steps.json \
  --json

Inline, for small payloads:

agent-device batch --steps '[{"command":"open","input":{"app":"settings"}},{"command":"wait","input":{"kind":"duration","durationMs":100}}]'

Step payload format

Pass batch a JSON array of steps:

[
  { "command": "open", "input": { "app": "settings" } },
  {
    "command": "wait",
    "input": { "kind": "selector", "selector": "label=\"Privacy & Security\"", "timeoutMs": 3000 }
  },
  {
    "command": "click",
    "input": { "target": { "kind": "selector", "selector": "label=\"Privacy & Security\"" } }
  },
  {
    "command": "get",
    "input": { "format": "text", "target": { "kind": "selector", "selector": "label=\"Tracking\"" } }
  }
]

Notes:

  • input is required and takes the same fields as the matching MCP or Node client command.
  • A step can only have command, input, and runtime keys; any other key is rejected.
  • Steps written as positionals/flags are rejected. Rewrite each step with structured input, for example {"command":"open","input":{"app":"settings","platform":"ios"}}.
  • batch and replay steps can't be nested inside a batch.
  • --on-error stop is the only error mode: the batch stops at the first failing step.
  • A batch runs at most 100 steps by default. Pass --max-steps <n> to raise or lower the limit, up to 1000.
  • A step without platform uses the batch's --platform. Session lock defaults don't override it.
  • Steps use the same session binding and session lock mode as the batch command itself.

Response shape

Success:

{
  "success": true,
  "data": {
    "total": 4,
    "executed": 4,
    "totalDurationMs": 1810,
    "results": [
      { "step": 1, "command": "open", "ok": true, "durationMs": 1020 },
      { "step": 2, "command": "wait", "ok": true, "durationMs": 320 },
      { "step": 3, "command": "click", "ok": true, "durationMs": 260 },
      { "step": 4, "command": "get", "ok": true, "durationMs": 210, "data": { "text": "..." } }
    ]
  }
}

Without --json, batch prints the overall completion line followed by a short summary of each step.

Failure:

{
  "success": false,
  "error": {
    "code": "COMMAND_FAILED",
    "message": "Batch failed at step 3 (click): ...",
    "details": {
      "step": 3,
      "command": "click",
      "executed": 2,
      "total": 4,
      "partialResults": [
        { "step": 1, "command": "open", "ok": true },
        { "step": 2, "command": "wait", "ok": true }
      ]
    }
  }
}

Tips for agents

  • Keep each batch to one screen flow.
  • After steps that change the UI (open, click, fill, swipe), add a wait or is exists step before reads that matter.
  • Treat earlier refs and snapshots as stale after the UI changes.
  • Prefer --steps-file over inline JSON.
  • Keep batches to about 5-20 steps.
  • When a batch fails, inspect details.step, details.partialResults, and details.dispatchedSteps before retrying.

Example recipes

Open app -> open thread -> type -> send

[
  { "command": "open", "input": { "app": "com.example.chat", "platform": "android" } },
  { "command": "wait", "input": { "kind": "text", "text": "Inbox", "timeoutMs": 3000 } },
  { "command": "press", "input": { "target": { "kind": "selector", "selector": "label=\"Inbox\" role=button" } } },
  { "command": "press", "input": { "target": { "kind": "selector", "selector": "label=\"Morgan Lee\"" } } },
  {
    "command": "fill",
    "input": {
      "target": { "kind": "selector", "selector": "label=\"Message\" role=text-field" },
      "text": "sent the update"
    }
  },
  { "command": "press", "input": { "target": { "kind": "selector", "selector": "label=\"Send\" role=button" } } },
  { "command": "wait", "input": { "kind": "text", "text": "sent the update", "timeoutMs": 3000 } }
]

Open app -> open action menu -> choose option -> verify

[
  { "command": "open", "input": { "app": "com.example.app", "platform": "android" } },
  { "command": "wait", "input": { "kind": "text", "text": "Home", "timeoutMs": 3000 } },
  {
    "command": "press",
    "input": { "target": { "kind": "selector", "selector": "label=\"More actions\" role=button" } }
  },
  { "command": "wait", "input": { "kind": "text", "text": "Scan document", "timeoutMs": 2000 } },
  { "command": "press", "input": { "target": { "kind": "selector", "selector": "label=\"Scan document\"" } } },
  { "command": "wait", "input": { "kind": "text", "text": "Document uploaded", "timeoutMs": 15000 } },
  { "command": "is", "input": { "predicate": "visible", "selector": "label=\"Document uploaded\"" } }
]

Avoid acting on a stale accessibility tree

The accessibility tree can lag behind fast UI changes. Add explicit waits, and split long workflows into separate batches by phase:

  1. navigate
  2. verify/extract
  3. cleanup

Need React or React Native expertise you can count on?