Agent Sandbox
TypeScript Client
This TypeScript client provides a high-level interface for creating and interacting with sandboxes managed by the Agent Sandbox controller, mirroring the Go client and Python client.
The surface covers the Kubernetes resource layer (provisioning a SandboxClaim, watching it to readiness, and tearing it down via SandboxClient / Sandbox) and the sandboxd runtime layer (sandbox.commands.{run,start}(), sandbox.files.{read,write,readStream,writeStream,exists,list,delete}(), sandbox.health(), and sandbox.metadata()), plus sandbox.status() for the Sandbox’s Kubernetes readiness. start() covers long-running and interactive processes: streamed output, stdin, signals, and a PTY with resizing.
Usage
import { SandboxClient } from "./dist/index.js";
const client = new SandboxClient({ namespace: "default" });
const sandbox = await client.createSandbox("my-warmpool");
try {
// Connects to sandboxd lazily on first use; both facades share one
// connection once established.
const result = await sandbox.commands.run("echo", ["hello"]);
console.log(result.stdout, result.exitCode);
// No shell is involved; invoke one explicitly for pipes, redirects, etc.
await sandbox.commands.run("sh", ["-c", "ls | wc -l"], {
env: { LC_ALL: "C" },
cwd: "work",
});
await sandbox.files.write("greeting.txt", "hello from the SDK\n");
const contents = await sandbox.files.read("greeting.txt");
console.log(new TextDecoder().decode(contents));
const listing = await sandbox.files.list(".");
console.log(listing.entries.map((e) => e.name));
} finally {
await sandbox.close();
}
Automatic expiration
Set shutdownAfterSeconds when creating a sandbox to have the controller delete
its claim and sandbox after the requested lifetime, even if the client process
exits before cleaning up:
import { SandboxClient } from "./dist/index.js";
const client = new SandboxClient();
const sandbox = await client.createSandbox("my-warm-pool", "default", {
shutdownAfterSeconds: 300,
});
The lifetime starts at the createSandbox call and includes provisioning time.
The option sets an absolute spec.lifecycle.shutdownTime and
spec.lifecycle.shutdownPolicy: "Delete" on the claim, matching the Python SDK’s
shutdown_after_seconds. It must be a positive integer that produces a valid
RFC3339 deadline; invalid values reject with SandboxError before provisioning.
Omitting it leaves expiration unset. Continue to call sandbox.close() when work
finishes; expiration provides a fallback if the client cannot clean up.
Pod labels and annotations
labels land on the SandboxClaim. Use podLabels and podAnnotations to set
metadata on the sandbox Pod instead, where code inside the sandbox can read it
through the Downward API:
const sandbox = await client.createSandbox("my-warm-pool", "default", {
podLabels: { "sandbox.users.io/session": "abc123" },
podAnnotations: { "sandbox.users.io/note": "demo" },
});
These populate spec.additionalPodMetadata, matching the Python SDK’s
pod_labels and pod_annotations. Pod label syntax is checked before
provisioning; the controller’s label-domain allowlist is enforced server-side.
Persistent volumes
Use volumeClaimTemplates to attach PersistentVolumeClaims to the sandbox Pod:
const sandbox = await client.createSandbox("my-warm-pool", "default", {
volumeClaimTemplates: [
{
metadata: { name: "my-volume" },
spec: {
accessModes: ["ReadWriteOnce"],
resources: { requests: { storage: "1Gi" } },
},
},
],
});
These populate spec.volumeClaimTemplates, matching the Python SDK’s
volume_claim_templates. Warm pool Pods do not have these volumes, so setting
this forces a cold start. The controller validates the templates against the
SandboxTemplate’s volume policy.
Environment variables
Use env to inject environment variables into the sandbox’s first container:
const sandbox = await client.createSandbox("my-warm-pool", "default", {
env: { API_KEY: "secret" },
});
These populate spec.env, matching the Python SDK’s env. Warm pool Pods are
already running without them, so setting this forces a cold start. The
SandboxTemplate’s envVarsInjectionPolicy decides whether the controller
accepts them.
Named claims
Use claimName to pick the SandboxClaim name, and adoptExisting to attach to
the claim if it already exists, for example when a process restarts:
const sandbox = await client.createSandbox("my-warm-pool", "default", {
claimName: "my-agent",
adoptExisting: true,
});
This matches the Python SDK’s claim_name and adopt_existing. The name must be
a DNS-1123 subdomain. Without adoptExisting, a name conflict rejects. An
adopted claim must not be terminating and must use the same warm pool; other
options are not reapplied.
A named claim belongs to the caller: a failed createSandbox() never deletes
it, and enableAutoCleanup() only closes its handle. deleteSandbox() and
deleteAll() still delete it.
Listing sandboxes
With Kubernetes credentials configured and permission to list SandboxClaims, run the following from this package directory after building locally:
import { SandboxClient } from "./dist/index.js";
const client = new SandboxClient({ namespace: "default" });
const allClaims = await client.listAllSandboxes("default");
const appClaims = await client.listAllSandboxes("default", "app=my-agent");
const devClaims = await client.listAllSandboxes(
undefined,
"env in (dev,test),!disabled",
);
The optional second argument is a Kubernetes label selector for
SandboxClaim.metadata.labels (set through createSandbox’s labels option),
not Pod labels. Omitting the selector or passing an empty string lists all claims
in the namespace. Omitting the namespace or passing undefined or an empty string
uses the client’s configured default namespace.
Kubernetes credentials
By default the client loads KUBECONFIG, then ~/.kube/config, then the in-cluster
service account. Pass kubeConfig to target a specific cluster or user instead:
import { KubeConfig } from "@kubernetes/client-node";
const kubeConfig = new KubeConfig();
kubeConfig.loadFromFile("/path/to/other-cluster.yaml");
const client = new SandboxClient({ kubeConfig });
The same config is used for every API call, watch, and port-forward. It is used as given, so the “no Kubernetes configuration found” error applies only to the default.
Timeouts
sandboxReadyTimeout(constructor /createSandbox()options) is in seconds and bounds waiting for theSandboxClaim/Sandboxto becomeReady. Default: 180.sandboxd.portForwardReadyTimeoutMs(constructor option, undersandboxd) is in milliseconds and bounds the shared connection to sandboxd, through a successful health check: forport-forwardconnectivity it starts when both local port-forward listeners are opened, and for the in-cluster modes it bounds health polling against the pod address (see Connectivity). It applies in full to every (re)connect attempt, including reconnects after a transport failure. Default: 30000.- Every
sandbox.files.*/sandbox.commands.run()/sandbox.health()/sandbox.metadata()call takes a per-calltimeoutMs(default 60000). This is a total budget for the call, including any time spent waiting on the shared connection above — a cold first call can spend most of its budget just connecting. Forsandbox.commands.start()it covers only the time until the process has started (see Interactive processes); the calls on the returned handle each take their owntimeoutMs. sandboxd.maxCommandOutputSizebounds the fully-decodedExecuteResponse(stdout + stderr + protobuf framing combined, not stdout alone) thatsandbox.commands.run()will accept.
Connectivity
sandboxd.connectivity selects how the SDK reaches sandboxd. The values are the same as the Go client’s Connectivity:
| Value | Path | Requirements |
|---|---|---|
"port-forward" (default) |
WebSocket port-forward brokered by the apiserver | A kubeconfig that allows pods/portforward. Works from anywhere, including a laptop or CI runner. |
"in-cluster-service" |
Dials the Sandbox’s headless Service by DNS name (status.serviceFQDN) |
The process runs inside the cluster, and the template sets spec.service: true. |
"in-cluster-pod-ip" |
Dials the pod IP (status.podIPs, IPv4 preferred) |
The process runs inside the cluster. |
"port-forward" doesn’t work on Kata or gVisor: their workload runs in a separate
kernel, so the pod’s host-side network namespace has nothing for the port-forward to
reach — use an in-cluster mode (the Sandbox NetworkPolicy must admit the client).
const client = new SandboxClient({
sandboxd: { connectivity: "in-cluster-service" },
});
The in-cluster modes take the apiserver off the data path. Each uses exactly one address, and neither falls back to the other or to port-forwarding:
- With
"in-cluster-service",createSandbox()/getSandbox()throwSandboxNoServiceErrorwhen the Sandbox has no Service, instead of falling back to the pod IP. Prefer this mode when sandboxes cross a trust boundary: the Service only selects its own Sandbox’s pod, and deleting the Sandbox deletes the Service, so connections fail instead of reaching another pod that inherited the IP. DNS caching still leaves a TTL-bounded window. - With
"in-cluster-pod-ip",createSandbox()/getSandbox()throwSandboxMetadataErrorwhen the Sandbox reports no pod IP. This mode needs no template change, but nothing detects that the pod was rescheduled: a handle keeps dialing the address it saw when it was created, which Kubernetes may since have reassigned to an unrelated pod. getSandbox(claimName, namespace, { waitForReady: false })skips both checks, because a Sandbox that is not Ready may not have its Service or pod IP yet. The error then surfaces on the firstfiles/commandscall; callgetSandbox()again once the Sandbox is Ready.
The addresses are available on the handle as sandbox.podIP and sandbox.serviceFQDN. They are "" when unknown.
Authentication
If sandboxd runs with --auth-token-sha256 or --auth-token-file, pass the token as sandboxd.authToken:
const client = new SandboxClient({
sandboxd: { authToken: process.env.SANDBOXD_TOKEN },
});
The SDK sends it as Authorization: Bearer <token> on every REST request and gRPC call, and never logs it or puts it in a trace or error. It must be non-empty printable ASCII with no spaces. A missing or wrong token fails the call with SandboxdApiError (code: "UNAUTHENTICATED") for files and metadata, and SandboxdRpcError (code: "unauthenticated") for commands. GET /v1/health needs no token, so the connect health check passes even with a wrong one and the first real call reports it.
With port-forward the token rides the TLS connection to the apiserver. sandboxd has no TLS, so with the in-cluster modes it is sent in plaintext, and in-cluster-pod-ip can send it to an unrelated pod if the IP was reassigned. Prefer in-cluster-service or port-forward. See Trust boundary.
Streaming
sandbox.files.readStream() / writeStream() transfer a file without buffering the whole payload in memory, unlike read()/write():
import { createReadStream, createWriteStream } from "node:fs";
import { Readable, Writable } from "node:stream";
// Upload a large local file without buffering it.
await sandbox.files.writeStream(
"data/input.bin",
Readable.toWeb(createReadStream("./input.bin")) as ReadableStream<Uint8Array>,
{ timeoutMs: 10 * 60_000 },
);
// Download and stream straight to disk.
const stream = await sandbox.files.readStream("data/output.bin", {
timeoutMs: 10 * 60_000,
});
await stream.pipeTo(Writable.toWeb(createWriteStream("./output.bin")));
A few things differ from the buffered methods:
- Ownership:
readStream()resolves once the response headers are validated, before the file is fully downloaded — you must read the returnedReadableStreamto completion or cancel it (e.g.reader.cancel()). An abandoned, unread stream keeps its operation in flight untilopts.timeoutMselapses or the sandbox is closed.writeStream()consumes its input stream at most once; the SDK cancels it on any termination after consumption starts (timeout, abort, an oversized upload, a non-204 response), but a validation failure that happens before the stream is touched (an invalid mode, or content that’s already locked by another reader) leaves the stream with you. - Timeouts:
opts.timeoutMs(same option as the buffered methods, default 60000) is a total budget from the call through full consumption. ForreadStream()it keeps running even while you’re not reading; forwriteStream()it includes time spent waiting on your input stream. A timeout errors the stream/promise and releases the operation without waiting for another read or write. - Size limits:
sandboxd.maxDownloadSize/sandboxd.maxUploadSizeare enforced incrementally as bytes arrive or are sent, not against aContent-Lengthheader — an oversized transfer is aborted mid-stream rather than buffered in full and then rejected. On an oversized upload, sandboxd may already have received a prefix within the limit before the abort; its atomic rename (see “No automatic retry” below) still keeps that prefix from ever becoming the target file’s visible content. close(): an unconsumedreadStream()or an in-flightwriteStream()counts towardclose()’s in-flight drain, bounded by the same cleanup timeout as every other operation.
Health and metadata
sandbox.health() and sandbox.metadata() take the same per-call options as the other runtime calls (RuntimeCallOptions: timeoutMs and signal). FileCallOptions is kept as an alias of it.
const { status, uptimeSeconds } = await sandbox.health();
const { env } = await sandbox.metadata();
console.log(env.SANDBOX_ID);
health()probes sandboxd’s/v1/healthand resolves with{ status: "ok", uptimeSeconds }. The lazy connect already waits for sandboxd to become healthy, so a first call against a not-yet-ready sandboxd fails with the connect’s timeout or connection error, not a 503; only on an already-established connection does an unready sandboxd (for example during shutdown) reject with aSandboxdApiError(status 503). A resolvedhealth()means “reachable and ready now”, not “just came up”.metadata()reads sandboxd’s/v1/metadataand resolves with{ env }: the orchestrator-injected variables sandboxd chooses to expose. sandboxd serves only names starting with its--metadata-env-prefix(defaultSANDBOX_) and withholds any name that looks like a credential (containingTOKEN,SECRET,KEY, and similar).envis therefore not sandboxd’s full environment, and is empty when nothing matches. Templates that need a value visible here must set it on the sandboxd container under the configured prefix; nothing in the controller injects one for you.- Neither call is retried automatically, and neither records any value in tracing spans (
metadata()records only the number of variables).
Sandbox status
sandbox.status() reads the Sandbox’s Ready condition from Kubernetes, without connecting to sandboxd:
const { status, message } = await sandbox.status();
// status: "SandboxReady" | "SandboxNotReady" | "SandboxNotFound"
It resolves with SandboxNotFound when the Sandbox object is gone (also after close()) and rejects with a SandboxError on any other read failure. The values match the Python client’s status().
Interactive processes
sandbox.commands.start(command, args?, options?) starts a process and resolves, once sandboxd reports it running, with a ProcessHandle. Like run(), [command, ...args] is the argv (no shell), and env/cwd behave the same.
// Stream output as it arrives.
const proc = await sandbox.commands.start("sh", ["-c", "for i in 1 2 3; do echo $i; sleep 1; done"]);
const decoder = new TextDecoder();
for await (const event of proc.events) {
if (event.type === "stdout") process.stdout.write(decoder.decode(event.data, { stream: true }));
if (event.type === "exit") console.log("exit code", event.exitCode);
}
// Feed stdin, and read output through callbacks instead.
const cat = await sandbox.commands.start("cat", {
onStdout: (chunk) => process.stdout.write(chunk),
});
await cat.write("hello\n");
await cat.closeStdin();
await cat.wait();
// A terminal: stderr is merged into stdout, and the size can change.
const shell = await sandbox.commands.start("sh", { pty: { cols: 80, rows: 24 } });
await shell.resize({ cols: 120, rows: 40 });
await shell.write("stty size\nexit\n");
- Consuming output: pass
onStdout/onStderr, iteratehandle.events, or callhandle.wait()(which discards the output). These are mutually exclusive —eventsthrows once callbacks orwait()are in use — except thatwait()takes over from aneventsloop you have left. Nothing is buffered on the SDK side: until one of them is in use the stream is not read, and the process blocks once its pipe fills. Output arrives asUint8Array, since a multi-byte character can straddle two chunks. If a callback throws, the process is killed andwait()rejects with that error. - Events:
eventsyieldsstdout,stderr(never for a PTY), and finally oneexitevent.wait()resolves with the same exit code. A process killed by a signal reports-1, not the shell’s128+N. Breaking out of afor awaitearly neither kills the process nor discards output; iteratingeventsagain continues from where you left off. Callingwait()after leaving the loop (break, return, or throw) instead discards the rest of the output until the exit, andeventsis unavailable from then on. If you drive the iterator by hand withnext(), call itsreturn()beforewait(), or nothing reads the stream andwait()does not settle until the process is ended some other way. Events this SDK version does not know (from a newer sandboxd) are skipped. - Control calls:
write(data)(strings are UTF-8; large payloads are sent in sequential chunks),closeStdin()(EOF),signal("SIGINT" | "SIGTERM" | "SIGKILL")andkill()(delivered to the whole process group), andresize({ cols, rows }). Each takes its owntimeoutMs/signal. After the process has exited they fail with aSandboxdRpcError:not_foundonce sandboxd has forgotten the process, but a call made shortly after the exit (even afterwait()has resolved) can first fail with another code, such asinternal;resize()on a process without a PTY fails withfailed_precondition. For a PTY processcloseStdin()is rejected: it would close the terminal itself, so send"\x04"(Ctrl-D) withwrite()instead. - Lifetime: the process lives exactly as long as its stream.
handle.close(), abortingoptions.signal,sandbox.close()/closeLocal(), or a lost connection ends the stream, and sandboxd then SIGKILLs the process’s whole process group. There is no way to detach and leave a process running, and a process is not restarted if the connection is re-established.options.timeoutMsbounds only the time until the process has started, never its lifetime. Closing one handle does not affect other calls sharing the connection, and a long-running process does not delaysandbox.close(). - Connection:
handle.pidis sandboxd’s own numbering and only meaningful on the connection that started the process. If that connection is replaced after a transport failure, the handle’s stream fails and its control calls throwSandboxConnectionError; they never reconnect and act on a different connection.
Execution target and path rules
sandbox.commands.run(command, args?, options?) (or run(command, options?)) passes [command, ...args] to sandboxd as the process’s argv, exactly like ProcessConfig.command — it is never wrapped in a shell, so there is no word splitting, globbing, or variable expansion. Use run("sh", ["-c", "..."]) when you need shell syntax. Because there is no shell in between, an executable that cannot be found is reported by sandboxd as a SandboxdRpcError with code not_found, not as a result with exit code 127.
options.envis merged over sandboxd’s own environment (it does not replace it).PATHset here does not change howcommanditself is looked up — sandboxd resolves it against its ownPATH— so pass an absolute path if you need a specific executable.options.cwdis resolved relative to the sandbox root (default: the sandbox root) by sandboxd, with the same symlink-aware confinement as the files API; a directory outside the sandbox root is rejected with aSandboxdRpcError(codepermission_denied).
run() executes inside the container running sandboxd. If your Pod spec’s sandboxd container has a different root filesystem than a “workload” sidecar container, run() only ever executes in the sandboxd container — a shared volume does not make binaries from another container available to it.
All file paths are sandbox-root-relative POSIX paths and are validated before any network request, without being decoded or normalized first:
""and"."(and equivalent all-dot/empty-segment forms) refer to the sandbox root.list()andexists()accept it;read()passes validation but then rejects because the root is a directory;write()/delete()reject it up front, as they do a trailing/.- An absolute path (leading
/) is always rejected. - Any
..path segment is always rejected, including forexists()(which never silently reportsfalsefor a rejected path — it throws).
This path confinement covers the files API and the working directory sandboxd runs commands from; it does not by itself isolate the command’s process tree, filesystem, or network access — that is the responsibility of the Pod’s runtimeClassName, securityContext, volumes, NetworkPolicy, and RBAC.
No automatic retry
A failed files.*, commands.run(), health() or metadata() call is never retried automatically by the SDK. If the underlying connection was invalidated by a transport failure, the next call you make reconnects from scratch; other calls already in flight on the same connection fail together with it.
What “retrying” safely means differs per method:
| Method | Automatic retry | Retrying from scratch | Constraints |
|---|---|---|---|
run() |
No | Re-run the command | A failed call may or may not have executed to completion server-side before the failure was observed — retrying blindly could re-run a command that already had side effects. |
health() / metadata() |
No | Call it again | Read-only; the result reflects sandboxd at the time of the call. |
start() and its handle’s write()/signal()/resize() |
No | Start the process again | A failed start() may have launched the process; a failed write() may have delivered a prefix of its data. A process interrupted by a lost connection is killed, not resumed. |
read() |
No | Call it again | The file may have changed between attempts. |
write() |
No | Resend the same content |
sandboxd replaces the target atomically via a temporary file and rename, so a failed call never leaves the target partially written. But if the acknowledgement was lost, the write may already have committed server-side, and a retry can overwrite a concurrent update from someone else. |
readStream() |
No | Call it again from the beginning | Discard any partial output and reset your destination first — a partial download is never resumed. The file may have changed between attempts. |
writeStream() |
No | Provide a fresh stream producing the same content | The input stream is consumed at most once, so retrying needs a new stream instance, not the same one rewound. Same atomicity/acknowledgement/concurrent-update caveats as write(). |
The atomicity guarantee — the target path never briefly shows partial content — is independent of whether you can tell a given call actually completed server-side, and of whether a retry might overwrite someone else’s concurrent write.
Trust boundary
With port-forward connectivity, the local TCP listeners the SDK opens for its port-forward tunnel (127.0.0.1, random ports) have no authentication of their own. Unless sandboxd requires a token, any other process in the same network namespace can reach the sandbox’s files/run API through them for as long as the connection is open. Treat other local processes as trusted, the same way you would for any other unauthenticated localhost service.
With the in-cluster modes, REST and gRPC travel in plaintext across the pod network. Unless sandboxd requires a token, anything that can reach the sandbox pod on those ports can use its files/run API, and with a token the token itself is sent in plaintext. Restrict that access with a NetworkPolicy or a service mesh.
Differences from the Go and Python clients
run()takes an argv (command,args) mirroring sandboxd’sProcessConfig, plusenvandcwd; the Go/Python clients take a single shell string that they wrap in/bin/sh -c.- File paths are never recorded in tracing spans or logs (only counts/sizes/booleans are), and absolute paths /
..segments are rejected by the client itself before any request is sent. - RuntimeClass (gVisor/Kata) is not observed or branched on anywhere in this layer; conformance on non-default runtimes is tracked separately and is not implied by this SDK’s tests passing on a standard cluster.
Not supported
- The legacy python-runtime. The SDK only talks to sandboxd.
- The Gateway and Direct connection modes, and access to sandboxd through sandbox-router (#1889). Use one of the
sandboxd.connectivityvalues above.
Publishing status
This package is not currently distributed in any form — there is no npm package and no git-based distribution channel today.
"private": true in package.json is set intentionally, as a safeguard against accidentally publishing an unfinished package to the npm registry (e.g. via a stray npm publish or an automated release step).
It is still under active development and its public API may change without notice.
Development / local usage
Until this package is published, use it by checking out this repository and building it locally from this directory:
git clone https://github.com/kubernetes-sigs/agent-sandbox.git
cd agent-sandbox/clients/typescript/agentic-sandbox-client
npm install
npm run build
sandbox.commands.run() and sandbox.commands.start() additionally require the optional @bufbuild/protobuf, @connectrpc/connect, and @connectrpc/connect-node peer dependencies (declared as optional peers in package.json). They are loaded lazily on first use, so sandbox.files.* and everything else in the package works without them installed; calling run() or start() without them throws a clear error naming the packages to install.
See src/index.ts for the full set of exports.