# How Can a Coding Agent Check Its UI Change Before Opening a PR?

> Write a Playwright check for the change before the edit, and have the agent run it in the sandbox after every edit until it exits 0. It also fails on console errors. Then host the app and screenshots on private URLs for a human. Our scripted loop took 26 seconds.

Source: https://sandboxer.com/guides/let-a-coding-agent-test-its-ui-change-in-a-sandbox. Updated 2026-10-27.

**Short answer:** Write a Playwright check for the change before the edit, and have the agent run it in the sandbox after every edit until it exits 0. It also fails on console errors. Then host the app and screenshots on private URLs for a human. Our scripted loop took 26 seconds.

"The tests pass" doesn't mean the UI works. A unit test won't see a button that is never rendered, or a click handler that throws. This guide gives the agent one command to run after each edit. It loads the page in real Chrome, clicks through the change, and returns a JSON verdict and screenshots. A second command publishes the result for a person. Everything below ran on prod on 2026-10-27, on `small` sandboxes (2 vCPU, 4 GB). No model ran: the agent's two edits were scripted, and the first one has a typo on purpose so you can see a failed check.

Other guides cover the pieces around this one. [Turn a GitHub issue into a pull request](/guides/turn-a-github-issue-into-a-pull-request-from-a-sandbox) covers tokens, cloning and pushing. [Sandboxes for QA and test automation](/guides/sandboxes-for-qa-and-test-automation) covers full suites, traces and flaky tests. [A Next.js dev server with a preview URL](/guides/run-a-next-js-dev-server-with-a-preview-url) covers hot reload over the hosted URL.

## At a glance

| Step | Command the agent runs | Measured |
| - | - | - |
| Fresh sandbox to ready | `sbx new --type small` (from outside) | 3.2 s |
| Copy the repo, `npm install` Vite 7.3.7 and Playwright 1.64.1 | `sbx scp`, `sbx exec` | 10.6 s |
| Edit 1 plus check: fails with `cuont is not defined` | `agent/ui-check.sh` | 2.5 s (starts the dev server) |
| Edit 2 plus check: passes | `agent/ui-check.sh` | 1.6 s |
| Host the app and screenshots, copy them out | `agent/share.sh`, `sbx scp` | 8.3 s |
| **Whole loop** | | **26.2 s** |

Times are wall clock from our machine, so they include CLI and network time. Playwright itself took 0.8 to 1.0 s per check. The 3.2 s create is one run from earlier on October 27, 2026. New `small` and `default` sandboxes from the standard image now start from a warm pool, and Sandboxer measured a median of 0.69 s to `ready` from US-East (90% within 1.15 s) ([changelog](/changelog)).

## 1. Write the check before the edit

The task in our test: "Add a Reset button that sets the counter back to 0." The app is a small Vite page with a `+1` button. Write the check from the task, before anyone touches the code, and run it once on the unchanged app. It should fail. If it passes, it doesn't test the change.

The script uses the Chrome that is already in the sandbox (`channel: "chrome"`), so there is no browser download. It treats any page error, console error or failed request as a failure. It prints one line of JSON for the agent and exits 1 on failure.

```javascript
// agent/verify.mjs: check the "Reset button" change in real Chrome and save screenshots.
import { chromium } from "playwright";
import { mkdirSync } from "node:fs";

const url = process.env.APP_URL ?? "http://127.0.0.1:5173/";
const out = process.env.OUT ?? "artifacts";
mkdirSync(out, { recursive: true });
const t0 = Date.now();
const checks = [];
const errors = [];
const check = (name, ok, detail = "") => checks.push({ name, ok, ...(detail && { detail }) });

const browser = await chromium.launch({ channel: "chrome" });
const page = await browser.newPage({ viewport: { width: 800, height: 500 } });
page.on("pageerror", (e) => errors.push(e.message));
page.on("console", (m) => m.type() === "error" && !m.text().startsWith("Failed to load resource") && errors.push(m.text()));
page.on("response", (r) => r.status() >= 400 && !r.url().endsWith("/favicon.ico") && errors.push(`${r.status()} ${r.url()}`));

try {
  await page.goto(url);
  for (let i = 0; i < 3; i++) await page.click("#inc");
  check("count is 3 after three clicks", (await page.textContent("#count")) === "3");
  const reset = page.getByRole("button", { name: "Reset" });
  check("Reset button is visible", await reset.isVisible());
  await page.screenshot({ path: `${out}/before-reset.png` });
  if (await reset.isVisible()) {
    await reset.click();
    const v = await page.textContent("#count");
    check("count is 0 after Reset", v === "0", `count=${v}`);
  }
  await page.screenshot({ path: `${out}/after-reset.png` });
} catch (e) {
  check("page loaded and ran", false, e.message.split("\n")[0]);
}
check("no console or page errors", errors.length === 0, errors.join(" | "));
await browser.close();

const ok = checks.every((c) => c.ok);
console.log(JSON.stringify({ ok, ms: Date.now() - t0, checks: checks.filter((c) => !c.ok), screenshots: [`${out}/before-reset.png`, `${out}/after-reset.png`] }));
process.exit(ok ? 0 : 1);
```

