# Sessions (https://tenki.cloud/docs/sandbox/sessions)

> For the complete documentation index, see [llms.txt](https://tenki.cloud/llms.txt)

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, socket bridging, and SSH. Pick your tool in any code block, and the selection syncs across the page.

## Create a session

**Python**
```python
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.

**TypeScript**
```typescript
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`.

**Go**
```go
// 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)
```

**CLI**
```bash
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:

| Option           | TypeScript                           | Python                            | Go                                      | CLI                                          |
| ---------------- | ------------------------------------ | --------------------------------- | --------------------------------------- | -------------------------------------------- |
| Name             | `name`                               | `name`                            | `WithName`                              | `--name`                                     |
| CPU / memory     | `cpuCores`, `memoryMb`               | `cpu_cores`, `memory_mb`          | `WithCPUCores`, `WithMemoryMB`          | `--cpu`, `--memory-mb`                       |
| Disk size        | `diskSizeGb`                         | `disk_size_gb`                    | `WithDiskSizeGB`                        | `--disk-size-gb`                             |
| Network          | `allowInbound`, `allowOutbound`      | `allow_inbound`, `allow_outbound` | `WithAllowInbound`, `WithAllowOutbound` | `--allow-inbound`, `--allow-outbound`        |
| Metadata / env   | `metadata`, `env`                    | `metadata`, `env`                 | `WithMetadata`, `WithEnvs`              | `--metadata`, `--env`                        |
| Tags             | `tags`                               | `tags`                            | `WithTags`                              | `--tags`                                     |
| SSH keys         | `sshAuthorizedKeys`                  | `ssh_authorized_keys`             | `WithSSHKeys`                           | `--authorized-key`, `--authorized-keys-file` |
| Volumes          | `volumes`                            | `volumes`                         | `WithVolume`                            | `--volume`                                   |
| Snapshot / image | `snapshotId`, `image`                | `snapshot_id`, `image`            | `WithSnapshot`, `WithImage`             | `--snapshot`, `--image`                      |
| Max duration     | `maxDurationMs`                      | `max_duration`                    | `WithMaxDuration`                       | `--max-duration`                             |
| Idle timeout     | `idleTimeoutMinutes`                 | `idle_timeout_minutes`            | `WithIdleTimeout`                       | `--idle-timeout`                             |
| Pause retention  | `pauseRetentionMs`                   | `pause_retention`                 | `WithPauseRetention`                    | `--pause-retention`                          |
| Sticky           | `sticky`                             | `sticky`                          | `WithSticky`                            | `--sticky`                                   |
| OpenCode         | `enableOpenCode`, `openCodeProvider` | `enable_opencode`                 | `WithOpenCode`, `WithOpenCodeProvider`  | n/a                                          |
| Clone repo       | `cloneRepoUrl`, `githubToken`        | `clone_repo_url`, `github_token`  | `WithCloneRepo`, `WithGitHubToken`      | n/a                                          |
| Wait for ready   | `waitReady`, `waitTimeoutMs`         | `wait`, `timeout`                 | `WithWaitReady`, `WithWaitTimeout`      | `--no-wait`, `--wait-timeout`                |

Unset options fall back to service defaults (2 vCPU, 4096 MB). Inbound and outbound networking are enabled by default in every SDK and the CLI, so the network options are only needed to opt out of inbound exposure.

## 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.

**TypeScript**
```typescript
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);
```

**Python**
```python
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)
```

**Go**
```go
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)
```

**CLI**
```bash
# 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

There are three ways to run a command, from least to most control:

* **One-shot** (`exec`): starts the process, buffers its output, and returns a single result when it exits. Use it for short commands where you only need the outcome.
* **Output streaming** (`session.stream()` in TypeScript, `session.Stream()` in Go, iterating `proc.stdout` in Python): incremental stdout/stderr chunks as the command produces them. Use it for output-heavy or long commands you want to follow live.
* **Process handle** (`sb.start()` in Python, `session.run()` in TypeScript, `session.Command(...).Stream()` in Go): the running process itself — pid, writable stdin, signals, and kill. Use it for interactive processes and anything you may need to stop early.

All three report the same [command result](#command-results) when the process exits.

**Python**
```python
# 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.

`start` takes the same options (with `stdin` in place of `input`, and no `check`) and returns a `Process` handle: `proc.pid`, iterable `proc.stdout` / `proc.stderr` (bytes chunks), `proc.close_stdin()`, `proc.signal("TERM")`, `proc.kill()`, and `proc.wait()`, which returns the same `CommandResult` as `exec`.

**TypeScript**
```typescript
// 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();

// Or take a process handle: stdin, signals, kill
const proc = session.run(["python3", "worker.py"], { cwd: "/home/tenki/app" });
console.log(await proc.pid);

const writer = proc.stdin.getWriter();
await writer.write(new TextEncoder().encode("job-1\n"));
await writer.close();

await proc.signal("TERM"); // or proc.kill()
const runResult = await proc; // the handle is awaitable
```

`exec` accepts `args`, `cwd`, `env`, `timeoutMs`, `onOutput`, and an `AbortSignal`; `stream` takes the same options and returns a `Stream` with `next()`, `wait()`, and `cancel()`.

`run` takes a full argv array plus `cwd`, `env`, `stdin`, and `privileged`. The `ProcessRunHandle` exposes `pid` (a promise), `stdout` / `stderr` (`ReadableStream<Uint8Array>`), a writable `stdin`, `signal()`, and `kill()`; awaiting the handle returns `{ exitCode, stdout, stderr, signal?, durationMs?, reason?, errno? }`.

**Go**
```go
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())
}
```

Stream incremental output instead of buffering it:

```go
stream, err := session.Stream(ctx, "npm", tenkisandbox.WithArgs("test"))
for {
  chunk, err := stream.Next()
  if errors.Is(err, io.EOF) {
    break
  }
  os.Stdout.Write(chunk.Data)
}
result, err := stream.Wait()
```

Or take a process handle — stdin, signals, kill — via `Command`:

```go
cmd := session.Command([]string{"python3", "worker.py"}, tenkisandbox.RunOptions{Dir: "/home/tenki/app"})
proc, err := cmd.Stream(ctx)

fmt.Fprintln(proc.Stdin, "job-1")
proc.Stdin.Close()
go io.Copy(os.Stdout, proc.Stdout)

err = proc.Signal(syscall.SIGTERM) // or proc.Kill()
result, err = proc.Wait()
```

Exec options: `WithArgs`, `WithTimeout`, `WithEnv`, `WithEnvs`; `Stream` takes the same options, and its handle adds `Cancel()`. `Command` takes a full argv slice plus `RunOptions` (`Env`, `Dir`, `Stdin`, `Timeout`, `Privileged`); `cmd.Exec(ctx)` runs it one-shot, `cmd.Stream(ctx)` returns a `RunHandle` with `PID`, `Stdin`, `Stdout`, `Stderr`, `Signal()`, `Kill()`, and `Wait()`.

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

**CLI**
```bash
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

### Command results

Every execution path reports the same result object when the process exits — `ExecResult` in TypeScript, `CommandResult` in Python, `Result` in Go. Captured `stdout` / `stderr` are raw bytes in all three; use the decoding helpers for text.

| Field          | TypeScript `ExecResult`                          | Python `CommandResult`                     | Go `Result`                                                 |
| -------------- | ------------------------------------------------ | ------------------------------------------ | ----------------------------------------------------------- |
| Exit code      | `exitCode`                                       | `exit_code`                                | `ExitCode`                                                  |
| Output (bytes) | `stdout`, `stderr` (`Uint8Array`)                | `stdout`, `stderr` (`bytes`)               | `Stdout`, `Stderr` (`[]byte`)                               |
| Output (text)  | `stdoutText(result)`, `stderrText(result)`       | `result.stdout_text`, `result.stderr_text` | `result.StdoutString()`, `result.StderrString()`            |
| Success check  | `isSuccess(result.status)`                       | `result.ok`, `result.check()`              | `result.Status.IsSuccess()`, `.IsFailed()`, `.IsTimedOut()` |
| Status         | `status` (`SUCCEEDED`, `FAILED`, `TIMED_OUT`, …) | derived from `exit_code` + `signal`        | `Status`                                                    |
| Duration       | `durationMs`                                     | `duration_ms`                              | `Duration`                                                  |
| Timing         | `startedAt`, `endedAt`                           | —                                          | `StartedAt`, `EndedAt`                                      |
| Failure detail | —                                                | `signal`, `reason`, `errno`                | —                                                           |
| Invocation     | `sessionId`, `command`, `args`                   | `argv`                                     | `SessionID`, `Command`, `Args`                              |

TypeScript's `run()` handle resolves to a slimmer `ProcessRunResult` instead: `exitCode`, `stdout`, `stderr`, plus `signal`, `durationMs`, `reason`, and `errno` (the raw errno on fork/exec failures such as `ENOENT` or `EACCES`).

## 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.

Every SDK covers the same surface: read and write, directory listing, metadata (`stat`), directory creation, and deletes. `mkdir` creates missing parent directories and `remove` deletes recursively. For large files, the streaming variants transfer data in chunks instead of buffering the whole file in memory.

**Python**
```python
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", include_hidden=True)
sb.fs.upload("./local.tar", "/home/tenki/local.tar")
sb.fs.download("/home/tenki/build.log", "./build.log")

# Metadata and directories
info = sb.fs.stat("/home/tenki/config.json")  # path, size, mode, is_dir, modified_unix_ns
sb.fs.mkdir("/home/tenki/data/raw")
sb.fs.remove("/home/tenki/data")

# Stream large files in chunks
for chunk in sb.fs.read_stream("/home/tenki/build.log"):
    handle(chunk)
sb.fs.write_stream("/home/tenki/dump.bin", chunks)
```

**TypeScript**
```typescript
import { createReadStream } from "node:fs";
import { Readable } from "node:stream";

await session.writeFile("/home/tenki/config.json", '{"key": "value"}');
const data = await session.readFile("/home/tenki/config.json"); // Uint8Array

// Directory listing, metadata, and directories
const entries = await session.list("/home/tenki", { includeHidden: true });
const info = await session.stat("/home/tenki/config.json"); // path, size, mode, isDir, modifiedUnixNs
await session.mkdir("/home/tenki/data/raw");
await session.remove("/home/tenki/data");

// Stream large files in chunks
const stream = await session.readFileStream("/home/tenki/build.log"); // ReadableStream<Uint8Array>
const fileStream = Readable.toWeb(createReadStream("./local.tar"));
await session.writeFileStream("/home/tenki/local.tar", fileStream);
```

**Go**
```go
err = session.WriteFile(ctx, "/home/tenki/hello.txt", []byte("hello"))
data, err := session.ReadFile(ctx, "/home/tenki/hello.txt")

// Directory listing, metadata, and directories
entries, err := session.List(ctx, "/home/tenki", tenkisandbox.ListOptions{IncludeHidden: true})
info, err := session.Stat(ctx, "/home/tenki/hello.txt") // Path, Size, Mode, IsDir, ModifiedUnixNs
err = session.Mkdir(ctx, "/home/tenki/data/raw")
err = session.Remove(ctx, "/home/tenki/data")

// Stream large files in chunks
r, err := session.ReadFileStream(ctx, "/home/tenki/build.log") // io.ReadCloser
defer r.Close()

f, err := os.Open("./local.tar")
err = session.WriteFileStream(ctx, "/home/tenki/local.tar", f)
```

**CLI**
```bash
# 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
```

The CLI covers `write` and `read`; use the SDKs for directory listing, metadata, and deletes.

## Port exposure and networking

Each session has independent inbound and outbound settings. Every SDK and the CLI request both at create
time, so both are enabled by default:

* `allow_outbound` lets the guest make outbound network calls, so package installs and `git clone` work
  out of the box
* `allow_inbound` enables inbound exposure workflows; with `allow_inbound=false`, port exposure is rejected

Both are create-time settings; you cannot toggle them afterwards. Each tool reports what a session was
created with:

| Setting    | TypeScript                | Python                          | Go                        | CLI                                        |
| ---------- | ------------------------- | ------------------------------- | ------------------------- | ------------------------------------------ |
| `inbound`  | `session.inboundEnabled`  | `sandbox.info.inbound_enabled`  | `session.InboundEnabled`  | `tenki sandbox get --session <session-id>` |
| `outbound` | `session.outboundEnabled` | `sandbox.info.outbound_enabled` | `session.OutboundEnabled` | `tenki sandbox get --session <session-id>` |

**Python**
```python
preview = sb.expose_port(3000, ttl=3600)  # ttl in seconds
print(preview.url)

ports = sb.list_exposed_ports()
sb.unexpose_port(3000)
```

**TypeScript**
```typescript
const port = await session.exposePort(3000, { ttlMs: 3600_000 });
console.log(port.previewUrl);
```

**Go**
```go
port, err := session.ExposePort(ctx, 3000)
ports, err := session.ListExposedPorts(ctx)
err = session.UnexposePort(ctx, 3000)
```

**CLI**
```bash
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.

## Unix socket bridging (dial)

`dial` opens a byte-stream connection from your local code to a Unix socket inside the guest. It is the private counterpart to port exposure: nothing is published on the internet — the connection rides the session's authenticated data plane. Use it to talk to guest services that listen on a socket rather than a TCP port (a language server, an agent's control socket, `docker.sock`).

