---
title: Commands
description: Execute commands, stream output, and interact with sandboxes
icon: "terminal"
---

Run commands, read their output, and check exit codes without SSH. Execution uses a dedicated host-to-guest channel, so it works even with networking disabled.

## Run the image default workload

Sandbox creation is boot-only: configuring an image, ENTRYPOINT, or CMD does not execute that workload. Use the default-workload methods when you want the SDK to resolve the effective OCI `ENTRYPOINT + CMD` and run it. A configured CMD override replaces the image CMD while preserving the effective entrypoint. If neither field supplies an executable, these methods return the typed `NoDefaultCommand` error instead of falling back to a shell.

<CodeGroup>
```typescript TypeScript
const sb = await Sandbox.builder("worker")
  .image("example/worker:latest")
  .cmd(["worker.py", "--once"])
  .create();
const output = await sb.execDefault();
```

```rust Rust
let sb = Sandbox::builder("worker")
    .image("example/worker:latest")
    .cmd(["worker.py", "--once"])
    .create()
    .await?;
let output = sb.exec_default().await?;
```

```python Python
sb = await Sandbox.create(
    "worker",
    image="example/worker:latest",
    cmd=["worker.py", "--once"],
)
output = await sb.exec_default()
```

```go Go
sb, err := m.CreateSandbox(ctx, "worker",
    m.WithImage("example/worker:latest"),
    m.WithCmd("worker.py", "--once"),
)
output, err := sb.ExecDefault(ctx)
```
</CodeGroup>

The default workload also supports streaming and interactive execution:

<CodeGroup>
```typescript TypeScript
// Choose one: stream output, or attach the current terminal.
const stream = await sb.execDefaultStream();
// const exit = await sb.attachDefault();
```

```rust Rust
// Choose one: stream output, or attach the current terminal.
let stream = sb.exec_default_stream().await?;
// let exit = sb.attach_default().await?;
```

```python Python
# Choose one: stream output, or attach the current terminal.
stream = await sb.exec_default_stream()
# exit_code = await sb.attach_default()
```

```go Go
// Choose one: stream output, or attach the current terminal.
stream, err := sb.ExecDefaultStream(ctx)
if err != nil {
    return err
}
defer stream.Close()
// exitCode, err := sb.AttachDefault(ctx)
```
</CodeGroup>

