Sandbox

Sessions

Create and drive Tenki Sandbox sessions covering lifecycle, command execution, file I/O, port exposure, and SSH access.

A session is a single running sandbox. This page covers the full session surface: lifecycle, command execution, file I/O, ports, and SSH. Pick your tool in any code block, and the selection syncs across the page.

Create a session

from tenki import Sandbox

sb = Sandbox.create(
    name="demo",
    cpu_cores=4,
    memory_mb=8192,
    allow_inbound=True,
    allow_outbound=True,
    env={"APP_ENV": "dev"},
    metadata={"owner": "alice"},
)

Sandbox.create waits by default via a single server-held request and returns an exec-ready session with data-plane access primed. Pass wait=False to return immediately in CREATING. Use Client().create(...) instead when you want to manage the client lifecycle yourself.

const session = await sandbox.create({
  name: "demo",
  cpuCores: 4,
  memoryMb: 8192,
  allowInbound: true,
  allowOutbound: true,
  env: { APP_ENV: "dev" },
  metadata: { owner: "alice" },
});

create() waits by default via a single server-held request and returns a run-ready session with data-plane access primed. Pass waitReady: false to return immediately in CREATING.

// Create waits by default and returns a RUNNING, exec-ready session.
session, err := client.Create(
  ctx,
  tenkisandbox.WithName("demo"),
  tenkisandbox.WithCPUCores(4),
  tenkisandbox.WithMemoryMB(8192),
  tenkisandbox.WithAllowInbound(true),
  tenkisandbox.WithAllowOutbound(true),
  tenkisandbox.WithEnvs(map[string]string{"APP_ENV": "dev"}),
  tenkisandbox.WithMetadata(map[string]string{"owner": "alice"}),
  tenkisandbox.WithWaitTimeout(3*time.Minute),
)

// Opt out when you want to orchestrate readiness separately.
session, err := client.Create(ctx, tenkisandbox.WithName("demo"), tenkisandbox.WithWaitReady(false))
// Later: session.WaitReady(ctx, 3*time.Minute)
tenki sandbox create \
  --name my-session \
  --cpu 4 \
  --memory-mb 8192 \
  --allow-inbound \
  --allow-outbound \
  --env APP_ENV=dev \
  --metadata owner=alice \
  --metadata purpose=review

By default, the CLI waits until the session is RUNNING and exec-ready before returning (a single server-held request). Pass --no-wait to return immediately.

Every create call takes the same options, named per tool:

OptionTypeScriptPythonGoCLI
NamenamenameWithName--name
CPU / memorycpuCores, memoryMbcpu_cores, memory_mbWithCPUCores, WithMemoryMB--cpu, --memory-mb
Disk sizediskSizeGbdisk_size_gbWithDiskSizeGB--disk-size-gb
NetworkallowInbound, allowOutboundallow_inbound, allow_outboundWithAllowInbound, WithAllowOutbound--allow-inbound, --allow-outbound
Metadata / envmetadata, envmetadata, envWithMetadata, WithEnvs--metadata, --env
TagstagstagsWithTags--tags
SSH keyssshAuthorizedKeysssh_authorized_keysWithSSHKeys--authorized-key, --authorized-keys-file
VolumesvolumesvolumesWithVolume--volume
Snapshot / imagesnapshotId, imagesnapshot_id, imageWithSnapshot, WithImage--snapshot, --image
Max durationmaxDurationMsmax_durationWithMaxDuration--max-duration
Idle timeoutidleTimeoutMinutesidle_timeout_minutesWithIdleTimeout--idle-timeout
Pause retentionpauseRetentionMspause_retentionWithPauseRetention--pause-retention
StickystickystickyWithSticky--sticky
OpenCodeenableOpenCode, openCodeProviderenable_opencodeWithOpenCode, WithOpenCodeProvidern/a
Clone repocloneRepoUrl, githubTokenclone_repo_url, github_tokenWithCloneRepo, WithGitHubTokenn/a
Wait for readywaitReady, waitTimeoutMswait, timeoutWithWaitReady, WithWaitTimeout--no-wait, --wait-timeout

Unset options fall back to service defaults (2 vCPU, 4096 MB). The CLI enables inbound and outbound networking by default; with the SDKs, set allowInbound / allowOutbound to enable them.

