PAIPAI

sandboxTools()

bash, read, write, edit, grep, glob, listDirectory — bound with sandboxCapability.

sandboxTools() registers seven filesystem tools. sandboxCapability({ provider }) tells createPai where they run. Production uses createGkeSandboxProvider().

import { createPai, defineAgent } from "@pai/core";
import { openai } from "@ai-sdk/openai";
import { sandboxTools, sandboxCapability } from "@pai/sandbox";
import { createGkeSandboxProvider } from "@pai/sandbox-gke";
import { z } from "zod";

export const assistantAgent = defineAgent({
  name: "assistant",
  model: openai("gpt-4.1-mini"),
  identity: z.object({
    userId: z.string(),
    workspaceId: z.string(),
  }),
  instructions: "Use sandbox tools for file work. Stay inside the workspace.",
  tools: { ...sandboxTools() },
});

export const pai = createPai({
  agents: {
    main: {
      agent: assistantAgent,
      capabilities: {
        sandbox: sandboxCapability({
          provider: createGkeSandboxProvider({
            namespace: "pai-sandbox-chat",
          }),
        }),
      },
    },
  },
  scopeKey: (identity) => identity.workspaceId,
});

Omitting the capability binding is a compile error if the agent declares sandbox tools.

Import

import {
  isSandboxSeedError,
  sandboxCapability,
  sandboxFailureMessage,
  sandboxFilename,
  SandboxSeedError,
  sandboxTools,
  type SandboxSeed,
  type SandboxSeedFile,
  type SandboxSeedIds,
  type SandboxSeedInput,
  type SandboxSeedLoader,
} from "@pai/sandbox";

Signature

function sandboxTools(): SandboxCapabilityToolSet;
function sandboxTools(options: { include: readonly SandboxToolName[] }): Pick<...>;
function sandboxTools(options: { exclude: readonly SandboxToolName[] }): Omit<...>;

function sandboxCapability<const TScope extends CapabilityScope = "thread">(options: {
  provider: SandboxProvider;
  scope?: TScope;
  createOptions?: SandboxSessionCreateOptions | ((input) => options);
  seed?: SandboxSeed<TScope>;
}): CapabilityBinding<Sandbox>;

function sandboxFailureMessage(error: unknown): string | undefined;

function sandboxFilename(name: string): string;

Parameters

sandboxTools(options?):

options?: { include?: SandboxToolName[]; exclude?: SandboxToolName[] }

include?: SandboxToolName[]

Subset: bash, read, write, edit, grep, glob, listDirectory. Mutually exclusive with exclude.

exclude?: SandboxToolName[]

sandboxCapability(options):

options: SandboxCapabilityOptions

provider: SandboxProvider

Where commands run. GKE in production.

scope?: "thread" | "rootThread" | "user" | "scope" | "global" | ((input) => string)

Instance key; see CapabilityScope. "thread" is one sandbox per thread, so a subagent's child thread gets its own. "rootThread" is one sandbox per top-level conversation, shared with every subagent delegated from it.

Defaults to thread.

createOptions?: SandboxSessionCreateOptions | ((input) => options)

Passed to atomic acquisition with the framework's binding key. An existing sandbox for that key keeps its original creation options.

seed?: SandboxSeed<TScope>

Files every sandbox of this binding should hold. PAI copies whatever the sandbox lacks before the first operation of each run and of each environment the process has not checked. A seed function's input is typed by scope. See Seed. createSandboxService() takes the same option.

Returns

sandboxTools returns a tool map. sandboxCapability returns a CapabilityBinding<Sandbox> for createPai({ agents: { main: { capabilities } } }).

Model input: reason

Every sandbox tool's input starts with a required reason string: a short, present-tense label written by the model for the person following along, such as "Drafting the summary". It describes the purpose of the step, not paths or commands, so a UI can show it as the step label. Execution ignores it. Direct callers such as runTool must pass it; input without it fails validation. toolReasonDescription(example) returns the schema description the tools use, for application tools that want the same field.

Workspace lifecycle

The first operation on a handle calls provider.create({ bindingKey }), which atomically creates or adopts the workspace for that scope key. Every provider must honor this contract, including after a process restart. The manager caches the session and renews its lifetime; reset and thread disposal use metadata listing to clean up without provisioning a replacement.

