Exec

Three ways to run a command in a sandbox:

Commands run as the user loam (uid 1000, passwordless sudo, home /home/loam) unless you give user (root, or any account on the sandbox's disk). Each gets the sandbox's env, then the call's env on top, and HOME, USER, LOGNAME and SHELL for its user. The command is argv, not a shell line: use ["sh", "-c", "..."] for pipes and variables.

Method and path
POST /v1/sandboxes/{sandbox}/exec/run one-shot
GET /v1/sandboxes/{sandbox}/exec streaming, WebSocket
GET /v1/sandboxes/{sandbox}/exec/sessions list sessions
GET /v1/sandboxes/{sandbox}/exec/sessions/{session} attach, WebSocket
POST /v1/sandboxes/{sandbox}/exec/sessions/{session}/signal signal a session

One-shot

POST /v1/sandboxes/{sandbox}/exec/run

Field
cmd argv; required
stdin base64; given to the command, then EOF
cwd working directory; default the user's home
env {"K": "V"} on top of the sandbox's
user default loam
timeout_s default 60, at most 3600; the command is killed after it

Answer: {"exit_code", "stdout", "stderr", "timed_out"}, with stdout and stderr base64. Output beyond 16 MiB per stream is cut, and the answer then has "truncated": true. A command killed by a signal has exit_code 128 + the signal (137 for KILL).

$ curl -sS -H "Authorization: Bearer $LOAM_TOKEN" -H 'Content-Type: application/json' \
    "$LOAM_API_URL/v1/sandboxes/scratch/exec/run" \
    -d "{\"cmd\":[\"sh\",\"-c\",\"wc -c; echo to-stderr >&2; exit 2\"],\"stdin\":\"$(printf hello | base64)\",\"cwd\":\"/tmp\",\"env\":{\"X\":\"1\"},\"timeout_s\":30}"
{"exit_code":2,"stdout":"NQo=","stderr":"dG8tc3RkZXJyCg==","timed_out":false}

jq decodes the output:

$ curl -sS -H "Authorization: Bearer $LOAM_TOKEN" -H 'Content-Type: application/json' \
    "$LOAM_API_URL/v1/sandboxes/scratch/exec/run" -d '{"cmd":["sh","-c","echo hi; echo err >&2"]}' \
    | jq -r '.stdout, .stderr | @base64d'
hi

err

$ curl -sS -H "Authorization: Bearer $LOAM_TOKEN" -H 'Content-Type: application/json' \
    "$LOAM_API_URL/v1/sandboxes/scratch/exec/run" -d '{"cmd":["sleep","10"],"timeout_s":1}'
{"exit_code":137,"stdout":"","stderr":"","timed_out":true}

Errors are about starting the command, not its exit code (a non-zero exit is a 200):

$ curl -sS ... -d '{"cmd":["nope"]}'
{"error":{"code":"invalid","message":"executable \"nope\" not found in PATH"}}
$ curl -sS ... -d '{"cmd":["id"],"user":"nobody2"}'
{"error":{"code":"invalid","message":"no user \"nobody2\" in this sandbox"}}
$ curl -sS ... -d '{"cmd":["true"],"timeout_s":4000}'
{"error":{"code":"invalid","message":"timeout_s must be between 1 and 3600"}}

The CLI's run is this route. It sends its own stdin when stdin isn't a terminal, so give </dev/null in scripts; it prints stdout and stderr and exits with the command's code.

$ echo hello | loam run scratch -- wc -c
6
$ loam run scratch --user root --cwd /etc --env A=1 --timeout 5m -- sh -c 'id -un; pwd' </dev/null
root
/etc

Go and TypeScript:

res, err := c.Run(ctx, "scratch", api.ExecRun{Cmd: []string{"sh", "-c", "echo hello from $(hostname)"}})
// res.ExitCode, res.Stdout ([]byte, decoded), res.Stderr, res.TimedOut
const r = await loam.sandbox('scratch').run({ cmd: ['sh', '-c', 'wc -c'], stdin: 'hello', timeout_s: 30 })
r.exit_code          // 0
r.stdout.toString()  // "5\n": stdout and stderr are Buffers

Streaming

GET /v1/sandboxes/{sandbox}/exec, upgraded to a WebSocket, with the Authorization header on the upgrade request.

Query parameters:

Parameter
cmd argv, one parameter per argument, in order (?cmd=sh&cmd=-c&cmd=echo%20hi); required
tty true for a pseudo-terminal; default false
cols, rows the TTY's first size
cwd working directory
env K=V, repeatable, on top of the sandbox's env
user default loam
detachable true: the session keeps running when the socket closes, and can be attached again

Frames

Binary frames carry bytes with a one-byte stream tag in front; text frames carry JSON control messages.

Direction Frame Meaning
client → server binary, first byte 0, then bytes stdin
client → server binary, the single byte 4 stdin EOF
client → server text {"type":"resize","cols":N,"rows":N} the TTY's size
client → server text {"type":"signal","signal":"TERM"} a signal to the command's process group; names as in kill -l, without SIG
server → client text {"type":"started","session":"s-…","pid":N} always the first frame once the command runs
server → client binary, first byte 1, then bytes stdout (with a TTY, all output)
server → client binary, first byte 2, then bytes stderr (never with a TTY)
server → client text {"type":"exit","code":N} the last frame; code is 128 + signal when killed. The socket then closes normally (1000).
server → client text {"type":"error","error":{"code","message"}} the command could not start (instead of started); then the socket closes

If the sandbox goes away under the session (paused, a memory checkpoint taken, deleted, restored to a checkpoint, its node lost), the socket closes with status 1011 and no exit frame. Treat that as "exit unknown", never as success. Every client below reports it as an error (client.ErrExitUnknown in Go, ExitUnknownError in TypeScript, exit status 255 from the CLI).

A refused upgrade (no key, no such sandbox) is an ordinary HTTP error in the usual shape, before any WebSocket:

$ curl -sS -i --http1.1 -H 'Connection: Upgrade' -H 'Upgrade: websocket' -H 'Sec-WebSocket-Version: 13' \
    -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' "$LOAM_API_URL/v1/sandboxes/scratch/exec?cmd=id"
HTTP/1.1 401 Unauthorized
Content-Type: application/json

{"error":{"code":"unauthorized","message":"a valid API key is required"}}

A raw client

curl can't speak WebSocket. This is the protocol by hand, in Node 22 or later with no packages: it sends a line of stdin and EOF, and prints every frame it gets.

// ws-exec.mjs
const base = process.env.LOAM_API_URL.replace(/^http/, 'ws')
const q = new URLSearchParams()
for (const a of ['sh', '-c', 'read name; echo "hello, $name"; echo oops >&2; exit 7']) q.append('cmd', a)
const ws = new WebSocket(`${base}/v1/sandboxes/scratch/exec?${q}`, {
  headers: { Authorization: `Bearer ${process.env.LOAM_TOKEN}` },
})
ws.binaryType = 'arraybuffer'
ws.onmessage = (ev) => {
  if (typeof ev.data === 'string') {
    console.log('text  ', ev.data)
    const msg = JSON.parse(ev.data)
    if (msg.type === 'started') {
      ws.send(new Uint8Array([0, ...new TextEncoder().encode('loam\n')])) // stdin
      ws.send(new Uint8Array([4]))                                       // stdin EOF
    }
    return
  }
  const b = new Uint8Array(ev.data)
  console.log('binary', b[0] === 1 ? 'stdout' : 'stderr', JSON.stringify(new TextDecoder().decode(b.subarray(1))))
}
ws.onclose = (ev) => console.log('close ', ev.code)
$ node ws-exec.mjs
text   {"type":"started","session":"s-5u6pz7c26b","pid":295}
binary stdout "hello, loam\n"
binary stderr "oops\n"
text   {"type":"exit","code":7}
close  1000

The same client with other parameters shows the other frames:

Query Frames
cmd=no-such-command {"type":"error","error":{"code":"invalid","message":"executable \"no-such-command\" not found in PATH"}}, close 1000
cmd=sleep&cmd=30, then {"type":"signal","signal":"TERM"} started, {"type":"exit","code":143}
cmd=sh&cmd=-c&cmd=stty size; tty; echo err >&2&tty=true&cols=100&rows=30 started, stdout "30 100\r\n", "/dev/pts/0\r\n", "err\r\n" (all on stream 1), exit 0
cmd=id&user=root&cwd=/etc&env=A=1 stdout "uid=0(root) gid=0(root) groups=0(root)\n"

The CLI

loam exec streams; loam console is exec -t of a login shell, for interactive use.

$ loam exec scratch -- sh -c 'echo out; echo err >&2; exit 3' </dev/null; echo "exit=$?"
err
out
exit=3
$ loam exec -t scratch -- bash                  # a TTY, sized and resized with your terminal
$ loam exec -d scratch -- python3 -m http.server 8080 </dev/null   # detached: prints the session ID
s-rhgpddrfzk
$ loam console scratch --user root
Flag
-t, --tty allocate a TTY (raw mode on your terminal)
-i, --stdin forward stdin even when it is a terminal (otherwise a terminal on stdin gets EOF)
-d, --detach start a detachable session, print its ID, and return
--detachable the session outlives this connection; with a TTY, Ctrl-] detaches
--cwd, --env K=V, --user as the query parameters

