# How Does an Agent Know a Service in a Sandbox Is Ready?

> Poll the real request, not the port. Inside the sandbox, loop curl against a health URL with a timeout. From outside, poll the hosted URL and ignore Sandboxer's own pages (they end in "Sandboxer · HTTP" and the status): 502 means nothing is listening yet. In our test, Grafana's port opened at 0.4 s but answered at 3.1 s.

Source: https://sandboxer.com/guides/wait-for-a-port-to-be-ready-in-a-sandbox. Updated 2026-10-27.

**Short answer:** Poll the real request, not the port. Inside the sandbox, loop curl against a health URL with a timeout. From outside, poll the hosted URL and ignore Sandboxer's own pages (they end in "Sandboxer · HTTP" and the status): 502 means nothing is listening yet. In our test, Grafana's port opened at 0.4 s but answered at 3.1 s.

An agent that starts a server and uses it in the next step needs a reliable "it's up" signal. `sbx new` and `sbx resume` only return once the sandbox itself is ready, so the gap is your app's own startup time. Everything below ran on prod on 2026-10-27, on a `small` sandbox (2 vCPU, 4 GB) with Grafana 12.2.0 in Docker as the real app. Tokens are replaced with `<token>`.

## At a glance

| Check | Says ready when | Trust it? |
| - | - | - |
| `ss -ltn` / `nc -z` on the port | A socket is listening | No. Docker's port forward listened at 0.4 s; Grafana answered at 3.1 s |
| `systemctl is-active` | The process started (`Type=simple`) | No. `active` while curl still failed |
| `curl -f` on a health URL, inside | The app served a 2xx | Yes. Use this inside the sandbox |
| Hosted URL, outside | A response that isn't a Sandboxer page | Yes. Use this from your agent's side |
| `systemctl start` with an `ExecStartPost=` wait | Your own check passed | Yes. `start` blocks until ready |

## Inside the sandbox: probe with a request

We pulled the image first (10.5 s), so the timings below are startup only. This script starts Grafana bound to `127.0.0.1` and times each signal:

```bash
ms() { echo $(( ($(date +%s%N) - T0) / 1000000 )); }
T0=$(date +%s%N)
docker run -d --name grafana --restart unless-stopped -p 127.0.0.1:3000:3000 grafana/grafana:12.2.0 >/dev/null
echo "docker run returned: $(ms) ms"
timeout 120 bash -c 'until ss -ltnH "sport = :3000" | grep -q .; do sleep 0.1; done' && echo "port 3000 listening (ss): $(ms) ms"
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 2 http://127.0.0.1:3000/api/health); echo "curl right then: HTTP $code"
timeout 120 bash -c 'until curl -fsS -o /dev/null --max-time 2 http://127.0.0.1:3000/api/health; do sleep 0.2; done' && echo "/api/health 200: $(ms) ms"
```

We sent it from our machine with `sbx exec -i $id "bash -s" < inside.sh`:

```text
docker run returned: 371 ms
port 3000 listening (ss): 384 ms
curl right then: HTTP 000
curl: (52) Empty reply from server
curl: (56) Recv failure: Connection reset by peer
…
/api/health 200: 3132 ms
```

The port was "open" for almost three seconds before anything could answer, because Docker's port forward listens as soon as the container starts. A port check would have handed the agent a dead endpoint. Three rules make the loop safe:

- Wrap it in `timeout`, so a crashed app fails the step instead of hanging it.
- Give curl `--max-time`, so one stuck connection can't eat the whole budget.
- Use `-f`, so an app's own `500` during warm-up counts as not ready.

## systemd: make `start` mean ready

A `Type=simple` unit reports `active` as soon as its process exists. Add an `ExecStartPost=` that waits, and `systemctl start` blocks until your check passes. We ran two units that both take three seconds to start serving:

```ini
# /etc/systemd/system/slow-b.service
[Service]
ExecStart=/bin/bash -c 'sleep 3; exec python3 -m http.server 8091 --bind 127.0.0.1'
ExecStartPost=/usr/bin/timeout 60 /bin/bash -c 'until curl -fs -o /dev/null http://127.0.0.1:8091/; do sleep 0.2; done'
```