Manage a session

WaitReady/wait_ready is only needed for sessions obtained via get/list or created with wait disabled; the create calls above already return ready sessions.

await session.refresh();
await session.extend(600_000); // +10 minutes
await session.pause();
await session.resume();
await session.close(); // or session.closeIfOpen(), or Symbol.asyncDispose

// List and inspect
const sessions = await sandbox.list();
const s = await sandbox.get(sessionId);
sb.wait_ready(180)
sb.refresh()
sb.extend(1800)  # +30 minutes (seconds or a timedelta)
sb.pause()
sb.resume()
sb.close()  # or sb.close_if_open(); the context manager closes on exit

# List and inspect
sessions = client.list()
sb = client.get(session_id)
err = session.WaitReady(ctx, 3*time.Minute)
err = session.Refresh(ctx)
err = session.Extend(ctx, 30*time.Minute)
err = session.Pause(ctx)
err = session.Resume(ctx)
err = session.Close(ctx)
err = session.CloseIfOpen(ctx)

// List and inspect
sessions, err := client.List(ctx)
session, err := client.Get(ctx, sessionID)
# List and inspect
tenki sandbox list
tenki sandbox list --json
tenki sandbox get --session <session-id>

# Pause, resume, terminate
tenki sandbox pause --session <session-id>
tenki sandbox resume --session <session-id>
tenki sandbox terminate --session <session-id>

Command execution

# One-shot: collects stdout/stderr and returns a result
result = sb.exec("bash", "-lc", "echo $APP_ENV && make test", env={"APP_ENV": "ci"}, timeout=120)
if not result.ok:
    raise RuntimeError(f"failed: exit={result.exit_code} stderr={result.stderr_text}")

# Or start a live process and stream output
proc = sb.start("npm", "test")
proc.close_stdin()
for chunk in proc.stdout:
    print(chunk.decode(), end="")
proc.wait().check()

exec accepts cwd, env, timeout, input, and check=True (raises CommandFailedError on a non-zero exit). Use sb.shell("...") for shell parsing.

Result helpers: result.stdout_text, result.stderr_text, result.ok, result.check().

// One-shot
const result = await session.exec("npm", {
  args: ["test"],
  timeoutMs: 60_000,
  onOutput: (chunk) => process.stdout.write(chunk.data),
});

// Or stream explicitly
const stream = await session.stream("npm", { args: ["test"] });
for (;;) {
  const chunk = await stream.next();
  if (!chunk) break;
  process.stdout.write(chunk.data);
}
await stream.wait();
result, err := session.Exec(
  ctx,
  "bash",
  tenkisandbox.WithArgs("-lc", "echo $APP_ENV && make test"),
  tenkisandbox.WithEnv("APP_ENV", "ci"),
  tenkisandbox.WithTimeout(2*time.Minute),
)
if err != nil {
  log.Fatal(err)
}

if !result.Status.IsSuccess() {
  log.Fatalf("failed: exit=%d stderr=%s", result.ExitCode, result.StderrString())
}

Exec options: WithArgs, WithTimeout, WithEnv, WithEnvs.

Result helpers: result.StdoutString(), result.StderrString(), result.Status.IsSuccess(), IsFailed(), IsTimedOut().

tenki sandbox exec --session <session-id> -c 'go test ./...'
tenki sandbox exec --session <session-id> --timeout 2m -c 'npm ci && npm test'

-c (short for --shell) runs the line through a shell so &&, pipes, redirects and globs work. Without it, exec runs the program directly with no shell; to pass flags straight to a program, put them after -- (for example tenki sandbox exec -- bash -lc '...').

CLI output includes:

  • streamed stdout and stderr
  • final status, exit code, and duration

File operations

File operations are rooted in the session's working directory, /home/tenki. Relative paths resolve from there; absolute paths outside it (including /tmp) are rejected with a permission error.

sb.fs.write_text("/home/tenki/config.json", '{"key": "value"}')
data = sb.fs.read_text("/home/tenki/config.json")