Without a TTY the CLI passes INT, TERM, HUP and QUIT on to the remote process group, so Ctrl-C stops the remote command. It forwards stdin whenever stdin isn't a terminal; give </dev/null in scripts.

Go and TypeScript

s, err := c.Exec(ctx, "scratch", client.ExecOptions{
	Cmd: []string{"sh", "-c", "for i in 1 2 3; do echo tick $i; sleep 1; done; exit 4"},
	IO:  client.IO{Stdout: os.Stdout, Stderr: os.Stderr}, // Stdin: an io.Reader, copied then closed
})
if err != nil {
	log.Fatal(err)
}
code, err := s.Wait() // 4, nil; client.ErrExitUnknown if the sandbox went away under it

Stream also has Write (stdin), CloseStdin, Resize, Signal and Close.

const p = await loam.sandbox('scratch').exec({
  cmd: ['sh', '-c', 'echo out; echo err >&2; exit 3'],
  stdout: process.stdout,   // a function, or anything with write()
  stderr: process.stderr,
})
await p.wait()              // 3; rejects with ExitUnknownError on a 1011 close
// also: p.write(data), p.closeStdin(), p.resize(cols, rows), p.signal('INT'), p.session

Sessions

Every streaming command is a session, s- and 10 characters. A session that isn't detachable ends (SIGHUP to its process group) when its socket closes. A detachable one keeps running with nobody attached. Either way, an exited session stays listed for 10 minutes, with its output. Pausing the sandbox closes every session's socket with 1011, but the sessions are in the memory snapshot: a detachable one can be attached again after the wake. A memory checkpoint closes every session's socket with 1011 too; the ones that aren't detachable end with their sockets, and a detachable one runs on and can be attached again at once. A stop or a restore to a disk-only checkpoint ends them all.

