Cubicle

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/sdk installed, with CB_SDK_API_URL and CB_SDK_API_KEY set. See the Quickstart.
  • A service account API key with write scope. 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

  1. 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}`)
        }
      })
    }
  2. Write nextCall with your LLM client:

    • Offer runCommandTool to 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 last as the result of the previous tool call, for example as JSON.
    • Return the model's run_command call with its ID and arguments, or null when the model answers without one.
  3. 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 maxOutputBytes to 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 through tail.
  • 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 happenedWhat runCommand returns
The command exited, with any exit codeexitCode, stdout and stderr. A non-zero code is normal.
It ran past its timeoutThe same, with exit code 124 and a note
Its output went past 32 KiBThe 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_failedNothing: 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 callId unique across tasks: the same key with a different command or timeout, or for another Box in the organization, fails with 409 idempotency_conflict. Keys are at most 200 characters, so keep callId short.
  • An unknown exec is never run again, so a retry with the same callId returns 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 fromA clean base image, or a template snapshotWhatever earlier tasks left
SetupEvery task boots a new BoxSoftware installed once stays installed
Between tasksDeleted, so it reserves nothingKeeps its CPU, memory and disk reserved, even stopped or hibernated
HibernationMatters only for tasks over an hourHibernates an hour after each start; wake: true handles it
Separation of tasksEach task has its own virtual machineTasks 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 Cubicle client per process. All of its waits share one pacer: at most maxPollsPerSecond polls 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 maxPollsPerSecond in 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 use sudo, 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:

inputResult
{ 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

  • CubicleApiError conflict "box is not running": the Box was stopped or hibernated after wake checked it. The refused request records nothing, so calling runCommand again with the same callId starts 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.
  • CubicleApiError idempotency_conflict: a callId was 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, head or grep, 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.
  • CubicleApiError forbidden "an explicit box grant is required", insufficient_scope, rate_limited, blocked or capacity: these are not specific to agents. Troubleshooting has the fix for each.

On this page