Freestyle Docs

Freestyle / Guides

How to Run Claude Code in a Sandbox

Bake Claude Code into a VM snapshot, then run it over PTY, one-off prompts, or the Claude Agent SDK.

Build a snapshot with 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 running on your own machine — bridging the SDK’s process to the VM through spawnClaudeCodeProcess.

Install the SDK

pnpm add freestyle@latest
bun add freestyle@latest
npm install freestyle@latest
yarn add freestyle@latest

Set your API key before calling the API:

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. vm.exec() runs as the image’s default user — ubuntu on the Ubuntu base image — so the installer lands a standalone binary at /home/ubuntu/.local/bin/claude, owned by the account that will run it. No Node required, and a snapshot captures it, so every VM booted from this snapshot already has the CLI on disk.

import { Freestyle } from "freestyle";

const freestyle = new Freestyle();

const { vm: builder } = await freestyle.vms.create({
  // Required: a VM reaches nothing it has not been allowed to.
  firewall: { rules: [{ action: "allow", source: {}, destination: { public: true } }] }, slug: "claude-code-builder" });

const install = await builder.exec("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("~/.local/bin/claude --version");
console.log(version.stdout?.trim()); // e.g. "2.1.165 (Claude Code)"

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

Install system packages with sudo, which the ubuntu account has without a password: vm.exec("sudo apt-get install -y ripgrep"). To run something as root outright, use vm.linuxUser("root").exec(...).

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.

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, and use the /login command. Claude Code prints an OAuth URL; open it, approve, and the CLI stores the credential under ~/.claude in the VM. Snapshot the VM afterward to bake the logged-in state into future sandboxes.

Snapshot Your Logged-In Claude Code from the CLI

Everything above — build a VM, install the CLI, sign in, snapshot — collapses into one command with the Freestyle CLI: snapshot create boots a builder VM, attaches your keyboard to whatever runs after --, and captures the VM the moment that command exits cleanly. Point it at Claude Code’s installer and login, and the snapshot comes out configured with your Claude Code:

npx freestyle@latest snapshot --slug claude-code -- \
  bash -c "curl -fsSL https://claude.ai/install.sh | bash && ~/.local/bin/claude auth login"

The login prints an OAuth URL — your keyboard is attached, so this is the interactive flow from the section above without any PTY code. Open the URL, approve, and the credential lands under ~/.claude in the VM; the command exits and the capture happens. A failed login or a Ctrl-C exits non-zero, which takes no snapshot — you can’t accidentally bake a half-configured machine.

On a base that already carries the CLI — the builder snapshot from earlier, say — the command shrinks to just the login:

npx freestyle@latest snapshot --base <builder-snapshot> --slug claude-code -- claude auth login

Every VM booted from the snapshot now runs Claude Code as you, no ANTHROPIC_API_KEY to thread through:

freestyle vm create --snapshot-id claude-code --slug agent-1
freestyle vm exec agent-1 -- claude -p "Summarize the files in my home directory"
freestyle vm exec -it agent-1 -- claude

One thing to be clear-eyed about: this snapshot holds a live credential for your Claude account. It is scoped to your team like every snapshot, but treat it like the secret it is — boot disposable per-task VMs from it and delete them when the work is done, and delete and rebuild the snapshot to rotate the credential.

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 inline to authenticate; HOME is already the default user’s, so the CLI finds the config the installer wrote.

const { vm, vmId } = await freestyle.vms.create({
  // Required: a VM reaches nothing it has not been allowed to.
  firewall: { rules: [{ action: "allow", source: {}, destination: { public: true } }] }, slug: "claude-code-sandbox", snapshotId, idleTimeoutSeconds: null });

const run = await vm.exec({
  command:
    `ANTHROPIC_API_KEY="${process.env.ANTHROPIC_API_KEY}" ` +
    `~/.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. vm.exec() already runs as the image’s default user rather than root, so it works as-is.

Give that user the directory it will edit, then snapshot so every VM inherits it:

await builder.exec("sudo install -d -o ubuntu -g ubuntu /srv/app");

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

Boot a VM from that snapshot and run the prompt. claude -p runs the whole agentic loop — reading, editing, running commands — and exits when done:

const { vm } = await freestyle.vms.create({
  // Required: a VM reaches nothing it has not been allowed to.
  firewall: { rules: [{ action: "allow", source: {}, destination: { public: true } }] }, slug: "claude-code-sandbox", snapshotId, idleTimeoutSeconds: null });

const run = await vm.exec({
  command:
    `cd /srv/app && TERM=xterm-256color ANTHROPIC_API_KEY="${process.env.ANTHROPIC_API_KEY}" ` +
    `~/.local/bin/claude -p "Turn this Vite app into a cookie-clicker game" --dangerously-skip-permissions`,
  timeoutMs: 300_000, // vm.exec supports up to five minutes
});
console.log(run.statusCode); // 0

Drop ANTHROPIC_API_KEY if you baked in an interactive login instead. TERM keeps the CLI from bailing as in the interactive section below.

On an image whose uid-1000 account does not exist, vm.exec() runs as root and --dangerously-skip-permissions refuses. Create a user and name it — vm.linuxUser("claude").exec(...) — or use an Ubuntu base snapshot.

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.

const { vm } = await freestyle.vms.create({
  // Required: a VM reaches nothing it has not been allowed to.
  firewall: { rules: [{ action: "allow", source: {}, destination: { public: true } }] }, slug: "claude-code-sandbox", 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(
  `PATH=$HOME/.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 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:

await builder.fs.writeTextFile(
  "/home/ubuntu/.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: { "/home/ubuntu": { hasTrustDialogAccepted: true } },
  }),
);

// The filesystem API writes as root, so hand the file to the account that runs
// the CLI — Claude Code rewrites this file as it goes.
await builder.linuxUser("root").exec("chown ubuntu:ubuntu /home/ubuntu/.claude.json");

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 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.

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 cats 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.

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 PATH=$HOME/.local/bin:/usr/bin:/bin; ` +
      `${exports} cd ${sh(options.cwd ?? "$HOME")}; 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.

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

const { vm } = await freestyle.vms.create({
  // Required: a VM reaches nothing it has not been allowed to.
  firewall: { rules: [{ action: "allow", source: {}, destination: { public: true } }] }, slug: "claude-code-sandbox", snapshotId, idleTimeoutSeconds: null });

const response = query({
  prompt: "List the files in my home directory and summarize what you find.",
  options: {
    spawnClaudeCodeProcess: spawnInVm(vm),
    pathToClaudeCodeExecutable: "/home/ubuntu/.local/bin/claude",
    env: { ...process.env, PATH: "/home/ubuntu/.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.

esc