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

@tangle-network/sandbox

v1.0.39

Published

Client SDK for the Tangle Sandbox platform - build AI agent applications with dev containers

Readme

@tangle-network/sandbox

TypeScript SDK for creating isolated development environments, running commands and agents, managing sessions, and coordinating fleets. The package is ESM-only and works in Node.js, edge runtimes, and browsers through separate entrypoints.

Install

npm install @tangle-network/sandbox

Set the customer API key and Sandbox API URL in your server environment.

export TANGLE_API_KEY=sk-tan-...
export SANDBOX_BASE_URL=https://sandbox.tangle.tools

Choose an entrypoint

| Need | Import | |---|---| | Full client, sandboxes, fleets, images, sessions, and trace utilities | @tangle-network/sandbox | | Edge-safe client without viem | @tangle-network/sandbox/core | | Browser or Worker access with a short-lived scoped token | @tangle-network/sandbox/runtime | | Browser status, claim, and release control for an interactive session | @tangle-network/sandbox/interactive-control | | Direct Tangle chain operations | @tangle-network/sandbox/tangle | | Server-side token issuance and validation | @tangle-network/sandbox/auth | | Collaborative document clients and file bridging | @tangle-network/sandbox/collaboration | | Browser session event streaming from the cell edge, with replay | @tangle-network/sandbox/session-gateway | | OpenAI-compatible request and event translation | @tangle-network/sandbox/openai | | Multiple managed Agents, framework tool packs, and the local MCP bridge | @tangle-network/sandbox/agent | | Browser-safe trace reads from Tangle Intelligence | @tangle-network/sandbox/intelligence | | Browser-safe public template catalog and publication jobs (PublicTemplatesClient) | @tangle-network/sandbox/public-templates |

Install an optional peer dependency only when the selected integration requires it. The Tangle quote digest, input-hash, and signature-validation helpers do not need viem at runtime. Tangle chain operations load the optional peer when called. The AI SDK tool pack supports the declared AI 6 and AI 7 peer ranges. The Mastra tool pack validates arguments before it calls a Sandbox operation. Both tool packs pass the selected session to operations that support sessions.

Credential boundary

TANGLE_API_KEY is an account-level control-plane credential. Keep it in a trusted server, CI secret store, or CLI configuration. Never ship it, a sandbox connection token, or provider credentials to browser JavaScript.

Mint a short-lived delegated token on the server:

const scoped = await box.mintScopedToken({
  scope: "session-runtime",
  sessionId: ownedRuntimeSessionId,
  ttlMinutes: 5,
});

Give the browser only scoped.token, scoped.expiresAt, and scoped.sidecarProxyUrl, then construct the browser-safe client:

import { createSandboxRuntimeClient } from "@tangle-network/sandbox/runtime";

const runtime = createSandboxRuntimeClient({
  baseUrl: delegated.sidecarProxyUrl,
  token: delegated.token,
});

Use scope: "session-runtime", access: "read-only" and the authorized runtime session ID for SessionGatewayClient. Give it scoped.runtimeUrl, scoped.token and runtimeSessionId to stream events and resume after a drop. Use scope: "read-only" for runtime reads that need no workspace or terminal mutation. The server must authorize every sandbox and session identifier before minting.

See the public /docs/authentication guide and examples/browser-streaming-resume.ts.

Quick start

import { Sandbox } from "@tangle-network/sandbox";
const client = new Sandbox({
  apiKey: process.env.TANGLE_API_KEY!,
  baseUrl: process.env.SANDBOX_BASE_URL!,
});
const box = await client.create({
  name: "sdk-quick-start",
  resources: {
    cpuCores: 2,
    memoryMB: 4096,
    diskGB: 20,
  },
});

try {
  await box.waitFor("running");

  const result = await box.exec("node --version && npm --version");
  if (result.exitCode !== 0) {
    throw new Error(result.stderr);
  }

  console.log(result.stdout);
} finally {
  await box.delete();
}

apiKey and baseUrl are required. Keep delete() in finally unless the sandbox must outlive the process. delete() answers once the sandbox is dead and billed; its container, volume and network are reclaimed behind the answer, and the acknowledgement reports removal: "pending". The sandbox is already gone for every read at that point. Deleted customer snapshot repositories, including their final snapshot, remain until their owner explicitly deletes them. Platform-managed repositories of internal-account boxes retire automatically after the deleted-retention window, subject to preservation holds and active exports/restores. The serving window comes from the storage owner; see deleted repository retention. Pass await box.delete({ until: "removed" }) when you need the reclaim itself, for example before reusing the same host capacity in a test; it reports removal: "complete", or "pending" if the wait runs out.

To remove every sandbox that one job created, tag them with metadata at create time and delete them by prefix:

const result = await client.deleteMatching({
  metadataPrefix: { retainedIdempotencyKey: `runtime:${runId}:` },
});

deleteMatching() deletes only your own sandboxes, each through the same delete as box.delete(). result.complete is true only when every match is confirmed gone. result.sandboxes reports each sandbox as destroyed, already_absent or failed, with the refusal's status and code. One call attempts every match once, so a refused delete does not hold back the others. The call is safe to repeat; a confirmed sandbox no longer matches.

