Troubleshooting
Concrete errors seen while validating this flow, and the fix.
| Symptom | Cause | Fix |
|---|---|---|
| Approval review rejects the Mode C Metro tunnel or dev-client Connect action | Review may lack or misinterpret the transport details or existing authorization; a signed URL alone does not establish private access | Follow Tunnel scope and approvals. Include the original authorization source and verified transport in the request; use reconsideration only where the host permits it. Preserve the requested live workflow while resolving approval. |
| Controller recording download fails or times out | The local transfer can fail even though EAS retains the recording | Fetch it from EAS session artifacts using the original EAS session id and the recording’s downloadUrl. |
Command simulator:start not found |
eas-cli too old (commands are hidden but present from ≥ 20.3.0) |
Run via npx --yes eas-cli@latest …, or upgrade eas-cli. |
simulator:start rejects --name (e.g. Nonexistent flag: --name) |
eas-cli too old — --name was added after simulator:start itself |
Run via npx --yes eas-cli@latest …, or upgrade eas-cli. If you can't upgrade, retry once without --name; the session starts unnamed. |
An Expo user account is required / whoami shows logged-out |
No browser login on a cloud/CI/headless box, or EXPO_TOKEN unset/invalid |
Set EXPO_TOKEN (expo.dev → Account → Access Tokens) in the env; verify npx --yes eas-cli@latest whoami. (Interactive machines can eas login.) |
simulator:start/build: no linked project / missing projectId |
A fresh create-expo-app isn't linked to EAS |
npx --yes eas-cli@latest init to create/link it (writes extra.eas.projectId). |
prebuild/eas build prompts for or fails on a missing iOS bundle identifier |
A fresh app often has no ios.bundleIdentifier |
Set it in app config (e.g. dev.<owner>.<slug>); confirm via npx expo config --json (may live in app.config.js). |
--max-duration-minutes rejected |
The requested duration may not be supported by the account; inspect the CLI error | Use the default session limit when the custom duration is unavailable. |
| Appium or browser-preview session stops despite ongoing interaction | Only activity reported through agent-device and argent resets --max-idle-time-minutes; Appium commands and browser-preview activity do not |
Use the maximum duration as the lifetime bound for Appium and user-driven previews. Customize it with --max-duration-minutes when supported by the account, and omit --max-idle-time-minutes unless inactivity from a supported controller is the intended stop condition. |
simulator:start fails with not enabled for this account / not-allowlisted |
EAS Simulator is limited-access and isn't enabled for this account | Don't retry. Confirm with simulator:availability, then hand off gracefully — tell the user and fall back to a local sim / EAS Build (see SKILL.md Check availability first). |
start keeps "Waiting for … session to be ready" but it never returns |
start's readiness poll can miss a session that's actually live |
Don't rely on it — poll npx --yes eas-cli@latest simulator:get --id <id> --json for status: IN_PROGRESS + a populated remoteConfig. |
ERR_NGROK_3200 / endpoint offline; Remote daemon is unavailable |
The session's tunnel/daemon dropped — left idle and timed out, or the VM was torn down | A drop invalidates the whole session (installed app, @e refs, Metro). Don't retry the failed verb — start a fresh session, reset the dotenv, and re-run install→open→drive from the top, acting immediately. |
| Two sessions running / orphaned session | A second start (e.g. to "retry" a slow boot) creates another session and overwrites the dotenv id, orphaning the first |
Poll the existing session instead. Find orphans with simulator:list --status in-progress and stop those you created with simulator:stop --id <id>. |
| A device verb hangs (no return for a minute+) | Slow daemon; press/screenshot can block ~90s |
Bound it with agent-device's own --timeout <ms> (e.g. --timeout 120000) — not a shell timeout wrapper (macOS has no timeout binary, so timeout 120 … fails with command not found and skips the verb). On timeout snapshot -i to see if the action landed before retrying (taps can double-fire). Don't blind-retry. |
install requires an active session or an explicit device selector |
install can't infer the device |
Pass --platform ios (or open something first to establish a session). |
DEVICE_NOT_FOUND: No device named <udid> when targeting a non-default device (iPad, second sim) |
In a remote session agent-device's --device resolves by name, not udid (despite the CLI docs) |
Pass the device name from agent-device devices (e.g. --device "iPad Pro 13-inch (M5)"), not the udid. |
Unknown command: tap |
The tap verb is press |
Use press <ref|selector> (e.g. press @e2 or press 'label="Open"'). |
SESSION_NOT_FOUND: No active session. Run open first. |
A verb (e.g. screenshot) ran before any app/session was opened — or you used Method 1 (simulator:start --open-url), which launches the app but creates NO agent-device session |
open <app|url> first (or pass --platform ios). After a Method-1 launch, attach without relaunching: agent-device open <bundleId> --foreground --platform ios (pass the bundle id — --foreground alone fails AMBIGUOUS_MATCH), then screenshot. |
| Screenshot looks plausible but the session/UI is wrong (e.g. Safari, an iPhone shot when you booted an iPad, or "incompatible Expo Go SDK") | agent-device silently falls back to a LOCAL simulator when .env.eas-simulator has no remote config — no error, believable-but-wrong output. Common cause: a concurrent simulator:start on the same account/machine overwrote the shared dotenv with its own id (the dotenv is a single file, NOT concurrency-safe). |
Confirm you're on the REMOTE VM: simulator:get --json returns the id start printed, AND the verb's "Session state:" path is under /Users/expo/ (remote), not /Users/<you>/ (local); devices --json host is a turtle-worker-*. The sessions/ vs remote-diagnostics/ directory name is NOT a reliable tell. If concurrency is possible, drive by explicit id — load the daemon vars from simulator:get --id <id> --json — instead of trusting the dotenv. |
simulator:exec / build / simulator:stop: "Run this command inside a project directory." |
Run from the wrong cwd | Run from the Expo project directory (where app.json/eas.json live). |
| New session's id shows as the previous one; "Overwriting previous simulator session (id: …)" | .env.eas-simulator names an earlier session |
Inspect it with simulator:get --json. Reuse it when it belongs to this run; stop it only when it is in scope and no longer needed. An IN_PROGRESS session may be intentionally concurrent, so preserve its id/config before resetting the dotenv and drive sessions by explicit id/config. Replacing the file does not stop the remote session. |
No .env.eas-simulator written after start |
--out-config-type env was selected, or the CLI reported a file-write failure |
Use the default --out-config-type dotenv for the exec flow. --json changes output and implies non-interactive mode, but does not by itself suppress the completed dotenv write. |
pod install fails: Unicode Normalization not appropriate for ASCII-8BIT |
Ruby 4 + CocoaPods with a non-UTF-8 locale | Re-run with LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 pod install. |
(Mode C) Deep-link open lands on the dev-client launcher, not the app |
The "Open in '<app>'?" system dialog wasn't accepted, so the deep link didn't take | Accept the dialog with agent-device alert accept 2500 --platform ios (not a UI tap). If it still lands on the launcher, fall back to "Enter URL manually" → fill the https://<host>.on.expo.app manifest URL → "Connect" (see run-your-app.md Mode C). |
| (Mode C) App shows expo-router "Unmatched Route" | The connect URL was parsed as a route path | press 'label="Go back"' (or navigate to /). |
(Mode C) Dev client shows a ? placeholder / blank after connect |
Bundle not fetched yet | press 'label="Reload"' and wait ~40-60s for the first build+transfer over the tunnel. |
(Mode C) expo start fails: "port 8081 already in use" |
Another Metro owns 8081 | Don't kill it. Start on your own --port — both EXPO_UNSTABLE_TUNNEL_V2=1 (account-signed) and plain ngrok accept any port. Only the LEGACY ws-tunnel path is 8081-locked (see the WS_TUNNEL_PORT row). Reuse a Metro only if you started it this session. |
(Mode C) expo start / node killed with exit 137 |
137 = SIGKILL — almost always the OOM killer (memory pressure, common in constrained cloud sandboxes, esp. a native build + Metro at once). Not a port clash. | Reduce memory pressure: don't run a native build and Metro concurrently; give the sandbox more memory; retry. |
| (Mode C) Edits won't live-reload no matter how often you reconnect | A release build is installed — its JS is baked in, so it ignores Metro | Stop reconnecting: install the dev (Debug) build, connect it to Metro, reload. Reconnecting a release build to Metro is a no-op. |
expo start --tunnel errors for a robot/EXPO_TOKEN user |
The ngrok robot-user guard blocks plain (ngrok) tunnels | Use ws-tunnel v2 (account-signed, any port): EXPO_UNSTABLE_TUNNEL_V2=1 expo start --tunnel --port <any> — needs login / an EAS-linked project. Do NOT use EXPO_FORCE_WEBCONTAINER_ENV; that forces the legacy path, which is 8081-locked. |
CommandError: WS-tunnel only supports tunneling over port 8081 |
You're on the legacy ws-tunnel path — no v2 account URL (older CLI where EXPO_UNSTABLE_TUNNEL_V2 is a no-op, not logged in, or EXPO_FORCE_WEBCONTAINER_ENV set) |
Get onto the account-signed v2 path: set EXPO_UNSTABLE_TUNNEL_V2=1 and log in / link the project — then any --port works. Otherwise use --port 8081, or the ngrok path (drop the flag; non-robot only). |
| Unexpected charges / a session you forgot | start --non-interactive does NOT auto-stop |
Always npx --yes eas-cli@latest simulator:stop --id <id>. List leftovers with npx --yes eas-cli@latest simulator:list. |
| Screenshot shows old content / my recent edits don't appear | Running a release build (Mode A/B) whose JS was baked in before your edits — typically a reused/stale build | A/B reflect code at build time, not now. Rebuild (ensure the build's fingerprint matches current source), or use Mode C (dev + Metro) so live edits show via Fast Refresh. The screenshot itself is fresh — it's the build that's stale. (9:41 in the status bar is the sim default, not staleness.) |
(Android) The emulator stopped, and agent-device boot times out (Daemon request timed out) while the device stays booted=false |
Without --headless, agent-device starts the emulator with a window. The EAS Linux image cannot run the windowed emulator, so it exits at once |
Boot headless. Set AGENT_DEVICE_HEADLESS=1 for the whole run, or pass --headless on each boot: AGENT_DEVICE_HEADLESS=1 npx --yes eas-cli@latest simulator:exec npx agent-device@latest boot --platform android --device <avd-name>. Get the AVD name from agent-device devices --platform android. The variable is read by the local client, so set it where you run the command. |
(argent) Every argent run/tools call returns 401 Unauthorized right after linking |
argent link without --yes no-ops on an already-linked URL ("Already linked. No changes."), keeping a stale token from a previous session |
Re-link with --yes so the new token is written — see the link command in controllers.md. |
/crashes returns 404 Not found on the preview API |
The session's preview server predates the crash and log routes | Use agent-device logs for the device log, and tell the user crash reports aren't available on that session. Don't call /logs there. See logs-and-crashes.md. |
sim_api stops with API_BASE: no preview API |
The API= lookup failed: the session is not IN_PROGRESS, is not iOS, or has no preview API |
Read the lookup's own error line above it. Check the --id, or start a new session. See logs-and-crashes.md. |
sim_api fails with 401 |
The session ended or its token changed, so the preview server rejects the old one | Run the API= lookup again. It prints the session's status when the session is no longer IN_PROGRESS. See logs-and-crashes.md. |
A crash shows up but its logTailSource is none, buffer-rolled-past, or no-app-lines with no lines |
Nothing held the device log when the crash happened, or a busy device rolled the buffer past it (common in the first minutes after boot) | Start the /crashes?tail=1 hold (or call sim_api /logs "snapshot=1&follow=1&limit=1" more often than every 8 seconds) before reproducing. After a boot, reproduce again once logging settles, a minute or two later. |
Performance expectations
Set the user's expectations honestly — this is experimental:
- Boot is variable: ~90s warm to ~15 min cold. Poll patiently.
snapshotcan be slow on iOS (tens of seconds).- First bundle load over the tunnel (Mode C) is the slow part; subsequent Fast Refreshes are fast.