Remote Proxy
Use agent-device proxy when the machine running your agent cannot access the iOS simulator, Android emulator, or physical device directly, but another Mac can. The proxy runs on the device host, fronts the local daemon over HTTP, and lets a remote agent-device client call it through cloudflared, ngrok, or another tunnel.
The proxy authenticates clients with a bearer token. You do not need agent-device auth.
Start the proxy on the device host
On the Mac with simulator or device access, run:
The command prints the local proxy URL and a daemon auth token. Keep the token secret: anyone who has it can control the daemon behind the proxy.
Expose the proxy through your tunnel:
By default the proxy binds 127.0.0.1. Use --host 0.0.0.0 only when you want the proxy reachable from the host network.
Connect from the agent machine
On the machine running the agent, connect to the public tunnel URL with the /agent-device base path and the printed token. The connection profile stores only routing metadata, never the token, so export the token once and every command in the shell picks it up:
--daemon-auth-token <token> works instead of the environment variable, but it authenticates only the command you pass it to. Later commands need the token again through the environment variable, a daemonAuthToken entry in your remote config profile, or the flag on each command.
connect proxy stores the proxy profile and client identity. open leases the device for you, and the lease expires after five minutes without commands. A lease allocated directly over the RPC without ttlMs gets the daemon's one-minute inactivity default instead. Either window starts when allocation completes, not when it was requested. close releases the active session and its device lease, unless the lease was allocated with retainOnClose; only leases.release, expiry, or daemon shutdown ends that one. disconnect clears local connection state.
Several agents can share one proxy when each follows the same connect proxy, open, commands, close, and disconnect flow. A busy device error means another agent holds the device until it closes its session or its lease expires.
Do not put the proxy endpoint, token, tenant, or provider fields in ./agent-device.json: repository
configuration accepts only project-safe automation defaults. Set the endpoint and token with connect proxy, user
config, an explicit --config file, or protected CI environment variables.
Human takeover
With a remote device already leased by open, pause mutations through the same connection:
The command runs in the foreground and keeps the hold until you press Ctrl+C. While it holds,
state-changing commands fail with DEVICE_IN_USE and details.reason: "human_control_active".
Snapshots, screenshots, selector reads, logs, and other read-only diagnostics still work, the agent
session stays open, and its lease does not expire from inactivity. The hold activates after
mutations already in progress finish. To check or recover a hold, run takeover status or
takeover release <hold-id> with the same session.
If the takeover process disappears, its hold expires on its own. When the last hold on the device
is released or expires, the lease's inactivity window starts over. A tenant can change only the
holds owned by its own lease.
If the requesting connection disconnects while the hold waits for those mutations, the pending hold is removed and never activates. This applies to both tenant RPCs and host PUTs. An active hold lasts until its TTL runs out or you release it.
Lease-owner operations use ordinary agent_device.command RPCs at POST /rpc, with command
human_control and positionals ["list"], ["put", "<hold-id>", "{\"ttlMs\":15000}"], or
["remove", "<hold-id>"]. Supply the same tenant, run, client, lease, backend, provider, and device
metadata as other requests. The PUT payload contains only reason and ttlMs; the server derives
the target from the admitted lease. It rejects caller-supplied scope.
Manage holds from the host
Automation on the device host can manage holds independently of a tenant. Read the daemon's httpPort and
token from daemon.json in its effective state directory, then use the loopback listener with
Authorization: Bearer <daemon-token> or X-Agent-Device-Token: <daemon-token>. An HTTP listener
is required. A tenant credential does not grant this capability.
The host PUT body names the exact lease contention identity, including its backend and provider.
Use the lease's deviceKey, not a bare device ID or a display name:
Repeated PUT renews the hold. Omitting ttlMs keeps it until explicit release or daemon shutdown.
Tenant RPCs cannot modify host holds. Multiple holds can coexist; mutations resume only when all
holds on the device end.
Holds, like leases, do not survive a daemon restart. Reconnect and put the hold back before a person continues on the device. Takeover requires a device-scoped remote lease; it does not fence off devices across local daemons on the same host.
Lease one macOS app to a client
A host can hand a client one macOS app instead of a device. A macos-app lease names that app by
bundle id, optionally pinned to one process (<bundleId>@<pid>). Only the host allocates it, on the
same loopback listener and daemon token as host holds; a tenant lease_allocate for macos-app is
refused:
The lease id is 16 to 128 hex characters the host chooses. Repeating the PUT renews the lease; a PUT
that names another scope for an existing id is refused. The lease stays allocated across the
client's close unless the body sets retainOnClose: false, and DELETE revokes it at once. It
expires after ttlMs without a renewal or a client request, like any lease. A client heartbeat can
shorten that window but never extend it past the ttlMs of the last PUT. A client cannot release
it: disconnect drops only its local connection state, and a tenant lease_release is refused with
MACOS_APP_LEASE_HOST_OWNED. The lease ends by the host's DELETE /admin/leases/<lease-id>, by
expiry, by daemon restart, or when a session it holds closes and the body set retainOnClose to
false.
The client connects with a remote config that names the lease, and runs open <bundleId>:
Requests under a macos-app lease are limited to open, close, snapshot, wait,
find, get, is, click, fill, press, type, focus, scroll, screenshot, and batch,
plus the lease's own heartbeat; doctor, devices, session list and the other
inventory commands are refused too.
open and close accept only the leased bundle id, only the app surface is allowed, screenshots
capture only the app window, inputs that name a host path or a launch (--save-script,
--launch-url, --launch-console, a screenshot path other than the client's own temp file) are
refused, device selectors (--udid, --serial, --device, --target) are refused, open and
batch must carry --platform macos, every other command must run in the session open created for
the leased app, and a pid-pinned lease stops working when that process exits. open
requires the daemon to run the native macOS app backend (AGENT_DEVICE_MACOS_APP_BACKEND=native).
A refusal fails with UNAUTHORIZED and details.reason: "MACOS_APP_LEASE_DENIED".
Responses under the lease name nothing else about the host. open omits the session state and log
paths and the device (device, id, kind), a failure omits logPath and diagnosticsRecord and
replaces host paths and the host name in its text with <host-path> and <host>, and a snapshot's
fallback screenshot path stays on the host; the screenshot arrives through the artifact route. The
request diagnostics route is not served to a tenant that held the lease, and a daemon started with
leases.require does not serve it at all.
These rules apply to requests made under the lease. To refuse requests that name no lease at all,
start the daemon with a policy that requires one (leases.require, below). The proxy token is shared
by every client of the proxy, so a host serving several clients through one proxy authenticates each
client itself and sets each request's tenant, session isolation, and lease before forwarding it.
Restrict what clients can do
Start the proxy with a daemon policy to confine every client to named devices and commands. The
daemon enforces the policy on every request, including batch steps and replay actions:
devices.allowlists the only devices clients can see (devices) or use. Useudidfor Apple devices andserialfor Android.commandstakes eitherallowordeny, not both. Withallow, commands added in later releases stay denied. Some client-side tools reach the daemon through internal commands: allowruntimeforreact-devtoolsand Maestro flows, andinstall-from-sourcefor remote installs.capabilities.deny: ["device-shutdown"]blocksshutdown,close --shutdown, and any other path that would shut the device down.leases.require: "macos-app"(the only accepted value) refuses every request that is not made under amacos-applease or that its allow list does not cover, including requests that name no lease and inventory commands such asdoctorandsession list.
The daemon reads the file once at start and refuses to start if it is invalid. If a daemon is
already running for the state directory with a different policy, the proxy refuses to reuse it;
stop that daemon first. A denied request fails with UNAUTHORIZED and
details.reason: "DAEMON_POLICY_DENIED".
What the proxy exposes
The proxy serves only the daemon HTTP contract: /health, /rpc, /upload plus resumable /upload/* routes, and /artifacts/*, with the same routes also available under /agent-device/*. Health checks are unauthenticated; command, upload, and artifact routes require the bearer token.
The proxy checks the client token and forwards authorized requests with the local daemon token. Remote clients never see the daemon token.
The proxy does not forward /admin/*, including human-control holds. A caller on the device host
must use the daemon's loopback port and local daemon token.
Embed the proxy in your own gateway
agent-device proxy is also available as a library, @agent-device/proxy, for gateways that front
daemons on many hosts. It serves the same routes with the same token handling, but you choose the
transport on both sides: it answers standard Fetch API Request objects, so you can host it on
any HTTP server or carry requests over WebSocket, and it can reach the daemon through your own
tunnel instead of HTTP. See the
package README
for the API.
Compatibility
Remote clients read /health before sending commands and compare the daemon RPC protocol version. Keep client and proxy versions close: patch-level differences usually work, and incompatible RPC protocol versions fail before any command runs.
/health also reports hostArch, the native CPU architecture of the machine serving it: the one its simulators run by default, even when Node itself runs under Rosetta. Macs report arm64 or x86_64; other hosts report x86_64 for x64 and Node's process.arch name otherwise (for example arm64). The top-level value describes the proxy's own machine, so a client behind a proxy reads upstream.hostArch for the host that runs the simulators, for example to build only that slice of a simulator app. Older daemons omit the field. leaseBackends lists the lease backends the daemon admits (macos-app only on a macOS host); a host
checks upstream.leaseBackends for macos-app before handing out a macOS app lease, and older
daemons omit it.
Clean up
Run agent-device disconnect when you finish the remote session. Stop the tunnel and the agent-device proxy process only when the host should stop accepting remote clients. Restarting the proxy generates a fresh token unless you supplied --daemon-auth-token explicitly.
