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 freestylebun add freestylenpm install freestyleyarn add freestyle 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. 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.
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.
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 /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.
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, and hand it ownership of the directory it will edit:
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:
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.
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 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(
"/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 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 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.
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.