Freestyle Docs

Freestyle / Guides

Migrate from E2B to Freestyle

Move an E2B sandbox integration to Freestyle VMs: map the Sandbox, commands, filesystem, PTY, template, and preview surfaces, and replace the timeout heartbeat with lifecycle policy.

Replace the e2b SDK with the freestyle SDK one surface at a time: sandbox lifecycle, commands, files, terminals, templates, preview URLs, and client access. Most calls have a direct equivalent. The parts that do not are the five-minute timeout heartbeat, streaming command output, and the filesystem watcher, and this guide gives a working replacement for each.

This guide covers the E2B JavaScript SDK v2 (Sandbox, commands, files, pty, and the Template builder). It does not cover the Code Interpreter or Desktop SDKs; see Jupyter and GNOME for those workloads.

Why Migrate

Freestyle VMs are full Linux virtual machines designed for long-running, complex tasks. E2B sandboxes are a good fit for short, retryable runs; the differences below matter once a workspace lives for hours or weeks.

  • Faster at the tail. VMs provision in milliseconds, with p99s under 400 ms, and a base snapshot boots in about a second. Your users see the p99, not the median, and the p99 is what a full VM booted from a memory snapshot improves.
  • No running-time ceiling. An E2B sandbox must pause or die at its timeout, and a run tops out at 24 hours. A Freestyle VM runs until you pause, stop, or delete it. Lifecycle policy is a set of optional windows, not a deadline you have to keep extending.
  • Pause is the resting state. Pausing preserves memory and running processes, holds no compute reservation, and does not count against your concurrent-VM limit. Read files and browse directories while compute stays paused. Commands, file writes, and traffic to its domain wake the VM automatically.
  • Snapshots are memory and disk. A snapshot captures a running VM exactly, with sub-millisecond interruption, so a dev server started once is already serving in every VM booted from it. That is what E2B’s start command does at build time, without a separate build system.
  • Higher-quality virtualization. Freestyle runs full hardware virtualization on bare metal, not a trimmed microVM: systemd is pid 1, Docker runs from boot, and nested virtualization, FUSE, eBPF, WireGuard, and the whole Linux networking stack work. If it runs on an EC2 instance, it runs in a Freestyle VM, so workloads that hit the walls of a sandbox, such as Docker Compose stacks, Kubernetes with k3s, Android emulators, and kernel-level tooling, need no workarounds.
  • Bigger VMs. Public plans go to 32 vCPU, 64 GiB of memory, and 256 GB of disk per VM, with freestyle/ubuntu-3xl at 64 vCPU and 128 GiB for custom limits, and a VM can be resized live while it runs.
  • Higher limits. Pro allows 400 concurrent VMs, 4,000 saved VMs, and 12,000 snapshots, and paused VMs do not count toward concurrency. See Pricing and Limits for every plan, and talk to sales when you need more.
  • Complete networking APIs. Every VM has a stable public IPv6 address and a firewall that describes exactly what it may reach. Put VMs on private VPCs, connect your own machines and production networks over WireGuard tunnels, route a domain to any port, reach VMs by name, gate public traffic with forward auth, send egress out through your own proxy, and inject secrets into outbound requests with TLS rules, all from one API instead of an allow list and a public URL.
  • Built for multiple customers. Key VMs by user with slugs, hand each client a scoped identity token, isolate tenants with VPCs, and hold provider secrets at the edge with TLS rules.

Map Your Current Setup

Inventory every Sandbox, commands, files, pty, and Template call your code makes, then map each to its Freestyle equivalent. The rows marked adapter have no one-line replacement; each has a section below.

Expect the Freestyle side of the table to be shorter. E2B’s SDK grows a purpose-built method for each job: a declarative template builder with its own copy, run, and ready-command steps, a background command handle, a batch write, a directory watcher, a git module, signed upload URLs. Freestyle keeps the API to the primitives a machine actually has: create and snapshot a VM, run a command, move bytes in and out, open a terminal, and describe its network. There is no builder DSL; the recommended way to build an image is to boot a base snapshot, set it up with the same commands you would run on any Linux box, and snapshot it, and Rebuild Templates As Snapshots shows that in full. The trade is deliberate. A builder can only express what its authors anticipated, while a shell script, a systemd unit, or a Dockerfile you already maintain can express anything, and running it yourself leaves the ordering, caching, error handling, and secrets under your control rather than behind a provider abstraction. The same holds for the other rows: where E2B ships a convenience, Freestyle expects you to compose it from exec, fs, and pty in a few lines you own.