Stop creating the job's sandboxes before you call deleteMatching(). result.complete covers only the sandboxes the call listed. A create that registers after the listing, or retries with the same idempotency key, can leave a match behind, so call again once in-flight creates have settled.

With { until: "removed" }, a destroyed sandbox counts as confirmed only when its removal is proven. If the removal wait runs out, the sandbox reports removal: "pending" and confirmed: false, and result.complete is false. That sandbox is already gone for every read, so a later call does not list it again.

Run a profile without choosing where

client.run() runs an AgentProfile on a prompt in the environment its needs require. It returns when the run settles.

const result = await client.run({
  profile: { name: "support", prompt: { systemPrompt: "Answer billing questions." } },
  prompt: "Which plan includes priority support?",
  // placement: "auto" (default) | "inference" | "worker" | "sandbox"
});
console.log(result.placement?.chosen, result.placement?.reason, result.text, result.costUsd);

auto chooses among three placements:

  • one Router call, when nothing needs a tool, a file or a session;
  • Tangle's sandbox-free agent loop, when the only tools are Hub connections, owner objects or http reads;
  • a sandbox, when the profile needs a harness, files, a shell or MCP servers.

A run that cannot be honoured is refused before anything is spent. client.runs.create(), get(), wait() and cancel() start, follow and stop a run separately; when wait() times out the run keeps executing until it settles or is cancelled. costUsd is the model spend Platform booked (sandbox time is billed separately). Runs are served by Platform at platformUrl.

Create a sandbox

Omit environment to use the server's default image. No image choice is needed for an ordinary sandbox:

const box = await client.create();

Set environment when the workload needs a custom container image or a published image ID. For example, this sandbox uses node:22:

const box = await client.create({
  name: "repo-review",
  environment: "node:22",
  git: {
    url: "https://github.com/acme/service.git",
    ref: "main",
  },
  env: {
    CI: "true",
  },
  resources: {
    cpuCores: 4,
    memoryMB: 8192,
    diskGB: 40,
  },
  maxLifetimeSeconds: 3600,
  idleTimeoutSeconds: 900,
  // Delete the sandbox after one hour parked in the stopped state.
  // Omit the field and the platform's default retention applies: the parked
  // sweeps keep the sandbox at least 30 days parked and may reclaim it after
  // that. An explicit delete, and platform policy, are unaffected.
  deleteAfterStoppedSeconds: 3600,
  metadata: {
    project: "service",
  },
});

For named environments, use the IDs returned by the server. Language names such as python or node do not select versioned container images; use an image reference such as python:3.12-slim or node:22 for that.

const environments = await client.environments.list();
for (const environment of environments) {
  console.log(environment.id, environment.description);
}

Use bare: true for a minimal container with command execution and lifecycle controls. Use agent: false for managed files, processes, and telemetry without configuring an agent backend.

Compliance posture is server-derived, never a create option

There is deliberately no compliance (or HIPAA/PHI) option on client.create(), on any other SDK call, or in the CLI. A caller-asserted flag could do two dishonest things: dodge the platform's observability gates, or claim a protection the platform never granted. Instead the platform derives the posture from the credential's verified attribution (Platform mints originAppCompliance at key verification; the sandbox API forwards it; the orchestrator pins it as an immutable container label).

What you get back is a read-only echo: SandboxInfo.compliance is present only as the positive claim { hipaa: true }, and hipaa: false is never emitted — absent means the platform made no claim about this sandbox.

Host-owned preview publication

An application that publishes previews outside the agent turn sets the admitted AgentProfile's metadata.sandbox.previewPublication to "host":

const profile = {
  prompt: { systemPrompt: "Build the app and update workspace-apps.json." },
  metadata: { sandbox: { previewPublication: "host" } },
} satisfies AgentProfile;

Sidecar then supplies host-handoff instructions instead of its default self-publication CLI/tool instructions. Keep the application's manifest and server contract in the profile instructions; the application must perform publication and confirm readiness. Without this exact value, Sandbox retains its default managed-preview instructions. This profile metadata is instruction policy, not a credential or filesystem security boundary. It does not grant publication authority or remove supplied tools. Version the profile when changing publication ownership and adopt the Sidecar release that supports this contract before relying on it in an existing sandbox.

Run several Agents in one Sandbox

Import the Agent entrypoint when one Sandbox should host several AgentProfiles. It re-exports the main client and installs the typed box.agents namespace.

import type { AgentProfile } from "@tangle-network/agent-interface";
import { Sandbox } from "@tangle-network/sandbox/agent";

const client = new Sandbox({
  apiKey: process.env.TANGLE_API_KEY!,
  baseUrl: process.env.SANDBOX_BASE_URL!,
});

const plannerProfile = {
  name: "planner",
  harness: "opencode",
  prompt: { appendSystemPrompt: "Plan before editing files." },
} satisfies AgentProfile;

const implementerProfile = {
  name: "implementer",
  harness: "claude-code",
  prompt: { appendSystemPrompt: "Implement the request and run tests." },
} satisfies AgentProfile;

const box = await client.create({
  agent: false,
});

