---
title: "How to Run Claude Code in a Sandbox"
description: "Bake the Claude Code CLI into a VM snapshot, run it interactively over the PTY API, run one-off prompts, and drive it from the Claude Agent SDK with spawnClaudeCodeProcess."
url: "/docs/guides/run-claude-code-in-a-sandbox"
---

Build a snapshot with [Claude Code](https://github.com/anthropics/claude-code) installed, then use that sandbox three ways: run a one-off prompt, attach an interactive terminal over the PTY API, and drive the CLI from the [Claude Agent SDK](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk) running on your own machine — bridging the SDK's process to the VM through `spawnClaudeCodeProcess`.


## Install the SDK

```bash pnpm
pnpm add freestyle
```

```bash bun
bun add freestyle
```

```bash npm
npm install freestyle
```

```bash yarn
yarn add freestyle
```

Set your API key before calling the API:

```bash
export FREESTYLE_API_KEY="your-api-key"
```


## Build a Snapshot with Claude Code Installed

Create a VM from the base image, install Claude Code with its official installer, and snapshot. The installer needs `HOME`, which the bare exec shell doesn't set, so export it first. The native installer drops a standalone binary at `/root/.local/bin/claude` — no Node required — and a snapshot captures it, so every VM booted from this snapshot already has the CLI on disk.

```ts
import { freestyle } from "freestyle";

const { vm: builder } = await freestyle.vms.create();

// The installer reads $HOME; the bare exec shell has none, so set it.
const install = await builder.exec(
  "export HOME=/root && curl -fsSL https://claude.ai/install.sh | bash",
);
console.log(install.statusCode); // 0

// Confirm the binary landed and prints a version.
const version = await builder.exec("/root/.local/bin/claude --version");
console.log(version.stdout?.trim()); // e.g. "2.1.165 (Claude Code)"

const { snapshotId } = await builder.snapshot();
await builder.delete();
```


## Authenticate

Claude Code needs Anthropic credentials to run. Two options:

**API key (simplest).** Set `ANTHROPIC_API_KEY` in the environment of whatever invokes the CLI. Every example below threads it through, so the key lives in your process — never baked into the snapshot.

```bash
export ANTHROPIC_API_KEY="sk-ant-..."
```

**Interactive login.** Skip the key and let a person sign in instead: attach a terminal (see [Run Claude Code Interactively](#run-claude-code-interactively-over-the-pty)), run `claude`, and use the `/login` command. Claude Code prints an OAuth URL; open it, approve, and the CLI stores the credential under `/root/.claude` in the VM. Snapshot the VM afterward to bake the logged-in state into future sandboxes.


## Run a One-Off Prompt

For a single non-interactive prompt, boot a VM from the snapshot and use `vm.exec()` with `claude -p`. `-p` (print mode) runs the prompt and prints the final answer to stdout, then exits. Pass `ANTHROPIC_API_KEY` and `HOME` inline so the CLI is authenticated and finds its config.

```ts
const { vm, vmId } = await freestyle.vms.create({ snapshotId, idleTimeoutSeconds: null });

const run = await vm.exec({
  command:
    `HOME=/root ANTHROPIC_API_KEY="${process.env.ANTHROPIC_API_KEY}" ` +
    `/root/.local/bin/claude -p "Write a haiku about sandboxes"`,
  timeoutMs: 120_000,
});
console.log(run.stdout);
```

`vm.exec()` buffers the whole run and returns `{ stdout, stderr, statusCode }`. Use it for prompts that finish on their own; for a live session you can type into, use the PTY.


## Run an Autonomous Task Headless

The one-off prompt above just returns text. A task that actually *edits files* needs a permission mode that never stops to ask — and that mode, `--dangerously-skip-permissions`, **refuses to run as root**. So do autonomous work as a **non-root user**.

Set one up on the builder, then snapshot so every VM inherits it. Create the user, put the CLI somewhere it can reach (`/root` isn't readable by other users), carry over the login from [Authenticate](#authenticate), and hand it ownership of the directory it will edit:

```ts
await builder.exec(`useradd -m -s /bin/bash claude
install -m 755 /root/.local/bin/claude /usr/local/bin/claude
cp -a /root/.claude /root/.claude.json /home/claude/
chown -R claude:claude /home/claude /srv/app`);

const { snapshotId } = await builder.snapshot();
```

Boot a VM from that snapshot and run the prompt **as the user** with `vm.user()`, which scopes the call to that Linux user (via the `X-Freestyle-Vm-Linux-User-Id` header). `claude -p` runs the whole agentic loop — reading, editing, running commands — and exits when done:

```ts
const { vm } = await freestyle.vms.create({ snapshotId, idleTimeoutSeconds: null });

const run = await vm.user({ username: "claude" }).exec({
  command:
    `cd /srv/app && HOME=/home/claude TERM=xterm-256color ` +
    `claude -p "Turn this Vite app into a cookie-clicker game" --dangerously-skip-permissions`,
  timeoutMs: 600_000, // autonomous runs take minutes — give them room
});
console.log(run.statusCode); // 0
```

To authenticate with a key instead of a copied login, drop the `cp` line and pass `ANTHROPIC_API_KEY="..."` in the command. `HOME=/home/claude` points the CLI at the user's config, and `TERM` keeps it from bailing as in the interactive section below.


## Run Claude Code Interactively over the PTY

`vm.exec()` is request/response — it can't stream output or take keystrokes. For an interactive Claude Code session, open a **PTY**: a real pseudo-terminal in the VM, streamed over a WebSocket. `vm.pty.open()` returns a session you write keystrokes to and whose output arrives on `onData`.

The WebSocket carries auth headers, which browsers can't set — so the PTY is **server-side only** (Node 22+). Open a shell, then write the command; output (including the terminal UI) streams back as raw bytes.

```ts
const { vm } = await freestyle.vms.create({ snapshotId, idleTimeoutSeconds: null });

const session = await vm.pty.open({
  cols: 100,
  rows: 30,
  onData: (bytes) => process.stdout.write(bytes), // raw terminal output
  onExit: (code) => console.log("\nclaude exited:", code),
});

// Launch Claude Code with the API key in its environment. TERM must be set —
// the PTY shell doesn't set it, and claude (a TUI) exits with "TERM environment
// variable not set" without it.
session.write(
  `HOME=/root PATH=/root/.local/bin:$PATH TERM=xterm-256color ` +
    `ANTHROPIC_API_KEY="${process.env.ANTHROPIC_API_KEY}" claude\n`,
);

// `session.write(data)` sends keystrokes; `session.signal("SIGINT")` sends Ctrl-C;
// `session.resize({ cols, rows })` reflows the UI; `session.detach()` closes your
// handle without killing the session (reattach later with vm.pty.attach()).
```

Pipe `onData` to your own terminal and forward `process.stdin` into `session.write()` to get a fully interactive Claude Code running inside the sandbox. This is also how you complete the [interactive login](#authenticate) flow.

A freshly installed Claude Code opens a one-time **onboarding wizard** before the chat: a theme picker, an `ANTHROPIC_API_KEY` confirmation (`Detected a custom API key… use this key?`, whose default is **No**), and a per-directory **trust** prompt. A person attaching a terminal just answers them, but to land straight in the chat — or to drive the CLI unattended — pre-seed `~/.claude.json` before you snapshot. Write it on the builder right after installing the CLI:

```ts
await builder.fs.writeTextFile(
  "/root/.claude.json",
  JSON.stringify({
    theme: "dark",
    hasCompletedOnboarding: true,
    // Claude identifies an approved key by its last 20 chars — not the whole key.
    customApiKeyResponses: {
      approved: [process.env.ANTHROPIC_API_KEY!.slice(-20)],
      rejected: [],
    },
    // Trust is per-directory: list the dir you launch `claude` in.
    projects: { "/root": { hasTrustDialogAccepted: true } },
  }),
);
```

Snapshot after writing this and every booted VM drops straight into the chat. Omit `customApiKeyResponses` if you authenticate with `/login` instead of a key.


## Drive Claude Code from the Claude Agent SDK

The [Claude Agent SDK](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk) runs Claude Code programmatically: you call `query()`, it spawns the CLI and talks to it over `stdin`/`stdout` using newline-delimited JSON. By default it spawns the CLI **locally** — but `spawnClaudeCodeProcess` lets you supply your own process. Point it at the sandbox and the SDK runs on your machine while Claude Code executes inside the VM.

```bash
npm install @anthropic-ai/claude-agent-sdk
```

The SDK only speaks its protocol when the CLI's stdio are **pipes**, not a terminal: on a TTY the CLI launches its interactive UI and reads stdin as keystrokes. A Freestyle PTY *is* a terminal, so you can't point the CLI straight at it. The fix is one shell trick — run the CLI as `cat | claude … | cat` inside the VM. Its stdin and stdout become pipes (so it runs headless and skips every first-run prompt), while the two `cat`s shuttle bytes to and from the PTY. The PTY is just the transport: put it in raw, no-echo mode so the terminal layer doesn't corrupt the stream, and use a sentinel byte to skip the one-line bootstrap echo.

```ts
import { Readable, Writable } from "node:stream";
import { EventEmitter } from "node:events";

const sh = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`; // single-quote for the shell

function spawnInVm(vm: any) {
  return (options: {
    command: string;
    args: string[];
    cwd?: string;
    env: Record<string, string | undefined>;
    signal: AbortSignal;
  }) => {
    const events = new EventEmitter();
    const stdout = new Readable({ read() {} });
    let session: any = null;
    let exitCode: number | null = null;
    let killed = false;
    const pending: Buffer[] = []; // writes that arrive before the PTY is open

    const stdin = new Writable({
      write(chunk, _enc, cb) {
        const buf = Buffer.from(chunk);
        session ? session.write(buf) : pending.push(buf);
        cb();
      },
    });

    // Bootstrap: raw mode → forward the API key (+ the SDK's own markers) →
    // cd → print the sentinel → run the CLI as `cat | claude | cat` so its
    // stdio are pipes (headless protocol), not the tty. \x1e is the first
    // byte of clean output.
    const SENTINEL = "\x1e";
    const keep = new Set(["ANTHROPIC_API_KEY", "CLAUDE_CODE_ENTRYPOINT", "CLAUDECODE"]);
    const exports = Object.entries(options.env)
      .filter(([k, v]) => v !== undefined && keep.has(k))
      .map(([k, v]) => `export ${k}=${sh(v as string)};`)
      .join(" ");
    const cli = [options.command, ...options.args].map(sh).join(" ");
    const boot =
      `stty -echo raw -onlcr 2>/dev/null; export HOME=/root PATH=/root/.local/bin:/usr/bin:/bin; ` +
      `${exports} cd ${sh(options.cwd ?? "/root")}; printf ${sh(SENTINEL)}; cat | ${cli} | cat\n`;

    let started = false;
    let head = "";
    vm.pty
      .open({
        cols: 80,
        rows: 24,
        onData: (bytes: Uint8Array) => {
          if (started) return stdout.push(Buffer.from(bytes));
          head += Buffer.from(bytes).toString("utf8");
          const i = head.indexOf(SENTINEL);
          if (i !== -1) {
            started = true;
            const rest = head.slice(i + SENTINEL.length);
            if (rest) stdout.push(Buffer.from(rest, "utf8"));
          }
        },
        onExit: (code: number | null) => {
          exitCode = code;
          stdout.push(null);
          events.emit("exit", killed ? 0 : code, null); // we killed it = clean
        },
        onError: (err: unknown) => events.emit("error", err),
      })
      .then((s: any) => {
        session = s;
        s.write(boot);
        for (const buf of pending.splice(0)) s.write(buf);
      })
      .catch((err: unknown) => events.emit("error", err));

    // The PTY can't deliver stdin-EOF, so the CLI won't exit on its own — tear
    // it down when the SDK aborts or calls kill() (the loop breaks on `result`).
    const teardown = () => {
      killed = true;
      try { session?.signal("SIGKILL"); } catch {}
    };
    options.signal.addEventListener("abort", teardown);

    return {
      stdin,
      stdout,
      get killed() { return killed; },
      get exitCode() { return exitCode; },
      kill() {
        teardown();
        return true;
      },
      on: (e: string, l: (...a: any[]) => void) => void events.on(e, l),
      once: (e: string, l: (...a: any[]) => void) => void events.once(e, l),
      off: (e: string, l: (...a: any[]) => void) => void events.off(e, l),
    };
  };
}
```

Now hand that to `query()`. Point `pathToClaudeCodeExecutable` at the CLI **in the VM** so the SDK passes the right command, and approve tools with `canUseTool` — the headless stand-in for a permission prompt, since the CLI can't prompt over a pipe. The streamed messages end with a `result` carrying the answer.

```ts
import { query } from "@anthropic-ai/claude-agent-sdk";

const { vm } = await freestyle.vms.create({ snapshotId, idleTimeoutSeconds: null });

const response = query({
  prompt: "List the files in /root and summarize what you find.",
  options: {
    spawnClaudeCodeProcess: spawnInVm(vm),
    pathToClaudeCodeExecutable: "/root/.local/bin/claude",
    env: { ...process.env, HOME: "/root", PATH: "/root/.local/bin:/usr/bin:/bin" },
    canUseTool: async (_tool, input) => ({ behavior: "allow", updatedInput: input }),
  },
});

for await (const message of response) {
  if (message.type === "result") {
    console.log(message.result);
    break; // one-shot: stop the stream; the bridge tears the CLI down
  }
}
```

The SDK forwards `env` to `spawnClaudeCodeProcess`, so `ANTHROPIC_API_KEY` from your process reaches the CLI in the VM. Claude Code runs entirely inside the sandbox — its `Bash`, `Read`, `Write`, and `Edit` tools all act on the VM's filesystem — while your code orchestrates from outside. `canUseTool` here approves everything; return `{ behavior: "deny", message }` to block a call, or inspect `input` to gate specific commands. Because Claude Code's stdio are pipes, no first-run prompts appear — the plain snapshot from the first step needs no extra setup.