The git module is a small example: E2B’s SDK assumes every sandbox has Git. A Freestyle VM does not have to, so there is no vm.git; run git with exec and let the edge inject the credentials.

E2BFreestyle
Sandbox.create(template, opts)freestyle.vms.create({ snapshotId, firewall, ... })
Sandbox.connect(sandboxId)freestyle.vms.ref(vmId); commands and file writes wake a paused VM, file reads leave it paused
sandbox.sandboxIdvmId, or the slug you chose
metadata, Sandbox.list({ query })metadata, freestyle.vms.list({ metadata, state })
sandbox.getInfo(), isRunning()vm.data() and its state
timeoutMs, setTimeout()idleTimeoutSeconds, maxRunSeconds, or your own vm.pause() (adapter)
lifecycle: { onTimeout: "pause" }The default: nothing deletes a VM unless autoDeleteSeconds or ttlSeconds says so
sandbox.pause()vm.pause()
sandbox.kill()vm.delete()
sandbox.fork(), createSnapshot()vm.snapshot(), then vms.create({ snapshotId })
commands.run(cmd, { cwd, envs, user, timeoutMs })vm.exec({ command, env, linuxUser, timeoutMs })
commands.run(cmd, { background, onStdout })PTY with exec, or nohup plus a log file (adapter)
commands.connect(pid), kill(pid)vm.pty.attach({ session }), vm.pty.close()
pty.create(), sendInput(), resize(), kill()vm.pty.open(), session.write(), resize(), pty.close()
files.read(), write(), list(), getInfo()vm.fs.readFile(), writeFile(), readDir(), stat()
files.write([...entries])Promise.all of writeFile calls
files.read(path, { format: "stream" })vm.fs.readFileStream()
files.rename()vm.exec("mv ...")
files.watchDir()inotifywait in the guest over a PTY (adapter)
uploadUrl(), downloadUrl()Your server proxies vm.fs, or the client uses an identity token
sandbox.getHost(port)A TLS rule from a style.dev name or your domain to the port
allowPublicTraffic: false, traffic tokenForward auth on the ingress rule
allowInternetAccess, allowOutFirewall rules; egress is denied until allowed
envs at createenv on each exec, or bake into the snapshot
envd access token for clientsIdentity tokens
Template().fromImage().runCmd()..., Template.build()Boot a base snapshot, set it up, vm.snapshot()
setStartCmd(cmd, readyCmd)Start a systemd unit, wait for it, snapshot while running
template:tagSnapshot slug
Secrets injected into requestsTLS transforms

Create, Find, And Reconnect

Sandbox.create() becomes vms.create(). Two things are different at the call site. firewall is required, because a VM reaches nothing it has not been allowed to, where an E2B sandbox has Internet access by default. And a slug gives the VM a name you choose, so a workspace can be addressed by your own identifier instead of a stored provider ID.

Install the SDK and set FREESTYLE_API_KEY in your backend. Then:

import { Freestyle } from "freestyle";

const freestyle = new Freestyle();

// Sandbox.create("my-template", { metadata: { workspaceId }, timeoutMs })
const { vm, vmId } = await freestyle.vms.create({
  snapshotId: "my-template", // your snapshot's slug; omit for freestyle/ubuntu
  slug: `workspace-${workspaceId}`,
  metadata: { workspaceId, tenant: "acme" },
  firewall: { rules: [{ action: "allow", source: {}, destination: { public: true } }] },
});

console.log(vmId); // vm-…

Sandbox.connect(sandboxId) becomes a handle. vms.ref() makes no network call, and a paused VM wakes on its next exec or file write, or on the first keystroke into one of its PTY sessions, which is what autoResume: true did for you on E2B:

const vm = freestyle.vms.ref(`workspace-${workspaceId}`); // or the vm-… id
const result = await vm.exec("uptime");

File reads, directory listings, and metadata checks leave the VM paused, so users can inspect a workspace without starting compute.

