PAIPAI

Sandbox

Give the agent bash and a filesystem in an isolated workspace.

A sandbox is a machine the agent can use: run a command, read a file, write a file. PAI ships the tools. A provider decides where they run.

Skip this page until the agent needs a filesystem. Production apps use GKE (@pai/sandbox-gke). The agent module never mentions the cluster — it only spreads sandboxTools().

Add the tools

server/assistant-agent.ts
import { defineAgent } from "@pai/core";
import { openai } from "@ai-sdk/openai";
import { sandboxTools } from "@pai/sandbox";
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 bash, read, write, edit, grep, glob, and listDirectory for file work. Stay inside the workspace.",
  tools: {
    ...sandboxTools(),
  },
});

That registers seven tools: bash, read, write, edit, grep, glob, listDirectory.

Each tool's input starts with a required reason: a short label the model writes for the person following along, such as "Drafting the summary". Show it as the step label in your UI instead of paths or commands.

bash, write, and edit are sequential: in one model step, each call runs alone and in the order the model wrote it, so a script written in a step can be run later in the same step. A call that fails stops the calls after it in that step: a command that exits non-zero or times out, an edit that does not apply, a write with status invalid, or a tool error such as a sandbox that cannot be reached. The failed call keeps its result, and each tool's description tells the model which results count. read, grep, glob, and listDirectory run together with each other.

Take a subset if you want:

sandboxTools({ include: ["bash", "read"] });
sandboxTools({ exclude: ["edit"] });

Bind a provider

Agents that declare sandbox tools must be registered with a capability binding. Omitting it is a compile error.

server/pai.ts
import { createPai } from "@pai/core";
import { sandboxCapability } from "@pai/sandbox";
import { createGkeSandboxProvider } from "@pai/sandbox-gke";
import { assistantAgent } from "./assistant-agent";

export const pai = createPai({
  agents: {
    main: {
      agent: assistantAgent,
      capabilities: {
        sandbox: sandboxCapability({
          provider: createGkeSandboxProvider({
            // The namespace the runtime module installed for this app.
            namespace: "pai-sandbox-chat",
          }),
        }),
      },
    },
  },
  scopeKey: (identity) => identity.workspaceId,
  userKey: (identity) => identity.userId,
});

The provider reaches each sandbox at its pod IP, so the backend needs a route to the cluster's pod network, and the application's allow_from must admit its address. Off-cluster, for example on Cloud Run, also pass cluster: { endpoint, caCertificate }. Sandboxes have no outbound network and keep their files under /workspace. Self-hosting covers the cluster side.

One conversation gets one sandbox by default. It is created on first use, reused while it is alive, and killed when the thread is deleted.

A subagent runs in its own child thread, so by default it gets its own sandbox. To let delegated work see the parent conversation's files, bind with scope: "rootThread": every thread delegated from a top-level thread, at any depth, shares that thread's sandbox. Register the same binding for each agent that uses it. The sandbox is killed when the top-level thread is deleted; deleting a delegated thread leaves it alone.

const sandbox = sandboxCapability({ provider, scope: "rootThread" });

createPai({
  agents: {
    main: { agent: assistantAgent, capabilities: { sandbox } },
    analyst: { agent: analystAgent, capabilities: { sandbox } },
  },
  scopeKey: (identity) => identity.workspaceId,
});

Seed the workspace

A sandbox starts empty, and it can be replaced at any time: it expires when idle, a node can lose it, and a reset destroys it. Files every sandbox should hold, such as skill scripts or the conversation's uploads, go in seed. PAI copies them into each environment before the agent uses it, and again into a replacement.

server/sandbox.ts
import { sandboxCapability, sandboxFilename } from "@pai/sandbox";
import { createGkeSandboxProvider } from "@pai/sandbox-gke";
import { storage } from "./runtime";
import { skills } from "./skills";

export const sandbox = sandboxCapability({
  provider: createGkeSandboxProvider({ namespace: "pai-sandbox-chat" }),
  seed: async ({ scopeKey, threadId }) => {
    // Every file the user sent, newest first: metadata only, from one indexed query.
    const { items } = await storage.listThreadAttachments({ scopeKey, threadId });
    return [
      ...skills.sandboxFiles,
      // A file sent in two messages is listed twice; its path makes it one file.
      ...items.map((upload) => ({
        // One folder per file, because names collide; the name stays writable.
        path: `/workspace/uploads/${upload.fileId}/${sandboxFilename(upload.filename ?? "upload")}`,
        // PAI reads it from the runtime's file provider only when the sandbox lacks it.
        fileId: upload.fileId,
      })),
    ];
  },
});