`dial` is SDK-only; there is no CLI equivalent. The optional connect timeout bounds how long the guest waits for the socket to accept.

**TypeScript**
```typescript
const conn = await session.dial("/home/tenki/app.sock", { connectTimeoutMs: 5_000 });

const writer = conn.writable.getWriter();
await writer.write(new TextEncoder().encode("PING\n"));
await writer.close();

for await (const chunk of conn.readable) {
  process.stdout.write(chunk);
}
```

`dial` returns `{ readable, writable }` web streams. Closing the writable half-closes the connection; the readable ends when the guest side closes.

**Python**
```python
conn = sb.dial("/home/tenki/app.sock", connect_timeout=5)
conn.write(b"PING\n")
print(conn.read(4096))
conn.close()
```

`dial` returns a raw IO connection (an `io.RawIOBase`), the same shape as `sb.ssh()`.

**Go**
```go
conn, err := session.Dial(ctx, "/home/tenki/app.sock", tenkisandbox.DialOptions{ConnectTimeout: 5 * time.Second})
if err != nil {
  log.Fatal(err)
}
defer conn.Close()

fmt.Fprint(conn, "PING\n")
buf := make([]byte, 4096)
n, _ := conn.Read(buf)
fmt.Print(string(buf[:n]))
```

`Dial` returns a `net.Conn`, so it plugs into anything that speaks the interface — for example an `http.Transport` whose `DialContext` returns it, to talk HTTP over the guest socket.

## 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.)

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

```typescript
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..."]);
```

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

```python
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..."])
```

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

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

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

**CLI**
Open an interactive shell:

```bash
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:

```bash
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:

```bash
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:

```bash
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.