```text
slow-a: start returned 15 ms, is-active=active, curl=000
slow-b: start returned 3116 ms, is-active=active, curl=200
```

`slow-a` is the same unit without the `ExecStartPost=` line. If the wait times out, the start fails and systemd marks the unit failed, which is the error you want an agent to see.

## From outside: read the hosted URL correctly

When your agent runs outside the sandbox, poll the [hosted URL](/guides/give-a-sandbox-port-an-https-url). Before your app answers, the Sandboxer proxy answers for it. Its pages are small HTML documents with a `<title>` and a last line of `Sandboxer · HTTP <code>`, so you can tell them apart from your app's own errors. These are the ones we got, with Grafana not yet started and port 3000 hosted with `--localhost`:

| Status | Title | What it means | What to do |
| - | - | - | - |
| `401` | Access token required | Private URL, no or wrong `_token` | Fix the URL. Retrying won't help |
| `502` | Nothing is answering | The route works but nothing accepted the request on that port | Keep polling: the app is still starting, crashed, or bound to `127.0.0.1` without `--localhost` |
| `404` | Port not hosted | The sandbox exists but this port isn't hosted | Run `sbx host`. A new route can also 404 for a second or two |
| `404` | Not found | No running sandbox uses this address. We also got this from a stopped sandbox | Check the state with `sbx info`; resume it if stopped |
| `503` | Waking this sandbox… | An [idle-paused](/guides/idle-auto-pause-and-wake-on-use) sandbox is resuming | Retry after the `Retry-After` seconds |

A private URL checks the token before it proxies, so without the token you get `401` whether or not your app is up. Any status that doesn't carry the `Sandboxer · HTTP` line came from your app, even a `502` from an nginx inside it.

## A reusable wait function in bash

This waits until the app itself answers through the hosted URL. It keeps a cookie jar so the private link's first `302` (which sets the auth cookie) is followed like a browser would.

```bash
# wait_for_url URL [TIMEOUT_SECONDS]
# Returns 0 once the app itself answers (any status), 1 on timeout,
# 2 on a Sandboxer 401 (bad token), 3 if the sandbox is stopped.
wait_for_url() {
  local url=$1 timeout=${2:-120} jar body code title start
  jar=$(mktemp) body=$(mktemp) start=$(date +%s%N)
  while (( ($(date +%s%N) - start) / 1000000000 < timeout )); do
    code=$(curl -sL -b "$jar" -c "$jar" -o "$body" --max-time 10 \
      -w '%{http_code}' "$url") || code=000
    title=""
    if grep -q 'Sandboxer · HTTP' "$body"; then  # a page from the proxy, not the app
      title=$(sed -n 's:.*<title>\(.*\)</title>.*:\1:p' "$body")
      case "$code $title" in
        "401 "*) echo "401 $title" >&2; rm -f "$jar" "$body"; return 2 ;;
        "503 Sandbox is not running") echo "stopped: resume it" >&2; rm -f "$jar" "$body"; return 3 ;;
      esac            # 404 route not live yet, 502 nothing listening, 503 waking: retry
    elif [[ $code != 000 ]]; then
      echo "ready in $(( ($(date +%s%N) - start) / 1000000 )) ms (HTTP $code)"
      rm -f "$jar" "$body"; return 0
    fi
    echo "  $(( ($(date +%s%N) - start) / 1000000 )) ms: $code $title" >&2
    sleep 1
  done
  echo "timed out after ${timeout}s (last: $code $title)" >&2
  rm -f "$jar" "$body"; return 1
}
```

We started it on our machine half a second before sending the Grafana start script above:

```bash
wait_for_url "https://alert-fluent-lemon-3000.box.sandboxer.com?_token=<token>" 120
```

```text
  331 ms: 502 Nothing is answering
  1664 ms: 502 Nothing is answering
  3003 ms: 502 Nothing is answering
  4342 ms: 502 Nothing is answering
ready in 6007 ms (HTTP 200)
```

That 6 s includes the `sbx exec` round trip that launched the container. Pass a health path (`…/api/health?_token=<token>`) if you need a 2xx rather than "the app answered".

## The same in Python

Standard library only. `urllib` follows the redirect and the cookie processor keeps the auth cookie.