seed is a list of files, or a function that returns one. Each file has an absolute path inside /workspace and one source: inline data, a fileId from the runtime's file provider, or a loader with a version for anything else. executable: true makes it executable. createSandboxService() and createInMemorySandbox() take the same option.

When PAI checks

PAI checks the seed before the first sandbox operation of each run, and before the first operation in an environment this process has not checked yet: a new sandbox, one another process created, or a replacement after expiry, loss or reset. Parallel operations wait for that check and then skip it. A run that never touches the sandbox starts no check and no sandbox. Commands and createSandboxService().acquire() have no run, so their operations check only an environment new to the process.

What it copies

A ledger inside the sandbox, /workspace/.pai/seed.json, records the version of each file PAI has written there. A check copies only the files whose version the ledger lacks: everything in a new sandbox, and usually nothing in a warm one. A file whose version changes is written again.

PAI never reads a seeded file back and never deletes one. If the agent edits or deletes a seeded file, the change stays until the file's version changes or the environment is replaced. A file you stop listing stays in the sandbox until the environment is replaced.

Versions, stored files and loaders

An inline file is versioned by the sha256 of its data.

A stored file, { path, fileId }, is a file from the runtime's file provider, such as an upload. A stored file never changes, so its id is its version. PAI reads it only when the sandbox lacks it, through the operation that triggered the check, so it reaches only that operation's scopeKey, and only up to the provider's write limit. A file deleted since you listed it is skipped this time, and the next check tries again. A check without a file provider skips stored files: createSandboxService().acquire() has none, so the next run through the binding copies them, and the in-memory sandbox has none, so it never does. A "global" or custom scope can share one sandbox across scopes, so its checks never read stored files.

For content from anywhere else, pass a loader as data with a version: a content hash or an immutable id, never a time. PAI calls a loader only when the sandbox lacks that version, one file at a time. Pass its signal to every read; it aborts on reset, disposal, and at the five-minute ceiling on one check. Return null to skip the file this time; the next check asks again.

A seed function receives only the ids the binding's scope keys the sandbox on, typed for that scope: 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. With "rootThread", list the whole conversation's files by rootThreadId, so every subagent finds them. Several processes may check one sandbox, so return the same list for the same state of that scope. The function runs at least once per run that uses the sandbox, so keep it cheap. Seed code must not use the sandbox it is seeding, which would wait on its own check.

When seeding fails

A failed check fails only the operation that triggered it, with SandboxSeedError. Its missingPaths lists every declared file the sandbox still lacks, except files skipped this time, and the model reads fixed text naming them, as with any sandbox failure. Files copied before the failure are kept. A file that cannot be loaded, including a stored file over the provider's write limit (maxFileBytes), loses only itself, so the files after it are still copied. A failed write stops the check, so cap a loader at that limit. A check that reaches the five-minute ceiling records nothing, so the next one starts over: keep what one check copies well within that time, for example with a byte budget on the conversation's uploads. Later operations in the same run go ahead without the missing files, and the next run copies them. An invalid list fails the same way: a relative or unnormalised path, a path outside /workspace or inside /workspace/.pai, a stored file with an empty fileId, or a loader with an empty version. A path listed twice is not an error: the path is the file's identity, so it is one file, from the last entry. A sandbox that cannot be reached is not a seed failure: the operation fails with SandboxUnavailableError, and the run's next operation checks again.

Keep seeded files apart from the agent's work

A seeded path can be overwritten whenever its version changes, and a replaced environment holds only the seeded files. Put seeded files in folders of their own, such as /workspace/uploads/<id>/, and have the agent write its work elsewhere. Tell the model in its instructions where the seeded files are, and that a reset brings them back but not its own files.

Commands for the UI

Resetting or browsing the workspace is a command, not a model turn. Bind the sandbox capability on the command — the name is the agent map key, not a field on defineCommand:

import { defineCommand } from "@pai/core";
import { sandbox } from "@pai/sandbox";
import { z } from "zod";

export const resetSandbox = defineCommand({
  expose: "client",
  input: z.object({}),
  capabilities: { sandbox },
  run: async (ctx) => {
    await ctx.capabilities.sandbox.reset();
    return { status: "reset" as const };
  },
});
defineAgent({
  commands: { resetSandbox },
});

After a reset, the next operation provisions a fresh sandbox that holds only the seeded files.

Next

On this page