To find a VM you did not store an ID for, filter by metadata. E2B’s Sandbox.list({ query: { metadata } }) takes an object; Freestyle takes key:value pairs in one string, and state is a single value rather than a list:

const { vms } = await freestyle.vms.list({ metadata: `workspaceId:${workspaceId}` });
const existing = vms.find((candidate) => candidate.state !== "stopped");

If your create path is idempotent on a slug, reassignSlug: true takes the slug from a VM that still holds it; without it, a taken slug is an error, which is usually the check you want.

getInfo() becomes vm.data(). Its state is one of starting, running, pausing, paused, or stopped, and createdAt, lastNetworkActivity, and metadata are on the same record. A missing VM is a FreestyleApiError with status 404; map it to whatever your E2B NotFoundError handling recreated.

sandbox.kill() becomes vm.delete(). Deleting also deletes the firewall and TLS rules that name the VM.

Replace The Timeout Heartbeat

The most common E2B integration pattern is a five-minute timeoutMs extended by setTimeout() from a client heartbeat, with onTimeout: "pause" so the sandbox survives. Do not port the heartbeat. A Freestyle VM has no deadline, so there is nothing to extend; instead you choose what should pause it.

Idle timeout. idleTimeoutSeconds pauses a VM after that many seconds without network activity. Traffic to its domain counts, and so does anything typed into a PTY. Output alone does not, and neither does CPU work with no traffic, so a VM that is only computing can idle out. Traffic to a paused VM’s domain wakes it.

const { vm } = await freestyle.vms.create({
  snapshotId: "my-template",
  firewall: { rules: [{ action: "allow", source: {}, destination: { public: true } }] },
  idleTimeoutSeconds: 600,
});

await vm.update({ idleTimeoutSeconds: -1 }); // remove it later

Run caps. maxRunSeconds pauses a VM once one run has lasted that long, whatever it is doing, and starting it again gives a fresh budget. ttlSeconds deletes it a fixed time after creation. autoDeleteSeconds deletes it after it has sat stopped or paused that long, and 0 makes it ephemeral, deleted the moment it stops. None of these are set unless you set them; some plans cap how long an unused VM is kept, and the cap shows in data().autoDeleteSeconds.

Your own policy. If your application already tracks sessions or leases, keep that logic and make it the one that decides. Pause when the last lease expires, and start when a client comes back:

// When the last lease on a workspace expires:
await vm.pause();

// When a client reopens it:
if ((await vm.data()).state === "paused") await vm.start();

start() resumes a paused VM with its processes and memory intact, and boots a stopped one fresh. Pause and resume are quick, so pausing on lease expiry and resuming on reconnect is cheap enough to do exactly when your accounting says to, rather than a few minutes later.

An E2B sandbox that was left with onTimeout: "kill" is gone at its deadline. On Freestyle nothing is deleted implicitly, so delete a workspace explicitly when its lifecycle ends, or set autoDeleteSeconds for a bounded cleanup.

Run Commands

commands.run() in the foreground becomes vm.exec(). The options rename: envs is env, user is linuxUser, and cwd is a cd inside the command string, which the guest runs through its shell. Two behaviors differ:

  • Exit status is a value, not an exception. E2B throws CommandExitError on a non-zero exit. Freestyle returns { stdout, stderr, statusCode } and leaves the check to you. statusCode is null when the timeout killed the command.
  • The timeout is capped at five minutes. timeoutMs accepts up to 300000. Anything that can run longer needs the detached pattern below.
// commands.run("npm test", { cwd: "/project/workspace", envs: { CI: "1" }, user: "node", timeoutMs: 120_000 })
const result = await vm.exec({
  command: "cd /project/workspace && npm test",
  env: { CI: "1" },
  linuxUser: "node",
  timeoutMs: 120_000,
});

if (result.statusCode !== 0) {
  throw new Error(`tests failed: ${result.stderr}`);
}

exec starts in the user’s home directory and runs as the VM’s default user, the account holding uid 1000, unless linuxUser names another. On the Ubuntu base snapshots that is ubuntu, with passwordless sudo; E2B’s default was user. If your image relies on a node account, create it in the snapshot and name it on every call, or use vm.linuxUser("node") once and call exec and pty on that handle.

stdin is a base64 string of up to 1 MiB and is delivered whole. There is no sendStdin or closeStdin; a process that needs interactive input gets a PTY.

