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 withCREATEOS_SANDBOX_BASE_URL - Auth: API key via the
apiKeyoption orCREATEOS_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:
TypeScript1import { CreateosSandboxClient } from "@nodeops-createos/sandbox";23const client = new CreateosSandboxClient();4const sandbox = await client.createSandbox({ rootfs: "base-debian-12" });56try {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.type | Extra fields | Notes |
|---|---|---|
"stdout" | data: string | A chunk of stdout text. |
"stderr" | data: string | A chunk of stderr text. |
"exit" | exitCode: number | Command finished; last event before the generator returns. |
"error" | message: string | Agent-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:
TypeScript1import { CreateosSandboxClient } from "@nodeops-createos/sandbox";23const client = new CreateosSandboxClient();4const sandbox = await client.createSandbox({ rootfs: "base-debian-12" });56try {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:
TypeScript1const ac = new AbortController();2setTimeout(() => ac.abort(), 30_000);34for 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:
TypeScript1import { CreateosSandboxClient } from "@nodeops-createos/sandbox";2import type { ExecStreamFrame } from "@nodeops-createos/sandbox";34const client = new CreateosSandboxClient();5const sandbox = await client.createSandbox({ rootfs: "base-debian-12" });6const id = sandbox.id;78try {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):
| Field | Type | Notes |
|---|---|---|
stdout | string? | Stdout chunk. |
stderr | string? | Stderr chunk. |
exit_code | number? | Process exit code. |
error | string? | Agent-level error message. |
hb | boolean? | 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.