```python
import http.cookiejar, re, sys, time, urllib.error, urllib.request

def wait_for_url(url: str, timeout: float = 120.0) -> tuple[float, int]:
    """Block until the app behind a hosted URL answers. Returns (seconds waited, status)."""
    opener = urllib.request.build_opener(
        urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()))
    start = time.monotonic()
    last = None
    while time.monotonic() - start < timeout:
        try:
            with opener.open(url, timeout=10) as r:   # follows the 302 that sets the cookie
                return time.monotonic() - start, r.status
        except urllib.error.HTTPError as e:
            body = e.read().decode("utf-8", "replace")
            if "Sandboxer · HTTP" not in body:         # the app's own 4xx/5xx: it is up
                return time.monotonic() - start, e.code
            title = re.search(r"<title>(.*?)</title>", body).group(1)
            if e.code == 401 or title == "Sandbox is not running":
                raise RuntimeError(f"{e.code} {title}") from None
            last = f"{e.code} {title}"                 # 404 / 502 / 503 waking: retry
        except (urllib.error.URLError, TimeoutError) as e:
            last = str(e)
        time.sleep(1)
    raise TimeoutError(f"not ready after {timeout}s (last: {last})")

if __name__ == "__main__":
    secs, status = wait_for_url(sys.argv[1])
    print(f"ready in {secs:.1f} s (HTTP {status})")
```

```text
$ python3 wait_for_url.py "https://alert-fluent-lemon-3000.box.sandboxer.com?_token=<token>"
ready in 0.6 s (HTTP 200)
$ python3 wait_for_url.py "https://alert-fluent-lemon-3000.box.sandboxer.com/"
RuntimeError: 401 Access token required
```

## After create, after resume, after a cold boot

| Event | Sandbox ready | App answered on the hosted URL | Why |
| - | - | - | - |
| `sbx new --type small` | 3.2 s | When you start it (3.1 s for Grafana) | Nothing runs until you start it |
| `sbx resume`, `memoryRestored: true` | 1.3 s | 0.65 s later (2.0 s in total), first poll | Memory restored: Grafana never stopped |
| `sbx resume --type default`, `memoryRestored: false` | 3.4 s | 0.66 s later (4.0 s in total) | Booted from disk; Docker's `--restart unless-stopped` started Grafana 1 s after boot |

After the cold boot, both systemd test units were `inactive`: we had started them but never enabled them, so nothing brought them back. A cold boot only restarts what is set to start on boot, such as an enabled systemd unit or a container with a restart policy. Check `memoryRestored` in the `sbx resume` output (see [pause and resume](/guides/pause-and-resume-a-sandbox-with-memory)), and run your wait function either way: it costs one request when the app is already up.

While the sandbox was stopped, its hosted URL returned `404 Not found` with and without the token. The bash function keeps retrying on that until its timeout, so have your agent resume the sandbox before it waits.

## FAQ

### Why does my health check pass but requests still fail?

You're probably checking the port, not the app. A listening socket can belong to Docker's port forward, a relay or a process still loading. Check a real request instead, and include a health path if the app has one.

### How do I tell a Sandboxer 502 from my app's 502?

Sandboxer's error pages end with `Sandboxer · HTTP 502` and have the title "Nothing is answering". A 502 without that line came from a server inside your sandbox, such as an nginx whose upstream is down.

### Why does my private URL return 401 even though the app is running?

The proxy checks the token before it forwards anything, so a missing or wrong `_token` always gets 401. Use the full URL from `sbx host`, and keep cookies between requests so the first `302` sets the auth cookie.

### How long should the timeout be?

Measure your app once and give it a few times that. Grafana needed about 3 s on a `small` sandbox; a database restoring data or a dev server compiling will need longer. The point of the timeout is to fail the agent's step with a clear error rather than hang.

### Do I need to wait again after a resume?

Usually it returns at once: with memory restored, the app never stopped. After a cold boot (a size change, or no machine with the same CPU type), the app starts again only if it's set to start on boot, so wait for it.

## Related

- [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url)
- [Pause and resume a sandbox with its memory](/guides/pause-and-resume-a-sandbox-with-memory)
- [Idle auto-pause and wake on use](/guides/idle-auto-pause-and-wake-on-use)
- [Hosting docs](/docs/hosting)
