Go client for tenki.sandbox.v1.SandboxService.
go get github.com/LuxorLabs/tenki-sdk-go/sandboxMore runnable snippets live under examples/.
export TENKI_API_KEY=tk_your_api_key
# TENKI_API_URL defaults to https://api.tenki.cloudpackage main
import (
"context"
"fmt"
"log"
"time"
tenkisandbox "github.com/LuxorLabs/tenki-sdk-go/sandbox"
)
func main() {
ctx := context.Background()
client, err := tenkisandbox.New()
if err != nil {
log.Fatal(err)
}
defer client.Close()
// create waits by default: the server holds the response until the sandbox
// is RUNNING with data-plane access primed, so it is exec-ready on return.
session, err := client.Create(ctx, tenkisandbox.WithWaitTimeout(2*time.Minute))
if err != nil {
log.Fatal(err)
}
defer session.Close(ctx)
result, err := session.Exec(
ctx,
"echo",
tenkisandbox.WithArgs("hello"),
tenkisandbox.WithTimeout(5*time.Second),
)
if err != nil {
log.Fatal(err)
}
fmt.Printf("status=%s exit=%d stdout=%s\n", result.Status, result.ExitCode, string(result.Stdout))
}client, err := tenkisandbox.New(
tenkisandbox.WithAuthToken("tk_your_api_key"),
tenkisandbox.WithBaseURL("https://api.tenki.cloud"),
tenkisandbox.WithHTTPTimeout(10*time.Second),
)Creation warnings are retained on session.Warnings and emitted to stderr by default.
Replace or suppress emission with WithWarningHandler:
client, err := tenkisandbox.New(
tenkisandbox.WithWarningHandler(func(warning tenkisandbox.SandboxWarning) {
log.Printf("sandbox warning [%s]: %s", warning.Code, warning.Message)
}),
)When WithSticky() and WithMaxDuration(...) are both supplied, sticky takes precedence and the requested duration is discarded.
The returned session contains STICKY_OVERRIDES_MAX_DURATION.
Sticky disables automatic idle pause and remains enabled across manual pause and resume.
Auth token resolution: WithAuthToken() > TENKI_AUTH_TOKEN env var > TENKI_API_KEY env var > error.
Base URL resolution: WithBaseURL() > TENKI_API_URL env var > https://api.tenki.cloud.
Version 1.0.0 removes all deprecated Project fields, messages, and RPCs from
the generated v1 API. The protobuf package remains tenki.sandbox.v1; use
Workspace-scoped fields and RPCs instead.
Version 0.6.0 removes WithCookieName and support for Ory session tokens and
browser cookie values. Use a tk_ API key or service token with
WithAuthToken; the SDK sends it as an Authorization: Bearer credential.
| Env Var | Description |
|---|---|
TENKI_AUTH_TOKEN |
Auth token fallback (API key or service token), first |
TENKI_API_KEY |
Auth token fallback (API key or service token) |
TENKI_API_URL |
Base URL fallback |
Use an API key or service token (tk_*), sent as Authorization: Bearer <token>.
Workspace API keys determine Sandbox scope automatically; ordinary calls do not require a Workspace ID.
New(opts ...Option) (*Client, error)(*Client).Create(ctx, opts ...CreateOption) (*Session, error)— preferred: waits by default and returns a RUNNING, exec-ready session(*Client).CreateAndWait(ctx, timeout, opts ...CreateOption) (*Session, error)— compatibility wrapper aroundCreate(*Client).List(ctx, opts...) ([]*Session, error)(*Client).Get(ctx, sessionID string) (*Session, error)(*Client).WhoAmI(ctx) (*Identity, error)(*Client).Close() error
(*Session).Exec(ctx, command string, opts ...ExecOption) (*Result, error)(*Session).WriteFile(ctx, path string, data []byte) error(*Session).ReadFile(ctx, path string) ([]byte, error)(*Session).WaitReady(ctx, timeout) error— alternative wait for sessions obtained viaGet/List;Createalready returns ready sessions by default(*Session).Pause(ctx) error(*Session).PauseAsync(ctx) error(*Session).WaitPaused(ctx, timeout) error- waits for durable pause completion; pass a non-positive timeout to wait untilctxis canceled(*Session).Resume(ctx) error(*Session).WaitResumed(ctx, timeout) error(*Session).Close(ctx) error
Pause preserves legacy completion semantics and returns only after the pause completes.
PauseAsync returns after the service accepts the request and moves the session to PAUSING.
Call WaitPaused after PauseAsync when the caller needs a durable checkpoint before continuing.
It returns when the session reaches PAUSED, returns ErrPauseFailed if a verified rollback restores RUNNING, and respects context cancellation.
if err := session.PauseAsync(ctx); err != nil {
return err
}
if err := session.WaitPaused(ctx, 0); err != nil {
return err
}DURABLE_CEPH snapshots can resume on a capability-matched host in the same datacenter while R2 replication continues.
DURABLE snapshots are portable to eligible hosts.
LOCAL_READY is not resumable for pause operations.
(*Session).Command(argv []string, opts ...RunOptions) *Command(*Command).Exec(ctx) (*Result, error)— buffered; blocks until the process exits(*Command).Stream(ctx) (*RunHandle, error)— live handle; blocks until the process has started(*RunHandle).Signal(os.Signal) error/(*RunHandle).Kill() error— enqueue a signal frame; both return once it is sent, not when the process has exited(*RunHandle).Wait() (*Result, error)— blocks until the process is actually dead
proc, _ := sess.Command([]string{"sleep", "3600"}).Stream(ctx)
_ = proc.Signal(syscall.SIGTERM) // returns once the frame is sent
res, _ := proc.Wait() // returns once the process has exited
// res.Status == CommandStatusFailed, res.ExitCode == -1Unlike the Python and TypeScript SDKs, Go's Result does not expose which
signal terminated the process — the wire value is only used to derive Status.
Kill() always sends SIGKILL — use Signal(syscall.SIGTERM) for a graceful
stop. Only SIGKILL, SIGTERM, SIGINT, SIGHUP, SIGUSR1 and SIGUSR2 reach the
guest; any other os.Signal is silently sent as SIGTERM, so a
Signal(syscall.SIGQUIT) stops the process gracefully rather than failing.
Signalling after the process has exited is best-effort: it usually returns a
stream error, but a caller must not rely on getting one. Wait() delivers its
result once, so call it from a single goroutine only.
Process lifetime is bound to the Run stream, not to the handle. Once that stream
tears down — you cancel the ctx you passed to Stream, the connection breaks,
or the edge times the idle connection out (~30s) — the platform sends SIGTERM,
escalates to SIGKILL after 5s and reaps within 10s.
Dropping the handle does not tear the stream down on its own: RunHandle has
no Close, and the request side is closed only after an exit frame or a stream
error. An abandoned process can keep running, and one that keeps writing output
keeps its own stream alive indefinitely. To stop a process, cancel the context or
signal it and Wait(). There is no reattach.
session.Git.Clone(ctx, repo, GitCloneParams)session.Git.Checkout(ctx, ref, GitCheckoutParams)session.Git.Diff(ctx, GitDiffParams)session.Git.Log(ctx, GitLogParams)session.Git.FetchPR(ctx, prNum, GitFetchPRParams)
(*Client).CreateVolume(ctx, opts ...CreateVolumeOption) (*Volume, error)(*Client).GetVolume(ctx, volumeID) (*Volume, error)(*Client).ListVolumes(ctx, opts...) ([]*Volume, error)(*Client).ResizeVolume(ctx, volumeID, newSizeBytes) (*Volume, error)(*Client).DeleteVolume(ctx, volumeID) error(*Session).AttachVolume(ctx, volumeID, mountPath, opts ...VolumeOption) error(*Session).DetachVolume(ctx, volumeID) error
(*Client).CreateSnapshot(ctx, sessionID, name, expiresAt) (*Snapshot, error)(*Client).GetSnapshot(ctx, snapshotID) (*Snapshot, error)(*Client).ListSnapshots(ctx, opts...) ([]*Snapshot, error)(*Client).DeleteSnapshot(ctx, snapshotID) (*Snapshot, error)(*Client).WaitSnapshotReady(ctx, snapshotID, timeout) (*Snapshot, error)
(*Client).CreateTemplate(ctx, opts ...TemplateOption) (*Template, error)(*Client).GetTemplate(ctx, templateID) (*Template, error)(*Client).ListTemplates(ctx, opts...) ([]*Template, error)(*Client).UpdateTemplate(ctx, templateID, opts ...TemplateOption) (*Template, error)(*Client).DeleteTemplate(ctx, templateID) (*Template, error)(*Client).BuildTemplate(ctx, templateID) (*TemplateBuild, error)(*Client).WaitForTemplateBuild(ctx, buildID) (*TemplateBuild, error)
Open an interactive SSH stream to a session over the gateway. SSHConn
implements io.ReadWriteCloser.
(*Session).SSH(ctx, opts ...SSHOption) (*SSHConn, error)(*Client).SSH(ctx, sessionID string, opts ...SSHOption) (*SSHConn, error)WithGatewayURL(string)- pin the gateway URL (otherwise derived from base URL)
conn, err := session.SSH(ctx)
if err != nil {
log.Fatal(err)
}
defer conn.Close()
io.Copy(os.Stdout, conn) // read sandbox output; write to conn to send inputExpose a port from inside the sandbox to the caller.
(*Session).ExposeHostPort(ctx, hostAddr string, opts ...HostPortTunnelOptions) (*HostPortTunnel, error)(*Session).HostPortTunnel(ctx, host string, port int, opts ...HostPortTunnelOptions)(*Session).ExposeHostPortResilient(ctx, hostAddr, opts ...ResilientHostPortTunnelOptions)- auto-reconnect(*HostPortTunnel).Terminated() <-chan HostPortTunnelTermination/.Close()
Publish a stable, browser-openable URL bound to a session port:
(*Client).CreatePreviewURL(ctx, slug string, sessionID *string, port *int32) (*PreviewURL, error)(*Client).BindPreviewURL(ctx, previewURLID, sessionID string, port int32)/UnbindPreviewURL(*Client).ListPreviewURLs(ctx)/GetPreviewURL/DeletePreviewURL
Publish and share sandbox images (templates/snapshots/images).
(*Client).PublishRegistryImage(ctx, opts ...RegistryPublishOption) (*RegistryPublishResult, error)(*Client).ListRegistryImages(ctx, opts ...RegistryListOption)/GetRegistryImage/ResolveRegistryRef(*Client).UnpublishRegistryImage/DeleteRegistryImage(*Client).DeleteRegistryImageVersion(ctx, imageID, snapshotID string) (*RegistryVersionDeleteResult, error)— delete one untagged, non-latest, unshared version- Sharing:
ShareImage,RevokeRegistryShareGrant,ListRegistryShareGrants,UnshareRegistryImage
WithAuthToken(string)- API key or service tokenWithBaseURL(string)default:https://api.tenki.cloudWithHTTPClient(*http.Client)WithHTTPTimeout(time.Duration)default:30sWithConnectClientOptions(...connect.ClientOption)WithWarningHandler(WarningHandler); passnilto suppress warning emission
WithName(string)WithAllowInbound(bool)default:true(sent explicitly on every Create)WithAllowOutbound(bool)default:true(sent explicitly on every Create)- both are create-time settings and cannot be changed on an existing session;
session.InboundEnabled/session.OutboundEnabledreport what a session was created with
- both are create-time settings and cannot be changed on an existing session;
WithEnvs(map[string]string)session-scoped env defaultsWithMaxDuration(time.Duration)WithSticky(); overrides and discardsWithMaxDurationWithCPUCores(int32)default:2WithMemoryMB(int32)default:4096WithMetadata(map[string]string)WithSSHKeys([]string)WithVolume(volumeID, mountPath, ...VolumeOption)WithSnapshot(snapshotID)/WithImage(image)(mutually exclusive)WithCloneRepo(repoURL)/WithGitHubToken(token)
WithArgs(...string)WithTimeout(time.Duration)— unbounded when unsetWithEnv(key, value string)WithEnvs(map[string]string)command env overrides
Commands are unbounded by default. WithTimeout sends the budget to the
guest-agent, which enforces it: on expiry it signals the process (SIGTERM,
escalating to SIGKILL) and reports the run as timed out.
A timeout is not a Go error — it comes back as an ordinary result with
CommandStatusTimedOut, carrying the output captured before the budget expired:
result, err := session.Exec(ctx, "bash",
tenkisandbox.WithArgs("-lc", "npm ci"),
tenkisandbox.WithTimeout(2*time.Minute),
)
if err != nil {
return err // transport or session failure, not a timeout
}
if result.Status.IsTimedOut() {
// result.Reason is "timeout", or "grace_timeout" when the guest could not
// reap the process. Partial output usually shows where it stalled.
log.Printf("timed out (%s); partial output: %s", result.Reason, result.StdoutString())
}The guest caps the request at its own configured maximum command timeout, and
older guest-agents accept WithTimeout but ignore it, leaving the run unbounded.
A command returns when its stdout and stderr reach EOF, not when the shell exits. A backgrounded process inherits both streams and holds them open, so this waits for the server rather than the shell:
// Hangs: the server inherits stdout/stderr.
session.Exec(ctx, "sh", tenkisandbox.WithArgs("-lc", "python3 -m http.server 3000 &"))Redirect both streams to detach it:
session.Exec(ctx, "sh",
tenkisandbox.WithArgs("-lc", "python3 -m http.server 3000 >/home/tenki/http.log 2>&1 &"))Redirecting only stdout is not enough — stderr still holds the stream open — and
nohup does not help, because it blocks SIGHUP rather than stream inheritance.
This is standard POSIX behavior, the same as Go's own exec.Cmd with piped output.
To keep hold of a service instead, use session.Command(...).Stream(ctx) and read
from the handle; for services you always want running, start them from a template
start command.
SDK maps Connect errors to typed errors. Use errors.Is:
if errors.Is(err, tenkisandbox.ErrSessionNotFound) { ... }
if errors.Is(err, tenkisandbox.ErrPermissionDenied) { ... }
if errors.Is(err, tenkisandbox.ErrCommandTimeout) { ... }
if errors.Is(err, tenkisandbox.ErrMissingAuthToken) { ... }tenkisandbox.GB // 1,000,000,000
tenkisandbox.GiB // 1,073,741,824
tenkisandbox.MB // 1,000,000
tenkisandbox.MiB // 1,048,576DefaultSessionCreateTimeout(3m)DefaultSnapshotCreateTimeout(5m)DefaultRestoreTimeout(5m)DefaultExecTimeout(30s) — a suggested value forWithTimeout, not applied automatically; commands are unbounded unless you set a timeout
- File ops are scoped to the sandbox home on server side. Use paths under
/home/tenki/.... - Process
cwdvalues follow the guest contract: relative paths are normalized under the sandbox guest workdir (/home/tenkiby default), absolute paths are used unchanged, and missing or non-directory targets fail before the process starts. cwdsets the spawned process's directory. A login shell (bash -lc) sources the guest's startup files first, so acdin~/.bashrcor~/.profileruns before your command and wins overcwd; usebash -cwhen the directory must hold.- Create/list ownership is derived from auth context.
- Volume size: 1 MiB - 100 GiB.
- Session CPU: 1-16 cores. Memory: 128-65536 MB, aligned to 2 MiB.