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 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 Python
Boot a builder VM and install Python with pip and venv. Ubuntu 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 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: "python-builder" });
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({
// Required: a VM reaches nothing it has not been allowed to.
firewall: { rules: [{ action: "allow", source: {}, destination: { public: true } }] }, slug: "python-sandbox", 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 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({
// Required: a VM reaches nothing it has not been allowed to.
firewall: { rules: [{ action: "allow", source: {}, destination: { public: true } }] },
slug: "python-server",
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 route a domain to the VM’s port so the open internet can reach it. Any unused subdomain of style.dev is yours to take, with no verification and no DNS records; to use a name you own, verify it and point its DNS at Freestyle first:
const domain = "my-app.style.dev"; // any unused style.dev subdomain
await freestyle.tls.rules.create({
action: "allow",
domain,
source: { public: true },
destination: { vmId, port: 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.