Give an AI agent a Box
Give an LLM agent a Cubicle Box as its sandbox with @cubicle/sdk, with a framework-neutral run_command tool covering sh -c, timeoutMs, maxOutputBytes, truncated and timedOut results, exit codes, wake, idempotency keys, one Box per task, cleanup in finally, rate limits and what API keys cannot do.
Give an LLM agent a Linux machine where it can run shell commands, write files, build code and run
tests, without running the model's commands on your own computer. This page builds that with
@cubicle/sdk: a run_command tool, and a loop that gives each task its own Box and deletes it
when the task ends. Your program holds the API key and talks to the model. When the model calls the
tool, your program runs the command in the Box with exec and returns the result to the model.
Why a Box
- Isolation: a Box is a virtual machine with its own kernel, so the code the agent runs stays in it. See Boxes.
- A full Linux system: the agent can write files, build code and run tests, and files persist between commands.
- Credentials stay out: the API key stays in your program. Neither the model nor the Box sees it. The Box can reach the public IPv4 internet, though, and the agent's code can read anything you put in it, so put nothing there that it should not be able to send out.
Before you begin
@cubicle/sdkinstalled, withCB_SDK_API_URLandCB_SDK_API_KEYset. See the Quickstart.- A service account API key with
writescope. See Create a service account and a key. - An organization that allows Box creation. Otherwise creating each task's Box fails with
403 box_create_disabled. - An LLM client that supports tool calls. The code here does not depend on one.
Build the tool
-
Save this module as
agent-box.ts. It defines the tool for the model, the function that runs a call, a wrapper that gives each task its own Box, and the agent loop:import { Cubicle, CubicleOperationError, type BoxRef } from "@cubicle/sdk" const cubicle = new Cubicle() // one client per process: its waits share one poll budget /** The tool as most LLM APIs describe one; rename the fields to match yours. */ export const runCommandTool = { name: "run_command", description: [ "Run a shell command in a Linux virtual machine and get its exit code, stdout and stderr.", "Commands run with sh -c as an unprivileged user without sudo, in the home directory.", "There is no stdin and no terminal, so use non-interactive options.", "Files persist between calls. Output over 32 KiB is cut off: pipe long output through tail.", ].join(" "), inputSchema: { type: "object", properties: { command: { type: "string", description: "A shell command line, such as: ls -la" }, timeoutSeconds: { type: "integer", minimum: 1, maximum: 600, description: "Default 60" }, }, required: ["command"], }, } export type RunCommandInput = { command: string; timeoutSeconds?: number } /** What the model gets back. A failed command is a result, not an exception. */ export type RunCommandResult = | { exitCode: number; stdout: string; stderr: string; notes: string[] } | { error: string } /** * Runs one tool call. `callId` must be unique to this call across all tasks, for example * `${taskId}:${toolCall.id}`: a retry with the same ID returns the first run's result. */ export async function runCommand( box: BoxRef, input: RunCommandInput, callId: string, ): Promise<RunCommandResult> { const seconds = Math.min(Math.max(Math.round(input.timeoutSeconds ?? 60), 1), 600) try { const result = await box.exec(["sh", "-c", input.command], { timeoutMs: seconds * 1000, maxOutputBytes: 32 * 1024, wake: true, // starts the Box again if it stopped or hibernated idempotencyKey: `run-command:${callId}`, }) const notes: string[] = [] if (result.timedOut) notes.push(`Stopped after ${seconds} s.`) if (result.truncated) notes.push("Output was cut off at 32 KiB.") return { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr, notes } } catch (error) { if (error instanceof CubicleOperationError && error.code === "operation_unknown") { return { error: "The command was interrupted and may or may not have run. Check first." } } if (error instanceof CubicleOperationError) { return { error: `Cubicle could not run the command or return its result: ${error.operation.error}`, } } throw error // rate limits, lost connections and failed Boxes end the task } } /** Runs `work` with a new Box, and deletes the Box afterwards, even when `work` throws. */ export async function withTaskBox<T>(work: (box: BoxRef) => Promise<T>): Promise<T> { // Without waiting, create returns the handle at once, so a Box that fails to boot is deleted too. const box = await cubicle.boxes.create({}, { wait: false }) try { await box.waitUntilReady() return await work(box) } finally { await box.delete({ wait: false }) } } /** A `run_command` call from the model: its ID and its arguments. */ export type ToolCall = { id: string; input: RunCommandInput } /** * One task's agent loop. `nextCall` is your LLM client: it sends the previous call's result * to the model, or the task when there is none, and returns the model's next tool call. */ export async function runTask( taskId: string, nextCall: (last: RunCommandResult | undefined) => Promise<ToolCall | null>, ): Promise<void> { await withTaskBox(async (box) => { let last: RunCommandResult | undefined for (let call = await nextCall(undefined); call; call = await nextCall(last)) { last = await runCommand(box, call.input, `${taskId}:${call.id}`) } }) } -
Write
nextCallwith your LLM client:- Offer
runCommandToolto the model, under the field names your LLM API uses for a tool's name, description and JSON Schema. - On the first call, send the task. On later calls, send
lastas the result of the previous tool call, for example as JSON. - Return the model's
run_commandcall with its ID and arguments, ornullwhen the model answers without one.
- Offer
-
Run a task with
runTask("task-42", nextCall). It creates a Box, runs each command the model asks for, and deletes the Box at the end, even when something throws.
Pass a command line or an argv
Exec runs an argv array without a shell. Models write shell command lines, with pipes, && and
redirects, so the tool takes a string and runs it as ["sh", "-c", command]. Use bash -c if your
prompts expect bash syntax.
A tool that takes an argv array instead needs no quoting and runs exactly one program, but models produce correct arrays less reliably. See Use shell syntax.
Set the timeout and output limit
- The tool sets
maxOutputBytesto 32 KiB to keep results small in the model's context. stdout and stderr share that budget, and output past it is discarded, so the end of a long output is lost: the tool's description tells the model to pipe throughtail. - The tool lets the model ask for up to 600 s, exec's longest
timeoutMs. For longer work, see Run work longer than 10 minutes.
Set a timeout and an output limit explains both limits, and Limits and defaults lists every exec limit.
Return results, not exceptions
| What happened | What runCommand returns |
|---|---|
| The command exited, with any exit code | exitCode, stdout and stderr. A non-zero code is normal. |
| It ran past its timeout | The same, with exit code 124 and a note |
| Its output went past 32 KiB | The same, with the first 32 KiB and a note |
The operation ended unknown (operation_unknown) | An error: the command may or may not have run |
The operation failed (operation_failed) | An error with the reason, such as a revoked grant |
Any other error, such as rate_limited or box_failed | Nothing: it throws, and the task ends |
A non-zero exit code is information the model needs, so return it rather than throwing. The Errors and retries page lists every error class and code.
Wake a Box that hibernated
runCommand passes wake: true, so exec starts the Box again when it has stopped or
hibernated, and waits until it
is ready. A command still running when the Box hibernates ends unknown. See
Wake a stopped or hibernated Box.
Retry tool calls safely
The SDK sends an idempotency key with every exec and reuses it when it retries that request
itself. If your agent framework retries a whole tool call on the same Box, for example after your
process restarts, the idempotencyKey built from callId makes the retry return the first run's
result instead of running the command again.
- Make
callIdunique across tasks: the same key with a different command or timeout, or for another Box in the organization, fails with409 idempotency_conflict. Keys are at most 200 characters, so keepcallIdshort. - An
unknownexec is never run again, so a retry with the samecallIdreturns the same error. To run the command again, the model makes a new tool call, which has a new ID.
See Idempotency keys for the full rules.
One Box per task, or a long-lived Box
One Box per task (withTaskBox) | A long-lived Box | |
|---|---|---|
| Starts from | A clean base image, or a template snapshot | Whatever earlier tasks left |
| Setup | Every task boots a new Box | Software installed once stays installed |
| Between tasks | Deleted, so it reserves nothing | Keeps its CPU, memory and disk reserved, even stopped or hibernated |
| Hibernation | Matters only for tasks over an hour | Hibernates an hour after each start; wake: true handles it |
| Separation of tasks | Each task has its own virtual machine | Tasks of one service account share its guest account and its files |
Use one Box per task when tasks come from different users or handle untrusted input. To skip setup in each task, prepare a Box once and create task Boxes from its snapshot; see Build a template Box. Several commands can run in one Box at once, so parallel tool calls work in either case.
Clean up
withTaskBox deletes each task's Box in finally. It creates the Box with { wait: false }, which
returns the handle before the Box is ready, so a Box that fails to boot is deleted too.
delete({ wait: false }) returns once the deletion is accepted; leave out wait: false to wait
until the Box's resources are released. A process that is killed skips finally, so sweep for
leftover Boxes on a schedule, as in Clean up Boxes.
Stay within rate limits
Each tool call sends one exec request, then polls its operation about once a second until the
command ends. wake: true adds a read of the Box, and a start when the Box is not running.
- Use one
Cubicleclient per process. All of its waits share one pacer: at mostmaxPollsPerSecondpolls a second, 5 by default, however many agents run. - Every agent that uses one service account shares that account's request budget, in every process.
Lower
maxPollsPerSecondin each process when several share an account; see Poll pacing and Rate limits.
What an agent cannot do
An agent's commands use a service account's API key, which limits what they can do:
- No
sudo. Commands run as the service account's guest account, which cannot usesudo, so they cannot install system packages. See Root and sudo. - No SSH and no private network. API keys can do neither. To use a service the agent starts in
the Box, run its client in the Box with exec, for example against
localhost. - No streaming, stdin or terminal. Output arrives when the command ends, and programs that prompt for input read end-of-file.
- No background services it can rely on. Cubicle does not guarantee that a process started by an exec keeps running after the exec returns. A background process that keeps stdout or stderr open holds the exec until its timeout.
- No file upload or download API. See Move files into and out of a Box.
Verify
Call runCommand with these inputs inside withTaskBox, and compare the results:
input | Result |
|---|---|
{ command: "id -un; exit 3" } | { exitCode: 3, stdout: "jv_…\n", stderr: "", notes: [] } |
{ command: "sleep 5", timeoutSeconds: 1 } | { exitCode: 124, stdout: "", stderr: "", notes: ["Stopped after 1 s."] } |
{ command: "seq 100000" } | { exitCode: 0, stdout: "1\n2\n…", stderr: "", notes: ["Output was cut off at 32 KiB."] } |
Give each call its own callId. Calling runCommand again with the first call's input and
callId returns the same result without running the command again.
Troubleshooting
CubicleApiErrorconflict"box is not running": the Box was stopped or hibernated afterwakechecked it. The refused request records nothing, so callingrunCommandagain with the samecallIdstarts the Box and runs the command.- The tool returns "The command was interrupted": the operation ended
unknown. The Box was stopped, hibernated or deleted while the command ran, for example by automatic hibernation, or the service on its Node restarted. CubicleApiErroridempotency_conflict: acallIdwas used for two different commands or Boxes. Include the task's ID in it.- The model keeps getting cut-off output: lower its output with
tail,headorgrep, or have it write output to a file and read parts of it. - The tool returns
unsupported Unicode escape sequence, or another reason an exec failed: Run commands lists each one with its fix. CubicleApiErrorforbidden"an explicit box grant is required",insufficient_scope,rate_limited,blockedorcapacity: these are not specific to agents. Troubleshooting has the fix for each.
Related
- Run commands: exec's options and results in full.
- Commands and operations: how exec, guest accounts and operations work.
- Errors and retries: every SDK error class and code.
- Move files into and out of a Box: files for an agent's task.
- Build a template Box: prepare software once for every task.
- Run CI jobs in Boxes: service accounts, sweeping for leftover Boxes and the CLI in scripts.
- Docs for AI tools: an
AGENTS.mdsnippet for the coding agent that maintains this integration, llms.txt and a Markdown copy of every page.
Use cases
End-to-end walkthroughs for reaching a goal with Cubicle Boxes, such as giving an AI agent a virtual machine to run code in, running CI jobs in a fresh Box per run and setting up remote development environments for a team.
Run CI jobs in Boxes
Run each CI job or scripted task in a fresh Cubicle Box created with a service account API key, with a GitHub Actions workflow that runs tests in a new Box per run, cleanup of leftover Boxes, the cubicle CLI with CB_CLI_API_KEY in shell scripts, grants on existing Boxes and what a CI job cannot do in a Box.

