Table of Contents

Exec and streaming

client.Exec (IExecClient) runs commands in a ready sandbox, and client.Watch (ISandboxWatchClient) streams its status, logs, and events. Commands are argument lists: nothing is parsed by a shell unless you run one (["sh", "-c", "..."]).

Run a command

RunAsync buffers the output and returns an ExecResult once the command exits. A non-zero exit code is a result (Succeeded is false), not an exception; failures to reach the sandbox throw OpenShellException.

var result = await client.Exec.RunAsync(
    "agent-1",
    ["python", "-c", "import sys; print(sys.stdin.read().upper())"],
    new ExecOptions
    {
        WorkingDirectory = "/workspace",
        Environment = new Dictionary<string, string> { ["LANG"] = "C.UTF-8" },
        Stdin = Encoding.UTF8.GetBytes("hello"),
        Timeout = TimeSpan.FromSeconds(30),
    });

if (!result.Succeeded)
{
    Console.Error.WriteLine($"exit {result.ExitCode}: {result.StderrText}");
}

Console.WriteLine(result.StdoutText);

ExecOptions sets the working directory, extra environment variables, standard input, a server-side execution timeout, a TTY, NoLoginShell, and an idempotency key (RequestId).

Python

ExecPythonAsync runs a Python source string with python -c (other SDKs send a pickled callable; .NET code cannot be pickled). ExecPythonStreamAsync streams instead.

// Runs the source with `python -c`; arguments arrive in sys.argv[1:].
var result = await client.Exec.ExecPythonAsync(
    "agent-1",
    "import sys; print(sum(int(a) for a in sys.argv[1:]))",
    new PythonExecOptions { Interpreter = "python3", Arguments = ["1", "2", "3"] });

Stream output

StreamAsync yields ExecOutput events as output arrives, and always ends with an ExecExit event, so a failing command cannot be missed. A stream that ends without an exit event throws OpenShellException (Unavailable).

var exitCode = -1;
await foreach (var e in client.Exec.StreamAsync("agent-1", ["sh", "-c", "echo out; echo err >&2; exit 3"]))
{
    switch (e)
    {
        case ExecOutput { IsStdErr: false } o:
            Console.Write(o.Text);
            break;
        case ExecOutput o:
            Console.Error.Write(o.Text);
            break;
        case ExecExit x:
            exitCode = x.ExitCode;   // always the last event
            break;
    }
}

ExecOutput.Data holds the raw bytes; Text decodes them as UTF-8.

Interactive sessions

InteractiveAsync starts a command with a bidirectional stream and returns an ExecInteractiveSession: write standard input, resize the terminal, close the input, and read output, concurrently. Read ReadEventsAsync once; WaitForExitAsync (or the ExitCode task) gives the exit code.

await using var session = await client.Exec.InteractiveAsync(
    "agent-1",
    ["bash", "-l"],
    columns: 120,
    rows: 40);

// Read the output (once) while writing input.
var output = Task.Run(async () =>
{
    await foreach (var e in session.ReadEventsAsync())
    {
        if (e is ExecOutput o)
        {
            Console.Write(o.Text);
        }
    }
});

await session.WriteStdinAsync(Encoding.UTF8.GetBytes("ls /workspace\n"));
await session.ResizeAsync(columns: 160, rows: 50);
await session.WriteStdinAsync(Encoding.UTF8.GetBytes("exit\n"));
await session.CompleteStdinAsync();   // EOF; output keeps flowing

var exitCode = await session.WaitForExitAsync();
await output;

Pass allocateTty: false for a pipe without a terminal. CancelAsync cancels the command and releases the stream, as disposing the session does.

Watch a sandbox

Watch.WatchAsync streams a sandbox's status changes, log lines, platform events (for example Kubernetes events), and draft-policy updates, as subclasses of WatchEvent:

Event Carries Enabled by
SandboxUpdated the new Sandbox FollowStatus (on by default)
LogReceived a LogLine FollowLogs
PlatformEventReceived a PlatformEvent FollowEvents
DraftPolicyUpdated a DraftPolicyUpdate WatchAsync only, whenever the draft changes
WatchWarning a recoverable loss, such as dropped lines; the stream continues always
var options = new WatchOptions
{
    FollowStatus = true,      // SandboxUpdated (the default)
    FollowLogs = true,        // LogReceived
    FollowEvents = true,      // PlatformEventReceived
    LogTailLines = 50,        // start with the last 50 lines
    StopOnTerminal = true,    // end when the sandbox reaches a terminal phase
};

await foreach (var e in client.Watch.WatchAsync("agent-1", options, cancellationToken))
{
    switch (e)
    {
        case SandboxUpdated u:
            Console.WriteLine($"phase {u.Sandbox.Phase}");
            break;
        case LogReceived l:
            Console.WriteLine($"[{l.Line.Source}] {l.Line.Level} {l.Line.Message}");
            break;
        case PlatformEventReceived p:
            Console.WriteLine($"{p.Event.Type} {p.Event.Reason}: {p.Event.Message}");
            break;
        case DraftPolicyUpdated d:
            Console.WriteLine($"{d.Update.TotalPending} policy chunks pending review");
            break;
        case WatchWarning w:
            Console.WriteLine($"warning: {w.Message}");
            break;
    }
}

The stream reconnects with exponential backoff when the gateway becomes unavailable and resumes from the last cursor it saw; WatchOptions.MaxReconnectAttempts bounds the consecutive attempts (null retries until cancelled). Each event carries an opaque Cursor. Save it to continue after a process restart:

// Store the cursor of the last event you handled; pass it back to continue after a process restart.
var options = new WatchOptions { FollowLogs = true, ResumeAfterCursor = loadCursor() };
await foreach (var e in client.Watch.WatchAsync("agent-1", options))
{
    if (e is LogReceived log)
    {
        Console.WriteLine(log.Line.Message);
    }

    if (!string.IsNullOrEmpty(e.Cursor))
    {
        saveCursor(e.Cursor);
    }
}

A cursor the gateway has already trimmed is terminal and throws; start a new watch without a cursor to recover.

Logs

GetLogsAsync reads the buffered log lines once; WatchLogsAsync follows logs and platform events without status updates. Filter by source (gateway, sandbox), minimum level, and time.

// A one-off read of the buffered logs.
var logs = await client.Watch.GetLogsAsync("agent-1", new LogOptions
{
    Lines = 200,
    Sources = ["sandbox"],
    MinLevel = "WARN",
});

foreach (var line in logs.Lines)
{
    Console.WriteLine($"{line.Time:O} {line.Level} {line.Message}");
}

// Or follow them: WatchLogsAsync never follows status, only the logs and events you ask for.
var follow = new WatchOptions { FollowLogs = true, LogTailLines = 20 };
await foreach (var e in client.Watch.WatchLogsAsync("agent-1", follow))
{
    if (e is LogReceived log)
    {
        Console.WriteLine(log.Line.Message);
    }
}

Owner routing

On a multi-replica gateway, exec, interactive exec, port forwarding, and SSH streams go straight to the replica that holds the sandbox's supervisor session. See Multi-replica gateways.