Consume the stream as shown in [Streaming output](#stream-output). Literal command execution and terminal attachment continue to ignore ENTRYPOINT and CMD.

## Execute a command

Run a command and wait for it to complete. You get back the exit code, stdout, and stderr.

<CodeGroup>
```typescript TypeScript
const output = await sb.exec("python", ["-c", "print('hello')"]);
console.log(output.stdout()); // "hello\n"
console.log(output.success);  // true
console.log(output.code);     // 0
```

```rust Rust
let output = sb.exec("python", ["-c", "print('hello')"]).await?;
println!("{}", output.stdout()?);     // "hello\n"
println!("{}", output.status().code); // 0
```

```python Python
output = await sb.exec("python", ["-c", "print('hello')"])
print(output.stdout_text)     # "hello\n"
print(output.success)         # True
print(output.exit_code)       # 0
```

```go Go
output, err := sb.Exec(ctx, "python", []string{"-c", "print('hello')"})
fmt.Print(output.Stdout())     // "hello\n"
fmt.Println(output.Success())  // true
fmt.Println(output.ExitCode()) // 0
```

```ruby Ruby
output = sb.exec("python", ["-c", "print('hello')"])
puts output.stdout     # "hello\n"
puts output.success?   # true
puts output.exit_code  # 0
```

```bash CLI
msb exec worker -- python -c "print('hello')"
```
</CodeGroup>

<span id="managed-jobs" />

## Background jobs

Use a background job for a command that should keep running after you disconnect. The local sandbox must already be running. Starting the job returns an ID you can use to check its progress or reconnect.

<CodeGroup>
```typescript TypeScript
const job = await sb.execDetachedWith("sh", (e) => e
    .args(["-c", "echo started; sleep 60; echo done"])
    .stdinNull());
console.log(job.id);
const result = await job.wait();
const output = await job.logs();
```

```rust Rust
use microsandbox::sandbox::jobs::JobLogOptions;

let job = sb.exec_detached_with("sh", |e| e
    .args(["-c", "echo started; sleep 60; echo done"])
    .stdin_null()).await?;
println!("{}", job.id());
let result = job.wait().await?;
let output = job.logs(&JobLogOptions::default()).await?;
```

```python Python
from microsandbox import Stdin

job = await sb.exec_detached(
    "sh", ["-c", "echo started; sleep 60; echo done"], stdin=Stdin.null(),
)
print(job.id)
result = await job.wait()
output = await job.logs()
```

```go Go
job, err := sb.ExecDetached(ctx, "sh",
    []string{"-c", "echo started; sleep 60; echo done"}, m.WithExecStdinNull(),
)
if err != nil {
    return err
}
defer job.Close()
fmt.Println(job.ID())
result, err := job.Wait(ctx)
if err != nil {
    return err
}
```

```ruby Ruby
job = sb.exec_detached("sh", ["-c", "echo started; sleep 60; echo done"], stdin: :null)
puts job.id
result = job.wait
output = job.logs
```

```bash CLI
msb create alpine --name worker
job=$(msb exec -d --no-stdin worker -- sh -c 'echo started; sleep 60; echo done')
msb logs worker --job "$job" --follow
msb wait worker --job "$job"
```
</CodeGroup>

Stopping the sandbox stops its jobs. Restarting never reruns them. Finish active jobs before taking a full snapshot or forking; pause and resume remain supported.

| Command | Purpose |
| --- | --- |
| `msb jobs <sandbox>` | List active jobs; `--all` includes retained history |
| `msb logs <sandbox> --job ID` | Read output; add `--follow` to stream it |
| `msb inspect <sandbox> --job ID` | Show command, state, input mode, and exit result |
| `msb attach <sandbox> --job ID` | Reconnect; Ctrl-] detaches without stopping the job |
| `msb wait <sandbox> --job ID` | Wait for exit; `--timeout 30s` limits the wait, not the job |
| `msb signal <sandbox> --job ID --signal TERM` | Send a Linux signal to the process group |
| `msb kill <sandbox> --job ID` | Request SIGKILL; use `wait` to confirm exit |
| `msb eof <sandbox> --job ID` | Close pipe input permanently; use Ctrl-D for a terminal |

Job IDs belong to one sandbox and are separate from process IDs. Without `--job`, `logs`, `inspect`, and `wait` refer to the sandbox itself. All commands also support the `msb sandbox ...` form.

<Accordion title="Input and interactive shells">

`--no-stdin` sends EOF and disables automatic terminal allocation. Otherwise, input stays open for a later attachment. `--tty` forces a terminal; `--no-tty` uses pipes. Detached launch never reads your terminal's input and cannot be combined with `--stream`.

<CodeGroup>
```bash Shell
shell_job=$(msb exec -d --tty worker -- sh)
msb attach worker --job "$shell_job"
# Press Ctrl-] to detach, then attach again with the same job ID.
```
</CodeGroup>

One attachment can write at a time; others can use `--read-only`. Detaching or reaching local input EOF leaves guest input open. An abrupt disconnect releases input ownership within 30 seconds. Terminal output combines stdout and stderr as `output`; pipes keep them separate.

</Accordion>

<Accordion title="History and limits">

Job metadata and captured output remain available after disconnect or sandbox shutdown. After a runtime restart, a job without a confirmed exit is marked `lost`.

- Up to 32 active jobs and 256 retained histories per sandbox.
- Two 512 KiB output segments per job. Reading pruned output reports a cursor-gap error.
- After 65,536 job admissions, restart the sandbox to launch more.

Use `--limit N` and `--cursor ID` to page through `jobs`. `jobs`, `inspect`, and `wait` accept `--format json`. Logs retain their usual tail, time, source, and format options.

If output storage fails, capture stops permanently for that job. Previously saved output stays readable, and the process and live attachments continue. Inspect the job for the cutoff. If metadata storage also fails, its diagnostic stays in memory until a later update can save it.

