How to stream command output from a sandbox
The first version of every sandbox integration uses blocking exec: run the command, wait, read stdout when it's over. That works right up until a command takes more than a few seconds, and then it stops working in three ways at once. You cannot show progress to a user. You cannot tell a slow command from a hung one. And if the command dies partway through, you lose the output that would have told you why.
The fix is streaming: the sandbox emits stdout and stderr chunks as they are produced, and finally an exit code. This walks through the mechanics — the wire protocol, both SDKs, and the parts that are subtly harder than they look.
The wire protocol is Server-Sent Events
PandaStack's streaming exec endpoint is `POST /v1/sandboxes/{id}/exec/stream`, and it responds with an SSE stream carrying three event types: `stdout` and `stderr` chunks, then a final `exit` event with the exit code. SSE rather than WebSockets, because the traffic is one-directional and SSE survives proxies, load balancers, and corporate networks with far less argument.
curl -N -X POST \
-H "Authorization: Bearer $PANDASTACK_API_KEY" \
-H "Accept: text/event-stream" \
-H "Content-Type: application/json" \
-d '{"cmd":"pip install -r requirements.txt"}' \
https://api.pandastack.ai/v1/sandboxes/$SANDBOX_ID/exec/stream
# event: stdout
# data: {"chunk":"Collecting flask\n"}
# event: stdout
# data: {"chunk":"Successfully installed flask-3.0.3\n"}
# event: exit
# data: {"exit_code":0}Python: callbacks per chunk
The Python SDK wraps the endpoint in `exec_stream`, which invokes your callbacks as chunks arrive and returns the exit code when the command finishes.
from pandastack import Sandbox
with Sandbox.create(template="base") as sb:
sb.exec("git clone --depth 1 https://github.com/acme/api /work")
def on_out(chunk: str) -> None:
print(chunk, end="", flush=True)
def on_err(chunk: str) -> None:
print(chunk, end="", flush=True)
code = sb.exec_stream(
"cd /work && npm ci && npm run build",
on_stdout=on_out,
on_stderr=on_err,
timeout_seconds=900,
)
if code != 0:
raise RuntimeError(f"build failed with exit code {code}")Two things to notice. `flush=True` is not optional — Python buffers stdout when it is not a terminal, so without it your streamed output is buffered by your own process after travelling all that way unbuffered. And the timeout is generous: streaming exists precisely for commands long enough that the default is wrong.
TypeScript: the same shape, async
import { Sandbox } from "@pandastack/sdk";
await using sb = await Sandbox.create({ template: "base" });
const exitCode = await sb.execStream("cd /work && npm test", {
onStdout: (chunk) => process.stdout.write(chunk),
onStderr: (chunk) => process.stderr.write(chunk),
});
if (exitCode !== 0) throw new Error("tests failed: " + exitCode);If you are forwarding this to a browser, do not accumulate the chunks and send them at the end — that reintroduces the exact problem you are solving. Pipe each chunk straight into your own SSE response or WebSocket as it arrives.
Chunks are not lines
This is the bug everyone writes once. A chunk is whatever the process happened to flush — it can be half a line, three lines, or a single byte. Any code that assumes one chunk equals one line will split words in the middle and mangle output the moment a build tool writes progress incrementally.
buffer = ""
def on_out(chunk: str) -> None:
global buffer
buffer += chunk
while "\n" in buffer:
line, buffer = buffer.split("\n", 1)
handle_line(line) # now you really do have a lineThe same rule applies to ANSI escape codes. Tools that draw progress bars emit cursor-movement sequences that look like garbage in a plain log viewer, and a sequence can be split across two chunks. Either render them properly with a terminal emulator on the front end, or strip them after reassembling lines — never mid-chunk, or you will strip half an escape sequence and leave the other half.
Three different streams, three different jobs
- Streaming exec — output of one command you started. This is what you want for builds, test runs, and agent tool calls.
- Sandbox logs (`GET /v1/sandboxes/{id}/logs?follow=1`) — the host-side VM log for the machine itself. Useful for boot and lifecycle problems, not for your process's output.
- PTY over WebSocket (`GET /v1/sandboxes/{id}/exec/pty`) — a real terminal, bidirectional, which is what you attach xterm.js to when a human needs to type. Interactive prompts need this; streaming exec has no input channel.
Choosing wrong here is a common source of confusion: people follow the sandbox log expecting their npm output, see kernel messages, and conclude that streaming is broken.
Making it survive contact with production
- Cap what you keep. A verbose build can emit tens of megabytes. Stream it to the user, but persist a bounded tail — the last few thousand lines is almost always enough to diagnose a failure, and unbounded storage is how a log becomes an incident.
- Handle disconnects. A dropped connection does not stop the command; it stops your view of it. Decide whether reconnecting resumes, restarts, or reports — and make sure you never silently run the same build twice.
- Never stream raw output straight into a model's context. Agent frameworks that pipe an entire build log into the next prompt burn a fortune in tokens on npm progress bars. Filter, truncate, and keep the tail plus any lines matching an error pattern.
- Set an idle timeout as well as a total timeout. A command that produces no output for ten minutes is usually stuck; a command that has run for ten minutes while printing may be perfectly healthy. Those are different conditions and deserve different limits.
- Log the exit code explicitly. The most common streaming bug in agent code is treating any completed stream as success, which turns a failed build into a confident wrong answer downstream.
The short version
Use streaming exec for anything that might take more than a couple of seconds, buffer chunks into lines before doing anything line-oriented, keep a bounded tail rather than the whole log, and always check the exit code rather than assuming a finished stream meant success. That is roughly forty lines of code, and it is the difference between an integration that feels alive and one where users stare at a spinner wondering whether to refresh.
Frequently asked questions
Why is my streamed output arriving all at once?
Something in the chain is buffering, and it is usually not the sandbox. The three usual culprits are your HTTP client buffering the response body — curl needs -N, most SDKs handle this for you — your own process buffering stdout when it is not attached to a terminal, which Python needs flush=True for, and the program inside the sandbox buffering its own output because it detected a pipe rather than a TTY. That last one is the sneakiest: many tools switch to block buffering when not on a terminal, and the fix is running them under a pseudo-terminal or setting the tool's own unbuffered flag, such as python -u.
Should I use SSE or WebSockets for this?
SSE when data flows one way, WebSockets when you need to send input back. Streaming a build's output is one-directional, and SSE gives you automatic reconnection semantics, ordinary HTTP infrastructure, and far fewer proxy problems. The moment a human needs to type — an interactive prompt, a debugger, a shell session — you need a bidirectional channel, which is why terminal access uses a WebSocket PTY endpoint instead. Using WebSockets for pure output streaming works but adds complexity you get nothing for.
How do I stream output to a browser through my own backend?
Chain the streams rather than collecting and forwarding. Your backend opens the sandbox's SSE stream and, for each chunk it receives, immediately writes a chunk to its own SSE response to the browser. The mistakes that break it are buffering the whole thing server-side before responding, forgetting to disable response buffering in a reverse proxy such as nginx, and not sending periodic keep-alive comments so that intermediaries do not close an idle connection during a quiet stretch of a long build.
What happens to the command if my connection drops?
It keeps running. The command is a process inside the sandbox, and your stream is just a view of its output — disconnecting closes the view, not the process. That is usually what you want for a long build, but it means you need a deliberate reconnection story: either poll for completion and fetch the result, or re-attach to output if the platform supports it. The dangerous non-decision is a client that retries the entire request on disconnect, which starts a second copy of the command while the first is still running.
How much output should I keep for an AI agent?
Far less than you are tempted to. A full npm or cargo build can be tens of thousands of lines, almost all of it progress noise, and feeding that into a model's context is expensive and actively unhelpful — the signal drowns. A good default is the last few hundred lines plus any line matching an error pattern, with the total capped at a few thousand tokens. If the agent needs more, let it ask for it explicitly by grepping the log inside the sandbox, which is both cheaper and closer to what a human debugging the same failure would do.
Keep reading
- Sandboxes on PandaStack — streaming exec, PTY terminals, and a filesystem API
- How to build a sandboxed AI coding agent
- Running agent shell commands in a sandbox
- Agent tool timeouts and cancellation
- Building a remote code execution API
49ms p50 cold start. Fork, snapshot, and scale to zero.