npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@tenkicloud/sandbox

v1.3.0

Published

TypeScript SDK for Tenki Sandbox

Readme

@tenkicloud/sandbox

TypeScript SDK for Tenki Sandbox, programmatic cloud sandboxes for AI agents.

Install

npm install @tenkicloud/sandbox

Requires Node.js 18 or newer. Bun 1.3 or newer also works.

Quick Start

import { TenkiSandbox } from "@tenkicloud/sandbox";

const sandbox = new TenkiSandbox(); // reads TENKI_AUTH_TOKEN
// create() waits by default via a single server-held request and returns a
// run-ready sandbox with data-plane access primed.
const session = await sandbox.create({
  cpuCores: 2,
  memoryMb: 4096,
});

try {
  const result = await session.run(["echo", "hello"]);
  console.log(result.exitCode); // 0
} finally {
  await session.close();
}

Session also implements AsyncDisposable, so await using works in runtimes that support explicit resource management.

Authentication

Pass an API key or service token starting with tk_ explicitly, or set TENKI_AUTH_TOKEN or TENKI_API_KEY:

const sandbox = new TenkiSandbox({ authToken: "tk_..." });

apiKey is an alias for authToken. If both are supplied, authToken wins. Blank or whitespace-only values count as absent.

The credential determines the Workspace automatically; ordinary Sandbox calls do not require a Workspace ID.

Migrating to v0.7.0

Version 0.7.0 removes the generated workspace settings and pause-retention RPCs. Use getUsage() for read-only workspace usage and limit data. Its shared concurrency entry now uses the max_concurrent_jobs key.

Migrating to v0.6.0

Version 0.6.0 removes ClientOptions.cookieName and support for Ory session tokens and browser cookie values. Pass a tk_ API key or service token through authToken; the SDK sends it as an Authorization: Bearer credential.

Sessions

const session = await sandbox.create({
  name: "my-sandbox",
  cpuCores: 4,
  memoryMb: 8192,
  diskSizeGb: 10,
  allowInbound: true,
  allowOutbound: true,
  env: { NODE_ENV: "production" },
  metadata: { purpose: "ci" },
  tags: ["ci"],
  cloneRepoUrl: "https://github.com/org/repo",
  githubToken: process.env.GITHUB_TOKEN,
  sticky: true,
});

await session.refresh();
await session.extend(10 * 60 * 1000);
await session.update({ name: "renamed", tags: ["ci", "kept"] });
await session.pause();
await session.resume();
await session.close();

idleTimeoutMinutes is deprecated and ignored: sandboxes do not auto-pause on idle. Use maxDurationMs, or session.pause() to pause explicitly.

sticky: true discards maxDurationMs and runs the session until it is terminated. Manual pause and resume do not clear sticky. The returned session.warnings array retains the structured warning. By default, TenkiSandbox writes each warning to console.warn. Set warningHandler to route warnings through your logger, or pass null to suppress emission:

const sandbox = new TenkiSandbox({
  warningHandler: (warning) => process.emitWarning(warning.message),
});

const session = await sandbox.create({ sticky: true, maxDurationMs: 60 * 60 * 1000 });
if (session.warnings[0]?.code === "STICKY_OVERRIDES_MAX_DURATION") {
  console.log("maxDurationMs was discarded");
}

Create from an image or snapshot:

await sandbox.create({ image: "workspace/name:tag" });
await sandbox.create({ snapshotId: "snap_..." });

Durable pause

pauseAsync() returns after the service durably accepts the request and moves the session to PAUSING on a node that supports asynchronous pause. Snapshot capture and persistence continue in the background; acceptance does not mean the VM has stopped or the snapshot is durable. pause() retains its existing completion behavior for compatibility. Call waitPaused() before resuming or relying on a durable checkpoint. It resolves at PAUSED and throws PauseFailedError if a verified rollback restores RUNNING. Pass 0 as timeoutMs to wait until an abort signal cancels the call.

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.

What survives a pause

Pause captures the VM's full memory, not only its disk: a resumed session is the same running machine, not a reboot. Processes keep their PIDs and their in-memory state, and the guest clock stops for the duration of the pause.

/tmp is cleared across a pause. Keep durable state under /home/tenki, which is preserved.

While a session is paused it serves no traffic: preview requests return 404 until it resumes. The client connection itself is not dropped — a held keep-alive connection returns 200 again after resume — so treat a paused session as unavailable rather than disconnected.