Stream And Detach Long Commands

commands.run(cmd, { background: true, onStdout }) has two jobs: it streams output as it arrives, and it keeps a process alive past the request. Freestyle splits those across two tools.

A PTY streams. vm.pty.open({ exec }) runs a command in a terminal, delivers output through onData as it is produced, reports the exit code on onExit, and has no duration limit. Name the session with slug so any process, including one that starts after yours crashes, can attach to it by name, which is what commands.connect(pid) did:

const build = await vm.pty.open({
  exec: "cd /project/workspace && npm run build",
  slug: "build-42",
  onData: (bytes) => process.stdout.write(bytes),
  onExit: (code) => console.log("build exited", code),
});

// Later, from anywhere:
const attached = await vm.pty.attach({
  session: "build-42",
  onData: (bytes) => process.stdout.write(bytes),
  onExit: (code) => console.log("build exited", code),
});

attached.signal("sigint"); // commands.kill(pid), gently
await vm.pty.close("build-42"); // commands.kill(pid), for real

A PTY merges stdout and stderr and applies terminal line discipline, so treat its bytes as a terminal transcript rather than as two clean pipes. open() with a slug is get-or-create: if a session with that name exists you get it back and exec does not run, so check session.created when you meant to start something new.

A detached process keeps clean streams. When you need separate stdout and stderr, an exit status, and no terminal in the way, start the job with nohup, redirect its output to files, and record the exit status when it finishes. exec returns as soon as the shell does:

const job = "job-42";
await vm.exec({
  command:
    `mkdir -p /var/tmp/jobs/${job} && cd /project/workspace && ` +
    `nohup bash -c 'npm run build; echo $? > /var/tmp/jobs/${job}/exit' ` +
    `> /var/tmp/jobs/${job}/stdout 2> /var/tmp/jobs/${job}/stderr < /dev/null &`,
});

Poll for the exit file and read the logs with ranged reads, which is how you tail without re-downloading:

const stdoutPath = `/var/tmp/jobs/${job}/stdout`;
let offset = 0;

async function drain() {
  const { size } = await vm.fs.stat(stdoutPath);
  if (size <= offset) return;
  const chunk = await vm.fs.readTextFile(stdoutPath, { offset, length: size - offset });
  offset = size;
  process.stdout.write(chunk);
}

while (!(await vm.fs.exists(`/var/tmp/jobs/${job}/exit`))) {
  await drain();
  await new Promise((resolve) => setTimeout(resolve, 1000));
}
await drain();

const exitCode = Number((await vm.fs.readTextFile(`/var/tmp/jobs/${job}/exit`)).trim());

To cancel, vm.exec("pkill -f 'npm run build'") or record $! to a pid file in the same launcher. To find running jobs, vm.exec("ps -eo pid,args") takes the place of commands.list().

For a service that should always be running, such as a dev server, neither pattern is right. Run it under systemd and snapshot the VM, as Rebuild Templates As Snapshots shows.

Move Files

sandbox.files becomes vm.fs. Reads and writes carry raw bytes with no size limit short of 16 GiB per file, and a write lands atomically after its hash is verified in the guest.

// files.read(path) / files.read(path, { format: "bytes" })
const text = await vm.fs.readTextFile("/project/workspace/package.json");
const bytes = await vm.fs.readFile("/project/workspace/logo.png");

// files.write(path, data)
await vm.fs.writeTextFile("/project/workspace/.env", "PORT=3000\n");
await vm.fs.writeFile("/project/workspace/data.bin", new Uint8Array(buffer), { mode: 0o644 });

// files.list(path), files.getInfo(path), files.exists(path)
const entries = await vm.fs.readDir("/project/workspace"); // [{ name, kind }]
const info = await vm.fs.stat("/project/workspace/package.json"); // size, owner, modified, isSymlink
const there = await vm.fs.exists("/project/workspace/dist");

// files.makeDir(path), files.remove(path)
await vm.fs.mkdir("/project/workspace/tmp");
await vm.fs.remove("/project/workspace/tmp");

readDir is one level deep and returns name and kind only; call stat for sizes and modification times, or vm.exec("find ... -printf") for a whole tree with metadata in one round trip, which replaces list(path, { depth }). rename is vm.exec("mv old new"). Batch writes are parallel single writes:

await Promise.all(files.map(({ path, data }) => vm.fs.writeFile(path, data)));

Uploads. writeFile takes a string, Uint8Array, or Blob. A Blob is streamed a chunk at a time, and above 8 MiB the SDK switches to resumable chunks on its own, so a local file goes in as await openAsBlob(path) with no size planning. It does not take a ReadableStream of unknown length; spool such a stream to a temporary file first, or build a tar archive and send that. For a directory, the CLI’s freestyle vm scp ./dir <vm>:/path copies recursively in either direction.

Downloads. readFile buffers; readFileStream returns a web ReadableStream to pipe to disk or to an HTTP response. Reads are ranged through offset and length, so a Range request from a browser can be served with an exact slice.

Signed URLs. There is no uploadUrl() or downloadUrl(), and the file API is available only to your API key, not to identity tokens. Proxy file traffic through your server, which keeps the key there and lets you authorize each request, streaming with readFileStream and Blob uploads so the server never holds a whole file.

Ownership. File operations run as root, so a file you write is root-owned unless the path already existed. If the application user must write it, chown after the upload or mkdir the tree as that user once in the snapshot; mode sets the permission bits on each write.

Watch A Directory

There is no watchDir() in the API. Run a watcher inside the guest and stream its output over a PTY. Install inotify-tools while building your snapshot:

await builder.exec("sudo apt-get install -y inotify-tools");

Then, per workspace, open one long-lived session and parse its lines. This covers recursive: true and the create, delete, modify, and move events E2B reported; chmod is the ATTRIB event:

const watcher = await vm.pty.open({
  exec:
    "inotifywait -m -r -q --format '%e\t%w%f' " +
    "--exclude '(node_modules|\\.git|\\.next|dist)/' " +
    "-e create,delete,modify,moved_from,moved_to,attrib /project/workspace",
  slug: "watch-workspace",
  onData: (bytes) => {
    for (const line of new TextDecoder().decode(bytes).split("\n")) {
      const [events, path] = line.trimEnd().split("\t");
      if (path) console.log(events, path); // e.g. "MODIFY /project/workspace/src/app.ts"
    }
  },
});

Because the session is named, a client that reconnects attaches to the same watcher instead of starting a second one, and vm.pty.close("watch-workspace") stops it. A line can be split across two onData calls; buffer to the newline if you parse strictly. Watcher output is terminal output, so it does not count as activity for an idle timeout.

If your file explorer only needs to catch up on reconnect rather than react live, vm.exec("find /project/workspace -newer /tmp/last-sync -type f") is cheaper than a permanent watcher.

Terminals

sandbox.pty maps to vm.pty with the operations renamed and the process ID replaced by a session that outlives your connection:

// pty.create({ cols, rows, onData, user })
const session = await vm.linuxUser("node").pty.open({
  cols: 120,
  rows: 30,
  slug: "main",
  replaceOnExit: true, // respawn the shell in place when it exits
  onData: (bytes) => socket.send(bytes),
  onExit: (code) => console.log("shell exited", code),
});

session.write("ls -la\n"); // pty.sendInput(pid, data)
session.resize({ cols: 200, rows: 60 }); // pty.resize(pid, size)
session.signal("sigint"); // Ctrl-C
session.detach(); // close the socket; the shell keeps running

const again = await vm.linuxUser("node").pty.attach({ session: "main", onData }); // pty.connect(pid)
await vm.linuxUser("node").pty.close("main"); // pty.kill(pid)

Sessions persist across client disconnects and across pause and resume, so a tmux layer you added on E2B for continuity is no longer necessary, though it keeps working if you keep it. Pausing freezes PTY processes with the VM; start the VM before attaching again. The SDK does not reconnect a dropped socket on its own; call attach() with the saved session again.

The PTY WebSocket carries authorization headers, which browsers cannot set, so open it from a server or a desktop process and relay bytes to the browser. Run a Web Terminal in a Sandbox has the relay.

Rebuild Templates As Snapshots

An E2B template is a build recipe; a Freestyle snapshot is a captured machine. Translate the recipe into setup commands, run them once on a VM booted from a base snapshot, and snapshot the result. Every VM created from that snapshot starts with the same disk and the same memory, including processes that were running at capture.