</Accordion>

<Accordion title="Timeouts and interrupted input">

Execution timeouts still apply after you disconnect. Older runtimes and unsupported backends reject job launch before dispatch. SDK job errors expose stable codes. If launch confirmation is lost, the error includes a job ID; inspect it before retrying to avoid starting the command twice.

While paused, you can inspect or wait for jobs, but waiting does not resume them. Signals take effect after resume. Full input buffers do not block signals or deadlines. Input bytes and EOF remain ordered; a signal may interrupt queued input. Inspect reports signal-delivery failures.

Ctrl-] remains available while input is blocked, with up to 1 MiB of buffered lookahead. Detaching waits up to two seconds for earlier input to be accepted. Overflow discards further input but keeps the attachment open until you detach, reach local EOF, or the job finishes.

`input_not_flushed` means some input was dropped or could not be confirmed. Do not resend it automatically: the job may already have received it. Detaching restores your terminal without stopping the job or closing its input.

A graceful sandbox stop interrupts queued command input while preserving filesystem ordering. If stop times out, it reports failure without forcing termination.

</Accordion>

## Execution options

These options apply to a single execution and don't change the sandbox's defaults.

<CodeGroup>
```typescript TypeScript
const output = await sb.execWith("python", (e) =>
    e.args(["compute.py"])
        .cwd("/app")
        .env("PYTHONPATH", "/app/lib")
        .timeout(30_000)
        .rlimit("nofile", 1024),
);
```

```rust Rust
let output = sb.exec_with("python", |e| e
    .args(["compute.py"])
    .cwd("/app")
    .env("PYTHONPATH", "/app/lib")
    .timeout(Duration::from_secs(30))
    .rlimit(RlimitResource::Nofile, 1024)
).await?;
```

```python Python
from microsandbox import Rlimit

output = await sb.exec(
    "python",
    ["compute.py"],
    cwd="/app",
    env={"PYTHONPATH": "/app/lib"},
    timeout=30.0,
    rlimits=[Rlimit.nofile(1024)],
)
```

```go Go
output, err := sb.Exec(ctx, "python", []string{"compute.py"},
    m.WithExecCwd("/app"),
    m.WithExecEnv(map[string]string{"PYTHONPATH": "/app/lib"}),
    m.WithExecTimeout(30*time.Second),
)
```

```ruby Ruby
output = sb.exec(
  "python", ["compute.py"],
  cwd: "/app",
  env: { "PYTHONPATH" => "/app/lib" },
  timeout: 30.0,
  rlimits: { nofile: 1024 }
)
```

```bash CLI
msb exec worker \
  -w /app \
  -e PYTHONPATH=/app/lib \
  --timeout 30s \
  --rlimit nofile=1024 \
  -- python compute.py
```
</CodeGroup>

## Shell commands

Run a command through the sandbox's configured shell (defaults to `/bin/sh`). Useful for pipelines, redirects, and other shell syntax that `exec` doesn't interpret.

<CodeGroup>
```typescript TypeScript
const output = await sb.shell("ls -la /app && echo done")
```

```rust Rust
let output = sb.shell("ls -la /app && echo done").await?;
```

```python Python
output = await sb.shell("ls -la /app && echo done")
```

```go Go
output, err := sb.Shell(ctx, "ls -la /app && echo done")
```

```ruby Ruby
output = sb.shell("ls -la /app && echo done")
```

```bash CLI
msb exec worker -- sh -c "ls -la /app && echo done"
```
</CodeGroup>

## Stream output

<Note>Ruby currently collects command output with `exec` and `shell`. It does not expose streaming handles, interactive attach, or session IDs.</Note>

For long-running processes or large output, streaming gives you stdout, stderr, and exit events as they happen instead of buffering everything until the command finishes.

<CodeGroup>
```typescript TypeScript
const handle = await sb.execStream("tail", ["-f", "/var/log/app.log"]);

for await (const event of handle) {
    switch (event.kind) {
        case "stdout": process.stdout.write(event.data); break;
        case "stderr": process.stderr.write(event.data); break;
        case "exited": console.log(`Exited: ${event.code}`); break;
    }
}
```