Seed

type SandboxSeed<TScope extends CapabilityScope = CapabilityScope> =
  | readonly SandboxSeedFile[]
  | ((input: SandboxSeedInput<TScope>) => readonly SandboxSeedFile[] | Promise<readonly SandboxSeedFile[]>);

type SandboxSeedFile =
  | { path: string; data: Uint8Array | string; executable?: boolean }
  | { path: string; fileId: string; executable?: boolean }
  | { path: string; data: SandboxSeedLoader; version: string; executable?: boolean };

type SandboxSeedLoader = (input: { signal: AbortSignal }) => Promise<Uint8Array | string | null>;

type SandboxSeedInput<TScope> = SandboxSeedIds<TScope> & { signal: AbortSignal };

SandboxSeedFile:

path: string

Absolute and normalised, inside /workspace and outside /workspace/.pai, which PAI keeps for its ledger. It is the file's identity: a path listed twice is one file, from the last entry.

data?: Uint8Array | string | SandboxSeedLoader

The content, or a loader PAI calls only when the sandbox lacks this version, one file at a time. A loader returns bytes, a string, or null to skip the file until the next check. Pass its signal to every read: it aborts on reset, disposal and the five-minute ceiling. Not allowed with fileId.

fileId?: string

A file from the runtime's file provider, such as an upload; its id is its version. PAI reads it only when the sandbox lacks it, through the operation that triggered the check, so only that operation's scopeKey is reachable, and up to the provider's maxFileBytes; a larger one fails alone. A deleted file is skipped until the next check. A check without a file provider (createSandboxService().acquire(), the in-memory sandbox) and a "global" or custom scope, which can share a sandbox across scopes, skip stored files.

version?: string

Required with a loader, and only allowed there: names the content, as a content hash or an immutable id, never a time. Inline data is versioned by its sha256.

executable?: boolean

Made executable (chmod +x) after it is written.

Defaults to false.

SandboxSeedInput holds only the ids the binding's scope keys the sandbox on, typed for that scope as SandboxSeedIds<TScope>: scopeKey and threadId for "thread", scopeKey and rootThreadId for "rootThread", scopeKey and userKey for "user", scopeKey for "scope", none for "global", and for a custom scope scopeKey, threadId, rootThreadId, and userKey when the operation has one. Each id a scope keys on is always set. signal aborts on reset, disposal, or at the five-minute ceiling.

A check copies each file whose version the ledger at /workspace/.pai/seed.json lacks, in list order, then records what it copied. PAI never reads a seeded file back or deletes one. A list function and its loaders must not use the sandbox they seed, which would wait on its own check.

When a check fails, the operation that triggered it rejects with SandboxSeedError, and isSandboxSeedError(error) identifies it. Its message is fixed text for the model, and cause holds the original error. missingPaths holds every declared file the sandbox still lacks, except files skipped this time, or is empty when the list itself failed or was invalid. A file that cannot be loaded, and a stored file over the sandbox's write limit, fail alone and the files after them are still copied; any other failed write stops the check. Later operations in the same run go ahead without the files, and the next run copies them. A sandbox that cannot be reached fails the operation with SandboxUnavailableError instead, and the run's next operation checks again. The Sandbox guide covers when checks run.

sandboxFilename(name) turns a user-supplied filename, such as an upload's filename, into one path segment the sandbox can write: separators and ASCII control characters become _, an empty name, . and .. become _, and a name over 200 bytes of UTF-8 is shortened without splitting a character, keeping an extension of up to 50 bytes. Use it for every untrusted name in a seed path, below a folder of your own; a . or .. segment makes the seed list invalid, and a name the filesystem refuses stops every later check at that file. The mapping does not change between releases, so a path built with it can be stored, for example in a tool output that tells the model where a file is.

Cap each loader at the provider's write limit (maxFileBytes on the GKE provider, 50 MiB by default), and keep your upload limit at or below it. A loaded or inline file the sandbox refuses for size stops the check at that file, so it fails again on every run; a stored file over the limit fails alone, and is missing on every run.

Command output