The JSON lists only the failed checks. That keeps the agent's context short, and each failure names what to fix.

Our first version flagged `Failed to load resource: … 404`. That was Chrome asking for `/favicon.ico`. Chrome's console message doesn't include the URL, so the script ignores that message and checks responses directly. It skips only the favicon.

## 2. One command that starts the app and runs the check

The agent shouldn't have to manage the dev server. This script starts it only if nothing answers yet, waits until it does, then runs the check:

```bash
#!/usr/bin/env bash
# agent/ui-check.sh: run after every edit. Exit code 0 means the change works.
set -u
cd "$(dirname "$0")/.."
if ! curl -fs -o /dev/null http://127.0.0.1:5173/; then
  setsid nohup npm run dev > dev.log 2>&1 < /dev/null &
  for i in $(seq 1 60); do
    curl -fs -o /dev/null http://127.0.0.1:5173/ && break
    sleep 0.25
  done
fi
node agent/verify.mjs
```

The dev script is `vite --host 127.0.0.1 --port 5173 --strictPort`. Pin the host. Without it, Vite 7.3.7 in the sandbox listened on `[::1]:5173` only, so every request to `127.0.0.1` was refused. Our first run timed out that way. `vite.config.js` sets `server: { allowedHosts: [".box.sandboxer.com"] }` so the hosted URL in step 4 isn't rejected ([hosting](/docs/hosting#apps-that-listen-on-localhost)).

Run on the unchanged app, the check fails the way it should:

```json
{"ok":false,"ms":858,"checks":[{"name":"Reset button is visible","ok":false}],"screenshots":["artifacts/before-reset.png","artifacts/after-reset.png"]}
```

## 3. The edit, check, fix loop

Tell the agent the rule in its instructions: "After every edit, run `agent/ui-check.sh`. Don't open a pull request until it exits 0." Our first scripted edit added the button but misspelled a variable:

```bash
sbx exec $id "bash edit1.sh && proj/agent/ui-check.sh"
```

```text
 index.html  | 1 +
 src/main.js | 4 ++++
 2 files changed, 5 insertions(+)
{"ok":false,"ms":857,"checks":[{"name":"count is 0 after Reset","ok":false,"detail":"count=3"},{"name":"no console or page errors","ok":false,"detail":"cuont is not defined"}],"screenshots":["artifacts/before-reset.png","artifacts/after-reset.png"]}
```

The button rendered, so a check that only looks for the button would have passed. The click is what failed, and the page error names the bug. The second edit fixed the typo:

```text
{"ok":true,"ms":922,"checks":[],"screenshots":["artifacts/before-reset.png","artifacts/after-reset.png"]}
```

Each round trip took about 1.5 s, because the dev server was already running. Vite serves the edited file on the next page load, so nothing needs a restart.

## 4. Publish the result for a human

Inside the sandbox, the `host` command publishes a port without an API key. It uses the sandbox's own `SANDBOXER_TOKEN` ([inside the sandbox](/docs/machines#inside-the-sandbox)). `host url <port>` prints the URL, hosting the port first if needed. This script serves the screenshots on a second port and writes the text the agent posts:

```bash
#!/usr/bin/env bash
# agent/share.sh: after ui-check.sh passes, publish the app and the screenshots on private URLs.
set -eu
cd "$(dirname "$0")/.."
pgrep -f "http.server 8081" > /dev/null || \
  setsid nohup python3 -m http.server 8081 --bind 127.0.0.1 --directory artifacts > /dev/null 2>&1 < /dev/null &
app=$(host url 5173 --localhost)
shots=$(host url 8081 --localhost)
cat > artifacts/REVIEW.md <<MD
UI check passed in the agent's sandbox ($(date -u +%FT%TZ)).

- Live app (private link): $app
- Screenshots: ${shots%%\?*}/before-reset.png?${shots#*\?} and ${shots%%\?*}/after-reset.png?${shots#*\?}

$(git diff --stat)
MD
```

The `REVIEW.md` it wrote (tokens replaced with `…`):

```text
UI check passed in the agent's sandbox (2026-10-27T15:57:21Z).

- Live app (private link): https://light-mild-papaya-5173.box.sandboxer.com?_token=…
- Screenshots: https://light-mild-papaya-8081.box.sandboxer.com/before-reset.png?_token=… and https://light-mild-papaya-8081.box.sandboxer.com/after-reset.png?_token=…

 index.html  | 1 +
 src/main.js | 4 ++++
 2 files changed, 5 insertions(+)
```

Both servers bind to `127.0.0.1` and are hosted with `--localhost`. A sandbox has a public IPv6 address, and loopback keeps them off it. From outside, each URL returned `401` without the token and `302` then `200` with it. The `302` sets a cookie and redirects to the clean path. Browsers keep the cookie. A `curl -L` without a cookie jar didn't, and got `401` on the redirect. If you script the screenshot download, use `-c`/`-b`, or copy the file out with `sbx scp`.