# Bytes, directory listing, and local <-> guest transfers
sb.fs.write_bytes("/home/tenki/blob.bin", b"\x00\x01")
entries = sb.fs.list("/home/tenki")
sb.fs.upload("./local.tar", "/home/tenki/local.tar")
sb.fs.download("/home/tenki/build.log", "./build.log")
await session.writeFile("/home/tenki/config.json", '{"key": "value"}');
const data = await session.readFile("/home/tenki/config.json");
err = session.WriteFile(ctx, "/home/tenki/hello.txt", []byte("hello"))
data, err := session.ReadFile(ctx, "/home/tenki/hello.txt")
# inline
tenki sandbox write --session <session-id> --path /home/tenki/app.env --data 'PORT=3000'

# from a local file
tenki sandbox write --session <session-id> --path /home/tenki/config.json --data-file ./config.json

# from stdin
cat ./local-file.txt | tenki sandbox write --session <session-id> --path /home/tenki/input.txt

# read to stdout
tenki sandbox read --session <session-id> --path /home/tenki/config.json

# read into a local file
tenki sandbox read --session <session-id> --path /home/tenki/build.log --out ./build.log

Port exposure and networking

Each session has independent inbound and outbound settings:

  • allow_outbound=true lets the guest make outbound network calls
  • allow_inbound=true enables inbound exposure workflows
preview = sb.expose_port(3000, ttl=3600)  # ttl in seconds
print(preview.url)

ports = sb.list_exposed_ports()
sb.unexpose_port(3000)
const port = await session.exposePort(3000, { ttlMs: 3600_000 });
console.log(port.previewUrl);
port, err := session.ExposePort(ctx, 3000)
ports, err := session.ListExposedPorts(ctx)
err = session.UnexposePort(ctx, 3000)
tenki sandbox expose --session <session-id> --port 3000
tenki sandbox ports --session <session-id>
tenki sandbox unexpose --session <session-id> --port 3000

When you expose a long-running server you started with exec, background it and detach its streams (>/tmp/server.log 2>&1 </dev/null &). exec streams the command's output until it closes, so a server that keeps the output stream open leaves exec waiting forever.

Use the previewUrl returned by each expose call rather than constructing hostnames yourself; the host pattern is not a stable contract.

SSH access

Connect to a session over SSH. The CLI gives you an interactive shell, managed local config, and key management; the SDKs expose a raw byte-stream transport for wiring into your own SSH tooling. (Set keys at create time with the sshAuthorizedKeys option in the table above.)

Raw byte-stream transport for your own SSH tooling, plus replacing the authorized keys on a running session:

const conn = await session.ssh();
await conn.write(new TextEncoder().encode("ls -la\n"));
const chunk = await conn.read(); // Uint8Array | null
if (chunk) process.stdout.write(chunk);
conn.close();

// Replace the authorized keys
await session.updateSshAuthorizedKeys(["ssh-ed25519 AAAA..."]);

Raw byte-stream transport (an io.RawIOBase), plus replacing the authorized keys on a running session:

conn = sb.ssh()
conn.write(b"ls -la\n")
print(conn.recv(4096).decode())
conn.close()

# Replace the authorized keys
sb.update_ssh_authorized_keys(["ssh-ed25519 AAAA..."])

Raw byte-stream transport for your own SSH tooling, plus replacing the authorized keys on a running session:

conn, err := session.SSH(ctx)
defer conn.Close()

// Replace the authorized keys
err = session.UpdateSSHAuthorizedKeys(ctx, []string{"ssh-ed25519 AAAA..."})

Open an interactive shell:

tenki sandbox ssh --session <session-id>

Useful flags: --user (default tenki), --identity-file, --batch-mode, --connect-timeout, --strict-host-key-checking. Pass standard SSH arguments after the session ID:

tenki sandbox ssh <session-id> -L 8080:127.0.0.1:8080

Install a managed entry in your local SSH config so you can use friendly aliases:

tenki sandbox ssh config install
tenki sandbox ssh config status
tenki sandbox ssh config uninstall
ssh sbx-<session-uuid>

Replace the authorized keys on a running session:

tenki sandbox ssh-keys set --session <session-id> --keys-file ~/.ssh/authorized_keys

Session metadata

Metadata tags a session with arbitrary string key-value pairs (metadata in the SDKs, --metadata key=value on the CLI, both shown above). Use it to filter sessions in dashboards, attribute billing, or tie a session to an upstream job ID. Metadata is opaque to the service; it is for your bookkeeping.