SSH is different: a held transport fails on use while paused and does not recover after resume, so reconnect rather than retry on it. A fresh connection succeeds as soon as the session is RUNNING again.

async function text(cmd: string): Promise<string> {
  const result = await session.exec(["bash", "-lc", cmd]);
  return new TextDecoder().decode(result.stdout).trim();
}

await text("echo hello > ~/marker");
await text("setsid nohup sleep 3600 >/dev/null 2>&1 </dev/null &");
const pid = await text("pgrep -n sleep");

await session.pause();
await session.resume();

console.log(await text("cat ~/marker")); // "hello" - the marker file survived
console.log(await text(`kill -0 ${pid} && echo alive`)); // "alive" - same process, same memory

Commands

const proc = session.run(["npm", "test"], { cwd: "app", env: { CI: "true" } });

const reader = proc.stdout.getReader();
try {
  for (;;) {
    const { done, value } = await reader.read();
    if (done) break;
    process.stdout.write(value);
  }
} finally {
  reader.releaseLock();
}

const result = await proc;
console.log(result.exitCode);

exec takes argv either as a command plus options.args or as a single array:

await session.exec("sh", { args: ["-c", "echo hi"] });
await session.exec(["sh", "-c", "echo hi"]); // equivalent

For simple commands, you can await run directly:

const result = await session.run(["echo", "hello"]);
console.log(new TextDecoder().decode(result.stdout));

Process cwd values follow the guest contract: relative paths are normalized under the sandbox guest workdir (/home/tenki by default), absolute paths are used unchanged, and missing or non-directory targets fail before the process starts.

cwd sets the spawned process's directory. A login shell (bash -lc) sources the guest's startup files first, so a cd in ~/.bashrc or ~/.profile runs before your command and wins over cwd; use bash -c when the directory must hold.

Timeouts and cancellation

Commands are unbounded by default. Pass timeoutMs for a budget, or a signal to cancel.

The budget is enforced by the guest-agent, which signals the process on expiry (SIGTERM, escalating to SIGKILL) and reports the run as timed out. That is not an exception — it resolves with status: "TIMED_OUT", carrying the output captured before the budget expired:

const result = await session.exec("bash", { args: ["-lc", "npm ci"], timeoutMs: 120_000 });
if (result.status === "TIMED_OUT") {
  // result.reason is "timeout", or "grace_timeout" when the guest could not reap
  // the process. Partial output usually shows where it stalled.
  console.error(result.reason, new TextDecoder().decode(result.stdout));
}

Aborting is an exception — the call rejects with the signal's reason:

const controller = new AbortController();
const pending = session.exec("bash", { args: ["-lc", "make build"], signal: controller.signal });
controller.abort();

The guest caps the request at its own configured maximum command timeout, and older guest-agents accept timeoutMs but ignore it, leaving the run unbounded.

Background processes and long-running services

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.
await session.exec("sh", { args: ["-lc", "python3 -m http.server 3000 &"] });

Redirect both streams to detach it:

await session.exec("sh", { args: ["-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 Node's own child_process.exec.

To keep hold of a service instead, use session.run() and read from the handle rather than awaiting it; for services you always want running, start them from a template startCmd instead.

That last option is the durable one. Every exec child runs inside the guest-agent's own systemd cgroup, so a guest-agent restart kills it, and nohup does not change that. Pause and resume restore VM memory, so a backgrounded process does survive a pause with the same PID — which makes an ad-hoc service look more durable than it is. Anything load-bearing belongs in a template start command with a readiness probe.

Stopping a process

signal() and kill() enqueue a signal frame and resolve immediately — they do not wait for the process to die, matching Node's child_process.kill(). Awaiting the handle is what resolves once the process has actually exited:

const proc = session.run(["sleep", "3600"]);
await proc.signal("SIGTERM"); // resolves once the frame is queued
const result = await proc; // resolves once the process has exited
console.log(result.signal); // "terminated"

result.signal carries the guest's own name for the signal, not the name you passed: SIGTERM reports "terminated" and SIGKILL reports "killed". Compare against those values, not against "TERM"/"KILL".

Signalling before the guest acknowledges the spawn is safe: the SDK may still be retrying the stream open, and a signal queued during that window is replayed onto the replacement stream rather than being dropped.

kill() takes no argument and always sends SIGKILL; use signal(name) for anything else. Only KILL, TERM, INT, HUP, USR1 and USR2 are supported (with or without the SIG prefix), and any unrecognized name is silently sent as SIGTERM — a typo like signal("SIGTRM") stops the process gracefully instead of throwing, and so does signal("unspecified"), which is a harmless no-op in the Python SDK. (Python raises ValueError for unknown names instead.) Signalling a process that has already exited is a no-op, not an error, so the result of signal()/kill() is best-effort by design.

Process lifetime is bound to the Run stream, not to the handle. Once that stream tears down — the connection breaks, or the edge times the idle connection out (~30s) — the platform sends SIGTERM, escalates to SIGKILL after 5s if the process is still alive, and reaps it within 10s.

Dropping the handle does not close the stream on its own: the outbound queue is closed only once the result settles. An abandoned process can keep running, and one that keeps writing output keeps its own stream alive indefinitely. To stop a process, signal it and await the handle rather than abandoning it. There is no reattach — a caller that loses the handle must respawn.

Files

Simple helpers:

await session.writeFile("/tmp/config.json", '{"key":"value"}');
const data = await session.readFile("/tmp/config.json");
console.log(new TextDecoder().decode(data));

Filesystem API:

await session.fs.mkdir("/tmp/example");
await session.fs.stat("/tmp/example");
const entries = await session.fs.list("/tmp");
await session.fs.remove("/tmp/example");

Disk space

The root disk defaults to 5 GB and holds the base image as well as your work, so a fresh sandbox already reports around 56% used. It is fixed at create time and cannot be grown in place — pass diskSizeGb (5–100) to create if you need more. Budget for it before a large install.

Every ExecResult carries disk. isDiskExhausted(result.disk) reports whether the sandbox ran out of space at any point during the command — check it even when the command exited 0, since npm reports ENOSPC as a warning and still exits successfully with a broken install.

Networking

allowInbound and allowOutbound both default to true, so a session reaches the internet and can expose ports without passing either option. They are create-time settings and cannot be changed on an existing session; session.inboundEnabled / session.outboundEnabled report what a session was created with.

Egress allowlist

Restrict outbound access with allowDomains and allowCidrs. A session that may only reach PyPI:

const session = await client.create({
  allowDomains: ["pypi.org", "*.pypi.org", "files.pythonhosted.org"],
});

allowOutbound: false still blocks everything, regardless of allowDomains/allowCidrs. Read the allowlist back from session.egress.

Expose a sandbox port:

const port = await session.exposePort(3000, { ttlMs: 60 * 60 * 1000, slug: "my-preview" });
console.log(port.previewUrl);

const ports = await session.listExposedPorts();
await session.unexposePort(3000);

Dial from your program into the sandbox:

const conn = await session.dial("/var/run/docker.sock");
// conn has Web Streams: { readable, writable }.

Expose a local host port to the sandbox:

const tunnel = await session.resilientHostPortTunnel("127.0.0.1", 3000, {
  sandboxPort: 8080,
});

const [address, port] = tunnel.endpoint();
await tunnel.close();

Git

await session.git.clone("https://github.com/org/repo", { depth: 1, directory: "/home/tenki/repo" });
await session.git.checkout("feature-branch");
const diff = await session.git.diff({ base: "main", head: "HEAD" });
const log = await session.git.log({ maxCount: 10 });
await session.git.fetchPR(42, { remote: "origin", directory: "/home/tenki/repo" });

Snapshots

const snapshot = await sandbox.createSnapshotAndWait(session.id, {
  name: "after-setup",
  expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
});

const restored = await sandbox.create({ snapshotId: snapshot.id });

Registry

const published = await sandbox.publishRegistryImage({
  fromSnapshotId: snapshot.id,
  workspaceId: "ws_...",
  name: "my-image",
  tag: "latest",
  visibility: "private",
});

const resolved = await sandbox.resolveRegistryRef("my-image:latest");
const next = await sandbox.create({ image: resolved.resolvedRef });

// Only untagged, non-latest, unshared versions can be deleted.
await sandbox.deleteRegistryImageVersion(
  "01900000-0000-7000-8000-000000000001",
  "01900000-0000-7000-8000-000000000002",
);

Templates

Templates are typed, immutable recipes built from a Git context into private digest-addressed images. TemplateSpec is a fluent immutable builder: every method returns a new spec, the default base is the Tenki image sandbox, and copy sources are always relative to the checked-out Git context - nothing is uploaded from your local filesystem.

import { TemplateSpec } from "@tenkicloud/sandbox";

const spec = new TemplateSpec()
  .fromImage("sandbox-v2") // or .fromTemplate(parent) / .fromSnapshot(snapshot)
  .withGitContext({ repo: "https://github.com/acme/node-api", ref: "main" })
  .workdir("/home/tenki/app")
  .run("npm ci", { name: "Install dependencies" })
  .runtimeEnv({ NODE_ENV: "production" })
  .startArgs(["npm", "start"], {
    runAt: "boot",
    readyWhen: [{ http: "http://localhost:3000/health" }],
  });

const violations = spec.validate(); // local [{ field, message }]; the server stays authoritative

const template = await sandbox.createTemplate({
  name: "node-api",
  spec,
});

Build it, observing ordered log/progress events. Build secrets are explicit request-time values: they never land in the template, spec hash, provenance, or logs. Interrupting the observer (handler error, AbortSignal, Ctrl-C) stops watching only; cancelling the remote build is always explicit via cancelTemplateBuild.

const build = await sandbox.buildTemplate(template, {
  buildSecrets: { GITHUB_TOKEN: token },
  waitForCompletion: true,
  onEvent(event) {
    // Discriminated union, delivered in order; async handlers are awaited sequentially.
    if (event.type === "log") process.stdout.write(event.data);
    else console.log(`[${event.phase}] ${event.state}`, event.step?.label);
  },
});

console.log(build.specHash, build.image?.digestRef); // e.g. acme/node-api@sha256:...

A waited build failure throws TemplateBuildFailedError carrying the final build. waitForTemplateBuild(build) reconnects to an in-flight build with the same ordered, deduplicated event delivery.

Launch sandboxes from the built image. waitForRuntime: true also waits for the declared runtime to become READY; runtime failure leaves the sandbox RUNNING and throws TemplateRuntimeFailedError carrying the session and redacted failure.

const session = await sandbox.create({ image: build.image, waitForRuntime: true });

Filesystem snapshots (the default) capture the built filesystem and start the runtime on boot. snapshotMode: "memory" (with runAt: "build" and a readyWhen deadline) captures the running process for near-instant restore; when memory restore is unavailable or incompatible the platform cold-boots the same image rootfs, starts the declared runtime, and waits on the same readyWhen contract - never silently falling back to a clean base image.

const memorySpec = new TemplateSpec()
  .withGitContext({ repo: "https://github.com/acme/node-api", ref: "main" })
  .run("npm ci")
  .startArgs(["npm", "run", "dev"], {
    runAt: "build",
    snapshotMode: "memory",
    readyWhen: { timeoutSeconds: 60, checks: [{ port: 3000 }] },
  });

Specs round-trip through strict authored JSON with JSON.stringify(spec) / TemplateSpec.fromJSON(json). Enum fields use short lowercase values: checkout mode is "contents" or "directory"; runtime runAt, restartPolicy, and snapshotMode use values such as "build", "on-failure", and "memory". Existing full protobuf enum names remain accepted, unknown fields are rejected, and the canonical spec hash is computed server-side only.

SSH

Use session.run() for normal command execution. session.ssh() is a low-level, bidirectional byte transport for SSH protocol implementations that accept a custom stream. It does not open a shell or execute command text itself.

A custom SSH integration needs a caller-owned key pair; the private key stays with your application. Pass the public key in OpenSSH authorized_keys format to Tenki, then authenticate the SSH client with the matching private key and returned certificate. The minimal SDK sequence is:

const credentials = await sandbox.issueSandboxSSHCert(session.id, publicKey);
const transport = await session.ssh();
try {
  // Adapt transport.read(), write(), and close() to your SSH client.
  // Authenticate with the matching private key and credentials.sshCert.
} finally {
  transport.close();
}

credentials.expiresAt is the absolute certificate expiry. credentials.renewalAfterMs is an optional elapsed-time hint for minting a new certificate before opening a later connection. credentials.permissions reports the SSH features allowed for the certificate.

For OpenSSH, save credentials.sshCert beside the private key as <key>-cert.pub, or pass it with CertificateFile. OpenSSH cannot consume session.ssh() directly because the SDK transport is not a TCP socket; it requires a transport adapter such as a ProxyCommand.

Gateway selection is automatic. Most users should not set gatewayAddress or TENKI_SANDBOX_GATEWAY_URL; they are advanced overrides for environments with a custom SSH gateway address. Setting either disables automatic selection. sshAuthorizedKeys installs public keys inside the sandbox, but connections through Tenki's SSH gateway also require a short-lived certificate from issueSandboxSSHCert.

Identity

const me = await sandbox.whoAmI();
console.log(
  me.ownerId,
  me.workspaces.map((w) => w.id),
);

Error Handling

All SDK errors extend SandboxError:

import { SandboxError } from "@tenkicloud/sandbox";

try {
  await session.run(["false"]);
} catch (err) {
  if (err instanceof SandboxError) {
    console.error(err.message);
  }
}

Size Constants

import { GB, GiB, KB, KiB, MB, MiB, TB, TiB } from "@tenkicloud/sandbox";

Workspace secrets

WorkspaceClient exposes workspace resources through workspace.secrets, independently of sandbox Sessions:

import { WorkspaceClient } from "@tenkicloud/sandbox";

await using workspace = new WorkspaceClient({ workspaceId });
const secret = await workspace.secrets.create(
  "TOKEN",
  valueBytes,
  {
    deliveryMode: "guest_and_injection",
    destinationMode: "unset",
  },
  requestId,
);

Authentication uses TENKI_AUTH_TOKEN or TENKI_API_KEY. Set baseUrl or TENKI_CLOUD_API_URL to override the cloud API endpoint independently of sandbox configuration. Set workspaceId once on the client. An omitted or empty workspace ID uses the authenticated workspace key's scope; other callers must specify one.

create, update, get, list, listVersions, revoke, and delete return metadata only. Values are Uint8Array; omitting value on update retains it, while an empty array creates an empty value. Update, revoke, and delete require expectedRevision. To select an existing version, update activeVersion with value omitted. Revoking without version irreversibly revokes the entire secret.

Mutations generate a request ID when none is supplied. For retries after an uncertain result, supply and reuse the same request ID and identical arguments. The SDK does not retry mutations automatically. WorkspaceSecretError.code preserves the RPC status, including revision conflicts.

Workspace secrets can be referenced by a managed runtime without passing their values:

const runtime = new TemplateSpec().start("npm start", {
  secretEnv: { API_TOKEN: "app-token" },
});
const session = await sandbox.create({
  image: "team/base:v1",
  directRuntime: runtime,
  secretOverrides: { "app-token": "development-token" },
});

The same secretEnv option works with startArgs and processCompose in stored templates. Names resolve in the launching workspace. directRuntime accepts a runtime-only TemplateSpec and starts at boot; use create options for the image and resources. Secret targets cannot also appear in ordinary runtime/session env. session.hasRuntimeSecrets and snapshot.hasRuntimeSecrets are read-only markers.

Runtime secret files

Put secrets://API_TOKEN in any text file, regardless of filename or extension. Built templates capture unresolved source text from the guest image; direct launches supply content read on the caller. Values resolve from the launching workspace before managed startup.

import { readFile } from "node:fs/promises";

// Built template: source is already inside the image.
const spec = new TemplateSpec().start("python3 /app/server.py", {
  secretFiles: [{ source: "/app/env.tpl-sandbox", path: "/app/.env" }],
});

// Direct launch: read on the caller and upload unresolved text.
const session = await client.create({
  image: "my-template:latest",
  secretFiles: [{ path: "/app/.env", content: await readFile("./env.tpl-sandbox", "utf8") }],
  secretOverrides: { API_TOKEN: "STAGING_API_TOKEN" },
});

Rendering performs one literal pass, without YAML/JSON/dotenv escaping, environment expansion, or recursive substitution. A backslash before a marker escapes it. Authors must ensure the actual consumer accepts the result; do not shell-source rendered secret text. Use a raw reference for exact-byte credentials, including binary values.

Allowed destinations are under /home/tenki/, /workspace/, or /app/. Files are private, owned by tenki, and replaced atomically. Duplicate destinations, unsafe paths, missing references, and injection-only plaintext delivery fail startup. Limits: 64 KiB per secret, 256 KiB per source/output file, 1 MiB total source/output, 32 files, and 64 references across environment and files.

Guest values remain frozen across retries, restart, and ordinary resume; replacement Sessions adopt updates. Escape a file marker as \secrets://NAME to preserve secrets://NAME for separately authorized outbound injection; the marker grants no authority by itself. See the file delivery contract for lifecycle and path details.