try {
  const [planner, implementer] = await box.agents.startMany([
    {
      id: "planner-v1",
      backend: { type: "opencode", profile: plannerProfile },
    },
    {
      id: "implementer-v1",
      backend: { type: "claude-code", profile: implementerProfile },
    },
  ]);

  const [plan, implementation] = await Promise.all([
    planner.prompt("Inspect the repository and produce a precise plan."),
    implementer.prompt("Inspect the repository and implement the request."),
  ]);

  console.log(plan.response);
  console.log(implementation.response);
} finally {
  await box.delete();
}

Each returned Agent is the existing durable SandboxSession handle. Different session ids may run in parallel; turns inside one session remain serialized. Use a stable id and box.agents.get(id) to reattach from another process.

Shared workspace is the default. Use the existing task-session Git-worktree path when file changes or profile control files must remain isolated:

const reviewer = await box.agents.start({
  id: "reviewer",
  backend: { type: "codex", profile: reviewerProfile },
  workspace: { mode: "isolated", parentSessionId: "implementer-v1" },
});

const changes = await reviewer.changes();
await reviewer.commit({ message: "Apply reviewer changes" });

An isolated Agent requires a Git repository and returns SandboxTaskSession. commit() applies the changes and removes the owned worktree and task branch. box.agents.stop(id) discards an uncommitted isolated generation and removes those owned Git resources before its durable session record; cleanup failure leaves the record intact for a safe retry.

Agents in one Sandbox share a broad trust and resource boundary. Use separate Sandboxes for mutually untrusted Agents, separate tenants, or hard resource isolation.

Applications that already import from the root can install the namespace with:

import { Sandbox } from "@tangle-network/sandbox";
import "@tangle-network/sandbox/agent";

See examples/multi-agent-sandbox.ts for a complete runnable example.

Lifecycle

The active states are pending, provisioning, running, stopped, failed, and expired. Deletion is permanent and is not a retained status.

await box.waitFor("running", { timeoutMs: 120_000 });
await box.stop();
await box.waitFor("stopped");
await box.resume({ timeoutMs: 120_000 });
await box.waitFor("running");
await box.delete();

The platform does not store secret-named public env values at rest. A stopped sandbox keeps them in its stopped runtime. When the platform removes that runtime, resume must supply them again:

try {
  await box.resume();
} catch (error) {
  if (!(error instanceof ResumeEnvRequiredError)) throw error;
  // error.missingEnvKeys names each key, for example ["APP_CAPABILITY_TOKEN"].
  await box.resume({ env: { APP_CAPABILITY_TOKEN: freshWorkspaceToken } });
}

Resume uses env only when it builds a new runtime. It rejects a key whose value the platform stores or can refresh itself.

Renew application credentials before dispatch when reusing a sandbox:

await box.setRuntimeEnv({ APP_CAPABILITY_TOKEN: freshWorkspaceToken });

Pass { ttlMs } for short-lived credentials. The runtime removes each injected key after that duration; refreshing the key starts a new lifetime. Older runtimes reject expiring credentials before injection.

New child processes and profile secret references use the updated values. Existing processes retain their environment. Updates do not change durable creation configuration, so repeat renewal after resume. Only trusted server code with owner runtime access may update these values. The runtime rejects platform-managed credentials, including TANGLE_API_KEY and its credential file. The separate app endpoint returns 404 on older runtimes before applying any values. Keep credentials outside profiles, public receipts, and logs.

Create a snapshot when later sandboxes need the same filesystem state.

const snapshot = await box.snapshot({
  tags: ["baseline"],
});

const restored = await client.create({
  fromSnapshot: snapshot.snapshotId,
});

try {
  await restored.waitFor("running");
} finally {
  await restored.delete();
}

Use the same idempotencyKey only when retrying the same logical create request.

Inspect SandboxError.createFailureDisposition only on the top-level error returned by create(). The value "not-admitted" proves that this call's attempts admitted no sandbox. An absent value means unknown, even when the last HTTP status is 429. Do not infer this call's disposition from cause or earlier calls with the same key. This evidence does not exclude a sandbox from an earlier call with that key. Cleanup may skip same-key recovery after "not-admitted" only when the key was fresh for this call. Transport failures and unclassified responses remain uncertain; keep their recovery path.

Read SandboxError.etaSeconds when the control plane publishes one. A capacity refusal can carry the cell's own estimate of when it expects to place a create again, measured from the host add already in flight. Use it to schedule your next attempt. It says nothing about whether this call admitted a sandbox, and it is not a retry hint: retryAfterMs alone paces the SDK's own attempts. An absent value means the cell published no estimate, not that the wait is short.

TimeoutError.timeoutMs is the configured budget; elapsedMs is the actual duration. A create() ends early when Retry-After exceeds its remaining budget. The shared raw transport owns authentication, cancellation and response deadlines for SDK clients and standalone probes; each caller keeps its retry and cleanup policy.

One sandbox per customer

An app that gives each customer its own agent keeps one isolated sandbox per customer. client.instances.ensure() returns the sandbox for your key, running. It creates the first sandbox, resumes a stopped one, and replaces one that was deleted or keeps failing to start. The platform records which sandbox serves each key, so your app stores no binding. Concurrent calls and lost responses converge on one sandbox.

