# Run Squiggle in Niteshift tasks

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](https://squiggle.sh/docs/setup) 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](https://squiggle.sh/docs/setup#keep-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 `port` your 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](https://squiggle.sh/docs/licence#run-squiggle-in-ci): the same `SQUIGGLE_LICENSE_TOKEN` works 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 to `license.squiggle.sh`: a task that can’t reach it stops with `E_LICENSE_UNREACHABLE`.

> **Only when you turn it on**
>
> Squiggle never guesses that it runs in a Niteshift task. Until you set the provider below, it looks for a simulator on its own machine, finds none on Linux, and stops with `E_NO_HOST`, which names the provider setting.

## 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:

```shell
npm install -g @squiggle.sh/cli
```

### 2 · Turn on the provider

In `.squiggle/config.jsonc`, set `device.provider` and commit it:

```ts
{
  "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](https://squiggle.sh/docs/niteshift#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](https://squiggle.sh/docs/agents).

## What a task looks like

1. The agent calls `status`. Before the app is connected, the reply is a step outside the app of kind `pushLaunchFile`: the routes to ask for and the command that pushes Squiggle’s launch file into the app.
2. 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`.
3. The app connects to Squiggle. `status` now names the provider (`device: { provider, by }`, where `by` is `config` or `env`), and every tool works on the app as it does on your Mac.
4. When a tool needs something outside the app, it returns that step, and the agent takes it with `lim`. After a `pushLaunchFile`, `launch` or `relaunch` it calls the tool again. A dialog, a picker or a link is the action itself, so after one of those it looks again with `describe` instead. The step’s `then` says 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:

```shell
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 flow` or `squiggle doctor` keeps 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](https://squiggle.sh/docs/agents/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

```shell
squiggle doctor
```

Under 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_evidence` posts nothing. It writes the files you pass it, with a `summary.md` to use as the pull request text, to a folder in `.squiggle/evidence` in 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": true` in `.squiggle/config.jsonc`, as on your Mac. `squiggle flow --attach-pr` attaches nothing in a task, because there is no recording.
- Runs, recordings and performance data stay in the sandbox’s `.squiggle` folder, and go when the sandbox does. Your agent’s Squiggle server keeps less there, as it does in CI (the `ephemeral` watch profile; see [Set what your agent keeps](https://squiggle.sh/docs/replay/incidents#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 `describe` and `find` return, 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](https://squiggle.sh/docs/niteshift#steps-outside-the-app). The Home button, `hideKeyboard`, `setLocation` and `setPermissions` report `unsupported`. Other keys, such as `pressKey: Enter`, fail. `back` is 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**: `screenshot` works: 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**: `--record` and `squiggle bisect` need 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 bench` keeps 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 `lim` output has it. A relaunch the task’s agent makes is not a crash.
- **One Squiggle at a time**: The MCP server, `squiggle flow` and `squiggle doctor` each listen on port 7780, so the second one to start stops with `E_AGENT_PORT_BUSY`. While the MCP server runs, run flows with its `run_flow` tool.
- **No squiggle up**: There is no simulator in the sandbox for `squiggle up` to 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:

1. The routes for 7777, 7780 and 7790 exist and reach the simulator.
2. The launch file is in the app: run the push command again, then relaunch.
3. 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.
