# How to Stream an Agent's Sandbox Output Live to a Web UI

> Start the command detached, attach to its output from your server with the SDK, and relay each chunk to the browser as Server-Sent Events. Use the byte offsets as event ids so a reconnect resumes with nothing lost or repeated. On prod, lines reached a Chrome page about 0.2 seconds after the sandbox printed them.

Source: https://sandboxer.com/guides/stream-sandbox-logs-to-a-web-ui. Updated 2026-10-27.

**Short answer:** Start the command detached, attach to its output from your server with the SDK, and relay each chunk to the browser as Server-Sent Events. Use the byte offsets as event ids so a reconnect resumes with nothing lost or repeated. On prod, lines reached a Chrome page about 0.2 seconds after the sandbox printed them.

## The options, measured

Sandboxer gives you five ways to read a command's output while it runs. We timed each one on prod on 2026-10-27 with a `small` sandbox in `eu-central`. The command printed a line with the sandbox's clock every 0.5 seconds, and the receiver noted when each line arrived.

| Way to read output | API / CLI / SDK | Median delay | Worst seen | Survives a dropped connection |
| - | - | - | - | - |
| Streamed command | `POST /commands` with `stream: true`, `sbx exec --stream`, `execStream()` | 63–66 ms | 71 ms | No: the command is tied to the request, at most 600 s |
| Exec WebSocket | `GET /exec/ws`, `sbx exec -i` / `-t`, `pty()` | 67 ms | 67 ms | No: built for interactive terminals |
| Attach to a detached command | `GET /commands/{processId}/stream`, `sbx exec --attach`, `attach()` | 171–197 ms | 895 ms | Yes: reattach from any byte offset |
| Poll status with offsets (every 1 s) | `GET /commands/{processId}?stdoutOffset=`, `status()` | 635 ms | 1,138 ms | Yes |
| Follow command history | `sbx history <id> <cmd> --follow` | 1,237 ms | 2,098 ms | Yes, and works after the sandbox stops |

Read the numbers this way:

- About 63 ms of every row is the network. Our test machine had a 127 ms round trip to the sandbox, so one way is roughly 63 ms. The streamed command and the WebSocket add almost nothing on top.
- Attach adds up to 200 ms because the sandbox reads the command's log file every 200 ms. The 895 ms outlier came from one `sbx exec --attach` run of 12 lines; the 80 lines through the SDK never went above 257 ms.
- Polling adds about half the interval on average. `sbx history --follow` polls every 2 seconds, so use it to read a log, not to drive a UI.

The SDK runs were 40 lines each, repeated twice for the streamed and attach rows; the CLI runs were 6 to 12 lines.

## Which one to use for a UI

| Your case | Use |
| - | - |
| An agent tool call that finishes in under 10 minutes, and the agent waits for the result | `execStream()`, and forward each chunk as it comes |
| A job the user watches, can reload the page on, or comes back to | `start()` + `attach()`, relayed over SSE (below) |
| A terminal the user types into | The exec WebSocket (`sandbox.pty()`); see the [API reference](/docs/api) |
| A serverless backend that can't hold a connection open | Poll `status()` with `stdoutOffset` every 1–2 s |
| Showing output after the job ended, or after the sandbox stopped | Command history: see [run a long job and check on it later](/guides/run-a-long-job-in-a-cloud-sandbox) |

The detached path costs about 0.1 seconds more than a streamed command. In return, the command keeps running if your server restarts, and a viewer can attach to it from any byte offset.

## Build it: a relay from sandbox to browser

Your API key must stay on your server, so the browser never talks to Sandboxer. A small relay holds the key, attaches to the command and re-emits each chunk as an SSE event. The event id is the pair of byte offsets (`stdout,stderr`). When the connection drops, the browser's `EventSource` sends that id back in the `Last-Event-ID` header, and the relay reattaches from exactly there.

```bash
npm i sandboxer-sdk
```

```javascript
// relay.mjs: start a command in a sandbox and relay its output to the browser over SSE.
// Run: SANDBOXER_API_KEY=... SANDBOX_ID=sb_... node relay.mjs
import http from "node:http";
import { Sandboxer } from "sandboxer-sdk";

const sandbox = new Sandboxer().sandbox(process.env.SANDBOX_ID);
const PORT = Number(process.env.PORT ?? 8787);

const page = `<!doctype html><pre id="out"></pre><script>
const pid = new URLSearchParams(location.search).get("pid");
const es = new EventSource("/stream?pid=" + pid);
es.onmessage = (e) => { out.textContent += JSON.parse(e.data).d; };
es.addEventListener("exit", (e) => { out.textContent += "\\n[exit " + e.data + "]"; es.close(); });
</script>`;

http.createServer(async (req, res) => {
  const url = new URL(req.url, "http://localhost");

  if (url.pathname === "/") {                       // the page: /?pid=123
    res.writeHead(200, { "content-type": "text/html" }).end(page);

  } else if (url.pathname === "/run" && req.method === "POST") {
    let cmd = "";
    for await (const chunk of req) cmd += chunk;
    const command = await sandbox.start(cmd);       // detached: returns at once
    res.writeHead(200, { "content-type": "application/json" })
       .end(JSON.stringify({ pid: command.processId, commandId: command.commandId }));

  } else if (url.pathname === "/stream") {
    const pid = Number(url.searchParams.get("pid"));
    // The SSE event id is "<stdout offset>,<stderr offset>" in bytes. On reconnect the browser
    // sends it back as Last-Event-ID and the relay resumes there: nothing lost or repeated.
    const off = { stdout: 0, stderr: 0 };
    [off.stdout = 0, off.stderr = 0] = String(req.headers["last-event-id"] ?? "0,0").split(",").map(Number);
    res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" });
    const abort = new AbortController();
    req.on("close", () => abort.abort());
    try {
      const frames = sandbox.attach(pid, { stdoutOffset: off.stdout, stderrOffset: off.stderr, signal: abort.signal });
      for await (const f of frames) {
        if (f.type === "stdout" || f.type === "stderr") {
          off[f.type] = f.nextOffset ?? off[f.type] + Buffer.byteLength(f.data ?? "");
          res.write(`id: ${off.stdout},${off.stderr}\ndata: ${JSON.stringify({ s: f.type, d: f.data })}\n\n`);
        } else if (f.type === "exit") {
          res.write(`event: exit\ndata: ${f.exitCode}\n\n`);
        }
      }
    } catch (err) {
      if (!abort.signal.aborted) res.write(`event: error\ndata: ${JSON.stringify(String(err))}\n\n`);
    }
    res.end();

  } else {
    res.writeHead(404).end();
  }
}).listen(PORT, "127.0.0.1", () => console.log(`relay on http://127.0.0.1:${PORT}`));
```

The `/run` route is there so you can try the relay. In a product, your agent starts the command, and `/stream` must check that the signed-in user owns the sandbox before attaching. The page keeps the process id in its URL, so a reload or a shared link opens the same output.

## Try it with curl

Start a job that writes to both streams and exits with code 3:

```bash
curl -sS -X POST localhost:8787/run \
  --data 'for i in $(seq 1 6); do echo "step $i"; echo "warn $i" >&2; sleep 1; done; exit 3'
