NodeOps
UK

How-to: stream command output

Stream live stdout/stderr from a long-running command instead of waiting for it to finish.

At a glance

  • Package: @nodeops-createos/sandbox (npm)
  • Import: import { createClient } from "@nodeops-createos/sandbox"
  • Base URL: https://api.sb.createos.sh — override with CREATEOS_SANDBOX_BASE_URL
  • Auth: API key via the apiKey option or CREATEOS_SANDBOX_API_KEY

Problem

You want to see stdout/stderr as a command produces it — not after it exits — so you can pipe logs into a UI, kill the command on a pattern match, or keep the user informed during long builds.

Availability caveat

On some control-plane versions the streaming exec endpoint returns 404. runCommand (buffered) is the reliable default. Use streamCommand only when the control plane is known to support it; if you receive a CreateosSandboxNotFoundError on the first iteration, fall back to runCommand.

Solution

sandbox.streamCommand is an async generator — no buffering, no waiting. It yields a discriminated ExecStreamEvent union; switch on event.type to handle each variant:

TypeScript
1import { CreateosSandboxClient } from "@nodeops-createos/sandbox";
2
3const client = new CreateosSandboxClient();
4const sandbox = await client.createSandbox({ rootfs: "base-debian-12" });
5
6try {
7 for await (const event of sandbox.streamCommand("npm", ["install"])) {
8 switch (event.type) {
9 case "stdout":
10 process.stdout.write(event.data);
11 break;
12 case "stderr":
13 process.stderr.write(event.data);
14 break;
15 case "exit":
16 console.log(`exited ${event.exitCode}`);
17 break;
18 case "error":
19 // Control-plane reported an agent-level error.
20 console.error("agent error:", event.message);
21 break;
22 case "heartbeat":
23 // Emitted every ~5 s to keep the connection alive. No payload.
24 break;
25 }
26 }
27} finally {
28 await sandbox.destroy();
29}

TypeScript narrows the union inside each case: event.data is only accessible under "stdout" / "stderr", event.exitCode only under "exit", event.message only under "error".

Event types

event.typeExtra fieldsNotes
"stdout"data: stringA chunk of stdout text.
"stderr"data: stringA chunk of stderr text.
"exit"exitCode: numberCommand finished; last event before the generator returns.
"error"message: stringAgent-level error from the control plane.
"heartbeat"Keepalive emitted every ~5 s. Safe to ignore.

Bail out early

Throw inside the loop to cancel the stream at any point. The generator unwinds and the HTTP connection closes:

TypeScript
1import { CreateosSandboxClient } from "@nodeops-createos/sandbox";
2
3const client = new CreateosSandboxClient();
4const sandbox = await client.createSandbox({ rootfs: "base-debian-12" });
5
6try {
7 const lines: string[] = [];
8 for await (const event of sandbox.streamCommand("bash", ["-lc", "while true; do date; sleep 1; done"])) {
9 if (event.type === "stdout") {
10 lines.push(event.data.trimEnd());
11 if (lines.length >= 5) throw new Error("done");
12 }
13 if (event.type === "error") throw new Error(event.message);
14 }
15} catch (err) {
16 if ((err as Error).message !== "done") throw err;
17} finally {
18 await sandbox.destroy();
19}

You can also pass an AbortSignal via options.signal to cancel from outside the loop — for example, on a wall-clock deadline:

TypeScript
1const ac = new AbortController();
2setTimeout(() => ac.abort(), 30_000);
3
4for await (const event of sandbox.streamCommand("make", ["build"], { signal: ac.signal })) {
5 if (event.type === "stdout") process.stdout.write(event.data);
6}

Raw frames (escape hatch)

For pipelines that need the server's native snake_case shape — log forwarders, raw proxies — bypass the ExecStreamEvent projection and drive the low-level transport directly:

TypeScript
1import { CreateosSandboxClient } from "@nodeops-createos/sandbox";
2import type { ExecStreamFrame } from "@nodeops-createos/sandbox";
3
4const client = new CreateosSandboxClient();
5const sandbox = await client.createSandbox({ rootfs: "base-debian-12" });
6const id = sandbox.id;
7
8try {
9 const frames = client.http.stream<ExecStreamFrame>(
10 "POST",
11 `/v1/sandboxes/${id}/exec`,
12 {
13 query: { stream: true },
14 body: { cmd: "sh", args: ["-c", "echo hello"], stream: true },
15 },
16 );
17 for await (const frame of frames) {
18 if (frame.stdout) process.stdout.write(frame.stdout);
19 if (frame.stderr) process.stderr.write(frame.stderr);
20 }
21} finally {
22 await sandbox.destroy();
23}

ExecStreamFrame wire fields (snake_case, server-native):

FieldTypeNotes
stdoutstring?Stdout chunk.
stderrstring?Stderr chunk.
exit_codenumber?Process exit code.
errorstring?Agent-level error message.
hbboolean?Heartbeat marker (emitted every ~5 s).

streamCommand is the right choice for most callers — it projects these fields into the typed ExecStreamEvent union so TypeScript's narrowing works without manual null-checks.

Not retried

Streaming requests are never retried by the SDK. A half-consumed NDJSON stream cannot be replayed — the server has already flushed frames that are gone. When the connection breaks the iterator throws a CreateosSandboxError and your loop unwinds. Reconnect and restart from scratch if you need retry semantics.

By contrast, runCommand (buffered, idempotent) is retried automatically on network errors and transient server failures (500/502/503/504). Prefer it when the command is safe to re-run and live output is not required.

See also

100,000+ Builders. One Workspace.

Get product updates, builder stories, and early access to features that help you ship faster.

CreateOS is a unified intelligent workspace where ideas move seamlessly from concept to live deployment, eliminating context-switching across tools, infrastructure, and workflows with the opportunity to monetize ideas immediately on the CreateOS Marketplace.