const instance = await client.instances.ensure({
  key: `customer:${customerId}`,
  profile: { version: release.id, backend: { type: "opencode", profile } },
  create: {
    resources: { cpuCores: 1, memoryMB: 2048, diskGB: 10 },
    deleteAfterStoppedSeconds: 30 * 86_400,
  },
});
const result = await instance.turn(text, { thread: "main", turnId: messageId });

Sessions bind to the profile version. A turn runs in the session <thread>-<version>, created from that version's backend. When you pass a new version, the sandbox and its files stay, and the next turn opens a session on the new profile.

A sandbox that cannot start is replaced after restartAfterMs (default 60 s) of failed starts. A failed start is a resume refused with CONTAINER_START_FAILED, or a sandbox the platform reports as failed. Until then ensure() throws InstanceRestartingError with retryAfterMs. A replaced sandbox is not deleted; its own retention policy deletes it. Every other refusal reaches your code and never replaces the sandbox. That covers a refusal of your key or its credentials (SANDBOX_ATTRIBUTION_MISMATCH, ROUTER_KEY_REFRESH_FAILED). It also covers a full or unavailable host (HOST_SLOT_UNAVAILABLE, HOST_UNAVAILABLE) and a lifecycle operation still in progress. Use adopt to move a sandbox you created earlier under a key. examples/instances.ts walks one key through each start.

Commands and files

exec() returns the exit code, standard output, standard error, and timing data.

After an admitted blocking command is aborted or times out, the shared process transport sends a tree kill and follows the authenticated PID result until it settles, within the independent cleanup budget owned by the shared process transport. A kill acknowledgement alone does not prove exit. Missing results, authentication failures, transport failures, and cleanup expiry leave termination unknown; the caller still receives its original abort or timeout error. The SDK fetch transport cancels the terminal response stream without materializing completed command output. Existing parsed-JSON probe adapters retain their response transport's eager parsing.

const test = await box.exec("pnpm test", {
  cwd: "/workspace/service",
  env: { CI: "true" },
  timeoutMs: 10 * 60 * 1000,
});

if (test.exitCode !== 0) {
  throw new Error(test.stderr);
}

The direct file helpers cover common text operations. The fs property exposes listing, metadata, upload, download, rename, and directory operations.

File downloads stream into a private temporary file. Set maxBytes and signal to bound the transfer. Use expectedSize or expectedSha256 to verify known content. The returned receipt contains the retained byte count and SHA-256 digest. A failed download preserves an existing destination and removes its temporary file.

Use fs.downloadArchive(remoteDirectory, localArchive, options) to retain a directory as streamed tar.gz bytes with the same bounds and receipt. The archive includes hidden files; it reads the directory without creating a transactional filesystem snapshot. maxBytes bounds compressed transfer size, not expanded content. Validate member paths, types, expanded bytes and completeness before extracting an untrusted archive. fs.downloadDir() shares this streaming transport and accepts download bounds, then uses the host tar program with its existing extraction semantics. It does not provide atomic directory publication or expanded-size limits.

await box.write("/workspace/service/config.json", '{"enabled":true}\n');
const config = await box.read("/workspace/service/config.json");
console.log(config);

File size contract

| Path | Limit | Constant | Use | |---|---:|---|---| | Decoded write() content | 5 MiB | FILE_DECODED_WRITE_MAX_BYTES | Common text and small binary writes | | Public API request | 1 MiB | SANDBOX_PROXY_REQUEST_MAX_BYTES | Requests routed through the Sandbox API | | Direct runtime file request | 32 MiB | SANDBOX_DIRECT_FILE_REQUEST_MAX_BYTES | Direct runtime requests | | Multipart upload | 5 MiB | FILE_MULTIPART_UPLOAD_MAX_BYTES | One multipart upload | | Chunked upload part | 768 KiB | FILE_UPLOAD_PART_MAX_BYTES | One fs.upload() or fs.uploadData() part | | Chunked upload session | 64 MiB | FILE_UPLOAD_SESSION_MAX_BYTES | One complete fs.upload() or fs.uploadData() call |

Inline resources.files use the same file delivery path during creation and fresh prompt turns. A full per-turn profile (including one passed to a recovered get() handle) is preserved in the caller and in its retained control reference, while the SDK writes the mounts before sending a profile without inline file bodies. Successful delivery is reused for the same mount set and filesystem incarnation; a changed set or a recovered handle stages the bytes and publishes only absent files. Existing files with identical bytes and modes are accepted; different content, permissions, and symlinks are refused before dispatch. Live agent edits remain untouched when the original handle reuses its receipt; a recovered handle without that receipt fails closed on conflicting mounts. Guarded publication uses the sandbox image’s Node executable. Explicit execution-event replay performs no materialization. primeProfile() also delivers files separately and caches the remaining profile; its returned hash names that transport profile. Skills and other non-file resources must still fit the gateway request bound.

Use fs.upload() for local files or fs.uploadData() for in-memory content larger than the single-request limit.

The instance also exposes git, tools, process, terminals, network, egress, previewLinks, images, and backend capability clients. Unsupported capabilities fail with a typed SDK error. box.images.publish() uses an admitted image builder for the sandbox runtime. A hardened runc box can publish through an isolated Firecracker builder when the cell enables that capability. Without a qualified builder, publication fails with IN_BOX_DOCKER_UNAVAILABLE or WORKSPACE_IMAGE_BUILDER_UNAVAILABLE.

