This guide builds a reusable snapshot with Python preinstalled, then defines a small runPython() helper that runs a snippet on a VM you create once and reuse for as many executions as you like.
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 Python
Boot a builder VM and install Python with pip and venv. Debian marks the system Python as externally managed, so create a virtual environment under /opt/venv and install dependencies into it. Snapshot the result so the install is baked in.
import { freestyle } from "freestyle";
const { vm: builder } = await freestyle.vms.create();
const install = await builder.exec(
"apt-get update -qq && apt-get install -y -qq python3 python3-venv python3-pip",
);
console.log(install.statusCode); // 0
// Create a venv we control, then install dependencies into it.
await builder.exec("python3 -m venv /opt/venv");
await builder.exec("/opt/venv/bin/pip install --quiet requests");
const { snapshotId } = await builder.snapshot();
await builder.delete();
apt-installed packages and everything in the venv persist into the snapshot, so every VM you boot from snapshotId has Python ready immediately.
A Reusable runPython() Utility
The VM is the reusable sandbox: create one from the snapshot and run as many snippets on it as you want. runPython() takes that vm as its first argument, writes the code to a file, and runs it with vm.exec — it never creates or deletes a VM, so there’s no per-call boot cost. Each call writes to a unique /tmp/main-<uuid>.py path via crypto.randomUUID(), so runs on the same VM never clobber each other’s file, whether they happen one after another or all at once. vm.exec is synchronous: it runs the script to completion and returns { stdout, stderr, statusCode }. The exec shell is non-login with no HOME and a bare PATH, so call the venv’s Python by its absolute path.
async function runPython(vm, code: string) {
const file = `/tmp/main-${crypto.randomUUID()}.py`;
await vm.fs.writeTextFile(file, code);
return await vm.exec(`/opt/venv/bin/python ${file}`);
}
Create one VM, then keep calling runPython(vm, ...). Here it runs a snippet that uses the requests dependency we baked into the snapshot, then a second snippet on the same VM:
const { vm } = await freestyle.vms.create({ snapshotId });
const out = await runPython(
vm,
[
"import sys, requests",
"print('python', sys.version.split()[0])",
"print('requests', requests.__version__)",
"print('sum', sum(range(10)))",
].join("\n"),
);
console.log(out.statusCode); // 0
console.log(out.stdout);
// python 3.13.5
// requests 2.34.2
// sum 45
// Same VM, another run — no new VM, no clash.
const greeting = await runPython(vm, "print('hello', 2 + 2)");
console.log(greeting.stdout); // hello 4
Do a Bit More
A raw { stdout, stderr, statusCode } is fine, but a useful helper should throw when the code fails and let you pass input. This variant takes the same reusable vm, forwards an args array into Python’s sys.argv, and throws on a non-zero exit, so failures surface as JavaScript exceptions instead of silently returning a non-zero status.
async function runPythonStrict(vm, code: string, { args = [] as unknown[] } = {}) {
const file = `/tmp/main-${crypto.randomUUID()}.py`;
await vm.fs.writeTextFile(file, code);
const quoted = args
.map((a) => `'${String(a).replace(/'/g, "'\\''")}'`)
.join(" ");
const result = await vm.exec(`/opt/venv/bin/python ${file} ${quoted}`);
if (result.statusCode !== 0) {
throw new Error(
`Python exited with ${result.statusCode}:\n${result.stderr ?? result.stdout}`,
);
}
return { stdout: result.stdout, stderr: result.stderr };
}
Run the enhanced variant on the same VM. Pass arguments and read them back from sys.argv:
const sum = await runPythonStrict(
vm,
"import sys; print('total:', sum(int(x) for x in sys.argv[1:]))",
{ args: [3, 4, 5] },
);
console.log(sum.stdout); // total: 12
A snippet that raises now rejects instead of returning quietly:
try {
await runPythonStrict(vm, "raise ValueError('boom')");
} catch (err) {
console.log(err.message); // Python exited with 1: ... ValueError: boom
}
Because every call writes a uniquely named file, you can even fan out concurrent runs on the one VM without them clashing:
const [a, b] = await Promise.all([
runPython(vm, "print('A', 111 * 2)"),
runPython(vm, "print('B', 222 * 2)"),
]);
console.log(a.stdout.trim(), "/", b.stdout.trim()); // A 222 / B 444
Run a Server
The same snapshot that runs one-off scripts can also host a long-running HTTP server. Create a fresh VM from snapshotId (use a distinct variable so it doesn’t collide with the run-code vm), write the server source and a systemd unit, start it, and route a public style.dev domain to it.
The server must bind 0.0.0.0 (not 127.0.0.1) so the platform can route external traffic to it. systemd is PID 1 on every VM, so a unit is the right way to keep the process alive. Units do not source a shell profile, so ExecStart uses the absolute venv Python path, and Environment= supplies the HOME the non-login shell omits.
const { vm: server, vmId } = await freestyle.vms.create({
snapshotId,
idleTimeoutSeconds: null,
});
Write the server. It listens on 0.0.0.0:3000 and answers every request with a plain-text greeting:
const source = `import http.server, socketserver
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(b"Hello from a Python server in a sandbox!\\n")
with socketserver.TCPServer(("0.0.0.0", 3000), Handler) as httpd:
httpd.serve_forever()
`;
await server.fs.writeTextFile("/srv/server.py", source);
Write a systemd unit that runs it. Restart=always keeps it alive, and WantedBy=multi-user.target makes it start on boot:
const unit = `[Unit]
Description=Python HTTP server
After=network.target
[Service]
ExecStart=/opt/venv/bin/python /srv/server.py
WorkingDirectory=/srv
Environment=HOME=/root
Restart=always
[Install]
WantedBy=multi-user.target
`;
await server.fs.writeTextFile("/etc/systemd/system/pyserver.service", unit);
Reload systemd, enable and start the unit, then poll until it serves HTTP 200 on localhost:
const enable = await server.exec(
"systemctl daemon-reload && systemctl enable --now pyserver",
);
console.log(enable.statusCode); // 0
let ready = false;
for (let i = 0; i < 30 && !ready; i++) {
await new Promise((r) => setTimeout(r, 1000));
const probe = await server.exec(
"curl -s -o /dev/null -w '%{http_code}' http://localhost:3000",
);
ready = probe.stdout?.trim() === "200";
}
console.log("ready:", ready); // true
Now map a domain to the VM’s port so the open internet can reach it. style.dev needs no verification, so the mapping is live as soon as you create it, and each run gets a unique subdomain:
const domain = `my-app-${crypto.randomUUID().slice(0, 8)}.style.dev`;
await freestyle.domains.mappings.create({ domain, vmId, vmPort: 3000 });
Fetch it from outside the VM:
const res = await fetch(`https://${domain}`);
console.log(res.status); // 200
console.log(await res.text()); // Hello from a Python server in a sandbox!
One snapshot now powers both modes: boot a VM and call runPython for throwaway scripts, or boot another, start a unit, and route a domain to serve traffic from the open internet.
Stream the Server’s Logs
vm.exec() buffers a command and only returns once it finishes, so it can’t show a long-running service’s output as it happens. To watch the logs live, open a PTY on the VM — a real terminal streamed over a WebSocket (server-side only, Node 22+) — and follow the unit’s journal. onData delivers the bytes as they arrive:
const session = await server.pty.open({
cols: 120,
rows: 30,
onData: (bytes) => process.stdout.write(bytes), // live log lines
});
// Follow the service; new lines stream in until you detach.
session.write("journalctl -u pyserver -f\n");
// session.detach() drops your handle — the service keeps running in the VM.