Run Squiggle in Niteshift tasks
For AI agents: this page is Markdown at https://squiggle.sh/docs/niteshift.md. Every docs page is listed at https://squiggle.sh/llms.txt, and all of them are one file at https://squiggle.sh/llms-full.txt.
Let the coding agent in a Niteshift task drive and test your iOS app on the simulator Niteshift provides. Squiggle drives the app from inside it, and hands steps outside the app, such as a launch, to the task's agent.
In a Niteshift task, the sandbox is a Linux machine and the iOS simulator runs on another machine. Squiggle runs in the sandbox and never touches the simulator: it reads and drives your app through two connections the app opens to it. Anything only the simulator can do, such as launching the app, is a step Squiggle asks the task’s agent to take with lim.
Before you start
- A Niteshift project whose tasks build your iOS app and install it on their simulator.
- A Debug simulator build of your app, with the Squiggle SDK added as Set up your app describes. In that build the SDK starts Squiggle’s agent inside the app by itself. A Release build has no agent unless your project keeps Squiggle in release builds, and Squiggle can’t drive it.
- Simulator-only routes from the sandbox to the simulator for ports 7777 (your app’s SDK connects here, or to the
portyour config sets), 7780 (the agent in your app connects here) and 7790 (your agent’s Squiggle server watches the app through it). Don’t declare them as service ports: a service port also publishes a preview. - A licence token, because a task is a new machine every time. See Run Squiggle in CI: the same
SQUIGGLE_LICENSE_TOKENworks in a task. Add it to the task’s secrets. A running task takes one of your plan’s CI places, like a CI runner. Allow outbound HTTPS from the sandbox tolicense.squiggle.sh: a task that can’t reach it stops withE_LICENSE_UNREACHABLE.
Set it up
1 · Install the CLI in the sandbox
Add the install to your project’s .niteshift/setup. On Linux, npm installs the x64 or arm64 build of the squiggle command:
npm install -g @squiggle.sh/cli2 · Turn on the provider
In .squiggle/config.jsonc, set device.provider and commit it:
{ "bundleId": "com.acme.shop", "device": { "provider": "niteshift" }, "prEvidence": true}To turn it on for one environment instead, set SQUIGGLE_DEVICE_PROVIDER=niteshift. The environment variable wins over the config. Squiggle drives the app named by apps.ios, or else by bundleId; set one, because the push command needs it. SQUIGGLE_APP_ID wins over both. Keep prEvidence if you want your agent to hand over screenshots; see Your data stays in the sandbox.
3 · Give the task’s agent Squiggle’s MCP server
Add the server to your tasks the same way as on your Mac, with the command squiggle mcp. See Connect your agent.
What a task looks like
- The agent calls
status. Before the app is connected, the reply is a step outside the app of kindpushLaunchFile: the routes to ask for and the command that pushes Squiggle’s launch file into the app. - The agent asks Niteshift for the routes, builds and installs the app, pushes the launch file with the command from the step, and launches the app with
lim. - The app connects to Squiggle.
statusnow names the provider (device: { provider, by }, wherebyisconfigorenv), and every tool works on the app as it does on your Mac. - When a tool needs something outside the app, it returns that step, and the agent takes it with
lim. After apushLaunchFile,launchorrelaunchit calls the tool again. A dialog, a picker or a link is the action itself, so after one of those it looks again withdescribeinstead. The step’sthensays which.
The launch file
Squiggle writes .squiggle/agent/launch.json in the sandbox. It holds a token for this run, and only the account that runs Squiggle can read it. The app reads the token at launch, and Squiggle refuses both of the app’s connections unless they carry it, so nothing else that reaches those ports can drive your app. The push command looks like this:
lim ios push-file "/workspace/shop/.squiggle/agent/launch.json" "Library/Application Support/squiggle/launch.json" --bundle-id com.acme.shop --container-type data- Push it before you launch the app. An app launched without a launch file picks one up within a few seconds of the push, but misses the logs from before it.
- Each start of the MCP server writes a new token. After the server restarts, push the file again and relaunch the app. A
squiggle floworsquiggle doctorkeeps the token already in the file. - Reinstalling the app, resuming the task or a fresh simulator removes the file from the app. Push it again.
Steps outside the app
Squiggle asks the task’s agent for these steps, and for nothing else:
| kind | What the agent does |
|---|---|
pushLaunchFile | Asks for the routes in routes, runs the push command in command, then launches or relaunches the app. refused is set when the app connected with another run’s launch file. |
launch | Launches the app named in bundleId. |
relaunch | Terminates the app and launches it again. A tool or step that would relaunch or stop the app asks for this. |
permissionDialog | A system dialog is in front of the app. Answers it as the step meant, at the coordinates in point. |
nativeControl | A sheet or picker that iOS draws over the app, such as the photo picker or the share sheet, covers the point. Taps what the step meant in it, at the coordinates in point. |
openUrl | Opens the link in appContent.url on the simulator: a quoted string, so drop the quotes. Asked for when the running app can’t open the link itself, such as one that has to start the app. A link longer than 200 characters arrives cut short and can’t be opened this way. |
Squiggle can’t read a dialog or a picker, so a selector for text in one, such as { text: "Allow" }, fails as not found. A permissionDialog or nativeControl step comes from a tap whose target in your app is covered: point is where the tap was aimed, and in a run_intent program packet.selector is what it was aimed at.
Each step has an ask: one sentence that says what to do. Text that came from your app travels apart from it, quoted, so a label in the app can never read as an instruction.
From a tool
Any MCP tool can return a step instead of its result, as an error with the code E_HANDOFF:
{ "code": "E_HANDOFF", "outsideTheApp": { "kind": "pushLaunchFile", "bundleId": "com.acme.shop", "file": "/workspace/shop/.squiggle/agent/launch.json", "command": "lim ios push-file …", "routes": [7777, 7780, 7790], "ask": "route ports 7777, 7780, 7790 to the simulator only, push Squiggle's launch file into com.acme.shop's data container, then launch or relaunch the app: lim ios push-file …" }, "then": "take this step, then call the tool again"}Inside a program
In a run_intent program, a step outside the app pauses the run with an escalation whose reason is outside_app; packet.outsideApp holds the step. Take it, then answer:
resume_intent { "runId": "r-4f2a", "retry": true }Here retry means done: the step is recorded as passed, not tried again. After a launch or relaunch the run first reconnects to the app. Then it goes on with the next step. To go on without it, answer skip: true; to stop, answer abort with a reason. See Intent programs.
In squiggle flow
A run from the terminal has nobody to ask. A step that needs the task’s agent, such as a launchApp step, reports unsupported and says which step it needed, and the run goes on. unsupported is neither a pass nor a failure. Launch the app before the run, and write flows that start from the running app.--fresh stops before the first step with E_HANDOFF (exit code 2) and prints the push step, because a relaunch is the agent’s to make.
Run flows one at a time with squiggle flow, or with the MCP server’s run_flow tool. squiggle flows runs a suite on this machine’s simulators, so in a task it stops with E_FLOWS_PROVIDER (exit code 2).
Check the setup
squiggle doctorUnder the provider, doctor says which provider is on and whether the config or the environment chose it, whether the agent in your app connected with this run’s token, whether that agent is the one this version of Squiggle expects, and then the usual SDK checks. That last one is the agent version line, a warning and never a failure: an agent from another version still drives, but may not answer what this version asks. Rebuild the app in the task with the SDK version that matches the CLI. If this build of Squiggle can’t name the agent it expects, the line says the agent isn’t judged. When the app isn’t connected, it prints the push command as the fix. squiggle doctor --fix repairs nothing here: every fix is a step for the task’s agent.
Your data stays in the sandbox
- Usage telemetry and crash reports are off, and no setting turns them on.
publish_evidenceposts nothing. It writes the files you pass it, with asummary.mdto use as the pull request text, to a folder in.squiggle/evidencein the sandbox, and the reply names it; attach those files to the pull request through the task. Only images and videos up to 25 MB each go in. It still needs"prEvidence": truein.squiggle/config.jsonc, as on your Mac.squiggle flow --attach-prattaches nothing in a task, because there is no recording.- Runs, recordings and performance data stay in the sandbox’s
.squigglefolder, and go when the sandbox does. Your agent’s Squiggle server keeps less there, as it does in CI (theephemeralwatch profile; see Set what your agent keeps).
Squiggle sends nothing out of the sandbox on its own. Licence checks still reach Squiggle’s licence server.
What doesn’t work in a task
- Anything outside your app
- Squiggle reads only your app’s own windows. A system dialog, a share sheet or a picker isn’t in what
describeandfindreturn, and a selector for text in one doesn’t match. A tap that lands on one comes back as a step for the task’s agent; see Steps outside the app. The Home button,hideKeyboard,setLocationandsetPermissionsreportunsupported. Other keys, such aspressKey: Enter, fail.backis a swipe from the left edge inside your app, so it works only where your app goes back on that swipe. - Screenshots show the app only
screenshotworks: the agent in your app draws its window. There is no status bar and no system dialog in the picture.- Metrics come from inside the app only
- The agent in your app reports its CPU (the whole app, the JS thread and the UI thread) all the time, and its memory once when a run starts and again at each memory warning, so memory growth over a run usually has no data. Nothing is measured from outside the app, so there are no thread counts or system figures.
- Busy streams are thinned
- Each live stream from the agent in your app, such as its network requests, sends at most 60 lines a second across the task’s routes. Lines over that are dropped in the app and counted as missing data, never as zeros.
- No video and no bisect
--recordandsquiggle bisectneed the simulator’s own machine.- Timings don’t compare with your Mac’s
- The simulator is on another machine, so a step takes a different time than on your Mac.
squiggle benchkeeps a task’s runs in their own series and never compares one with a run on your Mac. - No Studio
- Studio can’t show or drive the task’s simulator.
- Crashes come without a crash report
- Your agent’s Squiggle server still watches the app for errors, stalls and crashes, and records each one. A crash is the app quitting without coming back within 10 seconds, so the incident says the crash report isn’t available; the task’s
limoutput has it. A relaunch the task’s agent makes is not a crash. - One Squiggle at a time
- The MCP server,
squiggle flowandsquiggle doctoreach listen on port 7780, so the second one to start stops withE_AGENT_PORT_BUSY. While the MCP server runs, run flows with itsrun_flowtool. - No squiggle up
- There is no simulator in the sandbox for
squiggle upto launch the app on. The task’s agent launches it, after pushing the launch file.
Troubleshooting
The app connected with another run’s launch file
The step’s refused field, or doctor, says the app’s agent dialed back with another run’s launch file. The routes work; the app holds an older token, usually because the MCP server restarted. Push the launch file again and relaunch the app.
Nothing dialed back on :7780
doctor fails its app agent check, and squiggle flow stops with E_NO_AGENT. Check, in this order:
- The routes for 7777, 7780 and 7790 exist and reach the simulator.
- The launch file is in the app: run the push command again, then relaunch.
- The app is a Debug simulator build with the Squiggle SDK. A Release build has no agent, unless your project keeps Squiggle in release builds, and never connects.
The agent connected but the SDK didn’t
doctor says the agent is up but the SDK didn’t join. The app reads the port from the launch file when it starts: check the route for 7777 (or your port), push the file again and relaunch.