Skip to content
Guides · Agent cookbook

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

Updated October 27, 2026 .md

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

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.

npm i sandboxer-sdk
// 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:

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'
{"pid":1195,"commandId":"cmd_gyxkawsuf66y"}

Watch it, then cut the connection after 2.5 seconds:

timeout 2.5 curl -sN "localhost:8787/stream?pid=1195"
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:

curl -sN -H "Last-Event-ID: 21,21" "localhost:8787/stream?pid=1195"
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 shows sbx history.

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