Named native credentials

A trusted server can grant one stored Claude token to a running sandbox:

await box.grantNativeCredential({
  harness: "claude-code",
  cliAuth: { account: "research-seat", secretEnv: "RESEARCH_CLAUDE", format: "token" },
});

This uses the account-authenticated Sandbox API. The platform verifies personal ownership or current team write access, reads only that namespace's named secret, and installs it in the runtime's private native-credential store. Sandbox-scoped keys and isolated sandboxes cannot grant stored credentials. No token value returns to the caller or enters application environment access.

A runtime reporting claudeTokenContinuations: true can use a newly granted token on a new turn of an idle Claude session. The old adapter releases its lease before replacement; the transcript HOME and native session identity stay fixed. An active adapter refuses replacement. Other harnesses and credential formats are not qualified for this handoff. Existing execution retries must retain their original reference. A named reference cannot replace its installed token value; renewal needs a new owner-issued reference.

Prompts

Configure the agent backend when creating the sandbox or override it for one prompt. Provider credentials must come from your platform configuration or the backend model configuration.

const agentBox = await client.create({
  backend: {
    type: "opencode",
    modelId: process.env.MODEL_NAME!,
    provider: process.env.MODEL_PROVIDER!,
    apiKey: process.env.MODEL_API_KEY!,
  },
});

try {
  const result = await agentBox.prompt(
    "Run the tests, fix the failure, and explain the change.",
    {
      timeoutMs: 10 * 60 * 1000,
      requireVisibleAssistantOutput: true,
    },
  );

  if (!result.success) {
    throw new Error(result.error ?? `Agent stopped with ${result.status}`);
  }

  console.log(result.response);
  console.log(result.usage);
} finally {
  await agentBox.delete();
}

Use streamPrompt() when the current process will stay attached to the event stream. Use dispatchPrompt() when the run must continue after the caller disconnects.

Resume a session

A stable session ID preserves conversation context across prompts.

const sessionId = "review-42";
await box.prompt("Inspect the authentication flow.", {
  sessionId,
});
const resumed = await box
  .session(sessionId)
  .prompt("Now patch the highest-severity issue and run its tests.");

console.log(resumed.response);

Dispatch returns after the session starts. A later process can retrieve the sandbox and await the stored result.

const dispatched = await box.dispatchPrompt("Run the full test suite.", {
  sessionId: "ci-run-42",
});
const reloaded = await client.get(box.id);
if (!reloaded) {
  throw new Error(`Sandbox ${box.id} no longer exists`);
}
if (!dispatched.executionId) {
  throw new Error("Sandbox did not return the dispatched execution id");
}
const result = await reloaded
  .session(dispatched.sessionId)
  .result({ executionId: dispatched.executionId });
console.log(result.status, result.response);

Reusing the same caller-owned session ID makes a repeated dispatch find the existing session instead of starting another run. Keep the returned executionId with the session ID whenever another caller can add a turn to the same session. session.result({ executionId }) replays only that run, and session.interrupt({ executionId }) cancels only that run, so neither operation can move to a newer turn between lookup and action. prompt() also returns the execution ID that actually produced its result, including an idempotent replay that resolves to an earlier execution than the caller requested. Use session events with an event ID when the consumer must replay a dropped stream. Use the /session-gateway entrypoint for a browser's session event stream: it reattaches after a drop and resumes the running turn after the last event it delivered. A dispatched run that dies before producing output reports its cause on session.status(): failureReason.message carries the runtime's own error, such as a backend binary that could not be spawned or an agent profile that could not be materialized.

Fleets

A fleet groups several sandboxes under one lifecycle and policy. Use it for parallel jobs, coordinated workers, or workloads that need stable machine IDs.

const fleet = await client.fleets.createWithCoordinator({
  fleetId: "dependency-audit",
  defaults: {
    maxLifetimeSeconds: 1800,
  },
  policy: {
    maxMachines: 3,
    maxConcurrentCreates: 2,
    maxTotalCpu: 6,
    maxTotalMemoryMb: 12_288,
    maxSpendUsd: 2,
    allowAccelerators: false,
  },
  coordinator: {
    resources: { cpuCores: 1, memoryMB: 1024 },
  },
  workers: [
    {
      machineId: "worker-1",
      resources: { cpuCores: 2, memoryMB: 4096 },
    },
    {
      machineId: "worker-2",
      resources: { cpuCores: 2, memoryMB: 4096 },
    },
  ],
});

try {
  const results = await fleet.dispatchExec("pnpm test", {
    machines: fleet.ids,
    maxConcurrent: 2,
    retry: { attempts: 2 },
    timeoutMs: 10 * 60 * 1000,
  });

  for (const result of results) {
    console.log(result.machineId, result.ok, result.error?.message);
  }
} finally {
  await fleet.delete({ continueOnError: true });
}

Fleet policies are checked before provisioning and do not replace account quotas.

Agent fleets retain the default harness, model, and profile in each agent's specification. Per-agent declarations override those defaults. Fetched fleets preserve these settings when they run agent turns. Model API keys and authentication files stay outside fleet metadata.

Batch jobs

Use runBatch() for parallel tasks that do not need stable fleet membership. The default creates temporary sandboxes and removes them after the run.