Where to post it: a private link opens for anyone who has the full URL, so treat it like a password ([private and public](/docs/hosting#private-and-public)). It's fine in a pull request on a private repository. On a public repository, attach the screenshot file you copied out instead. Don't post the link there.

## 5. The whole loop from outside

This is the driver we timed. The two `edit` scripts stand in for the agent:

```bash
#!/usr/bin/env bash
# ui-loop.sh: fresh sandbox -> edit -> UI check (retry once) -> share.
set -u
t() { printf '%-28s %5.1fs\n' "$1" "$(echo "$(date +%s.%N) - $T0" | bc)"; }
T0=$(date +%s.%N)
id=$(sbx --json new --name ui-check --type small --ttl 3600 | jq -r 'select(.event=="ready") | .id')
t "sandbox ready ($id)"
sbx scp proj.tgz edit1.sh edit2.sh "$id":/home/user/ > /dev/null
sbx exec "$id" --timeout 300 "tar xzf proj.tgz && cd proj && npm install --no-audit --no-fund --silent && git init -q && git add -A && git -c user.name=agent -c user.email=agent@example.com commit -qm baseline"
t "repo copied, deps installed"
for attempt in 1 2; do
  sbx exec "$id" "bash edit$attempt.sh > /dev/null"          # the agent's edit
  if sbx exec "$id" --timeout 120 "proj/agent/ui-check.sh"; then t "check passed (attempt $attempt)"; break; fi
  t "check failed (attempt $attempt)"
done
sbx exec "$id" --timeout 60 "proj/agent/share.sh" > /dev/null
sbx scp "$id":/home/user/proj/artifacts/after-reset.png "$id":/home/user/proj/artifacts/REVIEW.md . > /dev/null
t "shared, artifacts copied out"
```

```text
sandbox ready (sb_pwhuxh9cl6)   3.2s
repo copied, deps installed   13.8s
{"ok":false,"ms":1028,"checks":[{"name":"count is 0 after Reset","ok":false,"detail":"count=3"},{"name":"no console or page errors","ok":false,"detail":"cuont is not defined"}],…}
check failed (attempt 1)      16.3s
{"ok":true,"ms":798,"checks":[],…}
check passed (attempt 2)      17.9s
shared, artifacts copied out  26.2s
```

`sbx exec` returns the check's exit code, so the driver doesn't parse the JSON to decide. It parses it only to give the agent the reason. The copied `after-reset.png` is an 800 x 500 PNG showing "Count: 0" and both buttons. When the human is done, `sbx delete $id --yes` removes the sandbox, and both URLs with it. Afterwards, `sbx info` returned `404 not_found`.

On `small` ($0.018/hour, half the `default` rate), a 30-second loop costs 30 / 3,600 × $0.018 = $0.00015. A sandbox kept running for an hour of human review costs $0.018.

## Things that tripped us up

- **`pkill -f vite` killed our own command.** `sbx exec` runs your command in a shell, and that shell's command line contained "vite" too. The exec ended with exit 143. Write the pattern as `pkill -f '[v]ite'`, which still matches the server but not the shell's own command line, or kill by PID.
- **`sbx exec` waits 30 s by default.** A first `npm install` or a slow page can go past that. Pass `--timeout`, up to 600 s, before the command. For longer runs, start with `--detach` and poll ([long-running jobs](/docs/long-running)).
- **A check that only looks for the element passes a broken change.** Our typo would have passed "Reset button is visible". Click it and assert the result.
- **Don't let the agent edit `verify.mjs`.** If it can, it can make the check pass without fixing anything. Keep the check outside the agent's task, or check `git diff --stat` for changes under `agent/`.

## FAQ

### Does the agent need a Sandboxer API key inside the sandbox?

No. The in-sandbox `host` command uses the sandbox's own `SANDBOXER_TOKEN`, which is scoped to that one sandbox. We ran `host url` without setting any key. The `sbx` CLI isn't installed inside sandboxes, and the loop doesn't need it there.

### Do I need to run `npx playwright install`?

Not for Chrome. `channel: "chrome"` uses the Google Chrome preinstalled in every sandbox. In our first run, `npm install` of Playwright and Vite took 7.3 s, with no browser download.

### Can a reviewer click around the live app, not just look at screenshots?

Yes. The app link is the real dev server, hosted on a private URL. The reviewer can use the app until you delete the sandbox. For previews that last the life of a pull request, see [a preview URL for every pull request](/guides/get-a-preview-url-for-every-pull-request).

### What if my app needs a database or a backend?

Start them in the same sandbox before the check, for example with Docker Compose, and keep their ports on `127.0.0.1`. The check only needs the page to load at `APP_URL`. Set it if your app isn't on port 5173.

### Did a real model make these edits?

No. Two shell scripts made the edits, one with a typo and one with the fix, so the loop's timings don't include model time. Everything else, including Chrome, the hosted URLs and the screenshots, ran for real on prod.

## Related

- [Turn a GitHub issue into a pull request from a sandbox](/guides/turn-a-github-issue-into-a-pull-request-from-a-sandbox)
- [Sandboxes for QA and test automation](/guides/sandboxes-for-qa-and-test-automation)
- [Run a Next.js dev server with a preview URL](/guides/run-a-next-js-dev-server-with-a-preview-url)
- [Hosting](/docs/hosting)