```rust Rust
let mut handle = sb.exec_stream("tail", ["-f", "/var/log/app.log"]).await?;

while let Some(event) = handle.recv().await {
    match event {
        ExecEvent::Stdout(data) => print!("{}", String::from_utf8_lossy(&data)),
        ExecEvent::Stderr(data) => eprint!("{}", String::from_utf8_lossy(&data)),
        ExecEvent::Exited { code } => break,
        _ => {}
    }
}
```

```python Python
from microsandbox import ExecEventType

handle = await sb.exec_stream("tail", ["-f", "/var/log/app.log"])

async for event in handle:
    match event.event_type:
        case ExecEventType.STDOUT: print(event.data.decode(), end="")
        case ExecEventType.STDERR: print(event.data.decode(), end="", file=sys.stderr)
        case ExecEventType.EXITED: print(f"Exited: {event.code}")
```

```go Go
handle, err := sb.ExecStream(ctx, "tail", []string{"-f", "/var/log/app.log"})
for {
    event, err := handle.Recv(ctx)
    if err != nil {
        return err
    }
    switch event.Kind {
    case m.ExecEventStdout:
        os.Stdout.Write(event.Data)
    case m.ExecEventStderr:
        os.Stderr.Write(event.Data)
    case m.ExecEventExited:
        fmt.Printf("Exited: %d\n", event.ExitCode)
    case m.ExecEventDone:
        return nil
    }
}
```

</CodeGroup>

With the local runtime's dedicated signal channel, `kill()` and other signals
return `execution_closed` immediately if the execution ended or its start was
rejected. Successful delivery does not confirm process exit; wait for completion.
A timeout reports whether the signal was queued and may still be delivered.

## Interactive attach

Bridges your terminal directly to a process inside the sandbox for a fully interactive PTY session. Useful for debugging, running REPLs, or anything that expects a real terminal.

<CodeGroup>
```typescript TypeScript
// Attach to the default shell
const exitCode = await sb.attachShell();

// Attach to a specific command with custom detach keys
await sb.attachWith("bash", (a) =>
    a.env("DEBUG", "1").cwd("/app").detachKeys("ctrl-q"),
);
```

```rust Rust
let exit_code = sb.attach_shell().await?;

let exit_code = sb.attach_with("python", |a| a
    .env("DEBUG", "1")
    .cwd("/app")
    .detach_keys("ctrl-q")
).await?;
```

```python Python
# Attach to the default shell
exit_code = await sb.attach_shell()

# Attach to a specific command with custom detach keys
exit_code = await sb.attach(
    "python",
    env={"DEBUG": "1"},
    cwd="/app",
    detach_keys="ctrl-q",
)
```

```go Go
// Attach to the default shell.
exitCode, err := sb.AttachShell(ctx)
if err != nil {
    return err
}
fmt.Println("shell exited with", exitCode)

// Attach to a specific command with options.
exitCode, err = sb.AttachWith(ctx, "python", nil,
    m.WithAttachUser("dev"),
    m.WithAttachEnv(map[string]string{"DEBUG": "1"}),
    m.WithAttachCwd("/app"),
    m.WithAttachDetachKeys("ctrl-q"),
)
if err != nil {
    return err
}
fmt.Println("python exited with", exitCode)
```

```bash CLI
# Attach to the default shell
msb exec -t worker

# Attach to a specific command
msb exec -t worker -e DEBUG=1 -w /app -- python
```

</CodeGroup>

<Tip>
  Press `Ctrl+]` (or your configured detach keys) to detach from the session without stopping the process. The process keeps running inside the VM and you can reattach later via its session ID.
</Tip>

## Write stdin

TypeScript, Rust, Python, and Ruby can pass input bytes with the command. Ruby does not expose a streaming stdin writer; Go uses a pipe to supply input and collect the result.

<CodeGroup>
```typescript TypeScript
const output = await sandbox.execWith("cat", (e) => e.stdinBytes(Buffer.from("hello\n")));
console.log(output.stdout());
```