const result = await client.runBatch({
  tasks: [
    { id: "api", message: "Review the API package and fix its tests." },
    { id: "web", message: "Review the web package and fix its tests." },
  ],
  backends: [{ id: "worker", type: "opencode" }],
});

console.log(result.totalSuccess, result.totalFailure);

Set persistent: true when later runs must use the same workspace. reuseKey selects that caller-owned workspace. idempotencyKey identifies one exact batch request for join or replay.

const request = {
  tasks: [{ id: "upgrade", message: "Upgrade dependencies and run tests." }],
  backends: [{ id: "worker", type: "opencode" }],
  persistent: true as const,
  reuseKey: "service-maintenance",
  git: { url: "https://github.com/acme/service.git" },
};

const result = await client.runBatch(request, {
  idempotencyKey: "service-maintenance-run-42",
});

Reuse an idempotency key only with the same request body. A matching active run is joined and a completed run is replayed. A different body with the same key is rejected. Disconnecting an unkeyed stream cancels server work, while a keyed run continues and can be joined again with the same key and body. streamBatch() accepts the same request and call options.

Temporary GPU leases

Attach a GPU only for the work that needs it. Omit provider to request the cheapest eligible configured option. Every lease requires a spend limit and lifetime.

let lease: Awaited<ReturnType<(typeof box)["gpu"]["attach"]>> | undefined;

try {
  lease = await box.gpu.attach({
    accelerator: {
      kind: "nvidia-3090",
      count: 1,
      memoryMB: 24_000,
    },
    maxSpendUsd: 1,
    maxLifetimeSeconds: 900,
    idleTimeoutSeconds: 120,
  });

  const run = await box.gpu.exec(lease.id, {
    command: "python eval.py",
    timeoutMs: 15 * 60 * 1000,
  });

  process.stdout.write(run.result.stdout);
  process.stderr.write(run.result.stderr);
} finally {
  if (lease) {
    await box.gpu.detach(lease.id);
  }
}

Always detach the lease in finally. Deleting the base sandbox remains a separate cleanup step.

Phone lines

A line gives a sandbox a messaging address that Tangle Hub receives on through an owned provider connection. Hub routes each message to the attached sandbox, runs one agent session per member, handles STOP and START, and answers on the same line.

const line = await client.lines.fromConnection({
  connectionId: "hubconn_...",
  transport: "imessage",
});
await box.lines.attach({
  number: line.id,
  mode: "personal",
  members: [{ address: "+15550100001", role: "owner" }],
});
// On the member's phone: text line.connect to line.routerAddress, then write.

The same connected identity can also give an email line with transport: "email". Hub subscribes the identity's mailbox, and each reply goes back in the sender's email thread. An email line answers declared members only, with email addresses. A From address is not authenticated, so a member's first mail is held and Hub asks their address to confirm by reply. Hub answers the held mail only when the reply starts with YES; any other reply drops it and is answered by itself. Later mail in that thread runs at once, because every Hub mail carries the member's line token in its subject. That token works like a password for 30 to 60 days: anyone who receives a forwarded Hub mail can write as that member. Forged mail can also use up a member's daily confirmation notices, but it cannot run a turn.

Other numbered connections select their provider-owned endpoint with phoneNumberId. Use transport: "sms" for SMS, MMS, or RCS messages, and transport: "whatsapp" for Linq WhatsApp. Resend email lines set address on a verified domain that can send and receive mail. Configure the connection's signed inbound webhook before creating a Resend line.

Use mode: "shared" with roles and several members for one agent that many people text. Each member gets their own thread; a shared-context role sees other shared members' recent exchanges, and an own-context role sees none. Unknown senders are rejected unless unknownSenders: "guest" admits them with the guest role. One sandbox serves every member, and a role with act tools can read every member's session files in it. So an attachment that uses an own-context role, including guest, must make every role in use chat; Hub refuses it otherwise. A chat role's turns get no shell, no file tools, no subagents, and none of the backend's MCP servers, connections or hooks. Attach with an API key that can run the sandbox: each turn runs as a short-lived child of that key.

To serve each person from their own isolated sandbox, attach the line with client.lines.attach() and instance instead of a sandbox. Each member runs in your named instance keyPrefix plus 32 hex characters of the SHA-256 of their address (lineInstanceKey()). Hub creates it on the member's first message, resumes it when stopped, and replaces it when deleted. No member shares a disk with another, so own-context roles may act. The box holds none of your secrets and has no SSH access; its in-box key cannot read the line. The key depends only on the prefix and the address, so a person keeps their box across a detach and re-attach. Detach such a line with client.lines.detach(line.id); box.lines.detach() detaches only a line attached to that box.

await client.lines.attach({
  number: line.id,
  mode: "shared",
  members: [{ address: "+15550100001", role: "owner" }],
  unknownSenders: "guest",
  roles: {
    owner: { context: "own", tools: "act" },
    guest: { context: "own", tools: "act" },
  },
  respond: { kind: "agent", backend: { type: "opencode", profile } },
  limits: { turnsPerMemberPerDay: 30 },
  instance: {
    keyPrefix: "person:",
    create: {
      resources: { cpuCores: 1, memoryMB: 2048, diskGB: 10 },
      egressPolicy: { mode: "strict", allowDomains: ["router.tangle.tools"] },
      idleTimeoutSeconds: 600,
    },
  },
});

