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, socket bridging, and SSH. Pick your tool in any code block, and the selection syncs across the page.
Create a session
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 |
| Allowed domains | allowDomains | allow_domains | WithAllowDomains | --allow-domain |
| Allowed CIDRs | allowCidrs | allow_cidrs | WithAllowCIDRs | --allow-cidr |
| 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 |
| 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 of 2 vCPU, 4096 MB, and a 5 GiB root disk.
Set diskSizeGb, disk_size_gb, WithDiskSizeGB, or --disk-size-gb to 20 for coding agents that clone repositories and install dependencies.
Inbound and outbound networking are enabled by default in every SDK and the CLI.
Use the network options to disable either direction, or set an outbound allowlist to restrict destinations.
Environment variables are plaintext configuration, not a secret-management channel.
Do not pass long-lived credentials through env.
Sticky sessions
sticky takes precedence over maxDurationMs, max_duration, WithMaxDuration, and --max-duration.
When both are set, Tenki discards the requested maximum duration and returns a create-session warning.
A sticky session runs until you terminate it.
A manual pause stops compute but does not clear sticky, so the session is still sticky after resume.
Sticky is not fixed at create time; see Updating a running session to toggle it, and to trade sticky for a bounded lifetime on a session that is already running.
Pausing and resuming
Pause and resume preserve the VM's memory and durable filesystem state, but active terminal, SSH, preview, and other TCP connections close when the session pauses.
Reconnect after the session returns to RUNNING.
/tmp is kept across a pause but cleared when you create a snapshot, so write logs and durable state under /home/tenki or on an attached volume.
A process you started over exec survives a pause with the same PID, but it runs inside the guest-agent's cgroup and does not survive a guest-agent restart, so start anything load-bearing from a template start command.
The SDK's synchronous pause() call waits for PAUSED.
By default, Tenki commits that state once the memory checkpoint and root disk are ready on the source host.
Replication to shared storage continues in the background, so you can request a resume without waiting for those uploads.
Until replication finishes, resume depends on the source host remaining available.
To return once the pause request is accepted, use the asynchronous pause method, then wait for PAUSED before resuming:
| SDK | Request pause | Wait for PAUSED |
|---|---|---|
| TypeScript | await session.pauseAsync() | await session.waitPaused(300_000) |
| Python | sb.pause_async() | sb.wait_paused(timeout=300) |
| Go | session.PauseAsync(ctx) | session.WaitPaused(ctx, 5*time.Minute) |
The CLI returns after acceptance by default.
Add --wait when the next command depends on the session being paused:
tenki sandbox pause --session <session-id> --wait
tenki sandbox resume --session <session-id>To create independent copies while the source continues running, see Fork a running session.
Create and wait
A waiting create can fail after the service has admitted the sandbox (3 minutes by default).
Each SDK reports this as a dedicated error that carries the sandbox handle, so you can close it, inspect it, or keep waiting:
| Meaning | TypeScript | Python | Go |
|---|---|---|---|
| Admitted, not ready inside the wait budget | WaitReadyFailedError | WaitReadyFailedError | *WaitReadyFailedError |
| Template runtime readiness failed | TemplateRuntimeFailedError | TemplateRuntimeFailedError | *TemplateRuntimeFailedError |
TemplateRuntimeFailedError carries the handle on the same field, so the same branch covers both. In Go, errors.Is(err, tenkisandbox.ErrWaitReadyFailed) matches too when you only need to classify the failure.
Scope-based cleanup — await using, with, defer session.Close(ctx) — only runs once create has returned, so it cannot cover a create that throws. Handle these errors explicitly wherever you create with waiting enabled.
A sandbox that never leaves CREATING is reaped for you. The case worth closing yourself is the one that reaches RUNNING just after your wait expired: it keeps running, and billing, until its max duration.
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.
Filtering a session list
list returns every non-terminated session in the workspace unless you narrow it. Tag filtering is an AND: a session must carry every tag you pass to match. Tags are trimmed and lowercased on both write and read, so filter casing never has to match how the tags were set.
| Filter | TypeScript | Python | Go |
|---|---|---|---|
| Tags (AND) | tags | tags | WithTagFilter |
| Sticky only / non-sticky | sticky | sticky | WithStickyFilter |
| Include terminated | includeTerminated | include_terminated | WithIncludeTerminated |
| Explicit workspace scope | workspaceId | workspace_id | WithWorkspaceID |
Terminated sessions are omitted by default. Ask for them explicitly when you are reconciling against your own records, since a session you cannot find is otherwise indistinguishable from one that has been cleaned up.
Workspace API keys infer their workspace server-side; the explicit workspace scope is for service tokens that can see more than one.
Updating a running session
Name, tags, and sticky are mutable after create. Only the fields you pass change.
| Field | TypeScript | Python | Go |
|---|---|---|---|
| Name | name | name | WithName |
| Tags | tags (or updateTags) | tags (or update_tags) | WithTags (or UpdateTags) |
| Sticky | sticky | sticky | WithSetSticky |
| Max duration | maxDurationMs | max_duration | WithSetMaxDuration |
Tags replace rather than merge, so pass the full set you want; an empty list clears them.
Setting a max duration requires passing sticky in the same call, and every SDK rejects the call locally when you omit it. Sticky still wins when both are set, so setting sticky true alongside a duration discards the duration and returns a warning. Pass sticky: false when the point is to give the session a lifetime. Update returns warnings the same way create does, so read them instead of assuming a field took effect.
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, iteratingproc.stdoutin 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 when the process exits.
For git, prefer the structured helpers over shelling out: clone, checkout, diff, log, and fetchPR are available on the session as git in every SDK, and return the raw git output. See Git helpers.
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.
Outbound allowlists
Requires Tenki CLI 1.7.0 or later, or SDK 1.1.0 or later for TypeScript, Python, or Go.
Set allowed domains or IPv4 CIDRs when you create a session to restrict its outbound destinations.
Tenki must enable session egress policies for your workspace first.
If creation returns failed_precondition with session egress policy is not enabled for this workspace, contact Tenki support to request enablement.
This example allows access to PyPI and its package download host:
Policy rules
- Domains must be hostnames such as
pypi.org, without a URL scheme, path, or port. A single leading wildcard matches exactly one label:*.example.commatchesapi.example.com, but does not matchexample.comora.b.example.com. - CIDRs must be IPv4 prefixes, such as
203.0.113.10/32for one address. IPv6, bare IP addresses without a prefix, and the default route0.0.0.0/0are rejected. A CIDR permits destinations in that range without requiring a matching domain, subject to workspace restrictions. - The service accepts up to 256 unique domains and 64 unique CIDRs. It lowercases domains, removes trailing dots, masks CIDR host bits, and sorts and deduplicates both lists before returning the policy.
- Omitting both lists, or passing two empty lists through the SDKs, adds no session allowlist.
An explicitly empty
egressobject in a raw API request is rejected. To block all outbound traffic, useallowOutbound: false,allow_outbound=False,WithAllowOutbound(false), or--allow-outbound=false. - The policy is fixed at create time; create a new session to change it. An allowlist cannot override disabled outbound access or widen workspace network permissions. Supplied allowlists still require valid input and workspace enablement even when outbound access is disabled.
The returned policy describes the session allowlist, not the combined workspace restrictions. Empty returned lists mean no session allowlist; outbound access can still be disabled or restricted by the workspace. The SDK reference lists the option and readback names for each language.
HTTPS request rules and saved policies
Use HTTPS interception to restrict HTTP methods and paths as well as destinations.
Tenki terminates HTTPS on port 443 at its egress proxy and checks each request before forwarding it.
The base image trusts Tenki's managed CA and sets SSL_CERT_FILE, REQUESTS_CA_BUNDLE, and NODE_EXTRA_CA_CERTS, so curl, git, pip, and npm work without extra setup.
Clients that pin the upstream certificate are incompatible.
HTTP rules require HTTPS interception and an allowed-domain list.
Create a named workspace policy once, then reuse it when creating sessions. With the CLI, save the policy as JSON:
{
"allow_domains": [
"github.com",
"api.github.com",
"codeload.github.com",
"raw.githubusercontent.com",
"pypi.org",
"pythonhosted.org",
"files.pythonhosted.org",
"npmjs.com",
"npmjs.org",
"www.npmjs.com",
"www.npmjs.org",
"registry.npmjs.org",
"yarnpkg.com",
"registry.yarnpkg.com",
"crates.io",
"index.crates.io",
"static.crates.io",
"proxy.golang.org",
"sum.golang.org",
"repo1.maven.org",
"repo.maven.apache.org",
"archive.ubuntu.com",
"security.ubuntu.com"
],
"intercept_https": true,
"http_rules": [
{ "domains": ["*"], "methods": ["GET", "HEAD"] },
{
"domains": ["github.com"],
"methods": ["POST"],
"path_regex": "^/[^/]+/[^/]+(\\.git)?/git-upload-pack$"
}
]
}Then create it:
tenki sandbox egress-policies create --file registries.json registriesWith the Go SDK, install it from main for the APIs below, then initialize an authenticated client and ctx as shown in the SDK reference:
go get github.com/LuxorLabs/tenki-sdk-go/sandbox@main_, err = client.CreateEgressPolicy(ctx, "registries", tenkisandbox.SessionEgressPolicy{
AllowDomains: []string{
"github.com", "api.github.com", "codeload.github.com", "raw.githubusercontent.com",
"pypi.org", "pythonhosted.org", "files.pythonhosted.org",
"npmjs.com", "npmjs.org", "www.npmjs.com", "www.npmjs.org", "registry.npmjs.org",
"yarnpkg.com", "registry.yarnpkg.com",
"crates.io", "index.crates.io", "static.crates.io",
"proxy.golang.org", "sum.golang.org", "repo1.maven.org", "repo.maven.apache.org",
"archive.ubuntu.com", "security.ubuntu.com",
},
InterceptHTTPS: true,
HTTPRules: []tenkisandbox.EgressHTTPRule{
{Domains: []string{"*"}, Methods: []string{"GET", "HEAD"}},
{
Domains: []string{"github.com"}, Methods: []string{"POST"},
PathRegex: `^/[^/]+/[^/]+(\.git)?/git-upload-pack$`,
},
},
})
if err != nil {
panic(err)
}
s, err := client.Create(ctx, tenkisandbox.WithEgressPolicies("registries"))
if err != nil {
panic(err)
}
defer s.Close(ctx)
fmt.Println(s.ID)The SDK reference imports github.com/LuxorLabs/tenki-sdk-go/sandbox as tenkisandbox.
The policy allows GET and HEAD to the listed hosts, plus Git smart-HTTP POST requests to GitHub's git-upload-pack endpoint for clone and fetch operations.
Other requests must match a rule to pass; a rejected HTTP request returns 403 with the body egress_http_rule_denied.
This example does not permit Git pushes or package publishing.
It also does not cover GitHub release downloads, which redirect to release-assets.githubusercontent.com, or Git LFS, whose batch API is a POST to /<owner>/<repo>.git/info/lfs/objects/batch.
Domains: []string{"*"} matches only destinations already admitted by the domain allowlist and workspace restrictions.
It does not allow every internet host.
PathRegex uses RE2 syntax and matches the entire escaped request path, excluding the query string.
Rules are alternatives: a request passes if any rule matches its domain, method, and optional path constraint.
An empty Methods list matches every method, and omitting HTTPRules adds no method or path restriction.
Keep HTTPS certificate verification enabled in the guest. Managed interception supports HTTP/1.1 upstreams on public IPv4 addresses; certificate pinning, client-certificate authentication, HTTP/2-only upstreams, WebSocket upgrades, and CONNECT tunnels are unsupported. The registry list is a starting point: add the download or mirror hosts your dependencies actually use, and allow additional methods only when your workflow needs them.
A session pins the named policy's contents at create time. Updating or deleting the saved policy does not change existing sessions; create a new session to use the updated policy. The policy must belong to the workspace where you create the session. Pin the saved policy when you create a session from the CLI:
tenki sandbox create --egress-policy registriesAdditional outbound TCP ports
With HTTPS interception enabled, public outbound TCP is limited to ports 80 and 443 unless you add ports explicitly.
Use WithAllowedPorts when a service on an allowed destination needs another TCP port:
s, err := client.Create(ctx,
tenkisandbox.WithEgressPolicies("registries"),
tenkisandbox.WithAllowedPorts(8443),
)
if err != nil {
panic(err)
}
defer s.Close(ctx)Omit WithAllowedPorts to keep only TCP 80 and 443.
Additional ports still require an allowed destination, but their traffic bypasses HTTP inspection and the method/path rules above.
Adding port 8443 does not apply HTTPS interception to that port or expose an inbound sandbox port.
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_outboundlets the guest make outbound network calls, subject to workspace restrictions and any session allowlistallow_inboundenables inbound exposure workflows; withallow_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> |
When you expose a long-running server you started with exec, background it and detach its streams (>/home/tenki/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.
For Tailscale-attached sessions, a preview URL and a tailnet port are independent inbound paths; see Preview URLs and tailnet access.
Every exposure is a preview URL in your workspace, and each workspace has a limit on how many it can hold.
Terminating a session deletes the preview URLs bound to it, and a preview URL with a TTL is deleted when the TTL passes.
Unexposing a port, from an SDK or with tenki sandbox preview-url unbind, only takes the URL offline.
It stays in the workspace and counts toward the limit until you delete it with the CLI's tenki sandbox preview-url delete <preview-url-id>, client.previewUrls.delete(id) in TypeScript, client.preview_urls.delete(id) in Python, or client.DeletePreviewURL in Go.
If you are about to terminate the session, skip unexposing and let terminate clean up.
Keep a preview URL across sessions
To hand out one URL that keeps working as you replace sessions, create a preview URL with a slug and move it from session to session. Unbind it before you terminate the old session; otherwise terminate deletes it.
tenki sandbox preview-url create --slug my-app --session <session-id> --port 3000
# Before you terminate the session
tenki sandbox preview-url unbind <preview-url-id>
tenki sandbox terminate --session <session-id>
# On the next session
tenki sandbox preview-url bind --session <new-session-id> --port 3000 <preview-url-id>A preview URL bound to one session must be unbound before you bind it to another.
Binding can change the hostname, for example when an unbound preview URL is first bound to a session, so use the preview_url printed by the latest create or bind.
The SDK clients have the same operations: create, bind, unbind, list, get, and delete on client.previewUrls in TypeScript and client.preview_urls in Python, and CreatePreviewURL, BindPreviewURL, UnbindPreviewURL, ListPreviewURLs, GetPreviewURL, and DeletePreviewURL in Go.
Named exposures
By default an exposure gets a generated hostname that changes from session to session. Pass a slug to ask for a named exposure instead, which is what you want when the URL has to be written into something that cannot be updated per session: an OAuth callback, a webhook registration, a config file baked into an image.
A slug names the exposure; it does not by itself decide how the URL is served. Some exposures are served through a workspace wildcard domain, and the returned exposure reports that separately, along with the readiness of the backing certificate:
| Field | TypeScript | Python | Go |
|---|---|---|---|
| Requested slug | slug | slug | Slug |
| Wildcard scheme used | wildcard | wildcard | Wildcard |
| Readiness | wildcardStatus | wildcard_status | WildcardStatus |
| Failure reason | wildcardStatusReason | wildcard_status_reason | WildcardStatusReason |
Readiness is one of UNSPECIFIED, PENDING, READY, FAILED, or DISABLED, and is only meaningful when the wildcard field is true. A wildcard-served preview URL comes back from expose immediately, but it does not serve traffic until its workspace domain reaches READY. Check readiness before handing the URL to a caller that fails closed on an error response, and read the reason field when it is FAILED.
Even with a slug, use the returned preview URL rather than assembling a hostname from the slug yourself.
Workspace preview slug
Wildcard-served exposures live under a preview slug reserved at the workspace level. Team-plan workspaces can reserve one slug (one per workspace) from Settings, using the slug input in the first card. It is shared across every session in the workspace, which is what keeps named exposures stable from one session to the next.
Once reserved, named exposures are served under that slug. A workspace slug of acme-studio produces preview URLs of the form https://<exposure>--acme-studio.sb.tenki.sh/, for example https://landing-preview--acme-studio.sb.tenki.sh/. Reserving a slug is a Team-plan feature; Starter workspaces get generated per-session hostnames instead. As above, treat the example as illustrative and hand callers the returned preview URL rather than assembling one from the slug yourself.
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.
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.)
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.