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@latestbun add freestyle@latestnpm install freestyle@latestyarn 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.