To check each answer before it is sent, name a reply gate. Hub posts { ...context, candidate } to the gate with its secret as a bearer token. It sends the exact answer the gate returns, or nothing. Hub never sends the agent's own text on a gated line, and refuses an answer over 4,000 characters instead of cutting it.

await box.lines.attach({
  number: line.id,
  mode: "personal",
  members: [{ address: "+15550100001", role: "owner" }],
  respond: {
    kind: "agent",
    gate: {
      url: "https://gtm.example.com/api/gtm/cited-draft",
      secret: process.env.GATE_KEY!,
      context: { workspaceId: "ws_..." },
      audience: "owner",
    },
  },
});

A gated attachment must reject unknown senders.

Members can also call an Inkbox iMessage line. Connect your ph0ny key in Hub, then name the ph0ny agent that voices the calls. That ph0ny agent is a second agent, with its own prompt, model and voice: it hears the caller and speaks every word. Hub answers only the attachment's members. Each question the ph0ny agent asks with its ask_workspace tool runs in the caller's own thread session, so your sandbox agent sees the call's questions next to the member's texts. The ph0ny agent does not see those texts; it knows only what ask_workspace returns.

await client.lines.enableVoice(line.id, {
  ph0nyConnectionId: "hubconn_phony_...",
  ph0nyAgentId: "agent_...",
});
// Later: await client.lines.disableVoice(line.id);

For an already-owned ph0ny number, create a separate voice line and attach the phone member to the intended sandbox. A dedicated voice line has no messaging inbound path, so each member needs prior consent with an owner-retained evidence reference. Hub refuses a missing or future-dated assertion and checks active consent again before admitting a call or granting Sandbox access.

const voiceLine = await client.lines.fromConnection({
  connectionId: "hubconn_phony_...",
  transport: "voice",
  phoneNumberId: "owned_ph0ny_number_id",
});
await box.lines.attach({
  number: voiceLine.id,
  mode: "personal",
  members: [{
    address: "+15550100001",
    role: "owner",
    voiceConsent: { recordedAt: "2026-10-08T02:00:00Z", sourceRef: "consent_receipt_id" },
  }],
});
await client.lines.enableVoice(voiceLine.id, {
  ph0nyConnectionId: "hubconn_phony_...",
  ph0nyAgentId: "agent_...",
  ph0nyPhoneNumberId: "owned_ph0ny_number_id",
});

A gated line takes no calls, because a spoken answer cannot pass the reply gate. Hub knows a caller only by caller ID, which can be spoofed, so give a voice line chat roles when a call must not reach the member's tools.

Hub sends the agent's visible text as the reply, unchanged; no other model rewrites it. Hub lines explains the text and voice paths, the notices a member gets, and measured reply times.

Runnable examples: lines-email.ts (an email line with a sandbox per member), lines-shared.ts (two members on one sandbox) and lines-voice.ts (voice on an iMessage line).

Public API map

The root entrypoint exports the primary client, instance, session, fleet, image, error, trace, and resource types.

| Surface | Start with | |---|---| | Account client | Sandbox | | One environment | SandboxInstance | | One or more managed Agents in a Sandbox | box.agents from the /agent entrypoint | | Continued conversation | SandboxSession | | One sandbox per customer | client.instances, HostedInstance | | Coordinated environments | SandboxFleetClient, SandboxFleet | | Reusable images | Image, ImageBuilder | | Phone and messaging lines | client.lines, box.lines | | Run a profile where its needs require | client.run(), client.runs | | Failures | SandboxError and its specific subclasses |

The main client provides create, list, get, usage, subscription, and health. Its managers cover environments, public templates, teams, secrets, SSH keys, fleets, and intelligence reports. The published TypeScript declarations are the complete method and option reference.

Handle SDK failures through the exported error hierarchy.

import { SandboxError } from "@tangle-network/sandbox";

try {
  await box.exec("pnpm test");
} catch (error) {
  if (error instanceof SandboxError) {
    console.error(error.code, error.status, error.endpoint, error.message);
    throw error;
  }

  throw error;
}

More documentation

Package development

pnpm --filter @tangle-network/sandbox check-types
pnpm --filter @tangle-network/sandbox test
pnpm --filter @tangle-network/sandbox build
pnpm --filter @tangle-network/sandbox verify-dist

Public type definitions live in src/types/ by capability; src/types.ts preserves the existing import path. For a type split, run node products/sandbox/sdk/scripts/check-type-capabilities.mjs <base-ref> from the repository root to compare every moved declaration and report module sizes. Compare two emitted declaration trees with node products/sandbox/sdk/scripts/verify-declarations.mjs <after-dist> --compare <before-dist>. The report separates raw artifact bytes from declaration and import identity; it preserves JSDoc and overload order.

License

MIT

Retain native evidence without loading it into memory

const evidence = await box.session(sessionId).exportRawEvidence(destination, {
  maxBytes: 4 * 1024 ** 3,
  maxFiles: 100_000,
  signal: AbortSignal.timeout(10 * 60_000),
});

