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.
| Error | The model reads |
|---|---|
SandboxUnavailableError, retryable | The sandbox is temporarily unavailable. Try again later. |
SandboxUnavailableError, not retryable; SandboxPolicyMismatchError | The sandbox is unavailable. |
SandboxNotFoundError | The sandbox was reset before this call finished. Files from earlier calls may be gone. |
SandboxQuotaExceededError | No sandbox is available right now. Try again later. |
SandboxSeedError | Some 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
- Sandbox
createGkeSandboxProvider()createInMemorySandbox()for tool testscreatePai()