```

```json
{"pid":1195,"commandId":"cmd_gyxkawsuf66y"}
```

Watch it, then cut the connection after 2.5 seconds:

```bash
timeout 2.5 curl -sN "localhost:8787/stream?pid=1195"
```

```text
id: 7,0
data: {"s":"stdout","d":"step 1\n"}

id: 7,7
data: {"s":"stderr","d":"warn 1\n"}

id: 14,7
data: {"s":"stdout","d":"step 2\n"}
…
id: 21,21
data: {"s":"stderr","d":"warn 3\n"}
```

Two seconds later, reconnect the way a browser would, with the last id:

```bash
curl -sN -H "Last-Event-ID: 21,21" "localhost:8787/stream?pid=1195"
```

```text
id: 35,21
data: {"s":"stdout","d":"step 4\nstep 5\n"}

id: 35,35
data: {"s":"stderr","d":"warn 4\nwarn 5\n"}

id: 42,35
data: {"s":"stdout","d":"step 6\n"}

id: 42,42
data: {"s":"stderr","d":"warn 6\n"}

event: exit
data: 3
```

Lines written while nobody watched (steps 4 and 5) arrive in one chunk, and nothing from before the cut repeats. The job never noticed the disconnect: it runs in the sandbox, not in the relay.

## What we saw in a browser

We loaded the page in headless Chrome (Playwright) and recorded `Date.now()` for each SSE message. Over two runs of 40 lines, the delay from `echo` in the sandbox to the message in the page was a median of 166 and 204 ms, with a worst case of 297 ms. That is the attach delay plus a few milliseconds for the local relay.

Then we killed the relay 3 seconds into a 10-line job and started it again 2 seconds later. Chrome's `EventSource` reconnected by itself and sent `Last-Event-ID`. The page ended with `step 1` through `step 10`, each once, then `[exit 0]`.

## Things that delay output

- **Your program buffers its own output.** Python buffers `print` when output isn't a terminal. Through `sbx exec --stream`, `python3 -c` printing a line every 0.5 s delivered all 6 lines together when it exited, the first one 3.07 s late. With `python3 -u` each line arrived 66 ms after it was printed. Use `-u` or `PYTHONUNBUFFERED=1`, and the equivalent flush option in other languages.
- **The sandbox clock is not your clock.** To measure delay, we compared the sandbox's timestamps with ours. Our sandbox's clock was 1.18 s behind, and `timedatectl` reported `System clock synchronized: no`. We measured the offset over a private hosted port (the shortest of 40 round trips) and subtracted it. Don't compute delays from raw sandbox timestamps.
- **Long quiet periods.** The attach stream sends a `heartbeat` frame after 15 seconds with no output and ends with a `reconnect` frame after 10 minutes. The SDK's `attach()` reopens it at the right offsets for you, and hides heartbeats unless you pass `heartbeats: true`.

## FAQ

### Can the browser connect to Sandboxer directly?

Not safely. Every endpoint needs an API key, and a key in the page would let anyone who opens DevTools control your sandboxes. Keep the key on your server and relay the output, as above.

### Why SSE and not a WebSocket to the browser?

The output only flows one way, and `EventSource` reconnects by itself and sends the last event id back. That id maps directly onto the attach stream's byte offsets, so resuming takes one header. Use a WebSocket when the user also types input.

### Does the command stop if the user closes the tab?

No. A detached command keeps running in the sandbox until it exits or you kill it. Closing the tab only ends that viewer's attach stream.

### How do I show the output after the job ended?

Read command history. It keeps the output of commands run through the API for 90 days and works while the sandbox is stopped; the [long job guide](/guides/run-a-long-job-in-a-cloud-sandbox) shows `sbx history`.

## Related

- [Run a long job in a cloud sandbox and check on it later](/guides/run-a-long-job-in-a-cloud-sandbox)
- [Long-running tasks: stream output, attach and history](/docs/long-running)
- [TypeScript SDK](/docs/sdk-typescript)
- [Give every user their own sandbox](/guides/give-every-user-their-own-sandbox)