Take a typical E2B template:

const template = Template()
  .fromImage("node:22")
  .setUser("root")
  .runCmd("apt-get update && apt-get install -y git tmux sqlite3")
  .runCmd("useradd -m -s /bin/bash node || true")
  .copy("scaffold/", "/project/workspace")
  .runCmd("chown -R node:node /project && cd /project/workspace && npm install")
  .setWorkdir("/project/workspace")
  .setUser("node")
  .setStartCmd("npm run dev -- -p 3000", waitForPort(3000));

await Template.build(template, "nextjs", { cpuCount: 2, memoryMB: 4096 });

Its Freestyle counterpart runs on the builder VM. The base snapshot decides the hardware, so pick the size in place of cpuCount and memoryMB; freestyle/ubuntu is 4 vCPU, 8 GiB, and already has Node.js LTS, Git, Docker, and Python. The start command becomes a systemd unit, and the ready command becomes a poll before the snapshot:

import { openAsBlob } from "node:fs";

const { vm: builder } = await freestyle.vms.create({
  snapshotId: "freestyle/ubuntu",
  slug: "nextjs-builder",
  firewall: { rules: [{ action: "allow", source: {}, destination: { public: true } }] },
});
const root = builder.linuxUser("root");

// runCmd(...) as root
await root.exec("apt-get update && apt-get install -y tmux sqlite3 inotify-tools");
await root.exec("id node >/dev/null 2>&1 || useradd -m -s /bin/bash node");

// copy("scaffold/", "/project/workspace") — pack it locally, unpack in the VM
await builder.fs.writeFile("/tmp/scaffold.tar", await openAsBlob("scaffold.tar"));
await root.exec("mkdir -p /project/workspace && tar -xf /tmp/scaffold.tar -C /project/workspace");
await root.exec("chown -R node:node /project");
await builder.linuxUser("node").exec({ command: "cd /project/workspace && npm install", timeoutMs: 300_000 });

// setStartCmd("npm run dev -- -p 3000", waitForPort(3000))
await builder.fs.writeTextFile(
  "/etc/systemd/system/app.service",
  `[Service]
User=node
WorkingDirectory=/project/workspace
ExecStart=/usr/bin/bash -lc 'exec npm run dev -- -H 0.0.0.0 -p 3000'
Restart=always

[Install]
WantedBy=multi-user.target
`,
);
await root.exec("systemctl daemon-reload && systemctl enable --now app");

let ready = false;
for (let attempt = 0; attempt < 60 && !ready; attempt++) {
  const probe = await builder.exec("curl -sf -o /dev/null http://127.0.0.1:3000 && echo ok");
  ready = probe.stdout?.trim() === "ok";
  if (!ready) await new Promise((resolve) => setTimeout(resolve, 1000));
}
if (!ready) throw new Error("dev server never became ready");

// Template.build(template, "nextjs")
const { snapshotId } = await builder.snapshot({ slug: "nextjs" });
await builder.delete();

snapshot() returns once the snapshot is materialized and ready to boot, and a VM created from it comes up with the dev server already listening. The Restart=always unit also covers E2B’s behavior of relaunching the start command; on Freestyle the process is simply still there, and systemd restarts it if it ever exits.

A few translations to keep in mind:

  • fromDockerfile. Either translate the RUN lines into exec calls as above, or keep the image and run it as a container inside the VM with Docker, which is already installed. The second is faster to migrate and the first gives the application the whole machine.
  • setEnvs and envs. Build-time variables are env on the exec that needs them. Variables every process should see go in /etc/environment or the systemd unit’s Environment= lines before the snapshot.
  • Names and tags. A snapshot has one slug. Publish a new version by snapshotting again and moving the slug with freestyle.vms.snapshots.update(newId, { slug: "nextjs" }), keeping the old snapshot addressable by ID for rollback. Snapshot slugs are per account.
  • Build logs. Each exec returns its output; print stdout and stderr as you go where onBuildLogs used to run.
  • Scripted builds. The CLI does the same thing in one command: freestyle snapshot create --base freestyle/ubuntu --script ./setup.sh --slug nextjs boots a VM, streams the script, captures the VM when it exits zero, and takes no snapshot if it fails. See Build A Snapshot From Scratch.

