feat: add exec methods to run commands in a more accessible way#82
Merged
Conversation
214a85c to
895a66f
Compare
mohamedveron
approved these changes
May 22, 2026
Contributor
|
@christianalfoni let's just remove interactive from here |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Run a sandbox command in one call and get back a guaranteed exit code + joined output
❌ Current behavior
To run a one-shot command and find out whether it succeeded, callers had to:
execs.create({ command, args, autostart: true })execs.streamOutput(id)(or pollgetOutput)for awaitloop, partition stdout vs stderr if they caredexitCodeand decide when "done" means done✅ New behavior
A single
sandbox.execs.exec(command, args, opts?)call creates the exec withautostart: trueandinteractive: false, consumes the SSE stream to completion, and returns{ exitCode, output }— withexitCodestatically typed asnumberbecause the SDK throws if the stream ends without one.getOutput()got the same{exitCode, output}treatment with optionalexitCode(since polling may catch a still-running process).streamOutput()remains the only way to get per-event metadata.Three-way API surface
The exec-output methods now have distinct, well-differentiated semantics:
Each has a clear purpose:
exec()— "I want to run a command and know the result" (fire-and-forget convenience)getOutput()— "I want a snapshot of what's been logged so far" (one-shot poll)streamOutput()— "I want events as they arrive, with types and sequence numbers" (live tailing)sequenceDiagram participant Caller participant Sandbox participant Agent Caller->>Sandbox: execs.exec(cmd, args, opts?) Sandbox->>Agent: createExec(autostart=true, interactive=false) Agent-->>Sandbox: exec.id Sandbox->>Agent: streamOutput(exec.id) [SSE] loop while process running Agent-->>Sandbox: ExecStdout {type, output, exitCode?} Note over Sandbox: accumulate chunks,<br/>capture exitCode if present end Agent-->>Sandbox: stream closes alt exitCode was seen Sandbox-->>Caller: { exitCode, output: joined string } else stream ended without exitCode Sandbox--xCaller: throws / raises RuntimeError end🤔 Assumptions
exitCodeon the terminating event when the process exits cleanly — confirmed against the generatedExecStdouttype, which documentsexit_codeas "only present when process has exited".exec()needed.create()parameters exceptautostartandinteractiveare useful throughexec()— exposed viaopts(TS:Omit<…, "command" | "args" | "autostart" | "interactive">; Python: explicit kwargspty,cwd,env,user).exec()want the joined output as a string. Callers needing per-chunk metadata (type, sequence, timestamp) can drop down tostreamOutput()— the only place that information now surfaces.🧠 Decisions
interactiveis hardcoded tofalseand excluded from the publicoptstype. Interactive mode keeps stdin open and would deadlock the convenience method, which has no way to feed input.ptystays available, even though it merges stderr into stdout at the OS level (the kernel reopens the slave PTY onto fds 0/1/2 of the child). Documented as the escape hatch for tty-aware tools (colorized output,npm installspinners, programs that refuse without a TTY).output: string, notExecStdout[], in bothexec()andgetOutput(). Matchsubprocess.run().stdout/ Node'schild_process.execcallback / Go'scmd.Output()— the conventional shape for "run a command, get its output". Per-event detail is now exclusively astreamOutput()concern.exec()andgetOutput()are deliberately symmetric — same{exitCode/exit_code, output: string}shape, differing only in whetherexitCodeis guaranteed (exec()throws on missing;getOutput()returnsundefined/None).RuntimeErroron missing exit code inexec()rather than silently returningexitCode: undefined. The convenience method's contract is "I waited for completion"; failing that contract should be loud. Callers needing to handle killed-process scenarios should usestreamOutput()directly.ExecOutputResultTypedDictexposed forgetOutput()'s return type — gives callers static typing without forcing them throughdict[str, Any].exec()still usesdict[str, Any]because itsexit_code: int(noNone) doesn't quite warrant a separate TypedDict yet — symmetric typing is a small follow-up if you want it.ExecStdoutre-exported at package root in both languages so users canimport { ExecStdout } from "@together-sandbox/sdk"/from together_sandbox import ExecStdoutwithout reaching into generated-client namespaces.execs.get_output()Python doc return type corrected from-> strto-> ExecOutputResult.execs.start()TypeScript doc: Python-style return arrow-> Execcorrected to: Exec, and code fence language fixed from```pythonto```typescript.execs.update()TypeScript doc removed — that method no longer exists; replaced in-place by the newexecs.exec()section.🔄 Discussions
The return shape iterated several times during the session before settling on the final design — worth noting because the journey shaped the final decision:
{ exitCode, stdout: string[], stderr: string[] }— ergonomic but threw away per-chunk metadatalist[ExecStdout]— mirror ofgetOutput, but the most useful field (exit code) got buried in list metadata{ exitCode, output: ExecStdout[] }with throw guarantee — first-class exit code, but list output forced caller-side filtering/joining{ exitCode, output: string }— final.exec()joins chunks itself since that's what 90% of callers want.getOutput()aligned to the same shape with optionalexitCode.The convergence point was: strings for the convenience methods, raw events only via
streamOutput(). That single principle made the API surface much sharper.🧪 Testing
test_execin the Python e2e suite — runssh -c "echo hello && echo oops >&2 && exit 3", assertsexit_code == 3and that both stdout and stderr content appear in the joinedoutputstring.test_exec_stdout_vs_stderrrewritten to usestreamOutput()directly — the joined-stringgetOutput()can no longer distinguish stream types, so the test now exercises the only API that still can (and asserts onevent["type"] == ExecStdoutType.STDOUT.valuefrom raw event dicts).get_output()-based tests (test_get_output,test_exec_exit_code,test_exec_with_cwd,test_exec_with_env,test_exec_with_args,test_send_stdin) simplified fromany(... for item in r)patterns to direct substring/equality checks againstr["output"]/r["exit_code"].Sandbox.ts,index.ts, both docs. Thefor awaitandevent.exitCodeusages typecheck against the generatedsandboxApi.ExecStdout; the structured return is correctly typed as{ exitCode: number; output: string }forexec()and{ exitCode: number | undefined; output: string }forgetOutput().📁 References