Skip to content
Guides · Agent cookbook

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

Updated October 27, 2026 .md

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:

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:

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:

# /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'
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. 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 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.

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

wait_for_url "https://alert-fluent-lemon-3000.box.sandboxer.com?_token=<token>" 120
  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.

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})")
$ 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), 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.

More guides

Top AI sandbox platforms for code execution in 2026, ranked

For running AI-written code we rank Sandboxer, Freestyle and E2B first: each gives code its own VM with an API or SDK and can pause with memory. Pick Daytona for Windows or GPUs, Modal or Northflank for GPUs at scale, E2B or Northflank to run in your own cloud, and Vercel Sandbox if your app lives on Vercel.

Read the guide
Best sandboxes for running coding agents (Claude Code, Codex, Gemini CLI) in 2026, ranked

For coding agents we rank Sandboxer first: a full Ubuntu VM with Claude Code and Codex preinstalled, Docker, and stop-and-resume that keeps the agent's session in memory. Freestyle and Amp Orbs also keep memory across pauses. Pick E2B for prebuilt agent templates on open source, Docker Sandboxes for free local runs, and Codespaces if you live in GitHub.

Read the guide
Best Daytona sandbox alternatives in 2026, ranked

The best Daytona alternatives depend on why you're switching. For a full Linux microVM on every sandbox with memory-saving stop and Docker, we rank Sandboxer first. E2B suits open source and self-hosting, Modal massive short-job fleets, Northflank bring-your-own-cloud with GPUs, and Vercel Sandbox apps on Vercel.

Read the guide
Best E2B alternatives in 2026, ranked

If you're leaving E2B for longer sessions or a fuller machine, we rank Sandboxer first, then Freestyle: both give each sandbox a full Linux VM that keeps memory across pauses, with Docker and no short session cap. Pick Daytona for Windows or GPUs, Northflank for bring-your-own-cloud, and stay on E2B if you need open source.

Read the guide