The Next.js and Vite guides carry complete builder scripts for those stacks.

Publish Previews

sandbox.getHost(port) returns a hostname that exists for every port. On Freestyle you declare which ports are public: one TLS rule routes one hostname to one VM port. Any unused subdomain of style.dev is free and needs no DNS, or use a domain you have verified.

// const url = `https://${sandbox.getHost(3000)}`
const domain = `ws-${workspaceId}.style.dev`;

await freestyle.tls.rules.create({
  action: "allow",
  domain,
  source: { public: true },
  destination: { vmId, port: 3000 },
});

const url = `https://${domain}`;

Declare the rule with the VM instead when the port is known at creation. An endpoint with no identity means the VM being created:

const { vm, vmId } = await freestyle.vms.create({
  snapshotId: "nextjs",
  firewall: { rules: [{ action: "allow", source: {}, destination: { public: true } }] },
  tls: {
    rules: [{ action: "allow", domain, source: { public: true }, destination: { port: 3000 } }],
  },
});

The edge terminates HTTPS and forwards the connection to the guest port, so the dev server must listen on 0.0.0.0. Rules are deleted with the VM. A preview on a paused VM wakes it, which is how autoResume treated preview traffic; give a workspace an idle timeout if you want it paused again after the visit.

Private previews. allowPublicTraffic: false plus the e2b-traffic-access-token header becomes forward auth. Freestyle calls your authorization endpoint for each request with the caller’s Authorization and Cookie headers and forwards only on a 2xx, so the check runs in your existing session system rather than as a shared token the browser must carry:

const auth = await freestyle.tls.forwardAuth.create({
  url: "https://api.acme.com/previews/authorize",
  headers: { authorization: `Bearer ${process.env.PREVIEW_AUTH_SECRET}` },
  timeoutMs: 1500,
  protectedCookies: ["__Host-acme-session"], // seen by your authorizer, never by the VM
});

await freestyle.tls.rules.create({
  action: "allow",
  domain,
  source: { public: true },
  destination: { vmId, port: 3000 },
  forwardAuth: { id: auth.id },
});

Your authorizer reads X-Freestyle-TLS-Rule-Id to learn which preview is being requested and returns 2xx to allow, 3xx to send a signed-out user to login, or 4xx to refuse. WebSocket upgrades pass through the same rule, so hot module reloading works behind it. A cookie-based sign-in handoff for many previews on one authorizer is in Protect A Sandbox With Forward Auth.

If you proxy previews through your own server today, injecting the E2B token upstream, keep the proxy and drop the token: with forward auth the edge asks your server instead of the other way around. A proxy that must stay in the path can instead use a protectedCookies or headers value it alone knows.

Hand Clients Scoped Access

E2B lets a backend hand its desktop or CLI clients the sandbox ID, the envd access token, and the traffic token so they can call the sandbox directly without the team API key. Freestyle’s equivalent is an identity: your server grants it one or more VMs, restricted to named Linux users, and mints a token the client uses as its own credential.

const { identity, identityId } = await freestyle.identities.create();
await identity.permissions.vm.grant({ vmId, allowedLinuxUsers: ["node"] });
const { id: tokenId, token } = await identity.tokens.create();

return { vmId, token }; // to the desktop app or CLI
import { Freestyle } from "freestyle";

const freestyle = new Freestyle({ identityAccessToken: token });
const workspace = freestyle.vms.ref(vmId).linuxUser("node");

await workspace.exec("git status");
const shell = await workspace.pty.open({ onData: render });

The grant carries allowedLinuxUsers, so a client holding it cannot run anything as root, and a call that names no user is refused. It also cannot touch VMs it was not granted or create account resources. Revoke a client with identity.tokens.revoke(tokenId) or identity.permissions.vm.revoke(vmId) without rotating anything else, and delete the identity when the user leaves. SSH accepts the same token, so ssh <vm>+node:<token>@beta-ssh.freestyle.sh gives a CLI a terminal with no code at all.

An identity token reaches only the exec and PTY routes. It cannot call vm.fs, which runs as root in the guest, so an E2B client that read and wrote files directly with its scoped session needs one of two changes. Route file traffic through your server, which holds the API key and can check the user’s permission on each request. Or move small transfers onto exec as the granted user, with stdin for uploads of up to 1 MiB and stdout for downloads, which keeps every byte under that user’s own permissions.