```rust Rust
let output = sandbox.exec_with("cat", |e| e.stdin_bytes(b"hello\n".to_vec())).await?;
println!("{}", output.stdout()?);
```

```python Python
output = await sandbox.exec("cat", stdin=b"hello\n")
print(output.stdout_text)
```

```go Go
handle, err := sandbox.ExecStream(ctx, "cat", nil, m.WithExecStdinPipe())
if err != nil {
    return err
}
defer handle.Close()
stdin := handle.TakeStdin()
if _, err := stdin.Write([]byte("hello\n")); err != nil {
    return err
}
if err := stdin.Close(); err != nil {
    return err
}
output, err := handle.Collect(ctx)
if err != nil {
    return err
}
fmt.Print(output.Stdout())
```

```ruby Ruby
output = sandbox.exec("cat", stdin: "hello\n")
puts output.stdout
```
</CodeGroup>

Streaming handles can also accept stdin. Enable `stdin_pipe` and write bytes to it. Combined with `tty: true`, this lets you drive interactive processes programmatically.

<CodeGroup>
```typescript TypeScript
const handle = await sb.execStreamWith("python", (e) => e.stdinPipe().tty(true));
const stdin = await handle.takeStdin();
await stdin!.write("print('hello')\n");
await stdin!.write("exit()\n");
await stdin!.close();
await handle.wait();
```

```rust Rust
let mut handle = sb.exec_stream_with("python", |e| e.stdin_pipe().tty(true)).await?;
let stdin = handle.take_stdin().unwrap();
stdin.write(b"print('hello')\n").await?;
stdin.write(b"exit()\n").await?;
handle.wait().await?;
```

```python Python
from microsandbox import Stdin

handle = await sb.exec_stream("python", stdin=Stdin.pipe(), tty=True)
stdin = handle.take_stdin()
await stdin.write(b"print('hello')\n")
await stdin.write(b"exit()\n")
await stdin.close()
await handle.wait()
```

```go Go
handle, err := sb.ExecStream(ctx, "python", nil, m.WithExecStdinPipe())
stdin := handle.TakeStdin()
_, _ = stdin.Write([]byte("print('hello')\n"))
_, _ = stdin.Write([]byte("exit()\n"))
_ = stdin.Close()
_, err = handle.Wait(ctx)
```

</CodeGroup>

## Output and stdin buffering

Each command has a 1 MiB output buffer, shared by stdout and stderr. When it fills, the guest stops reading the command's output; once the guest pipe buffers also fill, the command's writes block (or return `EAGAIN` for nonblocking writers) until you consume output. Nothing is dropped, and other threads in the guest keep running.

Read output while the command runs; waiting for a completion marker before reading can leave the command blocked. Collecting complete output uses memory proportional to its size. To let a command finish without a reader, redirect its output to a file. This applies to SDK, CLI, and SSH execution.

Stdin has a 64 KiB allowance per command. Writes wait for capacity and send straight from your buffer; cancelling a write can leave an already-delivered prefix. When a command both reads input and writes output, send and read concurrently so neither side blocks the other.

## Session IDs

Each streaming exec creates a session ID that you can use to correlate stream events and persisted log entries.

<CodeGroup>
```typescript TypeScript
const handle = await sb.execStream("python", ["server.py"])
const sessionId = handle.id
```

```rust Rust
let handle = sb.exec_stream("python", ["server.py"]).await?;
let session_id = handle.id().to_string();
```

```python Python
handle = await sb.exec_stream("python", ["server.py"])
session_id = handle.id
```

```go Go
handle, err := sb.ExecStream(ctx, "python", []string{"server.py"})
if err != nil {
    return err
}
sessionID, err := handle.ID()
```

</CodeGroup>

## Reference

For exact execution APIs, see [TypeScript](/sdk/typescript/execution), [Rust](/sdk/rust/execution), [Python](/sdk/python/execution), [Go](/sdk/go/execution), or [Ruby](/sdk/ruby/execution). For command-line execution, see [`msb run`](/cli/sandbox-commands#msb-run) and [`msb exec`](/cli/sandbox-commands#msb-exec).
