PandaStack

Stream Code Execution Output in Real Time

Stream code execution output in real time over SSE — pass on_stdout/on_stderr callbacks to exec_stream and get a build's output as it's produced.

Long-running commands — a multi-minute build, a training loop, a script that prints progress — shouldn't stay silent until they exit. sandbox.exec() buffers everything and hands you the full output only after the process finishes. For anything that takes more than a second or two, you want the output as it lands: that's exec_stream (Python) / execStream (TypeScript).

Under the hood this is streaming code execution output over real-time SSE: the agent on the host forwards the process's stdout/stderr line-by-line as Server-Sent Events, and the SDK invokes your callbacks for each chunk. You never touch the SSE wire format — you just pass on_stdout/on_stderr and get the exit code back when the process ends.

exec vs. exec_stream

execexec_stream
Output deliveryBuffered, returned at exitStreamed via callbacks as produced
Return valueExecResult (.stdout, .stderr, .exit_code, .duration_ms)int exit code
Best forShort commands, when you need the full stringLong jobs, live progress, log tailing
TransportRequest/responseSSE (Server-Sent Events)

Reach for plain exec when the command is quick and you want the whole result as a string. Reach for exec_stream when you want to watch the command run.

Stream a build

Here's a real example: install dependencies and run a build, printing every line as it happens. The callback fires once per output chunk; the call returns the process exit code.

import shlex
import sys
from pandastack import Sandbox

# PANDASTACK_API_KEY is read from the environment.
sandbox = Sandbox.create(template="code-interpreter")

try:
    # Clone, install, build — output streams live instead of blocking until exit.
    script = (
        "set -e; "
        "echo '== installing =='; pip install --quiet rich; "
        "echo '== building =='; "
        "for i in $(seq 1 5); do echo \"step $i/5\"; sleep 1; done; "
        "echo '== done =='"
    )

    exit_code = sandbox.exec_stream(
        f"bash -lc {shlex.quote(script)}",
        on_stdout=lambda chunk: print(chunk, end="", flush=True),
        on_stderr=lambda chunk: print(chunk, end="", file=sys.stderr, flush=True),
        timeout_seconds=300,
    )

    print(f"\nbuild exited with code {exit_code}")
    if exit_code != 0:
        raise SystemExit(exit_code)
finally:
    sandbox.kill()
import { Sandbox } from "@pandastack/sdk";

// PANDASTACK_API_KEY is read from the environment.
const sandbox = await Sandbox.create({ template: "code-interpreter" });

try {
  // Clone, install, build — output streams live instead of blocking until exit.
  const script = [
    "set -e",
    "echo '== installing =='; pip install --quiet rich",
    "echo '== building =='",
    "for i in $(seq 1 5); do echo \"step $i/5\"; sleep 1; done",
    "echo '== done =='",
  ].join("; ");

  const exitCode = await sandbox.execStream(`bash -lc ${JSON.stringify(script)}`, {
    onStdout: (chunk) => process.stdout.write(chunk),
    onStderr: (chunk) => process.stderr.write(chunk),
    timeoutSeconds: 300,
  });

  console.log(`\nbuild exited with code ${exitCode}`);
  if (exitCode !== 0) process.exit(exitCode);
} finally {
  await sandbox.kill();
}

Both calls block until the process exits, but your callbacks have already printed every line by then. The return value is just the exit code — there's no buffered .stdout to read, because you consumed it as it streamed.

How the callbacks work

ArgumentTypeFires when
on_stdout / onStdout(chunk: str) -> NoneA chunk of standard output is produced
on_stderr / onStderr(chunk: str) -> NoneA chunk of standard error is produced
timeout_seconds / timeoutSecondsnumberKills the command and ends the stream if exceeded

A few things to keep in mind:

  • Chunks, not lines. A callback may receive a partial line or several lines at once depending on how the process flushes. If you need clean line boundaries, buffer in your callback and split on \n yourself.
  • stdout and stderr stay separate. They arrive on distinct callbacks so you can route them differently (e.g. progress to stdout, warnings to a log).
  • The return value is the exit code. Non-zero means the command failed; check it the way you would ExecResult.exit_code from exec.
  • Timeouts apply to the whole command, not to gaps between chunks. If the job exceeds timeout_seconds, the stream ends and the command is killed.

Capturing while streaming

If you want both live output and the full text at the end, accumulate it in the callback:

buf = []
exit_code = sandbox.exec_stream(
    "python -u long_task.py",
    on_stdout=lambda c: (buf.append(c), print(c, end="", flush=True)),
)
full_output = "".join(buf)
const buf: string[] = [];
const exitCode = await sandbox.execStream("python -u long_task.py", {
  onStdout: (c) => {
    buf.push(c);
    process.stdout.write(c);
  },
});
const fullOutput = buf.join("");

Tip: Run Python with -u (or set PYTHONUNBUFFERED=1) inside the sandbox. Python buffers stdout when it's not attached to a TTY, which can make progress arrive in one burst at the end. -u forces line-buffered output so your callbacks fire as each line prints.

When to use the code interpreter instead

exec_stream is for shell commands and scripts where you care about live logs. If instead you're running Python cells and want structured results — return values, DataFrames, matplotlib charts captured as PNGs — use a code interpreter context (create_code_context / createCodeContext). That path gives you Execution.results with rich outputs rather than a raw text stream. The two are complementary: stream a pip install or a build with exec_stream, then run analysis cells in a CodeContext.

For the buffered request/response model and the full ExecResult shape, see the exec concept.

Next steps

On this page