Control Egress

An E2B sandbox has Internet access unless allowInternetAccess: false, and allowOut and denyOut refine that. A Freestyle VM has none until a firewall rule allows it, and only allow rules exist.

E2BFreestyle
allowInternetAccess: true (default)firewall: { rules: [{ action: "allow", source: {}, destination: { public: true } }] }
allowInternetAccess: falsefirewall: { rules: [] }
allowOut: ["1.1.1.1", "8.8.8.0/24"] with denyOut: allTrafficOne rule per range: destination: { cidr: "8.8.8.0/24" }
allowOut: ["api.github.com"]A TLS rule per exact domain, destination: { public: true }
allowOut: ["*.example.com"]Not available yet; only exact names steer
denyOut: [...] alongside broad allowNot available; start from nothing and allow what is needed
Secrets injected into requestsA transform on the TLS rule

A TLS egress rule does two things at once: it admits the VM to that domain with no broader Internet grant, and it can inject a header the guest never held. That is how you replace E2B secrets with a key that lives at the edge:

await freestyle.tls.rules.create({
  action: "allow",
  domain: "api.openai.com",
  source: { vmId },
  destination: { public: true },
  transform: [{ headers: { authorization: `Bearer ${process.env.OPENAI_API_KEY}` } }],
});

The OpenAI, Anthropic, and private Git guides cover the client-side certificate trust each stack needs.

Account For What Has No Equivalent

  • Streaming exec. vm.exec() buffers. Use a PTY or the detached pattern above; do not expect onStdout on a request-response call.
  • Sandbox-wide envs. Nothing sets environment for every process at create time. Pass env per exec, or set it in the snapshot.
  • fork({ count }). Snapshot once, then create as many VMs as you need from the snapshot; the snapshot is reusable and the VMs are independent.
  • Filesystem-only pause and reboot-on-resume. A Freestyle pause always keeps memory. For a fresh boot, power the VM off from inside the guest and start() it; a stopped persistent VM keeps its disk. See Stopped.
  • Lifecycle webhooks and the events API. Not offered. Poll vm.data() or vms.list({ state }), or emit events from the code that pauses and deletes, since on Freestyle that is your code.
  • Metrics. data().cpuTimeSeconds and lastNetworkActivity are the per-VM figures; read memory and disk usage from inside the guest.
  • Volumes and cloud buckets. Mount object storage from inside the VM with the tool of your choice, or share data between VMs over a VPC.
  • git module. Run git with exec; credentials for private remotes are injected at the edge by the Git guide.
  • MCP gateway and Code Interpreter. Run the servers or a Jupyter kernel in the VM and route a domain to them.
  • Client file access. Identity tokens cover exec and PTY only; see Hand Clients Scoped Access for the two ways to move files on a client’s behalf.

Verify Before Cutting Over

Run these against a real VM before moving traffic, in this order, because the later checks depend on the earlier ones:

  1. Image. Boot from your snapshot, confirm the application user, its packages, and the service are as expected, and that exec as that user can write to the workspace. Run your existing template verification against it.
  2. Commands. Run a job longer than five minutes through a named PTY or the detached pattern. Verify separate streams where you need them, the exit code, cancellation, and that a second process can attach after the first disconnects.
  3. Files. Exercise a large upload, a ranged download, a batch write, and the watcher; confirm ownership of written files matches what the application expects.
  4. Lifecycle. Leave a VM idle past its idleTimeoutSeconds, confirm it pauses, then wake it three ways: an exec, a PTY keystroke, and a preview request. Confirm a CPU-only job on a VM with no idle timeout is still running an hour later. Pause and resume with a PTY open and reattach.
  5. Previews. Load the preview, confirm an unauthorized request is refused at the edge, and confirm a WebSocket upgrade and hot reload work through forward auth, before and after a pause.
  6. Client access. From a client holding only an identity token, run a command and open a terminal as the granted user; confirm root is refused, another VM is refused, a file call is refused, and a revoked token stops working.

When those pass, point new workspaces at Freestyle and keep E2B available until existing ones have been recreated or drained. Delete each retired sandbox on E2B with kill(), since paused sandboxes there are kept until you do.

esc