bash returns a bounded output preview. When captured output exceeds that preview, it saves the complete captured text under .pai/ in the sandbox workspace and returns the path for read or grep. These log files share the workspace's lifetime. If saving fails or the capture limit discarded older output, the result says which limitation occurred.

Sandbox failures

When the sandbox itself fails, or could not be given its seeded files, the sandbox tools, the skill tool and Code Mode tell the model in fixed text. onRuntimeError receives the provider's error as the cause. Provider messages are written for operators and can name namespaces, claims and conditions, so the model never reads them.

ErrorThe model reads
SandboxUnavailableError, retryableThe sandbox is temporarily unavailable. Try again later.
SandboxUnavailableError, not retryable; SandboxPolicyMismatchErrorThe sandbox is unavailable.
SandboxNotFoundErrorThe sandbox was reset before this call finished. Files from earlier calls may be gone.
SandboxQuotaExceededErrorNo sandbox is available right now. Try again later.
SandboxSeedErrorSome starting files could not be copied into the sandbox, so these are missing: <paths>. Run the call again if it does not need them; the next run tries to copy them again.

SandboxSeedError lists at most 20 paths, then says how many more are missing. Errors the model can act on keep their own message, such as a path outside the workspace, a file over the size limit or a command timeout.

A custom tool that uses the sandbox gets the same text from sandboxFailureMessage(error), which returns undefined for any other error. Leave Stop alone, as the built-in tools do:

execute: async (ctx) => {
  try {
    const bytes = await ctx.capabilities.sandbox.readFile({
      path: ctx.input.path,
      signal: ctx.signal,
    });
    return { text: bytes && new TextDecoder().decode(bytes) };
  } catch (error) {
    const message = ctx.signal.aborted
      ? undefined
      : sandboxFailureMessage(error);
    if (message === undefined) throw error;
    throw new Error(message, { cause: error });
  }
};

Interactive processes

Tools that need request/reply communication can use ctx.capabilities.sandbox.startProcess(input). The provider session exposes the same method. Unlike runCommand, it takes an argument array, streams output without retaining it, and returns a handle once the process starts:

const process = await ctx.capabilities.sandbox.startProcess({
  command: ["node", "worker.mjs"],
  cwd: "/workspace",
  timeoutMs: 30_000,
  maxOutputBytes: 1024 * 1024,
  signal: ctx.signal,
  onStdout: (chunk) => consumeOutput(chunk),
});
try {
  await process.writeStdin({ data: "request\n" });
  const { exitCode } = await process.result;
} finally {
  await process.kill();
}

SandboxStartProcessInput accepts command: readonly string[], cwd, env, timeoutMs, maxOutputBytes, onStdout, onStderr and signal. There is no shell interpretation. Output callbacks receive arbitrary chunks, not lines or complete messages. The byte budget covers both streams even though output is not retained. Callbacks are synchronous; an asynchronous dispatcher must own and handle its pending work.

The SandboxProcess handle has:

  • writeStdin({ data: string | Uint8Array }): write in call order, awaiting backpressure. An input failure stops the process.
  • result: Promise<{ exitCode: number }>: nonzero exit codes are ordinary results; cancellation, timeout, output overflow and process loss reject.
  • kill(): cancel this process; repeated calls are safe.

One handle belongs to one execution in one sandbox. Session loss does not restart it, and reset or thread disposal cancels it. This differs from the retrying file/command helpers: repeating an interactive program could repeat application-side effects. Runtime shutdown drains admitted processes before closing the provider; each process's deadline still applies. The workspace keeps its normal lifetime.

codeModeTool() uses this channel to execute generated JavaScript remotely while keeping application tools in the trusted app.

Test fixtures

@pai/sandbox/test/fixtures exports runner-neutral createStubSandboxProvider and startTestProcess helpers usable from Jest or Vitest, with ESM and CommonJS entrypoints. createStubSandboxProvider provides explicit in-memory provider fixtures. Its default interactive-process handler rejects: tests must supply startProcess when they use it. startTestProcess is an optional handler for fixed, trusted test programs in local child processes. It provides no isolation and must never be bound to a product runtime. Use the real GKE provider with kind for process/network integration coverage.

The Vitest provider conformance suite is available from @pai/sandbox/test.

Learn more

On this page