Each session keeps the last 1 MiB of its output (stdout and stderr together, in the order written) for replay.

List sessions

GET /v1/sandboxes/{sandbox}/exec/sessions → {"sessions": [Session]}; a session is {"id", "cmd", "tty", "pid", "started_at", "exited", "exit_code"?, "attached"}.

$ curl -sS -H "Authorization: Bearer $LOAM_TOKEN" "$LOAM_API_URL/v1/sandboxes/scratch/exec/sessions" | jq -c '.sessions[]'
{"id":"s-5u6pz7c26b","cmd":["sh","-c","read name; echo \"hello, $name\"; echo oops >&2; exit 7"],"tty":false,"pid":295,"started_at":"2026-10-12T09:16:36.951557736Z","exited":true,"exit_code":7,"attached":false}
{"id":"s-p6mjlxcp7w","cmd":["sh","-c","for i in 1 2 3; do echo tick $i; sleep 1; done; sleep 600"],"tty":false,"pid":307,"started_at":"2026-10-12T09:17:05.497372569Z","exited":false,"attached":false}
$ loam sessions scratch
ID            PID  TTY  ATTACHED  STATUS    AGE  CMD
s-bj4vew4m37  303  no   no        exited 3  3m   sh -c echo out; echo err >&2; exit 3
s-rhgpddrfzk  309  no   no        running   1s   python3 -m http.server 8080 --directory /home/loam/site

Attach

GET /v1/sandboxes/{sandbox}/exec/sessions/{session}, a WebSocket with the same frames as exec. The first frame is {"type":"attached","session":"s-…","offset":N} instead of started; then the replay, then live output, then exit (at once, for a session that has already exited). offset is always there, 0 included.

?offset=N replays from byte N of the session's output (counting stdout and stderr together); the default replays everything kept. An offset older than what is kept starts at the oldest kept byte, and offset in the first frame says where the replay really starts. A plain GET without the upgrade is 400 invalid.

$ loam exec -d scratch -- sh -c 'for i in 1 2 3; do echo tick $i; sleep 1; done; sleep 600' </dev/null
s-p6mjlxcp7w
$ loam attach scratch s-p6mjlxcp7w --offset 7
tick 2
tick 3

(tick 1\n is the first 7 bytes.) Closing an attach leaves a detachable session running, but remember that the CLI passes Ctrl-C on to a non-TTY session: interrupting loam attach stops the command.

s, err := c.Attach(ctx, "scratch", "s-p6mjlxcp7w", client.AttachOptions{IO: client.IO{Stdout: os.Stdout}})
const again = await loam.sandbox('scratch').attach('s-p6mjlxcp7w', { offset: 0, stdout: process.stdout })

Signal a session

POST /v1/sandboxes/{sandbox}/exec/sessions/{session}/signal with {"signal": "TERM"} (names as in kill -l), to the session's process group → 204. 409 conflict once it has exited, 404 not_found for a session the sandbox doesn't have.

$ curl -sS -i -H "Authorization: Bearer $LOAM_TOKEN" -H 'Content-Type: application/json' \
    "$LOAM_API_URL/v1/sandboxes/scratch/exec/sessions/s-krbiocuwm6/signal" -d '{"signal":"KILL"}'
HTTP/2 204
$ loam kill scratch s-krbiocuwm6 --signal INT     # default TERM
$ curl -sS ... "$LOAM_API_URL/v1/sandboxes/scratch/exec/sessions/s-p6mjlxcp7w/signal" -d '{"signal":"KILL"}'
{"error":{"code":"conflict","message":"the session has exited"}}