destination must be a new caller-owned directory. The SDK verifies the archive, manifest, payload hashes, sizes, event counts, and capture identity before publishing it. captured, partial, and unavailable retain the existing coverage meaning. Partial exports include missing reasons; transfer or validation errors retain private staging bytes and name their location in the error. Treat those bytes as sensitive.

manifest.json uses tangle.raw-evidence-archive.v1. Native files and process sources reference exact binary payloads. Process NDJSON replaces only contentBase64 with a content file reference. Event NDJSON contains canonical reconstructed frames in order. Native symlinks remain metadata and credentials retain the existing exclusions.

The byte budget covers all uncompressed members, including records and metadata; maxArchiveBytes optionally bounds compressed transport separately. Metadata is limited to 64 MiB, individual records to 16 MiB, and event reconstruction retains at most 64 MiB of cumulative part text. Limits and incomplete snapshots never become complete coverage. The dedicated Sidecar archive route is required; an older deployment returns an error without falling back to the aggregate rawEvidence() API.

To keep the HOME walk but leave the bytes of directories the harness downloads again on the box, name them in omitContent. Each file below one keeps its metadata, has contentOmitted: true, and has no content or sha256:

const evidence = await box.session(sessionId).exportRawEvidence(destination, {
  maxBytes: 4 * 1024 ** 3,
  maxFiles: 100_000,
  omitContent: [".codex/plugins/cache/", ".codex/cache/remote_plugin_catalog/"],
});

The manifest lists the honoured prefixes in omitContent. A prefix that covers or enters the harness's native session store (for example .codex/ or .codex/sessions/) answers 400, so the session itself always keeps its bytes. The SDK allows at most 64 prefixes of at most 512 characters each, and 8 KiB of encoded query. A Sidecar older than the option ignores it and sends every file's bytes, so keep maxBytes sized for the whole HOME until every Sidecar you reach serves this version.

To keep only the native session store, ask for the session copies alone:

const native = await box.session(sessionId).exportRawEvidence(destination, {
  maxBytes: 3 * 1024 ** 3,
  maxFiles: 100_000,
  copy: "latest",
  parts: "native",
});
// Each attempt's session files: native.manifest.nativeSessions[].files

parts: "native" requires copy. The archive then holds the manifest and the nativeSessions copies only: no event log, process stream or HOME walk, so maxBytes bounds the copies however large the session's logs have grown, and a copy larger than the bound still fails. The manifest names what was left out (parts: "native", status: "partial", events_not_requested, process_io_not_requested). A sidecar older than the option serves the full archive within the same bounds.

To keep every native session store on the box before deleting it, export the box:

const stores = await box.exportRawEvidence(destination, {
  maxBytes: 3 * 1024 ** 3,
  maxFiles: 100_000,
});
// Each runtime HOME's store files: stores.manifest.homes[].files
// Each settled copy, with the HOME it came from: stores.manifest.nativeSessions[]

The box archive (tangle.raw-evidence-archive.box.v1) finds stores by walking the runtime HOMEs, not the session listing, so it also serves a box restored from a snapshot whose session listing is empty. It reads only files a harness contract declares as its session store (Claude Code projects, Codex rollouts, OpenCode's SQLite database, and the rest), never credentials or config. A HOME a running harness holds is copied without stopping it and marked live. It needs a token that is not bound to one session; a sidecar older than the route answers 404.

Prepared durable-plan follow-ups

The authenticated session.plan() result exposes sourceTurnId from that exact plan revision, so callers can resolve immutable profile context before deciding. For an isolated secret profile, decide without automatic redispatch:

const decision = await session.approvePlan(plan.id, {
  followUp: { mode: "caller" },
});
// Reject uses session.rejectPlan(feedback, plan.id, theSameOptions).
const resume = decision.followUp.resume;
if (resume) {
  // Resolve your immutable source profile using resume.sourceTurnId, then obtain
  // a new runtime grant. Neither grants nor preparation receipts are persisted
  // in the durable plan. Reuse the original isolated session creation identity.
  const backend = {
    ...freshSecretBackend,
    planContinuation: resume.continuation,
    interactions: { ...freshSecretBackend.interactions,
      plan: decision.plan.status === "rejected" },
  };
  const pins = await box.prepareSecretProfileRevision({ sessionId: session.id, backend });
  await session.sendMessage({
    parts: [{ type: "text", text: "Continue the decided plan." }],
    turnId: decision.followUp.turnId,
    ttlMs: modelTurnExecutionTtlMs, // Retain the application's normal execution policy.
    backend,
    expectedPublicProfileRequestDigest: pins.expectedPublicProfileRequestDigest,
    expectedSecretProfileReceipt: pins.expectedSecretProfileReceipt,
  }, { planFollowUp: resume });
}

The continuation and interaction controls must be present before preparation; changing them invalidates the public request digest. Admission remains bound to one existing plan, turn, lease, and execution. The returned credential is transient: never persist or log it. An expired or contested lease requires another matching decision request followed by a new grant and preparation. A completed replay returns its execution ID without new admission authority. Caller-mode rows remain outside automatic dispatch after expiry and retry. Older Workers reject the strict decision option before recording a decision; deploy the D1 caller-state migration before enabling the option. Ordinary decisions retain automatic dispatch.