# Freestyle Docs
Documentation for Freestyle VMs and Freestyle Git.
Each section below is a single docs page. The `Source:` line is the canonical URL of the page.
---
# Freestyle
Source: https://www.freestyle.sh/docs
Infrastructure for code your product runs but didn't write.
Freestyle gives AI products the infrastructure to run and store code they did not write — fast Linux VMs for execution, and multi-tenant Git for storage.
- [Quickstart](https://www.freestyle.sh/docs/quickstart): Create a VM, run your first command, and set up your Freestyle API key.
- [Freestyle VMs](https://www.freestyle.sh/docs/vms): Fast Linux VMs for agent workspaces, browser automation, debugging, and interactive user sessions.
- [Freestyle Git](https://www.freestyle.sh/docs/git): Multi-tenant Git repositories with scoped access, search, triggers, and GitHub sync.
- [Freestyle CLI](https://www.freestyle.sh/docs/cli): Install the CLI and run terminal commands for local development and debugging.
## AI-readable docs
Give our Bash API to your agent so it can use our docs: https://www.freestyle.sh/docs/bash
For example, give an agent this prompt:
````text
Help me create a vm using freestyle, use their docs api with
```bash
curl https://www.freestyle.sh/docs/bash
```
````
---
# Quickstart
Source: https://www.freestyle.sh/docs/quickstart
Install Freestyle and create your first VM.
Freestyle gives AI products the infrastructure to run and store code they did not write: fast Linux VMs for execution, and multi-tenant Git for storage.
## Install
```bash pnpm
pnpm add freestyle
```
```bash bun
bun add freestyle
```
```bash npm
npm install freestyle
```
```bash yarn
yarn add freestyle
```
Set your API key before calling the API:
```bash
export FREESTYLE_API_KEY="your-api-key"
```
## Create A VM
```ts
import { freestyle } from "freestyle";
const { vm } = await freestyle.vms.create();
const result = await vm.exec("echo 'hello from freestyle'");
console.log(result);
```
## Next Steps
- [Freestyle CLI](https://www.freestyle.sh/docs/cli) for terminal commands.
- [Freestyle VMs](https://www.freestyle.sh/docs/vms) for VM lifecycle, files, SSH, and client sessions.
- [Freestyle Git](https://www.freestyle.sh/docs/git) for repository storage, API access, search, triggers, and GitHub sync.
---
# Freestyle VMs
Source: https://www.freestyle.sh/docs/vms
Create and control fast Linux virtual machines for agent workspaces and user sessions.
Freestyle VMs are full Linux virtual machines designed for long running, complex tasks. They start quickly, can stop when idle, and can be started again by API calls, SSH, or network activity.
## Create A VM
```ts
import { freestyle } from "freestyle";
const { vm } = await freestyle.vms.create();
const result = await vm.exec("echo 'hello from freestyle'");
console.log(result);
```
## Work With Files
```ts
await vm.fs.writeTextFile("/tmp/hello.txt", "Hello from Freestyle");
const content = await vm.fs.readTextFile("/tmp/hello.txt");
console.log(content);
```
## Size A VM
VMs start with 4 vCPU, 8 GB RAM, and a 20 GB root filesystem by default. To size a VM for your workload, call `vm.resize()` after creating it.
```ts
await vm.resize({
cpu: 8,
memory: 16,
storage: 80,
});
```
## Clone A Repository
VMs work well with [Freestyle Git](https://www.freestyle.sh/docs/git) repositories. Create or import code into Git, grant access through your application, then clone and run it inside the VM.
```ts
const { repoId } = await freestyle.git.repos.create({
name: "workspace",
});
const { identity } = await freestyle.identities.create();
await identity.permissions.git.grant({ repoId, permission: "read" });
const { token } = await identity.tokens.create();
const { vm } = await freestyle.vms.create();
await vm.exec("mkdir -p /workspace");
await vm.exec(
`git clone https://x-access-token:${token}@git.freestyle.sh/${repoId} /workspace`,
);
await vm.exec("ls -la");
```
## Common Operations
```ts
await vm.exec("pwd");
await vm.start();
await vm.stop();
await freestyle.vms.delete({ vmId });
```
## Route Web Traffic
Attach a custom domain to a VM service by mapping a public hostname to a VM port.
```ts
await freestyle.domains.mappings.create({
domain: "app.example.com",
vmId,
vmPort: 3000,
});
```
See [VM Domains](https://www.freestyle.sh/docs/vms/domains) for verification, DNS, and mapping setup.
---
# VM Lifecycle
Source: https://www.freestyle.sh/docs/vms/lifecycle
Understand the VM states Freestyle exposes: running, stopped, forked, and deleted.
Freestyle VMs are meant to be controlled as durable runtime objects. Your application can start work, stop it, start it again later, fork it for parallel exploration, and delete it when it is no longer needed.
## Running
The VM is executing and can accept commands, SSH sessions, and network traffic.
```ts
const { vm } = await freestyle.vms.create();
await vm.exec("echo running");
```
## Stopped
Stopping shuts the VM down. Disk state is preserved, but memory is not. Use `stop()` when you explicitly want a fresh boot.
```ts
await vm.stop();
await vm.start();
```
## Resize
Use `resize()` to size a VM for your workload after it exists. Pass any of `cpu`, `memory`, and `storage` to change the VM's CPU, memory, or root filesystem size.
```ts
await vm.resize({
cpu: 8,
memory: 16,
storage: 80,
});
```
If the VM is running, Freestyle stops it during the resize and starts it again afterward. Disk state is preserved, but in-memory process state is not. `cpu` and `memory` must be powers of two, `storage` can grow the root filesystem but cannot shrink it, and requested sizes are subject to your account limits.
## Forked
Forking creates a new VM from the current running state. Use it when an agent needs to explore multiple branches of work from the same environment.
```ts
const { forks } = await vm.fork({
count: 1,
persistence: { type: "ephemeral" },
});
const [{ vm: forked }] = forks;
await forked.exec("echo 'work in parallel'");
```
## Idle Timeout
Configure an idle timeout to let Freestyle reclaim VMs that have no network activity.
```ts
await vm.start({ idleTimeoutSeconds: 600 });
```
Set `idleTimeoutSeconds` to `null` only for workloads that should stay running until you stop or delete them.
## Delete
Delete VMs when the workspace is finished.
```ts
await freestyle.vms.delete({ vmId });
```
Deleting is permanent for the VM. Keep source code and important state in [Freestyle Git](https://www.freestyle.sh/docs/git) or another durable system before deleting the VM.
---
# SSH Access
Source: https://www.freestyle.sh/docs/vms/ssh
SSH into Freestyle VMs with scoped identity tokens.
Freestyle VMs accept SSH through `vm-ssh.freestyle.sh`. Access is controlled with Freestyle identities and tokens.
## SSH Format
```bash
ssh @vm-ssh.freestyle.sh
ssh :@vm-ssh.freestyle.sh
ssh +:@vm-ssh.freestyle.sh
```
If you omit the token from the SSH URL, SSH prompts for it as the password.
## Create A Token
```ts
import { freestyle } from "freestyle";
const { vm, vmId } = await freestyle.vms.create();
const { identity } = await freestyle.identities.create();
await identity.permissions.vms.grant({
vmId,
});
const { token } = await identity.tokens.create();
console.log(`ssh ${vmId}:${token}@vm-ssh.freestyle.sh`);
```
## SSH As A Linux User
Linux users are normal guest OS users. Create them inside the VM, then grant an identity access to the matching username.
```ts
const { vm, vmId } = await freestyle.vms.create();
await vm.exec({
command: `
set -e
if ! id -u developer >/dev/null 2>&1; then
useradd --create-home --shell /bin/bash developer
fi
if getent group sudo >/dev/null 2>&1; then
usermod --append --groups sudo developer
fi
mkdir -p /home/developer/workspace
chown -R developer:developer /home/developer
`,
});
const { identity } = await freestyle.identities.create();
await identity.permissions.vms.grant({
vmId,
allowedUsers: ["developer"],
});
const { token } = await identity.tokens.create();
console.log(`ssh ${vmId}+developer:${token}@vm-ssh.freestyle.sh`);
```
Freestyle base images configure `sshd` with an `AuthorizedKeysCommand` that reads the current Freestyle VM SSH public key from VM metadata. Any Linux account you create can use Freestyle SSH as long as that SSH configuration remains in place and the account has a valid login shell.
For custom images or heavily modified SSH configs, keep this behavior enabled:
```bash
grep -R "AuthorizedKeysCommand /usr/local/bin/fetch-ssh-keys" /etc/ssh/sshd_config /etc/ssh/sshd_config.d
test -x /usr/local/bin/fetch-ssh-keys
```
You do not need to copy Freestyle access tokens into the VM. Tokens stay outside the VM and are checked by the SSH proxy before it connects to the Linux account.
## Multiple Developers
Create separate identities when different users or agents should have different VM permissions.
```ts
const { identity: alice } = await freestyle.identities.create();
await alice.permissions.vms.grant({
vmId,
allowedUsers: ["alice"],
});
const { identity: bob } = await freestyle.identities.create();
await bob.permissions.vms.grant({
vmId,
allowedUsers: ["bob"],
});
```
Keep your Freestyle API key server-side. Send only scoped access tokens to clients or developers.
## Editor Connections
Editor connections use the same Freestyle SSH proxy and scoped access tokens as command-line SSH. Create an identity, grant it access to the VM, mint a token, then pass that token in the editor connection URL.
For VS Code and Cursor use one of these URL formats:
```text
vscode://vscode-remote/ssh-remote+,@.vm-ssh.freestyle.sh?windowId=_blank
cursor://vscode-remote/ssh-remote+,@.vm-ssh.freestyle.sh?windowId=_blank
```
For cmux, use its SSH URL format:
```text
cmux://ssh?host=.vm-ssh.freestyle.sh&user=,&name=
```
---
# Client Sessions
Source: https://www.freestyle.sh/docs/vms/client-sessions
Let browser clients operate existing Freestyle VMs with scoped access tokens.
Client sessions let a browser or end-user agent operate an existing VM without receiving your Freestyle API key. Your server creates an identity, grants VM permissions, creates a token, and sends that token to the client.
## Issue A Client Token
```ts title="server.ts"
import { freestyle } from "freestyle";
const { vm, vmId } = await freestyle.vms.create();
const { identity } = await freestyle.identities.create();
await identity.permissions.vms.grant({
vmId,
});
const { token } = await identity.tokens.create();
return { token, vmId };
```
## Use The Token In A Client
```ts title="client.ts"
import { Freestyle } from "freestyle";
const freestyle = new Freestyle({
accessToken: token,
});
const { vm } = await freestyle.vms.get({ vmId });
await vm.exec("pwd");
```
## Limits
Client session tokens are for operations on existing VMs. They should not be used as a replacement for your server-side API key or for letting users create arbitrary resources.
A client token can start a stopped VM when it runs an operation, connects over SSH, or sends traffic to the VM.
---
# VM Domains
Source: https://www.freestyle.sh/docs/vms/domains
Route HTTPS traffic from custom domains to services running inside Freestyle VMs.
VM domains route public HTTPS traffic from a hostname you control to a port inside a Freestyle VM. Create a VM first, then create a domain mapping for the VM port that should receive traffic.
## Domain Flow
1. [Verify ownership](https://www.freestyle.sh/docs/vms/domain-verification) of the domain with a TXT record.
2. [Point DNS](https://www.freestyle.sh/docs/vms/domain-dns) at Freestyle.
3. Map the domain to a VM port.
4. Run a service in the VM that listens on that port.
## Create A VM And Map A Domain
Create the VM, start a service inside it, then map the public hostname to the service port.
```ts
import { freestyle } from "freestyle";
const domain = "app.example.com";
const { vm, vmId } = await freestyle.vms.create();
// Write a small HTTP server into the VM.
await vm.fs.writeTextFile(
"/root/server.js",
`
const http = require("http");
http
.createServer((_req, res) => {
res.writeHead(200, { "Content-Type": "text/html" });
res.end("Hello from a Freestyle VM
");
})
.listen(3000, "0.0.0.0");
`,
);
// Install Node and run the server under systemd, so it stays up and restarts.
await vm.exec("apt-get update && apt-get install -y nodejs");
const node = (await vm.exec("command -v node")).stdout!.trim();
await vm.fs.writeTextFile(
"/etc/systemd/system/app.service",
`[Service]
ExecStart=${node} /root/server.js
Restart=always
[Install]
WantedBy=multi-user.target`,
);
await vm.exec("systemctl daemon-reload && systemctl enable --now app");
await freestyle.domains.mappings.create({
domain,
vmId,
vmPort: 3000,
});
console.log(vmId);
```
## Map An Existing VM
```ts
await freestyle.domains.mappings.create({
domain: "app.example.com",
vmId: "your-vm-id",
vmPort: 3000,
});
```
## Unmap A Domain
Remove a mapping to stop routing traffic from the domain to the VM. The domain stays verified, so you can map it again later without re-verifying.
```ts
await freestyle.domains.mappings.delete({
domain: "app.example.com",
});
```
## Requirements
- The domain must be verified before it can be mapped.
- DNS must point at Freestyle before traffic reaches the VM.
- HTTPS is provisioned automatically.
- The service inside the VM must listen on the mapped `vmPort`.
- For HTTP servers, listen on `0.0.0.0`, not only `localhost`.
## Preview Domains
Every account can use `*.style.dev` for free as **preview domains**. Pick any unused `.style.dev` subdomain and map it straight to a VM — a preview domain needs no DNS and no verification (none of the [Requirements](#requirements) above apply), and HTTPS is provisioned automatically. They're ideal for previews, demos, and testing:
```ts
await freestyle.domains.mappings.create({
domain: `preview-${crypto.randomUUID().slice(0, 8)}.style.dev`,
vmId,
vmPort: 8080,
});
```
Bring your own domain when you're ready for production; a `*.style.dev` preview domain is the zero-setup option in the meantime.
---
# Domain Verification
Source: https://www.freestyle.sh/docs/vms/domain-verification
Verify that you own a custom domain before routing it to a Freestyle VM.
Before a custom domain can route to a Freestyle VM, verify that you own it. Verification creates a TXT record challenge for the domain.
You can verify domains in the [Freestyle Dashboard](https://dash.freestyle.sh/dashboard/domain-ownership) or through the SDK.
## Create A Verification
```ts
import { freestyle } from "freestyle";
const { verificationId, record, instructions } =
await freestyle.domains.verifications.create({
domain: "example.com",
});
console.log(verificationId);
console.log(record);
console.log(instructions);
```
Freestyle returns a TXT record like this:
```ts
{
type: "TXT",
name: "_freestyle_custom_hostname.example.com",
value: "",
}
```
Add that TXT record in your DNS provider. Some providers automatically append the domain name, so if `_freestyle_custom_hostname.example.com` does not verify, try `_freestyle_custom_hostname` as the record name.
## Complete Verification
After adding the TXT record, complete verification by domain:
```ts
await freestyle.domains.verifications.complete({
domain: "example.com",
});
```
Or complete verification by ID:
```ts
await freestyle.domains.verifications.complete({
verificationId,
});
```
## List Verifications
```ts
const verifications = await freestyle.domains.verifications.list();
for (const verification of verifications) {
console.log(verification.domain, verification.verificationCode);
}
```
## Cancel A Verification
```ts
await freestyle.domains.verifications.cancel({
domain: "example.com",
verificationCode: "",
});
```
## List Verified Domains
```ts
const domains = await freestyle.domains.list({
limit: 50,
});
for (const domain of domains) {
console.log(domain.domain, domain.verifiedDns);
}
```
Verification proves ownership. It does not point the domain at Freestyle or route traffic to a VM. After verification, [configure DNS](https://www.freestyle.sh/docs/vms/domain-dns) and create a [domain mapping](https://www.freestyle.sh/docs/vms/domain-mappings).
---
# Domain DNS
Source: https://www.freestyle.sh/docs/vms/domain-dns
Configure DNS records for custom domains that route to Freestyle VMs.
After you [verify ownership](https://www.freestyle.sh/docs/vms/domain-verification), point the domain at Freestyle so public traffic can reach your VM mapping.
## Apex Domain
For an apex domain such as `example.com`, add an A record:
```text
Type: A
Name: @
Value: 35.235.84.134
```
## Subdomain
For a subdomain such as `app.example.com`, add an A record for the subdomain:
```text
Type: A
Name: app
Value: 35.235.84.134
```
## Wildcard Subdomain
For all subdomains under a domain, add a wildcard A record:
```text
Type: A
Name: *
Value: 35.235.84.134
```
## Check DNS
DNS changes can take time to propagate. Use `dig` to verify the record resolves to Freestyle:
```bash
dig app.example.com
```
Expected answer:
```text
;; ANSWER SECTION:
app.example.com. 60 IN A 35.235.84.134
```
If the answer shows a name such as `app.example.com.example.com`, your DNS provider probably appended the zone name. Change the record name to the relative name, such as `app`, instead of the full hostname.
## Route Traffic
DNS only sends traffic to Freestyle. To send traffic to a VM, create a domain mapping:
```ts
await freestyle.domains.mappings.create({
domain: "app.example.com",
vmId: "your-vm-id",
vmPort: 3000,
});
```
See [Domain Mappings](https://www.freestyle.sh/docs/vms/domain-mappings) for mapping and cleanup examples.
---
# Domain Mappings
Source: https://www.freestyle.sh/docs/vms/domain-mappings
Create, list, and delete mappings from custom domains to VM ports.
A domain mapping connects one public hostname to one port inside a VM. Use mappings when you want to attach or move domains after a VM already exists.
## Create A Mapping
```ts
import { freestyle } from "freestyle";
await freestyle.domains.mappings.create({
domain: "app.example.com",
vmId: "your-vm-id",
vmPort: 3000,
});
```
The VM service must listen on `vmPort`. For web servers, bind to `0.0.0.0` so traffic from outside the VM can reach the process.
## List Mappings
```ts
const { mappings } = await freestyle.domains.mappings.list({
domain: "app.example.com",
limit: 50,
});
for (const mapping of mappings) {
console.log(mapping.domain, mapping.vmId, mapping.vmPort);
}
```
## Delete A Mapping
```ts
await freestyle.domains.mappings.delete({
domain: "app.example.com",
});
```
Deleting a mapping stops new traffic from routing to that VM port. It does not delete the VM, DNS record, or domain verification.
## Multiple Ports
Use separate domains for separate VM services:
```ts
await freestyle.domains.mappings.create({
domain: "api.example.com",
vmId,
vmPort: 3000,
});
await freestyle.domains.mappings.create({
domain: "terminal.example.com",
vmId,
vmPort: 8453,
});
```
Each domain gets HTTPS automatically after DNS and mapping are in place.
---
# Domains CLI
Source: https://www.freestyle.sh/docs/vms/domains-cli
Use the Freestyle CLI to verify domains and map them to VM ports.
Use the Domains CLI for domain verification and VM domain mappings.
## Prerequisites
Set your API key:
```bash
export FREESTYLE_API_KEY="your-api-key"
```
Run commands through `npx`:
```bash
npx freestyle domains --help
```
## Verify A Domain
Create a verification request and print the TXT record details:
```bash
npx freestyle domains verify example.com
```
Complete verification by domain:
```bash
npx freestyle domains complete --domain example.com
```
Or complete verification by verification ID:
```bash
npx freestyle domains complete --verification-id
```
List pending verification requests:
```bash
npx freestyle domains verifications
```
## List Verified Domains
```bash
npx freestyle domains list
```
Use JSON output when scripting:
```bash
npx freestyle domains list --json
```
## Map A Domain To A VM
```bash
npx freestyle domains map app.example.com --vm-id --vm-port 3000
```
List mappings:
```bash
npx freestyle domains mappings
```
Filter mappings by domain:
```bash
npx freestyle domains mappings --domain app.example.com
```
Delete a mapping:
```bash
npx freestyle domains unmap app.example.com
```
For application workflows, use the SDK so your server can verify domains, configure DNS instructions, create VMs, and create domain mappings as separate steps.
---
# VPCs
Source: https://www.freestyle.sh/docs/vms/network/vpcs
Create private networks for Freestyle VMs and attach VM network interfaces to them.
VPCs let Freestyle VMs communicate over private IP addresses. Use them when a group of VMs should share an internal network for databases, services, worker pools, or agent environments that should not depend on public VM domains.
## Terms
- A VPC is a private network for your VMs. VMs in the same VPC can talk to each other without using public domains.
- A private IP is an address that only works inside the VPC, like `192.168.10.10`.
- A CIDR is the address range for the VPC. `192.168.10.0/24` means addresses from `192.168.10.1` through `192.168.10.254` are available for VMs.
- A NIC is a network interface on a VM. Attaching a NIC to a VPC is how the VM joins the private network.
## Create A VPC
```ts
import { freestyle } from "freestyle";
const { vpcId, vpc } = await freestyle.vpc.create({
name: "workspace-network",
cidr: "192.168.10.0/24",
});
console.log(vpcId);
```
The VPC CIDR is the private address range available to VMs attached to the network. Pick a range that does not overlap with your home, office, or VPN network.
## Attach VMs
Attach a VM by passing a routed network interface in `vms.create`:
```ts
const { vm: apiVm } = await freestyle.vms.create({
nics: [
{
default: true,
vpc: vpcId,
mode: "routed",
ipv4: "192.168.10.10",
},
],
});
const { vm: workerVm } = await freestyle.vms.create({
nics: [
{
default: true,
vpc: vpcId,
mode: "routed",
ipv4: "192.168.10.11",
},
],
});
```
Use `default: true` when the VPC interface should be the VM's default route. Use `mode: "routed"` for VPC networking.
## Test Private Connectivity
VMs in the same VPC can reach each other by private IP:
```ts
const result = await apiVm.exec("ping -c 3 192.168.10.11");
console.log(result.stdout);
```
Run services on `0.0.0.0` inside the destination VM when other VMs should connect to them. Run it under systemd so it stays up across the VM's life:
```ts
const python = (await workerVm.exec("command -v python3")).stdout!.trim();
await workerVm.fs.writeTextFile(
"/etc/systemd/system/http.service",
`[Service]
ExecStart=${python} -m http.server 8080 --bind 0.0.0.0
Restart=always
[Install]
WantedBy=multi-user.target`,
);
await workerVm.exec("systemctl daemon-reload && systemctl enable --now http");
```
Then connect from another VM over the VPC address:
```ts
const response = await apiVm.exec("curl http://192.168.10.11:8080");
console.log(response.stdout);
```
## Connect From Your Computer
Use [VPNs](https://www.freestyle.sh/docs/vms/network/vpns) to create an ephemeral WireGuard connection from your computer into a VPC.
## Current Limits
- VPC VM interfaces use `mode: "routed"`.
- A VM can attach one routed VPC interface.
- Choose a VPC CIDR that does not overlap with networks your VM or VPN client already uses.
---
# VPNs
Source: https://www.freestyle.sh/docs/vms/network/vpns
Use WireGuard to connect your computer to a Freestyle VPC.
VPNs let your computer join a Freestyle VPC with WireGuard. Create an ephemeral WireGuard session for a VPC, bring the generated config up with the WireGuard CLI, then connect to VMs by their private VPC IPs.
## Terms
- A VPN is a temporary network connection from your computer into a private network.
- WireGuard is the VPN tool Freestyle uses. It creates an encrypted tunnel between your computer and the VPC.
- A tunnel is the private path that carries VPC traffic from your computer to Freestyle.
- A peer is the other side of the WireGuard connection. Your computer is one peer; Freestyle is the other peer.
- A WireGuard config is the small file `wg-quick` uses to create the tunnel. It includes a private key, so treat it like a credential.
## Install WireGuard Tools
Install the WireGuard CLI on your computer:
```bash macOS
brew install wireguard-tools
```
```bash Ubuntu/Debian
sudo apt-get update
sudo apt-get install -y wireguard-tools
```
```bash Fedora
sudo dnf install -y wireguard-tools
```
## Create A VPN Session
Create a VPC, attach a VM, and bring the tunnel up with `wg-quick`:
```ts title="vpn.ts"
import { freestyle } from "freestyle";
import { spawn } from "node:child_process";
import { rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
const { vpcId, vpc } = await freestyle.vpc.create({
name: "workspace-network",
cidr: "192.168.10.0/24",
});
const { vmId } = await freestyle.vms.create({
nics: [
{
default: true,
vpc: vpcId,
mode: "routed",
ipv4: "192.168.10.10",
},
],
});
const connection = await vpc.wireguard.createEphemeral();
const configPath = join(tmpdir(), `wg-${connection.sessionId}.conf`);
await writeFile(configPath, connection.clientConfig, { mode: 0o600 });
await new Promise((resolve, reject) => {
const child = spawn("sudo", ["wg-quick", "up", configPath], {
stdio: "inherit",
});
child.once("error", reject);
child.once("exit", (code) =>
code === 0 ? resolve() : reject(new Error(`wg-quick up failed with ${code}`)),
);
});
// Keep the tunnel tied to this script: when the process exits from Ctrl-C or
// SIGTERM, bring WireGuard down and release the ephemeral Freestyle session.
const close = async (code = 0) => {
await new Promise((resolve) => {
spawn("sudo", ["-n", "wg-quick", "down", configPath], {
stdio: "ignore",
}).once("exit", () => resolve());
});
await connection.close().catch(() => undefined);
await rm(configPath, { force: true });
process.exit(code);
};
process.once("SIGINT", () => void close(130));
process.once("SIGTERM", () => void close(143));
console.log(`Created VM ${vmId} in VPC ${vpcId}`);
console.log("Try: ping -c 3 192.168.10.10");
console.log("Press Ctrl-C to disconnect.");
await new Promise(() => {});
```
Run the script:
```bash
export FREESTYLE_API_KEY="your-api-key"
bun run vpn.ts
```
The generated WireGuard config contains a client private key. This script writes it to your OS temp directory, removes it on shutdown, and closes the ephemeral Freestyle VPN session when you press Ctrl-C.
While the script is running, your computer has a route to the VPC CIDR. Traffic to `192.168.10.10` goes through WireGuard; normal internet traffic keeps using your regular network.
Verify that WireGuard has a peer and has sent traffic:
```bash
sudo wg show
```
Connect to a VM by its VPC IP:
```bash
ping 192.168.10.10
```
When you are done, press Ctrl-C in the script terminal. The script runs `wg-quick down`, removes the temp config file, and closes the ephemeral Freestyle VPN session.
## Use A System Config Path
For repeated local testing, you can save `connection.clientConfig` as `freestyle-vpc.conf`, install it under `/etc/wireguard`, and refer to it by name:
```bash
sudo mkdir -p /etc/wireguard
sudo install -m 600 freestyle-vpc.conf /etc/wireguard/freestyle-vpc.conf
sudo wg-quick up freestyle-vpc
sudo wg show freestyle-vpc
sudo wg-quick down freestyle-vpc
```
## What The Config Contains
`vpc.wireguard.createEphemeral()` returns a standard WireGuard config in `clientConfig`:
```ini
[Interface]
Address = 100.96.0.2/32
PrivateKey = generated-client-private-key
[Peer]
PublicKey = freestyle-server-public-key
AllowedIPs = 192.168.10.0/24
Endpoint = vpn-endpoint.example.com:51820
PersistentKeepalive = 25
```
`AllowedIPs` is the VPC CIDR, so only VPC traffic is routed through the tunnel.
## Close The Session
Ephemeral sessions are meant to be short lived. Close the session from your app when the user disconnects:
```ts
await connection.close();
```
If your process exits before it can close the session, Freestyle eventually expires the VPN resources.
---
# PTY Sessions
Source: https://www.freestyle.sh/docs/vms/pty
Open persistent interactive shells on a VM that survive WebSocket disconnects, VM suspends, and forks.
A PTY (pseudo-terminal) session is a long-lived interactive shell that lives inside the VM and that you can attach to, detach from, and reattach to over a WebSocket. Sessions survive client disconnects, VM suspends, and VM forks, letting agents drive interactive programs (REPLs, editors, package managers, debuggers) without re-spawning every command.
Use a PTY when `vm.exec()` is too coarse: the program needs an interactive terminal, you want to send keystrokes mid-run, you need scrollback across reconnects, or you're running a background server (a dev server, a file watcher) whose output you want to read later.
## Open A Session
```ts
const session = await vm.pty.open({
cols: 120,
rows: 30,
onData: (bytes) => process.stdout.write(bytes),
onExit: (code) => console.log("program exited with", code),
});
session.write("echo hello\n");
console.log(session.sessionId);
```
With no `exec`, the agent spawns the user's login shell (or `/bin/sh`). The session is backed by a real PTY, so the shell is interactive — prompt, line editing, job control — and an explicit `exec` runs interactively too (a REPL like `python3`, a TUI like `htop`). `cols` and `rows` default to 80 x 24. Keep `session.sessionId` if you might reattach later.
## Reattach Across Disconnects
Calling `.detach()` (or losing the network) leaves the program running in the VM. Reconnect with `attach({ sessionId })` and you get the current screen replayed plus live output from that point.
```ts
session.detach();
// later, or from a different process / machine
const rebound = await vm.pty.attach({
sessionId: session.sessionId,
onData: (bytes) => process.stdout.write(bytes),
});
```
`detach()` closes only the local handle; the server-side session keeps running. The original `session` object is unusable after detach — `write` / `resize` / `signal` throw, telling you to `vm.pty.attach({ sessionId })` to get a fresh handle.
## Send Input, Resize, Signal
```ts
session.write("ls -la\n");
session.write(new Uint8Array([0x03])); // Ctrl-C as raw byte
session.resize({ cols: 200, rows: 60 });
session.signal("SIGINT");
```
`write()` accepts `string` (UTF-8 encoded) or `Uint8Array`. `signal()` accepts `SIGINT` (delivered as Ctrl-C on stdin) and `SIGKILL` (terminates the session's root process).
## Behavior Across VM Lifecycle
PTY sessions are first-class with respect to the VM lifecycle:
- **VM suspend** (explicit or idle) — session transitions to `Suspended` and stays reattachable; the next attach also wakes the VM.
- **VM fork** — every child inherits the parent's non-`Exited` sessions under the same `sessionId`, seeded with the parent's at-fork screen; output diverges per child from there.
- **Program exits** — session goes to `Exited`, stays visible in `list()` for ~60s, and `onExit(code)` fires.
- **VM stop / kill** — sessions terminate, attached WebSockets are evicted.
The key invariant: **an attached PTY does NOT hold the VM open or block a lifecycle operation.** If the VM is suspended (idle or explicit) or torn down, attached clients are evicted with a clean WebSocket close; the session itself stays reattachable as long as the VM is reachable again.
If you need a VM to stay running while a PTY session is in use, configure `idleTimeoutSeconds: null` (or a long value) on `vm.start()`. The platform's idle monitor measures only VM network-interface traffic, so PTY input — and even `vm.exec()` — does not reset the timer. To keep the VM alive without disabling idle-suspend entirely, the program in the VM has to generate outbound network traffic (an HTTP request, etc.) on its own.
## Auto-Reconnect
The SDK reconnects transparently on transient WebSocket drops, including the eviction triggered when a VM suspends. The reattach also wakes the VM if needed, so callers rarely have to think about transient hiccups.
Defaults: 5 attempts, exponential backoff from 500ms doubling to 8s. Writes called during a reconnect attempt are queued and flushed once the new WebSocket opens. `onClose` fires only when the session is *terminally* closed — program exited, caller called `detach()`, or reconnect attempts were exhausted.
```ts
const session = await vm.pty.open({
exec: "/bin/sh",
reconnect: { maxAttempts: 10, baseDelayMs: 200 },
onReconnecting: (attempt, max) => console.log(`reconnect ${attempt}/${max}`),
onReconnect: () => console.log("reconnected"),
});
// or disable entirely:
const noRetry = await vm.pty.open({ exec: "/bin/sh", reconnect: false });
```
With `reconnect: false` the original `session` is dead the moment its WebSocket closes — capture `sessionId` ahead of time and call `vm.pty.attach({ sessionId })` to get a new session object.
## Inheritance On Fork
```ts
const session = await vm.pty.open({ exec: "/bin/sh" });
const parentSessionId = session.sessionId;
session.write("echo parent\n");
const { forks } = await vm.fork({ count: 2 });
for (const { vm: child } of forks) {
// Every fork has the parent's session under the same session_id,
// seeded with the parent's at-fork screen.
const childSession = await child.pty.attach({ sessionId: parentSessionId });
childSession.write("echo from-fork\n"); // diverges from parent here
}
```
Writes on a fork's session go to that fork's process only; output never crosses between siblings or parent. Sessions that had already `Exited` in the parent are not inherited.
## List And Close
```ts
const { sessions } = await vm.pty.list();
for (const s of sessions) {
console.log(s.sessionId, s.exec, s.running, s.suspended, s.attachedCount);
}
await vm.pty.close({ sessionId });
```
`Running`, `Suspended`, and recently-`Exited` sessions stay in `list()` for ~60 seconds after exit so reattach can read the final exit code. `close()` kills the underlying program (SIGKILL semantics) and removes the session.
## Multi-User Isolation
If the VM is configured with linux users, PTY sessions are scoped per user. A session opened with `vm.user("alice").pty.open()` runs as `alice`. A `vm.user("bob")` caller does not see Alice's sessions in `list()` and gets `403 SessionUserMismatch` if it tries to attach or close one.
A plain `vm.pty.list()` (with no `vm.user(...)`) is the VM owner and sees every session on the VM, including user-scoped ones.
```ts
const aliceSession = await vm.user("alice").pty.open({ exec: "/bin/sh" });
// from a Bob-authenticated client, list() does NOT show Alice's session.
const { sessions: bobView } = await vm.user("bob").pty.list();
// from the VM owner, list() shows everything.
const { sessions: ownerView } = await vm.pty.list();
```
## Caps And Limits
- **Sessions per VM:** 128. Going over the cap returns `SessionCapReached` (HTTP 429). `Exited` sessions count toward the cap until the 60-second retention window expires and the reaper clears them.
- **Scrollback:** ~10,000 lines per session, rendered ANSI-aware so the snapshot reproduces colors and cursor position.
- **Signals:** `SIGINT` (Ctrl-C on stdin) and `SIGKILL` (terminates the session). No other signals are exposed.
- **Errors:** pre-upgrade errors (auth, VM not running, session cap) come back as normal HTTP JSON. Errors after the WebSocket is open come back as RFC 6455 close codes (1008 / 1011).
---
# Freestyle Git
Source: https://www.freestyle.sh/docs/git
Create and manage multi-tenant Git repositories for user- and agent-generated code.
Freestyle Git is hosted Git designed for products that manage code on behalf of users or AI agents. You can create repositories by API, grant scoped Git access, inspect repository contents, search code, attach automation triggers, and sync with GitHub.
## Create A Repository
```ts
import { freestyle } from "freestyle";
const { repoId, repo } = await freestyle.git.repos.create({
name: "my-repo",
});
console.log(repoId);
```
## Clone A Repository
Create an identity, grant repository access, and issue a token.
```ts
const { identity } = await freestyle.identities.create();
await identity.permissions.git.grant({
permission: "write",
repoId,
});
const { token } = await identity.tokens.create();
console.log(
`git clone https://x-access-token:${token}@git.freestyle.sh/${repoId}`,
);
```
Use the token with native Git:
```bash
git clone https://x-access-token:@git.freestyle.sh/
```
## Read Files By API
```ts
const ref = freestyle.git.repos.ref({ repoId });
const file = await ref.contents.get({ path: "README.md" });
console.log(file);
```
---
# Repositories
Source: https://www.freestyle.sh/docs/git/repositories
Create, list, delete, and authenticate Freestyle Git repositories.
## Create An Empty Repository
```ts
import { freestyle } from "freestyle";
const { repoId, repo } = await freestyle.git.repos.create({
name: "my-repo",
});
```
## Create From A Source Repository
Fork an existing repository and preserve its history:
```ts
await freestyle.git.repos.create({
source: {
url: "https://github.com/user/repo.git",
rev: "main",
},
});
```
## List Repositories
```ts
const result = await freestyle.git.repos.list({
limit: 20,
cursor: "0",
});
for (const repo of result.repositories) {
console.log(repo);
}
```
## Delete A Repository
```ts
await freestyle.git.repos.delete({
repoId: "your-repo-id",
});
```
Deleting a repository permanently removes its Git data.
## Authenticate Native Git
Repositories are private by default. Grant access through Freestyle identities.
```ts
const { identity } = await freestyle.identities.create();
await identity.permissions.git.grant({
permission: "write",
repoId,
});
const { token } = await identity.tokens.create();
console.log(
`git clone https://x-access-token:${token}@git.freestyle.sh/${repoId}`,
);
```
For local testing with your API key:
```bash
git -c http.extraHeader="Authorization: Bearer $FREESTYLE_API_KEY" \
clone https://git.freestyle.sh/
```
## Public Repositories
```ts
await freestyle.git.repos.create({
public: true,
});
```
Public repositories can be cloned without authentication, but pushes still require write access.
---
# Git API
Source: https://www.freestyle.sh/docs/git/api
Read and modify repository data through Freestyle Git APIs.
Use the Git API when your application needs repository data without cloning locally.
## Get File Or Directory Contents
```ts
import { freestyle } from "freestyle";
const repo = freestyle.git.repos.ref({
repoId: "your-repo-id",
});
const file = await repo.contents.get({
path: "src/index.ts",
rev: "main",
});
console.log(file);
```
Directory responses include nested entries:
```ts
const dir = await repo.contents.get({
path: "src",
rev: "main",
});
console.log(dir);
```
## Download Archives
```ts
import { writeFile } from "node:fs/promises";
const tarball = await repo.contents.downloadTarball({ rev: "main" });
await writeFile("repo.tar", Buffer.from(tarball));
const zip = await repo.contents.downloadZip({ rev: "main" });
await writeFile("repo.zip", Buffer.from(zip));
```
## Branches
```ts
const branch = await repo.branches.get({ branchName: "main" });
console.log(branch.sha);
const branches = await repo.branches.list();
console.log(branches);
```
Create a branch:
```ts
await repo.branches.create({
name: "feature/new-flow",
});
await repo.branches.create({
name: "feature/from-commit",
sha: "a1b2c3d4e5f6",
});
```
Set the default branch:
```ts
await repo.branches.setDefaultBranch({
defaultBranch: "main",
});
```
## Commits
List commits:
```ts
const commits = await repo.commits.list();
console.log(commits);
```
Create a commit by writing file changes:
```ts
await repo.commits.create({
branch: "main",
message: "Update README",
files: [{ path: "README.md", content: "# Updated README\n" }],
});
```
## Tags
```ts
const tags = await repo.tags.list();
const tag = await repo.tags.get({
tagName: "v1.0.0",
});
console.log(tag);
```
---
# Search
Source: https://www.freestyle.sh/docs/git/search
Search repository contents, filenames, commit messages, and diffs.
Freestyle Git search lets your product inspect code without cloning a repository.
## Content Search
Search file contents at a branch, tag, or commit.
```ts TypeScript
import { freestyle } from "freestyle";
const repo = freestyle.git.repos.ref({ repoId: "your-repo-id" });
const result = await repo.search({
query: "TODO",
rev: "main",
pathPattern: "src/**/*.ts",
excludePattern: "**/*.test.ts",
});
for (const file of result.files) {
for (const match of file.matches) {
console.log(`${file.path}:${match.lineNumber} ${match.line}`);
}
}
```
```bash cURL
curl "https://api.freestyle.sh/git/v1/repo/${REPO_ID}/search?query=TODO&pathPattern=src/**/*.ts" \
-H "Authorization: Bearer ${FREESTYLE_API_KEY}"
```
Use regex patterns when you need structural matches:
```ts
const result = await repo.search({
query: "^(export\\s+)?(async\\s+)?function\\s+\\w+",
isRegex: true,
pathPattern: "**/*.ts",
});
```
## Filename Search
```ts
const result = await repo.searchFiles({
query: "index",
maxResults: 20,
});
for (const file of result.files) {
console.log(file.path);
}
```
## Commit Message Search
```ts
const result = await repo.searchCommits({
query: "fix",
maxResults: 10,
});
for (const commit of result.commits) {
console.log(`${commit.sha.slice(0, 7)} ${commit.message}`);
}
```
## Diff Search
Use diff search when you need to find when text was added or changed.
```ts
const result = await repo.searchDiffs({
query: "deprecatedMethod",
maxResults: 20,
});
for (const commit of result.commits) {
for (const file of commit.files) {
console.log(commit.sha, file.path);
}
}
```
## Pagination
Use `maxResults`, `offset`, and `hasMore` to page through large result sets.
```ts
let offset = 0;
const files = [];
while (true) {
const result = await repo.search({
query: "TODO",
maxResults: 50,
offset,
});
files.push(...result.files);
if (!result.hasMore) break;
offset += 50;
}
```
---
# Triggers
Source: https://www.freestyle.sh/docs/git/triggers
Send webhooks when Freestyle Git repositories receive pushes.
Triggers automate work when repository events occur, such as pushes to a branch.
## Create A Webhook Trigger
```ts
import { freestyle } from "freestyle";
const { repo } = await freestyle.git.repos.create({
name: "automation",
});
const { triggerId } = await repo.triggers.create({
trigger: {
event: "push",
branches: ["main"],
globs: ["*.js"],
},
action: {
action: "webhook",
endpoint: "https://your-webhook-url.com",
},
});
console.log(triggerId);
```
`branches` and `globs` are optional filters.
## Payload
Webhook triggers send a payload like this:
```ts
interface GitTriggerPayload {
event: "branchUpdate";
repoId: string;
branch: string;
commit: string;
}
```
## Local Development
Use a tunnel such as Tailscale Funnel to expose a local development server.
```bash
tailscale funnel 3000
```
Use the generated public URL as the trigger endpoint while developing.
## Webhook Signing
Webhooks include a JWT signature in the `x-freestyle-signature` header. The JWT contains a `body_sha256` claim for the raw request body.
```ts
import crypto from "node:crypto";
import { createRemoteJWKSet, jwtVerify } from "jose";
const JWKS = createRemoteJWKSet(
new URL("https://git.freestyle.sh/.well-known/jwks.json"),
);
async function verifyWebhook(request: Request) {
const signature = request.headers.get("x-freestyle-signature");
if (!signature) throw new Error("Missing signature");
const bodyText = await request.text();
const { payload } = await jwtVerify(signature, JWKS, {
algorithms: ["EdDSA"],
});
const bodyHash = crypto.createHash("sha256").update(bodyText).digest("hex");
if (payload.body_sha256 !== bodyHash) {
throw new Error("Payload verification failed");
}
return JSON.parse(bodyText);
}
```
## List Triggers
```ts
const { triggers } = await repo.triggers.list();
for (const trigger of triggers) {
console.log(trigger.id, trigger.trigger.event);
}
```
## Delete A Trigger
```ts
await repo.triggers.delete({
triggerId: "trigger-id",
});
```
---
# GitHub Sync
Source: https://www.freestyle.sh/docs/git/github-sync
Synchronize Freestyle Git repositories with GitHub repositories.
Freestyle can synchronize repositories between Freestyle Git and GitHub. Use this when your product stores source code in Freestyle but users still need visibility or workflows in GitHub.
## How Sync Works
Freestyle uses GitHub Apps for repository-specific access. When code is pushed to either side, Freestyle syncs changes to the other side while avoiding destructive force pushes.
Freestyle syncs:
- Branch creation and updates
- Commit history
- Tags
- Branch deletions
If branches diverge, Freestyle avoids overwriting data. Resolve conflicts manually in either repository, then sync resumes.
## Create Or Connect A GitHub App
1. Open the [Freestyle Dashboard](https://dash.freestyle.sh).
2. Go to **Git > Sync**.
3. Create a GitHub App or connect an existing app.
4. Install the app on the GitHub repositories you want to sync.
For app builders, send users to your GitHub App installation URL so they can authorize repository access.
## Link Repositories In The Dashboard
1. Go to **Git > Repositories**.
2. Select the Freestyle repository.
3. Click **Configure GitHub Sync**.
4. Choose the GitHub repository.
5. Save the configuration.
## Link Repositories By SDK
```ts
import { freestyle } from "freestyle";
const { repo } = await freestyle.git.repos.create({
name: "synced-repo",
});
await repo.githubSync.enable({
githubRepoName: "owner/repo",
});
const syncConfig = await repo.githubSync.get();
console.log(syncConfig?.githubRepoName);
await repo.githubSync.disable();
```
## Safety Rules
- Freestyle does not force-push to resolve conflicts.
- GitHub App permissions are scoped to installed repositories.
- Repository sync should be configured only after the GitHub App has access to the target repository.
---
# Freestyle CLI
Source: https://www.freestyle.sh/docs/cli
Install and use the Freestyle CLI for local VM and Git workflows.
The Freestyle CLI is for one-off operations while developing, debugging, or automating local workflows. Use the SDK for application code, and use the CLI when you want to inspect or operate on VMs and Git repositories from a terminal.
## Install
Run the CLI without installing it globally:
```bash
npx freestyle --help
```
Or install the package globally:
```bash pnpm
pnpm add -g freestyle
```
```bash bun
bun add -g freestyle
```
```bash npm
npm install -g freestyle
```
```bash yarn
yarn global add freestyle
```
Then verify the command is available:
```bash
freestyle --help
```
## Authenticate
Authenticate the CLI in one of two ways.
Log in through your browser with OAuth. This stores your credentials and sets a default team, so it's the easiest option for local development:
```bash
freestyle login
```
Or set an API key directly, which is the better fit for CI and scripts:
```bash
export FREESTYLE_API_KEY="your-api-key"
```
If `FREESTYLE_API_KEY` is set, the CLI uses it. Otherwise it falls back to your `freestyle login` session.
## VM Commands
Create a VM and run a command:
```bash
npx freestyle vm create --exec 'uname -a'
```
Start a temporary SSH session:
```bash
npx freestyle vm create --ssh --delete
```
See [VM CLI](https://www.freestyle.sh/docs/vms/cli) for VM list, exec, SSH, and delete commands.
## Git Commands
Create a repository:
```bash
npx freestyle git create
```
List repositories:
```bash
npx freestyle git list --limit 20
```
See [Git CLI](https://www.freestyle.sh/docs/git/cli) for repository lifecycle commands and native Git usage.
---
# VM CLI
Source: https://www.freestyle.sh/docs/vms/cli
Use the Freestyle CLI for quick VM operations and debugging.
The Freestyle CLI is useful for one-off operations while developing or debugging.
## Prerequisites
Set your API key:
```bash
export FREESTYLE_API_KEY="your-api-key"
```
Run commands through `npx`:
```bash
npx freestyle --help
```
## List VMs
```bash
npx freestyle vm list
```
Use JSON output when scripting:
```bash
npx freestyle vm list --json
```
## Create And Exec
```bash
npx freestyle vm create --exec 'uname -a'
```
Delete the VM after the command completes:
```bash
npx freestyle vm create --exec 'npm test' --delete
```
## SSH
```bash
npx freestyle vm create --ssh
```
For a temporary debug session:
```bash
npx freestyle vm create --ssh --delete
```
To create a VM from a snapshot and connect over SSH:
```bash
npx freestyle vm create --snapshot --ssh
```
## Operate On An Existing VM
```bash
npx freestyle vm exec 'pwd'
npx freestyle vm delete
```
Use the SDK for long-lived application workflows and the CLI for operational tasks.
---
# Git CLI
Source: https://www.freestyle.sh/docs/git/cli
Use Freestyle CLI commands for Git repository lifecycle operations.
Use the CLI for quick repository operations. Use native Git for clone, push, and fetch.
## Prerequisites
```bash
export FREESTYLE_API_KEY="your-api-key"
```
Run commands with `npx freestyle`.
## Create Repositories
```bash
npx freestyle git create
```
Create with options:
```bash
npx freestyle git create \
--name my-repo \
--public \
--default-branch main
```
Create from an existing source:
```bash
npx freestyle git create \
--source-url https://github.com/owner/repo.git \
--source-rev main
```
## List Repositories
```bash
npx freestyle git list --limit 20
npx freestyle git list --json
```
## Delete A Repository
```bash
npx freestyle git delete
```
## Use Native Git
```bash
git clone https://x-access-token:@git.freestyle.sh/
git push origin main
```
For hooks, GitHub Sync, repository contents, and search, use the SDK/API docs.
---
# Guides
Source: https://www.freestyle.sh/docs/guides
Task-oriented How-To guides for getting things done with Freestyle.
Guides are short, task-oriented recipes — each one walks through a single "How do I…"
end to end. For complete API and concept reference, see the [Docs](https://www.freestyle.sh/docs).
- [How to Run Node.js in a Sandbox](https://www.freestyle.sh/docs/guides/run-nodejs-in-a-sandbox): Build a reusable VM snapshot with the Node.js runtime, then run as many JavaScript snippets as you like on one long-lived sandbox VM.
- [How to Run Claude Code in a Sandbox](https://www.freestyle.sh/docs/guides/run-claude-code-in-a-sandbox): Bake the Claude Code CLI into a VM snapshot, run it interactively over the PTY API, run one-off prompts, and drive it from the Claude Agent SDK with spawnClaudeCodeProcess.
- [How to Run GitHub Copilot in a Sandbox](https://www.freestyle.sh/docs/guides/run-github-copilot-in-a-sandbox): Run the GitHub Copilot CLI headless inside a sandbox and drive it from the Copilot SDK over a private VPC — or from your own machine over a WireGuard VPN. The runtime link is raw-TCP JSON-RPC, so it needs VPC reachability and a fine-grained Copilot token, not an HTTPS domain or a classic PAT.
- [How to Run Docker in a Sandbox](https://www.freestyle.sh/docs/guides/run-docker-in-a-sandbox): Bake the Docker Engine and Compose plugin into a VM snapshot, then boot sandboxes that run containers and multi-service docker compose stacks — with native overlayfs storage, cgroup v2, and published ports — and stream container logs live.
- [How to Run OpenClaw in a Sandbox](https://www.freestyle.sh/docs/guides/run-openclaw-in-a-sandbox): Install OpenClaw in a VM snapshot, run its agent gateway under systemd, and open the web UI in your browser over a Freestyle domain with token auth — HTTP and WebSocket proxied directly. Bring an OpenAI key for the model.
- [How to Give Your Mastra Agent a Sandbox](https://www.freestyle.sh/docs/guides/give-your-mastra-agent-a-sandbox): Give a Mastra agent a fresh Linux VM to work in by implementing Mastra's Workspace sandbox interface on Freestyle — then watch a computer-use agent inspect the machine, write code, and run it. Bring a model API key.
- [How to Give Your OpenAI Agent a Sandbox](https://www.freestyle.sh/docs/guides/give-your-openai-agent-a-sandbox): Give an OpenAI Agents SDK agent a fresh Linux VM by implementing the SDK's SandboxClient on Freestyle — run commands, and turn any port the agent serves into a real HTTPS domain. Bring an OpenAI key.
- [How to Run a Web Terminal in a Sandbox](https://www.freestyle.sh/docs/guides/run-a-web-terminal-in-a-sandbox): Stream a VM's PTY to the browser — bridge it to an xterm.js client through a small WebSocket proxy (read-only or interactive), or bake ttyd into a snapshot.
- [How to Run Python in a Sandbox](https://www.freestyle.sh/docs/guides/run-python-in-a-sandbox): Build a reusable VM snapshot with Python, then run many scripts on one long-lived, isolated sandbox VM.
- [How to Run Postgres in a Sandbox](https://www.freestyle.sh/docs/guides/run-postgres-in-a-sandbox): Build a snapshot with PostgreSQL already running, then boot a VM and run SQL queries against the live database.
- [How to Run Bun in a Sandbox](https://www.freestyle.sh/docs/guides/run-bun-in-a-sandbox): Bake the Bun runtime into a VM snapshot, then reuse one sandbox VM to run as much TypeScript and JavaScript as you like.
- [How to Run Python with uv in a Sandbox](https://www.freestyle.sh/docs/guides/run-python-with-uv-in-a-sandbox): Bake uv into a VM snapshot, then reuse one sandbox to run many Python scripts with a small helper.
- [How to Run MongoDB in a Sandbox](https://www.freestyle.sh/docs/guides/run-mongodb-in-a-sandbox): Bake a running MongoDB server into a VM snapshot, then run real document insert and find queries inside an isolated sandbox with no startup wait.
- [How to Run a Vite Dev Server in a Sandbox](https://www.freestyle.sh/docs/guides/run-vite-in-a-sandbox): Bake a Vite dev server into a VM snapshot, run it under systemd, and open it on a public domain.
- [How to Run PHP in a Sandbox](https://www.freestyle.sh/docs/guides/run-php-in-a-sandbox): Bake the PHP runtime into a VM snapshot, boot one VM, and run many PHP scripts on it with an isolated, reusable runPhp() helper — then serve PHP over a public domain from the same snapshot.
- [How to Run a Jupyter Notebook Server in a Sandbox](https://www.freestyle.sh/docs/guides/run-jupyter-in-a-sandbox): Bake JupyterLab into a VM snapshot, boot it as a systemd service, and open the notebook on a public domain.
- [How to Run VS Code in the Browser in a Sandbox](https://www.freestyle.sh/docs/guides/run-vs-code-in-a-sandbox): Bake code-server — VS Code in the browser — into a VM snapshot, run it under systemd, and open the editor on a public domain.
- [How to Run Go in a Sandbox](https://www.freestyle.sh/docs/guides/run-go-in-a-sandbox): Bake the Go toolchain into a reusable VM snapshot, then run as many Go programs as you like on one long-lived sandbox VM.
- [How to Run a Next.js Dev Server in a Sandbox](https://www.freestyle.sh/docs/guides/run-nextjs-in-a-sandbox): Bake a Next.js dev server into a VM snapshot, run it under systemd, and open it on a public domain — binding 0.0.0.0 and allowing the dev origin so the App Router app loads with HMR over HTTPS.
- [How to Run Java in a Sandbox](https://www.freestyle.sh/docs/guides/run-java-in-a-sandbox): Bake the JDK into a VM snapshot, boot one VM, and run many Java programs on it with an isolated, reusable runJava() helper — then serve a Java HTTP server from the same snapshot over HTTPS.
- [How to Run Deno in a Sandbox](https://www.freestyle.sh/docs/guides/run-deno-in-a-sandbox): Bake the Deno runtime into a VM snapshot, then reuse one isolated sandbox to run as many TypeScript snippets as you like — or boot the same snapshot as a public HTTP server.
- [How to Run Ruby in a Sandbox](https://www.freestyle.sh/docs/guides/run-ruby-in-a-sandbox): Build a reusable VM snapshot with the Ruby runtime, then run as many Ruby scripts as you like on one long-lived sandbox VM.
- [How to Run Redis in a Sandbox](https://www.freestyle.sh/docs/guides/run-redis-in-a-sandbox): Bake a running Redis server into a VM snapshot, then create fresh sandboxes that serve key-value queries instantly with no startup step.
- [How to Run Supabase in a Sandbox](https://www.freestyle.sh/docs/guides/run-supabase-in-a-sandbox): Self-host the full Supabase stack — Postgres, Auth, PostgREST, Realtime, Storage, and Studio — on one resized VM with Docker Compose, and open the Kong API gateway on a public domain.
- [How to Run Convex in a Sandbox](https://www.freestyle.sh/docs/guides/run-convex-in-a-sandbox): Run the open-source Convex backend self-hosted with Docker, generate an admin key, and point the Convex CLI and client SDK at it over a public domain.
- [How to Run Hasura in a Sandbox](https://www.freestyle.sh/docs/guides/run-hasura-in-a-sandbox): Run the Hasura GraphQL Engine over Postgres with Docker, then open the console and the auto-generated GraphQL API on a public domain.
- [How to Run InstantDB in a Sandbox](https://www.freestyle.sh/docs/guides/run-instantdb-in-a-sandbox): Self-host the InstantDB realtime database — its Clojure backend, custom Postgres, and MinIO — with Docker, and reach the HTTP + WebSocket API over a public domain.
- [How to Run Hexclave in a Sandbox](https://www.freestyle.sh/docs/guides/run-hexclave-in-a-sandbox): Self-host Hexclave (formerly Stack Auth) — auth, teams, and API keys — with Docker: Postgres, ClickHouse, and the server behind an nginx path split, on a resized VM and a public domain.
---
# How to Give Your OpenAI Agent a Sandbox
Source: https://www.freestyle.sh/docs/guides/give-your-openai-agent-a-sandbox
Give an OpenAI Agents SDK agent a fresh Linux VM by implementing the SDK's SandboxClient on Freestyle — run commands, and expose any port the agent serves as a real HTTPS domain. Then watch a computer-use agent inspect the machine, write code, and run it. Bring an OpenAI key.
The [OpenAI Agents SDK](https://openai.github.io/openai-agents-js/guides/sandbox-agents/) runs an agent's shell commands inside a **sandbox** — the isolated machine the agent drives through tools like `exec_command`. The SDK ships sandbox clients for a handful of providers; this guide writes one for Freestyle. Implement the SDK's `SandboxClient` against a Freestyle VM and your agent gets a fresh, disposable Linux box — and, because it's a real VM, any port it serves becomes a real HTTPS domain.
## Requirements
- **A Freestyle API key** — to create the VM and map domains.
- **An OpenAI API key** — the agent loop calls a model (`OPENAI_API_KEY`, read from the environment). The sandbox client itself works without one; the `run()` loop doesn't.
- **Node.js 20+**.
## Install
```bash pnpm
pnpm add @openai/agents freestyle
```
```bash bun
bun add @openai/agents freestyle
```
```bash npm
npm install @openai/agents freestyle
```
```bash yarn
yarn add @openai/agents freestyle
```
```bash
export FREESTYLE_API_KEY="your-api-key"
export OPENAI_API_KEY="sk-..."
```
## Implement a Freestyle Sandbox Client
The SDK's `SandboxClient` is tiny: a `backendId` and a `create()` that returns a **session**. The session is where the work happens. For a shell-driven agent the runtime only needs three things from it: a `state` whose `manifest` it reads each turn, an `execCommand()` that the `exec_command` tool calls (it returns the string the model sees), and one teardown method (`close()` here) to dispose the VM. Everything else on the session interface is optional.
We add one more: `resolveExposedPort()`, which turns a VM port into a Freestyle [domain](https://www.freestyle.sh/docs/vms/domains). The SDK's `urlForExposedPort` rebuilds the URL from `{ host, port, tls }`, so returning `{ host: ".style.dev", port: 443, tls: true }` yields exactly `https://.style.dev/`.
```ts title="freestyle-sandbox.ts"
import { freestyle } from "freestyle";
import {
Manifest,
normalizeSandboxClientCreateArgs,
getRecordedExposedPortEndpoint,
recordExposedPortEndpoint,
} from "@openai/agents/sandbox";
import type {
SandboxClient,
SandboxClientCreateArgs,
SandboxSession,
SandboxSessionState,
ExecCommandArgs,
ExposedPortEndpoint,
} from "@openai/agents/sandbox";
type Vm = Awaited>["vm"];
// POSIX single-quote so paths with spaces survive the shell.
const sh = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`;
// How long a single agent command may run before we give up on it. Freestyle's
// exec is blocking, so we don't implement the SDK's background-session model.
const EXEC_TIMEOUT_MS = 120_000;
export class FreestyleSandboxClient implements SandboxClient {
readonly backendId = "freestyle";
constructor(private readonly options: { snapshotId?: string } = {}) {}
// The runtime calls create() with { manifest, options, ... }; users may also call
// create(manifest). normalizeSandboxClientCreateArgs accepts both and always
// hands back a real Manifest (the runtime dereferences session.state.manifest).
async create(
args?: SandboxClientCreateArgs | Manifest,
manifestOptions?: Record,
): Promise {
const { manifest } = normalizeSandboxClientCreateArgs(args, manifestOptions);
const { vm, vmId } = await freestyle.vms.create({ name: "openai-sandbox", snapshotId: this.options.snapshotId });
// Commands default to running in the manifest root (/workspace) — make sure it exists.
await vm.exec(`mkdir -p ${sh(manifest.root)}`);
return new FreestyleSandboxSession(vm, vmId, { manifest });
}
}
class FreestyleSandboxSession implements SandboxSession {
private closed = false;
constructor(
private readonly vm: Vm,
private readonly vmId: string,
readonly state: SandboxSessionState,
) {}
// The shell capability's exec_command tool calls this; the returned string is
// what the model sees. Run the command in the VM and report its exit code.
async execCommand(args: ExecCommandArgs): Promise {
const workdir = args.workdir ?? this.state.manifest.root;
const startedAt = Date.now();
let exitCode = 0;
let output = "";
try {
const res = await this.vm.exec({
command: `cd ${sh(workdir)} && ${args.cmd}`,
timeoutMs: EXEC_TIMEOUT_MS,
});
exitCode = res.statusCode ?? 0;
output = [res.stdout, res.stderr].filter(Boolean).join("");
} catch (err) {
exitCode = 1;
output = err instanceof Error ? err.message : String(err);
}
const wallTimeSeconds = (Date.now() - startedAt) / 1000;
return [
`Wall time: ${wallTimeSeconds.toFixed(4)} seconds`,
`Process exited with code ${exitCode}`,
"Output:",
output,
].join("\n");
}
// The standout Freestyle hook: expose a port as a real HTTPS domain. The SDK's
// urlForExposedPort turns { host, port: 443, tls: true } into https:///.
async resolveExposedPort(port: number): Promise {
const cached = getRecordedExposedPortEndpoint(this.state, port);
if (cached) return cached;
const domain = `sandbox-${crypto.randomUUID().slice(0, 8)}.style.dev`;
await freestyle.domains.mappings.create({ domain, vmId: this.vmId, vmPort: port });
// Key the cache by the requested port (the endpoint's own port is 443).
return recordExposedPortEndpoint(this.state, { host: domain, port: 443, tls: true }, port);
}
// The runtime tears a session down via stop/shutdown/delete/close — delete the VM.
async close(): Promise {
if (this.closed) return;
this.closed = true;
await freestyle.vms.delete({ vmId: this.vmId });
}
}
```
`execCommand` returns a string, not a structured result — the shell tool surfaces that text straight to the model, so we format the exit code and output into it. We don't implement the SDK's still-running/`sessionId` streaming model: `vm.exec` is blocking, so each command runs to completion. For long-lived processes (a dev server), start them with `systemd-run` and stream the journal over the [PTY API](https://www.freestyle.sh/docs/vms/pty), as in the other guides.
## Try It Without an Agent
Drive the session directly — no LLM, no token spend — to confirm the Freestyle wiring before you run a model:
```ts
import { FreestyleSandboxClient } from "./freestyle-sandbox";
import { urlForExposedPort } from "@openai/agents/sandbox";
const client = new FreestyleSandboxClient();
const session = await client.create(); // boots a fresh VM
// Run a command — exactly what the agent's exec_command tool does.
console.log(await session.execCommand!({ cmd: "uname -a" }));
// Write and run a script in the workspace (/workspace is the default cwd).
await session.execCommand!({ cmd: "echo 'print(sum(range(10)))' > sum.py" });
console.log(await session.execCommand!({ cmd: "python3 sum.py" })); // → 45
// Start something on a port, then expose it as a real domain.
await session.execCommand!({
cmd: "systemd-run --unit=app --working-directory=/workspace python3 -m http.server 8000",
});
const endpoint = await session.resolveExposedPort!(8000);
const url = urlForExposedPort(endpoint, "http"); // https://sandbox-xxxx.style.dev/
console.log(url, (await fetch(url)).status); // → https://… 200
await session.close!(); // deletes the VM
```
The methods are marked optional on the `SandboxSession` interface, so the `!` just tells TypeScript our client always defines them.
## Build the Computer-Use Agent
Give the client to a `SandboxAgent` and `run()` it. The `shell()` capability exposes the `exec_command` tool, so the model drives the VM — read the system, write a script, run it — to satisfy the prompt. The SDK creates the VM on the first turn and tears it down (calling `close()`) when the run ends.
```ts
import { run } from "@openai/agents";
import { SandboxAgent, shell } from "@openai/agents/sandbox";
import { FreestyleSandboxClient } from "./freestyle-sandbox";
const agent = new SandboxAgent({
name: "computer-use",
model: "gpt-5.5", // any model the SDK supports; set that provider's key
instructions:
"You operate a fresh Linux VM. Use exec_command to run shell commands — " +
"inspect the system, install tools, write and run code — to accomplish the task.",
capabilities: [shell()],
});
const result = await run(
agent,
"What OS, kernel, and CPU count does your machine have? Then write a Python " +
"script that prints the first 10 primes and run it.",
{ sandbox: { client: new FreestyleSandboxClient() } },
);
console.log(result.finalOutput);
```
The model calls `exec_command` itself — `uname`/`nproc`, then writes a script and runs `python3` — all inside the Freestyle VM, and summarizes what it found. Set `OPENAI_API_KEY` first; the SDK reads it from the environment.
## Serve a Port on a Real Domain
`resolveExposedPort` is the part of this client that no local sandbox can offer: when the agent starts something listening on a port, you get a public HTTPS URL for it. Map the port, then build the URL with the SDK's `urlForExposedPort`:
```ts
import { urlForExposedPort } from "@openai/agents/sandbox";
const session = await new FreestyleSandboxClient().create();
// …the agent (or your own code) starts a server on 5173…
await session.execCommand!({
cmd: "systemd-run --unit=app --working-directory=/workspace python3 -m http.server 5173",
});
const endpoint = await session.resolveExposedPort!(5173);
console.log(urlForExposedPort(endpoint, "http")); // https://sandbox-xxxx.style.dev/
console.log(urlForExposedPort(endpoint, "ws")); // wss://sandbox-xxxx.style.dev/
```
A `*.style.dev` subdomain needs no DNS or verification, and the mapping is cached per port — call `resolveExposedPort(5173)` again and you get the same domain. Expose a tool that hands the URL to the model and your agent can build a web app and link you straight to it.
## Beyond the Basics
The session holds a real Freestyle VM, so capabilities beyond the SDK's shell interface are a method away:
- **File edits via `apply_patch`** — add a `createEditor()` to the session that returns an [`Editor`](https://openai.github.io/openai-agents-js/) (`createFile` / `updateFile` / `deleteFile`) and include `filesystem()` in `capabilities`. The model then edits files with the structured `apply_patch` tool instead of shell here-docs.
- **Snapshots & resume** — implement the client's `serializeSessionState` / `resume` backed by `vm.snapshot()` to persist an agent's workspace across runs.
- **Fork & VPC** — fork the prepared VM into parallel agent sandboxes, or boot it on a private VPC, exactly as in the [Mastra guide](https://www.freestyle.sh/docs/guides/give-your-mastra-agent-a-sandbox#beyond-the-wrapper). Both are plain Freestyle SDK calls on the VM your `create()` returns.
---
# How to Give Your Mastra Agent a Sandbox
Source: https://www.freestyle.sh/docs/guides/give-your-mastra-agent-a-sandbox
Give a Mastra agent a fresh Linux VM to work in by implementing Mastra's Workspace sandbox interface on Freestyle — then watch a computer-use agent inspect the machine, write code, and run it. Bring a model API key.
[Mastra](https://mastra.ai) agents run shell commands in a [Workspace](https://mastra.ai/docs/workspace/sandbox) **sandbox** — the isolated machine the agent drives. This guide gives your Mastra agent that machine on Freestyle: implement the sandbox interface against a Freestyle VM, and the agent gets a fresh, disposable Linux box — a "computer-use" loop that inspects the system, writes code, and runs it.
## Requirements
- **A Freestyle API key** — to create the VM.
- **A model API key** — the agent needs an LLM (e.g. `OPENAI_API_KEY` for `openai/gpt-5.5`). The sandbox itself works without one; the agent loop doesn't.
- **Node.js 20+**.
## Install
```bash pnpm
pnpm add @mastra/core freestyle
```
```bash bun
bun add @mastra/core freestyle
```
```bash npm
npm install @mastra/core freestyle
```
```bash yarn
yarn add @mastra/core freestyle
```
```bash
export FREESTYLE_API_KEY="your-api-key"
export OPENAI_API_KEY="sk-..." # or whichever provider your model uses
```
## Implement a Freestyle Sandbox
Mastra's `MastraSandbox` base class wants four things — `id`, `name`, `provider`, `status` — plus a `start()` that boots the machine, a `destroy()` that tears it down, and an `executeCommand()` that runs a command and returns a `CommandResult`. Back each one with the Freestyle SDK: `start()` creates a VM, `executeCommand()` runs through `vm.exec()`, `destroy()` deletes it.
```ts title="freestyle-sandbox.ts"
import { MastraSandbox } from "@mastra/core/workspace";
import type { CommandResult, ExecuteCommandOptions, ProviderStatus } from "@mastra/core/workspace";
import { freestyle } from "freestyle";
type Vm = Awaited>["vm"];
type CreateOptions = NonNullable[0]>;
// POSIX single-quote so args/values with spaces survive the shell.
const sh = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`;
export class FreestyleSandbox extends MastraSandbox {
readonly id = `freestyle-${crypto.randomUUID().slice(0, 8)}`;
readonly name = "FreestyleSandbox";
readonly provider = "freestyle";
status: ProviderStatus = "pending";
// Public on purpose: reach the full Freestyle SDK — fork, snapshot, VPC, PTY —
// straight off the wrapper (see "Beyond the Wrapper" below).
vm?: Vm;
private _vmId?: string;
// Pass through any vms.create options — snapshotId, nics (VPC), name, etc.
constructor(private readonly options: CreateOptions = {}) {
super({ name: "FreestyleSandbox" });
}
// Boot a fresh VM. The base class handles the status transitions.
async start(): Promise {
const { vm, vmId } = await freestyle.vms.create({ name: "mastra-sandbox", ...this.options });
this.vm = vm;
this._vmId = vmId;
}
// Run a command in the VM and map it to Mastra's CommandResult.
async executeCommand(
command: string,
args: string[] = [],
options: ExecuteCommandOptions = {},
): Promise {
await this.ensureRunning(); // lazily calls start() if needed
let line = [command, ...args.map(sh)].join(" ");
if (options.env) {
const exports = Object.entries(options.env)
.filter(([, v]) => v != null)
.map(([k, v]) => `${k}=${sh(String(v))}`)
.join(" ");
if (exports) line = `${exports} ${line}`;
}
if (options.cwd) line = `cd ${sh(options.cwd)} && ${line}`;
const startedAt = Date.now();
const res = await this.vm!.exec({ command: line, timeoutMs: options.timeout });
const exitCode = res.statusCode ?? 0;
return {
success: exitCode === 0,
exitCode,
stdout: res.stdout ?? "",
stderr: res.stderr ?? "",
executionTimeMs: Date.now() - startedAt,
command,
args,
};
}
async destroy(): Promise {
if (this._vmId) await freestyle.vms.delete({ vmId: this._vmId });
this.vm = undefined;
this._vmId = undefined;
}
getInstructions(): string {
return "You control a fresh Debian Linux VM via execute_command. Run shell commands to inspect the system, install packages, and write and run code.";
}
}
```
This implements only `execute_command` (foreground). Mastra's `get_process_output` / `kill_process` (background processes) need a process manager; without one the runtime returns a clear "feature not supported" error. For long-running processes, stream them with the [PTY API](https://www.freestyle.sh/docs/vms/pty).
## Try It Without an Agent
Drop the sandbox into a `Workspace` and call it directly — no LLM needed — to confirm the Freestyle wiring before you spend tokens:
```ts
import { Workspace } from "@mastra/core/workspace";
import { FreestyleSandbox } from "./freestyle-sandbox";
const workspace = new Workspace({ sandbox: new FreestyleSandbox() });
await workspace.init(); // boots the VM
const uname = await workspace.sandbox!.executeCommand!("uname", ["-a"]);
console.log(uname.stdout.trim()); // Linux … 6.1.0-15-freestyle … x86_64
const cwd = await workspace.sandbox!.executeCommand!("pwd", [], { cwd: "/tmp" });
console.log(cwd.stdout.trim()); // /tmp
await workspace.destroy(); // deletes the VM
```
`executeCommand` returns `{ success, exitCode, stdout, stderr, executionTimeMs }`, with `cwd`, `env`, and `timeout` honored.
## Build the Computer-Use Agent
Give the workspace to a Mastra `Agent`. Mastra automatically exposes `execute_command` as a tool, so the model can drive the VM — read the system, write a script, run it — to satisfy a prompt. The VM is created on the first command.
```ts
import { Agent } from "@mastra/core/agent";
import { Workspace } from "@mastra/core/workspace";
import { FreestyleSandbox } from "./freestyle-sandbox";
const workspace = new Workspace({ sandbox: new FreestyleSandbox() });
const agent = new Agent({
id: "computer-use",
model: "openai/gpt-5.5", // any model Mastra supports; set that provider's key
instructions:
"You operate a fresh Linux VM. Use execute_command to run shell commands — " +
"inspect the system, install tools, write and run code — to accomplish the task.",
workspace,
});
const result = await agent.generate(
"What OS, kernel, and CPU count does your machine have? Then write a Python " +
"script that prints the first 10 primes and run it.",
);
console.log(result.text);
await workspace.destroy(); // delete the VM when the session is done
```
The agent calls `execute_command` itself — `uname`/`nproc`, then `cat > primes.py` and `python3 primes.py` — all inside the Freestyle VM, and summarizes what it found.
## A Sandbox per User
In a multi-tenant app, give each user their own VM by passing a **resolver** instead of a single instance. Mastra calls it per request, and the caller owns the returned sandbox's lifecycle:
```ts
const workspace = new Workspace({
sandbox: ({ requestContext }) => {
const userId = requestContext.get("user-id") as string;
// look up or lazily create this user's snapshot, then boot from it
return new FreestyleSandbox({ snapshotId: snapshotFor(userId) });
},
});
```
Pair this with a [snapshot](https://www.freestyle.sh/docs/guides/run-nodejs-in-a-sandbox) of each user's prepared environment and Freestyle boots a ready-to-use machine per session.
## Beyond the Wrapper
Mastra's interface only needs `executeCommand` — but the sandbox is a real Freestyle VM, and we kept its handle public as `sandbox.vm`. Everything the Freestyle SDK can do is still there, none of which Mastra's abstraction models: [forking](https://www.freestyle.sh/docs/vms/lifecycle) the VM, [snapshots](https://www.freestyle.sh/docs/guides/run-nodejs-in-a-sandbox), putting it on a [VPC](https://www.freestyle.sh/docs/vms/network/vpcs), mapping a [domain](https://www.freestyle.sh/docs/vms/domains), or opening a [PTY](https://www.freestyle.sh/docs/vms/pty). Reach for them straight off the wrapper.
For example, let an agent prepare an environment once, then **fork** that VM into N copies — each inherits the parent's disk *and* memory, so the setup is already done in every one — to fan work out in parallel:
```ts
import { Workspace } from "@mastra/core/workspace";
import { FreestyleSandbox } from "./freestyle-sandbox";
const sandbox = new FreestyleSandbox();
const workspace = new Workspace({ sandbox });
await workspace.init();
// Prepare the environment once (clone a repo, install deps, …).
await sandbox.executeCommand("git", ["clone", "https://github.com/acme/app", "."], { cwd: "/root" });
await sandbox.executeCommand("npm", ["install"], { cwd: "/root" });
// Fan the prepared VM out into 5 ready-to-go copies.
const { forks } = await sandbox.vm!.fork({ count: 5 });
const results = await Promise.all(
forks.map(({ vm }, i) =>
vm.exec({ command: `npm test -- --shard=${i + 1}/${forks.length}`, timeoutMs: 300_000 }),
),
);
```
Because the constructor passes its options straight to `vms.create`, networking is just as direct — boot the agent's sandbox on a private [VPC](https://www.freestyle.sh/docs/vms/network/vpcs) by handing it a NIC:
```ts
import { freestyle } from "freestyle";
const { vpcId } = await freestyle.vpc.create({ cidr: "192.168.10.0/24" });
const sandbox = new FreestyleSandbox({
nics: [{ default: true, vpc: vpcId, mode: "routed", ipv4: "192.168.10.10" }],
});
```
The Mastra wrapper gives the agent a clean `execute_command`; `sandbox.vm` gives you the whole platform underneath it.
---
# How to Run Claude Code in a Sandbox
Source: https://www.freestyle.sh/docs/guides/run-claude-code-in-a-sandbox
Bake the Claude Code CLI into a VM snapshot, run it interactively over the PTY API, run one-off prompts, and drive it from the Claude Agent SDK with spawnClaudeCodeProcess.
Build a snapshot with [Claude Code](https://github.com/anthropics/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](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk) running on your own machine — bridging the SDK's process to the VM through `spawnClaudeCodeProcess`.
## Install the SDK
```bash pnpm
pnpm add freestyle
```
```bash bun
bun add freestyle
```
```bash npm
npm install freestyle
```
```bash yarn
yarn add freestyle
```
Set your API key before calling the API:
```bash
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.
```ts
import { freestyle } from "freestyle";
const { vm: builder } = await freestyle.vms.create({ name: "claude-code-builder" });
// 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.
```bash
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-code-interactively-over-the-pty)), 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.
```ts
const { vm, vmId } = await freestyle.vms.create({ name: "claude-code-sandbox", 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](#authenticate), and hand it ownership of the directory it will edit:
```ts
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:
```ts
const { vm } = await freestyle.vms.create({ name: "claude-code-sandbox", 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.
```ts
const { vm } = await freestyle.vms.create({ name: "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(
`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](#authenticate) 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:
```ts
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](https://www.npmjs.com/package/@anthropic-ai/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.
```bash
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 `cat`s 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.
```ts
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;
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.
```ts
import { query } from "@anthropic-ai/claude-agent-sdk";
const { vm } = await freestyle.vms.create({ name: "claude-code-sandbox", 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.
---
# How to Run GitHub Copilot in a Sandbox
Source: https://www.freestyle.sh/docs/guides/run-github-copilot-in-a-sandbox
Run the GitHub Copilot CLI headless inside a sandbox and drive it from the Copilot SDK over a private VPC — or from your own machine over a WireGuard VPN. The runtime link is raw-TCP JSON-RPC, so it needs VPC reachability and a fine-grained Copilot token, not an HTTPS domain or a classic PAT.
Run the [GitHub Copilot CLI](https://github.com/features/copilot/cli) headless inside a sandbox VM and drive it from the [Copilot SDK](https://github.com/github/copilot-sdk) running somewhere else. The SDK talks to its runtime over JSON-RPC on a **raw TCP socket**, so the client has to reach the VM on a private network: another VM on the same **VPC**, or your own machine joined to the VPC over a **WireGuard VPN**.
## Requirements
Before you start, make sure you have:
- **A Freestyle API key** — to create the VM, VPC, and VPN.
- **A GitHub Copilot subscription**, and a **fine-grained** personal access token with the **`Copilot requests: read`** permission. **Classic `ghp_` tokens do not work** — Copilot rejects them outright (more on this below).
- **Node.js 22+** wherever the SDK client runs (the base sandbox image already ships Node).
- **WireGuard tools** (`wireguard-tools`) on your machine *only if* you want to connect from your laptop rather than from a second VM.
## Why this needs a VPC
The Copilot SDK speaks `Content-Length`-framed **JSON-RPC** to its runtime — over the runtime's stdio when it spawns it locally, or over a **raw TCP socket** when you point it at an already-running server (`copilot --headless --port`). It is **not HTTP**.
That rules out a Freestyle [domain](https://www.freestyle.sh/docs/vms/domains): domains are an HTTP reverse proxy and can't carry a raw-TCP JSON-RPC stream. The client instead has to reach the VM over its **private address**, which means one of:
- **Your own machine over a [WireGuard VPN](https://www.freestyle.sh/docs/vms/network/vpns)** — join the VPC from your laptop, then connect to the private IP.
- **Another VM on the same [VPC](https://www.freestyle.sh/docs/vms/network/vpcs)** — VMs on a VPC reach each other by private IP, no VPN needed.
This guide does both.
## Install the SDK
Install `freestyle` wherever you orchestrate VMs:
```bash pnpm
pnpm add freestyle
```
```bash bun
bun add freestyle
```
```bash npm
npm install freestyle
```
```bash yarn
yarn add freestyle
```
Set your API key:
```bash
export FREESTYLE_API_KEY="your-api-key"
```
The Copilot SDK (`@github/copilot-sdk`) is installed separately, wherever the **client** runs — the second VM, or your laptop. We'll get to it below.
## Get a Copilot token
The headless runtime authenticates to Copilot with a GitHub token in `COPILOT_GITHUB_TOKEN` (or `GH_TOKEN` / `GITHUB_TOKEN`). It must be a **fine-grained personal access token** with the **`Copilot requests: read`** permission.
A **classic `ghp_` token will not work**, even one that has the `copilot` scope — the CLI refuses it:
```text
Error: Classic Personal Access Tokens (ghp_) are not supported by Copilot.
Please use a Fine-Grained Personal Access Token or another authentication method.
```
Create a fine-grained token at [github.com/settings/personal-access-tokens](https://github.com/settings/personal-access-tokens), grant it `Copilot requests: read`, and export it:
```bash
export COPILOT_GITHUB_TOKEN="github_pat_..."
```
In `forUri` mode (below) the SDK **client** cannot supply auth — the **server** holds it. So this token lives on the VM that runs `copilot --headless`, not on the client.
## Create a VPC and the agent VM
Create a VPC, then attach a VM to it with a routed interface at a fixed private IP. Use `idleTimeoutSeconds: null` — the idle monitor only watches network traffic, and a headless Copilot server generates none, so it would otherwise be suspended out from under you.
```ts
import { freestyle } from "freestyle";
const { vpcId, vpc } = await freestyle.vpc.create({
name: "copilot-vpc",
cidr: "192.168.10.0/24",
});
const { vm: agent } = await freestyle.vms.create({
name: "copilot-agent",
idleTimeoutSeconds: null,
nics: [{ default: true, vpc: vpcId, mode: "routed", ipv4: "192.168.10.10" }],
});
```
The VPC's routed NIC still has internet egress, so the VM can `npm install` even though its default route is the VPC.
## Install and launch the headless runtime
Install the Copilot CLI and run it as a **systemd service** — systemd is PID 1 in the VM, so it supervises the process and restarts it if it dies. The server binds `0.0.0.0` so VPC peers can reach it, and reads its fine-grained token plus a **connection token** (a shared secret it requires from every client) from a root-only environment file.
`npm` installs the CLI under nvm, so resolve its absolute path for the unit's `ExecStart` — systemd doesn't search `PATH`.
```ts
await agent.exec("npm install -g @github/copilot");
const copilot = (await agent.exec("echo $(npm prefix -g)/bin/copilot")).stdout!.trim();
// A shared secret every client must present (used by both "drive it" sections).
const connectionToken = "a-long-random-shared-secret";
// Secrets go in a root-only env file, kept out of the unit so they don't show up
// in `systemctl cat`.
await agent.fs.writeTextFile(
"/etc/copilot.env",
`COPILOT_GITHUB_TOKEN=${process.env.COPILOT_GITHUB_TOKEN}
COPILOT_CONNECTION_TOKEN=${connectionToken}
COPILOT_AUTO_UPDATE=false`,
);
await agent.exec("chmod 600 /etc/copilot.env");
await agent.fs.writeTextFile(
"/etc/systemd/system/copilot.service",
`[Service]
Environment=HOME=/root
EnvironmentFile=/etc/copilot.env
ExecStart=${copilot} --headless --host 0.0.0.0 --port 4321
WorkingDirectory=/root
Restart=always
[Install]
WantedBy=multi-user.target`,
);
await agent.exec("systemctl daemon-reload && systemctl enable --now copilot");
```
Binding `0.0.0.0` sounds broad, but Freestyle exposes no public raw-TCP ingress — the port is only reachable on the VPC (and over the VPN). The `COPILOT_CONNECTION_TOKEN` is the actual gate: without it the server still starts, but warns that it accepts any client.
Wait until the service is serving before connecting (the base image has Node but not `nc`/`ss`):
```ts
const check =
`node -e "const s=require('net').connect(4321,'127.0.0.1');` +
`s.on('connect',()=>{console.log('OPEN');process.exit(0)});` +
`s.on('error',()=>process.exit(1))"`;
for (let i = 0; i < 20; i++) {
const r = await agent.exec({ command: check, timeoutMs: 5_000 });
if (r.stdout?.includes("OPEN")) break;
await agent.exec("sleep 2");
}
```
Inspect or follow it any time with `systemctl status copilot` or `journalctl -u copilot`.
## 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](https://www.freestyle.sh/docs/vms/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:
```ts
const session = await agent.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 copilot -f\n");
// session.detach() drops your handle — the service keeps running in the VM.
```
## Drive it from your computer
Your machine isn't on the VPC, so join it over a [WireGuard VPN](https://www.freestyle.sh/docs/vms/network/vpns). Create an ephemeral session and write its config:
```ts
import { writeFile } from "node:fs/promises";
const connection = await vpc.wireguard.createEphemeral();
await writeFile("freestyle-vpc.conf", connection.clientConfig, { mode: 0o600 });
```
Install the WireGuard CLI:
```bash macOS
brew install wireguard-tools
```
```bash Ubuntu/Debian
sudo apt-get update
sudo apt-get install -y wireguard-tools
```
Bring the tunnel up — this needs root. Its `AllowedIPs` is just the VPC CIDR, so only VPC traffic is routed; pick a CIDR that doesn't overlap your LAN:
```bash
sudo wg-quick up ./freestyle-vpc.conf
```
Now your machine can reach the agent by its private IP. Connect with `RuntimeConnection.forUri`, passing the **connection token** — the client's only auth input, since the GitHub token stays on the server:
```ts title="client.mjs"
import { CopilotClient, RuntimeConnection, approveAll } from "@github/copilot-sdk";
const client = new CopilotClient({
connection: RuntimeConnection.forUri("192.168.10.10:4321", {
connectionToken: process.env.COPILOT_CONNECTION_TOKEN,
}),
});
await client.start();
// approveAll lets the agent run tools (shell, edits) inside the agent VM.
// To forbid tools, pass a handler that returns { kind: "reject" } instead.
const session = await client.createSession({ onPermissionRequest: approveAll });
const idle = new Promise((resolve) => {
session.on("assistant.message", (event) => console.log(event.data.content));
session.on("session.idle", () => resolve());
});
await session.send({ prompt: "Run `hostname` with the shell tool and reply with its output." });
await idle;
await session.disconnect();
await client.stop();
```
Install the SDK and run it from your machine:
```bash
npm install @github/copilot-sdk
COPILOT_CONNECTION_TOKEN="a-long-random-shared-secret" node client.mjs
```
The shell tool runs **inside the agent VM**, so the hostname it prints is the agent's, not your laptop's — the agent really ran there, driven from your computer over the tunnel. Tear the tunnel down and close the session when you're done:
```bash
sudo wg-quick down ./freestyle-vpc.conf
```
```ts
await connection.close();
```
## Drive it from another VM on the VPC
A VM that's already on the VPC needs no VPN — it's on the private network already. Add one, install the SDK, upload the **same `client.mjs`**, and run it there:
```ts
import { readFileSync } from "node:fs";
const { vm: peer } = await freestyle.vms.create({
name: "copilot-peer",
idleTimeoutSeconds: null,
nics: [{ default: true, vpc: vpcId, mode: "routed", ipv4: "192.168.10.11" }],
});
await peer.exec("mkdir -p /root/app && cd /root/app && npm init -y && npm install @github/copilot-sdk");
await peer.fs.writeTextFile("/root/app/client.mjs", readFileSync("client.mjs", "utf8"));
const run = await peer.exec({
command: `cd /root/app && COPILOT_CONNECTION_TOKEN='${connectionToken}' node client.mjs`,
timeoutMs: 180_000,
});
console.log(run.stdout); // the agent VM's hostname — it ran the shell tool there
```
Same `forUri("192.168.10.10:4321")`, same connection token — only the network path differs.
## Locking it down
Two independent gates protect the runtime:
- **The connection token** (`COPILOT_CONNECTION_TOKEN` on the server, `connectionToken` on the client) authenticates the *transport*. A client without it is rejected with `AUTHENTICATION_FAILED` before any session is created.
- **`onPermissionRequest`** on the client authorizes each *tool call*. `approveAll` lets the agent act; a handler returning `{ kind: "reject" }` keeps it to text only. Because the client decides, you never need `--allow-all` on the server.
---
# How to Run OpenClaw in a Sandbox
Source: https://www.freestyle.sh/docs/guides/run-openclaw-in-a-sandbox
Install OpenClaw in a VM snapshot, run its agent gateway under systemd, and open the web UI in your browser over a Freestyle domain with token auth — HTTP and WebSocket proxied directly. Bring an OpenAI key for the model.
[OpenClaw](https://github.com/openclaw/openclaw) is an agent framework whose **gateway** is a web UI for chatting with agents, served over HTTP with a WebSocket transport. Because it's HTTP + WebSocket — not a raw socket — a Freestyle [domain](https://www.freestyle.sh/docs/vms/domains) proxies it directly: run the gateway in a sandbox, route a domain to it, and open the chat UI in your browser.
## Requirements
- **A Freestyle API key** — to create the VM and the domain mapping.
- **An OpenAI API key** — OpenClaw's default agent runs on an OpenAI model (`openai/gpt-5.2` below). The gateway starts without it, but the agent can't answer until it's set.
- **Node.js** — already on the base sandbox image; `openclaw` installs through `npm`.
## Install the SDK
```bash pnpm
pnpm add freestyle
```
```bash bun
bun add freestyle
```
```bash npm
npm install freestyle
```
```bash yarn
yarn add freestyle
```
Set your keys before calling the API:
```bash
export FREESTYLE_API_KEY="your-api-key"
export OPENAI_API_KEY="sk-..."
```
## Build a Snapshot with OpenClaw Installed
Install the `openclaw` CLI with `npm` and pick the default agent model — `config set` only writes config under `~/.openclaw`, so no key is needed yet. Snapshot the result so every VM boots with OpenClaw ready.
```ts
import { freestyle } from "freestyle";
const { vm: builder } = await freestyle.vms.create({ name: "openclaw-builder" });
await builder.exec({ command: "npm install -g openclaw", timeoutMs: 600_000 });
// npm installs the CLI under nvm, so resolve its absolute path for later.
const openclaw = (await builder.exec("echo $(npm prefix -g)/bin/openclaw")).stdout!.trim();
await builder.exec(`HOME=/root ${openclaw} config set agents.defaults.model.primary openai/gpt-5.2`);
const { snapshotId } = await builder.snapshot();
await builder.delete();
```
## Run the Gateway
Boot a VM from the snapshot and run the gateway as a **systemd service** so it stays up. Three things matter: your `OPENAI_API_KEY` (a root-only env file), a **token** clients present as `?token=` — pass it with `--token` on the command line, since the gateway doesn't reliably read it from the environment — and the **domain whitelisted as an allowed Control-UI origin**. The UI is a browser app that opens a WebSocket back to the gateway, and the gateway rejects origins it doesn't know. `--bind lan` makes it listen on `0.0.0.0` so the domain proxy can reach it.
```ts
const { vm, vmId } = await freestyle.vms.create({ name: "openclaw-server", snapshotId, idleTimeoutSeconds: null });
const openclaw = (await vm.exec("echo $(npm prefix -g)/bin/openclaw")).stdout!.trim();
const token = "a-long-random-token"; // clients use ?token=
const domain = `openclaw-${crypto.randomUUID().slice(0, 8)}.style.dev`; // mapped below
// Whitelist the domain origin, or the browser's Control-UI WebSocket is rejected.
await vm.exec(
`HOME=/root ${openclaw} config set gateway.controlUi.allowedOrigins '["https://${domain}"]'`,
);
await vm.fs.writeTextFile("/etc/openclaw.env", `OPENAI_API_KEY=${process.env.OPENAI_API_KEY}`);
await vm.exec("chmod 600 /etc/openclaw.env");
await vm.fs.writeTextFile(
"/etc/systemd/system/openclaw.service",
`[Service]
Environment=HOME=/root
EnvironmentFile=/etc/openclaw.env
ExecStart=${openclaw} gateway --allow-unconfigured --bind lan --auth token --token ${token} --port 18789
WorkingDirectory=/root
Restart=always
[Install]
WantedBy=multi-user.target`,
);
await vm.exec("systemctl daemon-reload && systemctl enable --now openclaw");
```
Wait until it's serving (the base image has Node but not `nc`/`ss`):
```ts
const check =
`node -e "const s=require('net').connect(18789,'127.0.0.1');` +
`s.on('connect',()=>{console.log('OPEN');process.exit(0)});` +
`s.on('error',()=>process.exit(1))"`;
for (let i = 0; i < 20; i++) {
const r = await vm.exec({ command: check, timeoutMs: 5_000 });
if (r.stdout?.includes("OPEN")) break;
await vm.exec("sleep 2");
}
```
## Open It on a Domain
Map the `domain` you chose above to port `18789`. A `*.style.dev` subdomain needs no DNS or verification. The domain proxies both the HTTP web UI and the WebSocket transport, and because you whitelisted its origin, the Control UI connects.
```ts
await freestyle.domains.mappings.create({ domain, vmId, vmPort: 18789 });
console.log(`Open https://${domain}/?token=${token}`);
```
Visit `https:///?token=`. Keep the token secret — anyone with it can reach the gateway.
## Authorize the Browser
The token isn't the gateway's only check. By default it also requires **device pairing**: the first time a browser opens the Control UI — even with a valid token — the gateway shows a device ID and `Device pairing required`, and refuses to connect until that browser is trusted. There are a few ways to clear it; pick based on how locked down you want to be.
**Approve the device (keep pairing on).** Trust the specific browser from the gateway host, using the ID shown in the pairing prompt. A leaked token then still can't connect on its own — the device also has to be approved, so this is the stricter setup. Do it from your app with the SDK, or as a one-off straight from your terminal with the [Freestyle CLI](https://www.freestyle.sh/docs/cli) — no code:
```ts SDK
// `` is the UUID the browser's "Device pairing required" message shows.
await vm.exec(`HOME=/root ${openclaw} devices approve `);
```
```bash CLI
# `` is the UUID from the browser's "Device pairing required" message.
# The PATH line resolves the nvm-installed `openclaw` binary.
npx freestyle vm exec \
'export PATH="$(npm prefix -g)/bin:$PATH"; HOME=/root openclaw devices approve '
```
Each new browser pairs once (`openclaw devices list` shows pending/approved ones).
**Drop pairing (token only).** If the token should be the single gate — fine for a quick solo session, or when the gateway is already private (see the VPC section below) — disable device auth when you configure the gateway, alongside the `allowedOrigins` call and **before** it starts:
```ts
await vm.exec(`HOME=/root ${openclaw} config set gateway.controlUi.allowInsecureAuth true`);
await vm.exec(`HOME=/root ${openclaw} config set gateway.controlUi.dangerouslyDisableDeviceAuth true`);
```
`dangerouslyDisableDeviceAuth` removes the device-fingerprint approval, so any browser with the `?token=` URL connects with no extra step. The `dangerously` is not for show: the token becomes the *only* thing guarding the gateway, so treat the URL like a password — keep it out of anything public, rotate the token if it leaks, and prefer pairing (or a private VPC) for anything shared.
## Stream the Gateway Logs
`vm.exec()` buffers a command and only returns once it finishes, so it can't show the gateway's output as it happens. To watch the logs live, open a [PTY](https://www.freestyle.sh/docs/vms/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:
```ts
const session = await vm.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 openclaw -f\n");
// session.detach() drops your handle — the gateway keeps running in the VM.
```
## Keep It Private on a VPC
A public `*.style.dev` domain is convenient, but it puts the gateway on the open internet behind only the token. To keep it private, skip the domain: run the gateway on a [VPC](https://www.freestyle.sh/docs/vms/network/vpcs) and reach it from your own machine over a [WireGuard VPN](https://www.freestyle.sh/docs/vms/network/vpns), so the Control UI never leaves the private network.
Put the gateway VM on a VPC at a fixed private IP, and whitelist that **private-IP origin** — plain `http://`, since you hit the IP directly through the encrypted tunnel (no public TLS):
```ts
const { vpcId, vpc } = await freestyle.vpc.create({ cidr: "192.168.10.0/24" });
const { vm, vmId } = await freestyle.vms.create({
name: "openclaw-gateway",
snapshotId,
idleTimeoutSeconds: null,
nics: [{ default: true, vpc: vpcId, mode: "routed", ipv4: "192.168.10.10" }],
});
const openclaw = (await vm.exec("echo $(npm prefix -g)/bin/openclaw")).stdout!.trim();
const token = "a-long-random-token";
await vm.exec(
`HOME=/root ${openclaw} config set gateway.controlUi.allowedOrigins '["http://192.168.10.10:18789"]'`,
);
```
Write the env file and systemd unit exactly as in [Run the Gateway](#run-the-gateway) — same `--bind lan --auth token --token … --port 18789` — and start it. Then join the VPC from your laptop with WireGuard:
```ts
import { writeFile } from "node:fs/promises";
const connection = await vpc.wireguard.createEphemeral();
await writeFile("freestyle-vpc.conf", connection.clientConfig, { mode: 0o600 });
```
Bring the tunnel up (the one-time `wireguard-tools` install is in the [VPN docs](https://www.freestyle.sh/docs/vms/network/vpns)) and open the gateway by its private IP:
```bash
sudo wg-quick up ./freestyle-vpc.conf
# open in your browser: http://192.168.10.10:18789/?token=
sudo wg-quick down ./freestyle-vpc.conf
```
The token gate and [device pairing](#authorize-the-browser) work the same here; the only changes from the domain setup are the routed VPC NIC, the `http://:18789` origin, and reaching it over WireGuard instead of a public domain.
---
# How to Run Node.js in a Sandbox
Source: https://www.freestyle.sh/docs/guides/run-nodejs-in-a-sandbox
Build a reusable VM snapshot with the Node.js runtime, then run as many JavaScript snippets as you like on one long-lived sandbox VM.
Build a snapshot with the Node.js runtime baked in, create one VM from it, then wrap it in a small `runNode()` helper that runs as many scripts as you like on that single sandbox.
## Install the SDK
```bash pnpm
pnpm add freestyle
```
```bash bun
bun add freestyle
```
```bash npm
npm install freestyle
```
```bash yarn
yarn add freestyle
```
Set your API key before calling the API:
```bash
export FREESTYLE_API_KEY="your-api-key"
```
## Build a Snapshot with the Node.js Runtime
The base image is minimal, so install Node.js explicitly. Use [nvm](https://github.com/nvm-sh/nvm) so the version is pinned and reproducible — install it into a fixed `NVM_DIR`, then install the Node version you want. The exec shell is a non-login `sh`, so set `NVM_DIR` and source `nvm.sh` in each command. Once the runtime is in place, snapshot the VM and delete the builder.
```ts
import { freestyle } from "freestyle";
const { vm: builder } = await freestyle.vms.create({ name: "node-builder" });
// Install nvm into a fixed directory, then install Node 22 and make it the default.
await builder.exec(
"export HOME=/root NVM_DIR=/opt/nvm && mkdir -p $NVM_DIR && " +
"curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash",
);
await builder.exec(
"export HOME=/root NVM_DIR=/opt/nvm && . $NVM_DIR/nvm.sh && nvm install 22 && nvm alias default 22",
);
// Source nvm so the right node is on PATH for every command below.
const node = "export NVM_DIR=/opt/nvm && . $NVM_DIR/nvm.sh &&";
// Confirm the runtime is present (nvm puts its node first on PATH).
console.log((await builder.exec(`${node} node -v`)).stdout?.trim()); // v22.22.3
console.log((await builder.exec(`${node} npm -v`)).stdout?.trim()); // 10.9.8
// Bake it into a reusable snapshot, then drop the builder.
const { snapshotId } = await builder.snapshot();
await builder.delete();
```
Every VM you create from `snapshotId` boots with Node.js ready, so you only pay the install cost once.
## A Reusable runNode() Utility
The VM is your reusable sandbox: create it once, then run as many snippets as you like on it. Wrap that in a helper that takes the `vm` as its first argument, writes the script with `vm.fs.writeTextFile`, and runs it synchronously with `vm.exec`. Because Node was installed with nvm, the run command reuses the same `node` prefix so the right `node` is on `PATH`. `vm.exec` blocks until the program exits and hands you `{ stdout, stderr, statusCode }`, so a single call captures the whole run.
```ts
async function runNode(vm, code: string) {
const file = `/tmp/main-${crypto.randomUUID()}.mjs`;
await vm.fs.writeTextFile(file, code);
return await vm.exec(`${node} node ${file}`);
}
```
The helper never creates or deletes a VM — it just writes and runs. Each call writes to a unique `/tmp/main-.mjs` path, so running many snippets on the same VM, sequentially or concurrently, can never overwrite each other's script.
Create one VM and reuse it across every call:
```ts
const { vm } = await freestyle.vms.create({ name: "node-sandbox", snapshotId });
const sumResult = await runNode(
vm,
`const nums = [1, 2, 3, 4, 5];
const sum = nums.reduce((a, b) => a + b, 0);
console.log(\`node \${process.version}\`);
console.log(\`sum=\${sum}\`);`,
);
console.log(sumResult.statusCode); // 0
console.log(sumResult.stdout); // node v22.22.3\nsum=15
// Same VM, another snippet — no new machine needed.
const upperResult = await runNode(
vm,
`console.log("hello".toUpperCase());`,
);
console.log(upperResult.stdout.trim()); // HELLO
```
## Pass Arguments and Throw on Failure
A raw `{ stdout, stderr, statusCode }` is easy to misuse — it is simple to forget to check `statusCode`. Make the helper safe by default: take the same reused `vm`, accept an `args` array forwarded to `process.argv`, throw when the script exits non-zero, and return just the captured streams. Like `runNode`, it never touches the VM lifecycle.
```ts
async function runNodeStrict(vm, code: string, args: string[] = []) {
const file = `/tmp/main-${crypto.randomUUID()}.mjs`;
await vm.fs.writeTextFile(file, code);
const argv = args
.map((a) => `'${String(a).replace(/'/g, "'\\''")}'`)
.join(" ");
const result = await vm.exec(`${node} node ${file} ${argv}`);
if (result.statusCode !== 0) {
throw new Error(
`node exited with status ${result.statusCode}: ${result.stderr ?? ""}`,
);
}
return { stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
}
```
Pass the same `vm` you already created. The `args` are single-quoted before they reach the shell, so they arrive intact in `process.argv`.
```ts
const { stdout } = await runNodeStrict(
vm,
`const [a, b] = process.argv.slice(2).map(Number);
console.log(\`product=\${a * b}\`);`,
["6", "7"],
);
console.log(stdout); // product=42
// A failing script rejects instead of returning a bad result.
await runNodeStrict(vm, "process.exit(3);"); // throws: node exited with status 3
```
## Run a Server
The same snapshot that runs one-off scripts can also host a long-lived HTTP server. A Freestyle snapshot is a full memory and disk capture, so it preserves a *running* service: stand the server up under systemd on a fresh VM from `snapshotId`, then map a domain and reach it from the public internet.
Create a separate VM from the same snapshot (a distinct `server` binding, and capture its `vmId` for routing):
```ts
const { vm: server, vmId } = await freestyle.vms.create({
name: "node-server",
snapshotId,
idleTimeoutSeconds: null,
});
```
Write the server to `/srv`. It must bind `0.0.0.0` (not `127.0.0.1`) so traffic routed in from outside the VM can reach it.
```ts
await server.fs.writeTextFile(
"/srv/server.js",
`const http = require("http");
const httpServer = http.createServer((req, res) => {
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("Hello from a Node.js server in a sandbox!\\n");
});
httpServer.listen(3000, "0.0.0.0", () => {
console.log("listening on 0.0.0.0:3000");
});
`,
);
```
Run the server under **systemd** so it is supervised and restarts on crash. A systemd unit does not source your shell profile, so `ExecStart` must use the *absolute* `node` path, and any variables go through `Environment=`. Resolve that absolute path first:
```ts
const nodePath = (
await server.exec(
"export NVM_DIR=/opt/nvm && . $NVM_DIR/nvm.sh && command -v node",
)
).stdout.trim(); // /opt/nvm/versions/node/v22.22.3/bin/node
await server.fs.writeTextFile(
"/etc/systemd/system/nodeserver.service",
`[Unit]
Description=Node HTTP server
After=network.target
[Service]
ExecStart=${nodePath} /srv/server.js
WorkingDirectory=/srv
Restart=always
Environment=HOME=/root
[Install]
WantedBy=multi-user.target
`,
);
await server.exec(
"systemctl daemon-reload && systemctl enable --now nodeserver",
);
```
Wait until the server actually answers — poll for HTTP 200 on `localhost:3000` so you know it is listening before you route traffic to it.
```ts
let ready = false;
for (let i = 0; i < 30 && !ready; i++) {
const probe = await server.exec(
"curl -s -o /dev/null -w '%{http_code}' http://localhost:3000 || true",
);
ready = probe.stdout?.trim() === "200";
if (!ready) await new Promise((r) => setTimeout(r, 1000));
}
if (!ready) throw new Error("server did not become ready");
```
Map a domain to the VM's port. Pick your own unique subdomain under `style.dev` — it needs no DNS verification.
```ts
const domain = `my-app-${crypto.randomUUID().slice(0, 8)}.style.dev`; // choose your own unique subdomain
await freestyle.domains.mappings.create({ domain, vmId, vmPort: 3000 });
```
Fetch it from outside the VM:
```ts
const res = await fetch(`https://${domain}`);
console.log(res.status); // 200
console.log(await res.text()); // Hello from a Node.js server in a sandbox!
```
Snapshot this VM too and every machine you create from it boots with the server already listening on port 3000 — no startup step, just map a domain and go.
## 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](https://www.freestyle.sh/docs/vms/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:
```ts
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 nodeserver -f\n");
// session.detach() drops your handle — the service keeps running in the VM.
```
---
# How to Run Bun in a Sandbox
Source: https://www.freestyle.sh/docs/guides/run-bun-in-a-sandbox
Bake the Bun runtime into a VM snapshot, then reuse one sandbox VM to run as much TypeScript and JavaScript as you like.
This guide bakes the Bun JavaScript/TypeScript runtime into a reusable snapshot, then defines a small `runBun()` utility that takes a VM and runs a snippet on it. You boot one VM from the snapshot and reuse it across as many runs as you like, capturing each snippet's output.
## Install the SDK
```bash pnpm
pnpm add freestyle
```
```bash bun
bun add freestyle
```
```bash npm
npm install freestyle
```
```bash yarn
yarn add freestyle
```
Set your API key before calling the API:
```bash
export FREESTYLE_API_KEY="your-api-key"
```
## Build a Snapshot with the Bun Runtime
The base image is minimal, so install Bun explicitly. The exec shell is non-login with `HOME` unset, so set `HOME` and a fixed `BUN_INSTALL` dir, and install `unzip` since the installer needs it. Snapshot the VM once Bun is in place, then delete the builder.
```ts
import { freestyle } from "freestyle";
const { vm: builder } = await freestyle.vms.create({ name: "bun-builder" });
await builder.exec("apt-get update -qq && apt-get install -y -qq unzip");
await builder.exec(
"export HOME=/root && export BUN_INSTALL=/opt/bun && curl -fsSL https://bun.sh/install | bash",
);
// Confirm the binary lives at a known absolute path.
const version = await builder.exec("/opt/bun/bin/bun --version");
console.log(version.stdout); // e.g. 1.3.14
const { snapshotId } = await builder.snapshot();
await builder.delete();
```
The `/opt/bun` install directory persists into every VM created from this snapshot, so you only pay the install cost once.
## A Reusable runBun() Utility
With the snapshot built, wrap a single run in a helper that takes the VM as its first argument. It writes the code to a uniquely named file and runs it synchronously with `vm.exec`. The helper owns no VM lifecycle: you create the VM once and reuse it across every call. Bun is already installed, so call it by its absolute path.
```ts
async function runBun(vm, code: string) {
const file = `/tmp/main-${crypto.randomUUID()}.ts`;
await vm.fs.writeTextFile(file, code);
return await vm.exec(
`export HOME=/root && cd /tmp && /opt/bun/bin/bun run ${file}`,
);
}
```
Each call writes to a unique `/tmp/main-.ts` path via `crypto.randomUUID()`, so many runs on the same VM — sequential or concurrent — can never overwrite each other's code. Each call returns `{ stdout, stderr, statusCode }`, where `statusCode` of `0` means success.
Boot one VM from the snapshot, then run as many snippets as you like on it before deleting it once:
```ts
const { vm } = await freestyle.vms.create({ name: "bun-sandbox", snapshotId });
const first = await runBun(
vm,
`const greet = (name: string): string => \`Hello, \${name}!\`;
console.log(greet("Bun"));
console.log("Bun version:", Bun.version);`,
);
console.log(first.statusCode); // 0
console.log(first.stdout);
// Hello, Bun!
// Bun version: 1.3.14
// The same VM happily runs another, unrelated snippet.
const second = await runBun(vm, `console.log(2 + 40);`);
console.log(second.stdout); // 42
```
The VM is the reusable sandbox: create it once and run any number of snippets on it. The unique filename is what keeps those runs from clashing.
## Do a Bit More: Typed Errors and Ad-Hoc Dependencies
The bare helper returns a status code you have to check by hand, and it can only run code that uses Bun's built-ins. A second helper, `runBunStrict`, adds two things that make it genuinely useful: it throws on a non-zero exit so failures surface as exceptions, and it accepts an `install` option that `bun add`s npm packages before the run. Like `runBun`, it never touches the VM lifecycle, so it drops onto the same VM you already booted.
```ts
async function runBunStrict(vm, code: string, { install = [] }: { install?: string[] } = {}) {
const file = `/tmp/main-${crypto.randomUUID()}.ts`;
await vm.fs.writeTextFile(file, code);
if (install.length > 0) {
const add = await vm.exec(
`export HOME=/root && cd /tmp && /opt/bun/bin/bun add ${install.join(" ")}`,
);
if (add.statusCode !== 0) throw new Error(`bun add failed: ${add.stderr}`);
}
const r = await vm.exec(
`export HOME=/root && cd /tmp && /opt/bun/bin/bun run ${file}`,
);
if (r.statusCode !== 0) {
throw new Error(`runBunStrict exited with ${r.statusCode}: ${r.stderr ?? r.stdout}`);
}
return r;
}
```
A snippet can pull in a dependency for the run, and a crash throws instead of returning a bad status code:
```ts
// Reuse the same `vm` from before — no need to create another.
const result = await runBunStrict(
vm,
`import { nanoid } from "nanoid";
console.log("id length:", nanoid().length);`,
{ install: ["nanoid"] },
);
console.log(result.stdout); // id length: 21
// A non-zero exit now raises an error you can catch.
try {
await runBunStrict(vm, `process.exit(3);`);
} catch (err) {
console.error(err.message); // runBunStrict exited with 3: ...
}
```
The dependency is installed into the running VM, so later snippets on it can use it too, while the snapshot itself never changes.
## Run a Server
The same runtime snapshot is enough to host a long-lived HTTP server. Boot a fresh VM from it, write the server source and a systemd unit, and let systemd keep the process up. Reuse the `snapshotId` you built above — Bun already lives at `/opt/bun/bin/bun`.
systemd units do not source `~/.bashrc`, so the unit sets `HOME` with `Environment=` and calls Bun by its absolute path. The server binds `0.0.0.0` so traffic from outside the VM can reach it.
```ts
const { vm: server, vmId } = await freestyle.vms.create({
name: "bun-server",
snapshotId,
idleTimeoutSeconds: null,
});
const source = `Bun.serve({
port: 3000,
hostname: "0.0.0.0",
fetch(req) {
const url = new URL(req.url);
return new Response(
JSON.stringify({ message: "Hello from Bun on Freestyle!", path: url.pathname }),
{ headers: { "content-type": "application/json" } },
);
},
});
`;
const unit = `[Unit]
Description=Bun HTTP server
After=network.target
[Service]
Type=simple
Environment=HOME=/root
WorkingDirectory=/srv
ExecStart=/opt/bun/bin/bun /srv/server.ts
Restart=always
[Install]
WantedBy=multi-user.target
`;
await server.exec("mkdir -p /srv");
await server.fs.writeTextFile("/srv/server.ts", source);
await server.fs.writeTextFile("/etc/systemd/system/bun-server.service", unit);
```
Enable the service, then poll until it actually answers on `localhost` so you only route traffic to a server that is really up.
```ts
await server.exec("systemctl daemon-reload && systemctl enable --now bun-server");
let ready = false;
for (let i = 0; i < 30; i++) {
const probe = await server.exec(
"curl -s -o /dev/null -w '%{http_code}' http://localhost:3000",
);
if (probe.stdout.trim() === "200") {
ready = true;
break;
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
if (!ready) throw new Error("Bun server never became ready");
```
Now map a domain to the VM's port with `freestyle.domains.mappings.create`. Pick your own unique subdomain under `style.dev`; `style.dev` domains need no verification, so the mapping works immediately.
```ts
// Pick a unique subdomain you control.
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:
```ts
const res = await fetch(`https://${domain}`);
console.log(res.status); // 200
console.log(await res.text()); // {"message":"Hello from Bun on Freestyle!","path":"/"}
```
## 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](https://www.freestyle.sh/docs/vms/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:
```ts
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 bun-server -f\n");
// session.detach() drops your handle — the service keeps running in the VM.
```
---
# How to Run Deno in a Sandbox
Source: https://www.freestyle.sh/docs/guides/run-deno-in-a-sandbox
Bake the Deno runtime into a VM snapshot, then reuse one isolated sandbox to run as many TypeScript snippets as you like — or boot the same snapshot as a public HTTP server.
This guide builds a reusable snapshot with the Deno runtime preinstalled, then wraps it in a `runDeno()` helper that takes a VM and runs one TypeScript snippet on it synchronously. Boot a single VM from the snapshot and reuse it across many runs — sequentially or concurrently. The same snapshot also boots a long-lived HTTP server you can route a public domain to.
## Install the SDK
```bash pnpm
pnpm add freestyle
```
```bash bun
bun add freestyle
```
```bash npm
npm install freestyle
```
```bash yarn
yarn add freestyle
```
Set your API key before calling the API:
```bash
export FREESTYLE_API_KEY="your-api-key"
```
## Build a Snapshot with the Deno Runtime
Create a builder VM, install Deno explicitly, and snapshot it so the runtime is baked in. The base image is minimal, so install `unzip` (the Deno installer needs it) and pin an install directory. The exec shell is non-login with no `HOME`, so set one for the installer. Delete the builder VM once the snapshot is captured.
```ts
import { freestyle } from "freestyle";
const { vm: builder } = await freestyle.vms.create({ name: "deno-builder" });
await builder.exec("apt-get update -qq && apt-get install -y -qq unzip curl");
await builder.exec(
"export HOME=/root && export DENO_INSTALL=/opt/deno && curl -fsSL https://deno.land/install.sh | sh",
);
// Verify the binary landed at /opt/deno/bin/deno
const version = await builder.exec("/opt/deno/bin/deno --version");
console.log(version.stdout); // deno 2.8.2 (stable, release, x86_64-unknown-linux-gnu)
const { snapshotId } = await builder.snapshot();
await builder.delete();
console.log("snapshot ready:", snapshotId);
```
## A Reusable runDeno() Utility
With the snapshot in hand, the run helper is small. It takes a `vm` as its first argument, writes the code to a file, and runs it once with `vm.exec` — no VM lifecycle inside the helper. `vm.exec` is synchronous and returns `{ stdout, stderr, statusCode }`. Use the absolute binary path, since the exec shell has a minimal `PATH`.
```ts
async function runDeno(vm, code: string) {
const file = `/tmp/main-${crypto.randomUUID()}.ts`;
await vm.fs.writeTextFile(file, code);
return await vm.exec(`/opt/deno/bin/deno run ${file}`);
}
// One VM is a reusable sandbox: boot it once, run as many snippets as you like.
const { vm } = await freestyle.vms.create({ name: "deno-sandbox", snapshotId });
const result = await runDeno(
vm,
`const greet = (name: string): string => \`Hello, \${name}, from Deno \${Deno.version.deno}!\`;
console.log(greet("there"));`,
);
console.log(result.stdout); // Hello, there, from Deno 2.8.2!
// Reuse the same VM for another run — no new VM needed.
const sum = await runDeno(vm, `console.log(2 + 40);`);
console.log(sum.stdout); // 42
```
Each call writes to a unique `/tmp/main-.ts` path via `crypto.randomUUID()`, so reusing one VM is safe even when runs happen concurrently — no two calls can clobber each other's file. The VM itself is the reusable sandbox: create it once and run any number of snippets. Deno is secure by default, so a program that needs file, network, or env access has to opt in with `--allow-*` flags.
## Do a Bit More: Typed Results, Permissions, and Args
`runDeno()` is fine for quick runs, but a real helper should fail loudly and let the caller grant capabilities. Add a second, stricter helper alongside it — `runDenoStrict()` — that still takes the `vm` first, throws on a non-zero exit, accepts a `permissions` list that maps to Deno's `--allow-*` flags, and forwards `args` to `Deno.args`. It is a distinct function from `runDeno`, so both can live in the same script, and it runs on the same reusable VM you created above.
```ts
interface RunOptions {
permissions?: string[]; // e.g. ["net", "read"] -> --allow-net --allow-read
args?: string[]; // forwarded to Deno.args
}
async function runDenoStrict(vm, code: string, { permissions = [], args = [] }: RunOptions = {}) {
const file = `/tmp/main-${crypto.randomUUID()}.ts`;
await vm.fs.writeTextFile(file, code);
const flags = permissions.map((p) => `--allow-${p}`).join(" ");
const argv = args.map((a) => `'${a}'`).join(" ");
const cmd = `/opt/deno/bin/deno run ${flags} ${file} ${argv}`.trim();
const r = await vm.exec(cmd);
if (r.statusCode !== 0) {
throw new Error(`Deno exited ${r.statusCode}:\n${r.stderr ?? ""}`);
}
return { stdout: r.stdout ?? "", stderr: r.stderr ?? "", statusCode: r.statusCode };
}
// Same `vm` from above — pass args through to Deno.args.
const echo = await runDenoStrict(vm, `console.log(\`args: \${Deno.args.join(", ")}\`);`, {
args: ["alpha", "beta"],
});
console.log(echo.stdout); // args: alpha, beta
// Grant network access to fetch a real endpoint.
const fetched = await runDenoStrict(
vm,
`const r = await fetch("https://example.com");\nconsole.log(r.status);`,
{ permissions: ["net"] },
);
console.log(fetched.stdout); // 200
// Unique filenames keep concurrent runs on the one VM from clashing.
const [a, b] = await Promise.all([
runDenoStrict(vm, `console.log("A");`),
runDenoStrict(vm, `console.log("B");`),
]);
console.log(a.stdout, b.stdout); // A B
```
A snippet that throws now surfaces as a rejected promise instead of a silent non-zero exit, so failures are easy to catch in calling code. Use `runDeno` for quick, fire-and-forget runs and `runDenoStrict` when you want failures to throw and need permissions or args.
## Run a Server
The same runtime snapshot runs a long-lived HTTP server. Instead of executing one-shot snippets, boot a fresh VM from `snapshotId`, write the server source plus a systemd unit that keeps it running, then map a public domain to it. Use a distinct `server` binding so it lives alongside the run-code VM above — `idleTimeoutSeconds: null` keeps the server from being paused while idle.
```ts
const { vm: server, vmId } = await freestyle.vms.create({
name: "deno-server",
snapshotId,
idleTimeoutSeconds: null,
});
```
Write the server to `/srv`. `Deno.serve` binds `0.0.0.0` so traffic from outside the VM reaches it:
```ts
await server.exec("mkdir -p /srv");
await server.fs.writeTextFile(
"/srv/server.ts",
`Deno.serve({ port: 3000, hostname: "0.0.0.0" }, (req) => {
const url = new URL(req.url);
return new Response(
JSON.stringify({ message: "Hello from Deno!", path: url.pathname }),
{ headers: { "content-type": "application/json" } },
);
});`,
);
```
systemd is PID 1 in the VM. A unit file keeps the server running and restarts it if it crashes. Units do not source a shell profile, so use the absolute path to the `deno` binary in `ExecStart` and set `HOME` explicitly:
```ts
await server.fs.writeTextFile(
"/etc/systemd/system/deno-server.service",
`[Unit]
Description=Deno HTTP server
After=network.target
[Service]
Environment=HOME=/root
ExecStart=/opt/deno/bin/deno run --allow-net /srv/server.ts
WorkingDirectory=/srv
Restart=always
[Install]
WantedBy=multi-user.target`,
);
```
Enable and start the service, then wait until it actually serves HTTP `200` on `localhost` before mapping a domain:
```ts
await server.exec("systemctl daemon-reload && systemctl enable --now deno-server");
let ready = false;
for (let i = 0; i < 30; i++) {
const probe = await server.exec(
"curl -s -o /dev/null -w '%{http_code}' http://localhost:3000",
);
if (probe.stdout.trim() === "200") {
ready = true;
break;
}
await new Promise((resolve) => setTimeout(resolve, 2000));
}
if (!ready) throw new Error("Deno server never became ready");
```
Map a domain to the VM's port. `style.dev` subdomains need no verification, so the route is live immediately, and each run gets a unique subdomain:
```ts
const domain = `my-app-${crypto.randomUUID().slice(0, 8)}.style.dev`;
await freestyle.domains.mappings.create({ domain, vmId, vmPort: 3000 });
```
Now the server is reachable from anywhere. Fetch it from outside the VM:
```ts
const res = await fetch(`https://${domain}`);
console.log(res.status); // 200
console.log(await res.text()); // {"message":"Hello from Deno!","path":"/"}
```
The snapshot is your reusable image: it boots either as a sandbox for one-shot snippets or as a server you route a domain to. You pay the install cost once, then launch as many VMs from it as you need — map a fresh domain to each server VM to run multiple copies side by side.
## 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](https://www.freestyle.sh/docs/vms/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:
```ts
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 deno-server -f\n");
// session.detach() drops your handle — the service keeps running in the VM.
```
---
# How to Run Python in a Sandbox
Source: https://www.freestyle.sh/docs/guides/run-python-in-a-sandbox
Build a reusable VM snapshot with Python, then run many scripts on one long-lived, isolated sandbox VM.
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
```bash pnpm
pnpm add freestyle
```
```bash bun
bun add freestyle
```
```bash npm
npm install freestyle
```
```bash yarn
yarn add freestyle
```
Set your API key before calling the API:
```bash
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.
```ts
import { freestyle } from "freestyle";
const { vm: builder } = await freestyle.vms.create({ name: "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-.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.
```ts
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:
```ts
const { vm } = await freestyle.vms.create({ name: "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.
```ts
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`:
```ts
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:
```ts
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:
```ts
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.
```ts
const { vm: server, vmId } = await freestyle.vms.create({
name: "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:
```ts
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:
```ts
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`:
```ts
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:
```ts
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:
```ts
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](https://www.freestyle.sh/docs/vms/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:
```ts
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.
```
---
# How to Run Python with uv in a Sandbox
Source: https://www.freestyle.sh/docs/guides/run-python-with-uv-in-a-sandbox
Bake uv into a VM snapshot, then reuse one sandbox to run many Python scripts with a small helper.
This guide bakes the [uv](https://docs.astral.sh/uv/) Python package manager into a reusable snapshot, then wraps it in a `runUv()` helper that takes a VM as its first argument, writes the code to a uniquely-named file, runs it synchronously, and returns the captured output. You create the sandbox once and run as many scripts on it as you like. The same snapshot also powers a long-lived web server you can route a public domain to.
## Install the SDK
```bash pnpm
pnpm add freestyle
```
```bash bun
bun add freestyle
```
```bash npm
npm install freestyle
```
```bash yarn
yarn add freestyle
```
Set your API key before calling the API:
```bash
export FREESTYLE_API_KEY="your-api-key"
```
## Build a snapshot with uv
Create a builder VM from the minimal base image, install uv, and pin a Python version, then snapshot the machine so the install is baked in. The exec shell is a non-login `sh` with no `HOME` and a bare `PATH`, so set `HOME` and call the uv binary by its absolute path.
```ts
import { freestyle } from "freestyle";
const { vm: builder } = await freestyle.vms.create({ name: "python-uv-builder" });
const install = await builder.exec(
"export HOME=/root && curl -LsSf https://astral.sh/uv/install.sh | sh",
);
console.log(install.statusCode); // 0
const python = await builder.exec(
"export HOME=/root && /root/.local/bin/uv python install 3.12",
);
console.log(python.statusCode); // 0
const { snapshotId } = await builder.snapshot();
await builder.delete();
```
uv installs to `/root/.local/bin/uv` and the pinned Python lives under `/root/.local/share/uv`, so both persist in the snapshot. Every VM you create from `snapshotId` boots with Python ready, so you only pay the install cost once.
## A reusable runUv() utility
The VM is the reusable sandbox. Create it once and pass it into a helper that writes the code to a uniquely-named file and runs it with `vm.exec`. The helper never creates or deletes the VM, so the same machine serves every call. The `crypto.randomUUID()` filename means sequential or concurrent runs never clash, since no two calls can overwrite each other's script. `vm.exec` runs synchronously and returns `{ stdout, stderr, statusCode }` after the script exits.
```ts
async function runUv(vm, code: string) {
const file = `/tmp/main-${crypto.randomUUID()}.py`;
await vm.fs.writeTextFile(file, code);
return await vm.exec(
`export HOME=/root && /root/.local/bin/uv run --python 3.12 ${file}`,
);
}
```
Create one VM from the snapshot, then call `runUv()` as many times as you want on it. The returned `stdout` holds whatever each script printed:
```ts
const { vm } = await freestyle.vms.create({ name: "python-uv-sandbox", snapshotId });
const hello = await runUv(
vm,
'import platform\nprint("hello from uv", platform.python_version())\n',
);
console.log(hello.stdout); // hello from uv 3.12.13
console.log(hello.statusCode); // 0
const math = await runUv(vm, "print(2 + 2)\n");
console.log(math.stdout); // 4
```
## A stricter runUvStrict() with args, ad-hoc deps, and a typed result
The base `runUv()` hands back a raw `{ stdout, stderr, statusCode }`, which is easy to misuse — it is simple to forget to check `statusCode`. Add a second, stricter helper, `runUvStrict()`, that lives alongside `runUv()` and makes the safe behavior the default: it accepts command-line arguments, installs ad-hoc dependencies for a single run, throws when the script exits non-zero, and returns just the captured streams. Like `runUv()`, it still takes the same `vm` so you keep reusing one sandbox. uv's `--with` flag installs packages into an ephemeral environment just for that run, and `sys.argv` receives anything you append after the script path.
```ts
async function runUvStrict(
vm,
code: string,
{ args = [], dependencies = [] }: { args?: string[]; dependencies?: string[] } = {},
): Promise<{ stdout: string; stderr: string }> {
const file = `/tmp/main-${crypto.randomUUID()}.py`;
await vm.fs.writeTextFile(file, code);
const withFlags = dependencies.flatMap((d) => ["--with", d]);
const command = [
"export HOME=/root &&",
"/root/.local/bin/uv run --python 3.12",
...withFlags,
file,
...args,
].join(" ");
const { stdout, stderr, statusCode } = await vm.exec(command);
if (statusCode !== 0) {
throw new Error(`Python exited with status ${statusCode}:\n${stderr ?? stdout}`);
}
return { stdout: stdout ?? "", stderr: stderr ?? "" };
}
```
Run it against the same `vm`. Pass arguments through `sys.argv`, or name a dependency to install it on the fly:
```ts
const argv = await runUvStrict(vm, 'import sys\nprint("args:", " ".join(sys.argv[1:]))\n', {
args: ["alpha", "beta"],
});
console.log(argv.stdout); // args: alpha beta
const cow = await runUvStrict(vm, 'import cowsay\ncowsay.cow("uv installs deps on demand")\n', {
dependencies: ["cowsay"],
});
console.log(cow.stdout.includes("uv installs deps on demand")); // true
```
Because each call writes its own uniquely-named file, you can even fan out runs concurrently on the one VM without them clashing:
```ts
const [one, two] = await Promise.all([
runUvStrict(vm, "import sys\nprint(sys.argv[1])\n", { args: ["one"] }),
runUvStrict(vm, "import sys\nprint(sys.argv[1])\n", { args: ["two"] }),
]);
console.log(one.stdout, two.stdout); // one two
```
A script that exits non-zero rejects the promise:
```ts
await runUvStrict(vm, 'raise SystemExit("boom")\n'); // throws: Python exited with status 1: boom
```
## Run a Server
The same uv snapshot is all you need to host a long-lived web server. Create a fresh VM from it — give the binding a distinct name like `server` so it stays separate from the run-code `vm` above — and pass `idleTimeoutSeconds: null` so the machine stays up while it serves traffic:
```ts
const { vm: server, vmId } = await freestyle.vms.create({
name: "python-uv-server",
snapshotId,
idleTimeoutSeconds: null,
});
```
Write the server source to `/srv`. The app must bind `0.0.0.0` (not `127.0.0.1`) so traffic routed to the VM can reach it:
```ts
const source = `from flask import Flask
app = Flask(__name__)
@app.get("/")
def index():
return "hello from a uv-managed python server\\n"
if __name__ == "__main__":
app.run(host="0.0.0.0", port=3000)
`;
await server.exec("mkdir -p /srv");
await server.fs.writeTextFile("/srv/server.py", source);
```
Initialize a uv project in `/srv` and add Flask. uv writes a `pyproject.toml` and a locked virtual environment under `/srv`:
```ts
const deps = await server.exec(
"export HOME=/root && cd /srv && " +
"/root/.local/bin/uv init --python 3.12 --no-readme --bare && " +
"/root/.local/bin/uv add flask",
);
console.log(deps.statusCode); // 0
```
Register the server as a systemd service. systemd is PID 1 on the VM, so a unit with `Restart=always` keeps the server alive and brings it back on boot. Units do not source a shell profile, so use the absolute `uv` path and declare `Environment=HOME=/root` and `WorkingDirectory=/srv` explicitly:
```ts
const unit = `[Unit]
Description=uv python server
After=network.target
[Service]
Environment=HOME=/root
WorkingDirectory=/srv
ExecStart=/root/.local/bin/uv run --python 3.12 server.py
Restart=always
[Install]
WantedBy=multi-user.target
`;
await server.fs.writeTextFile("/etc/systemd/system/uvserver.service", unit);
const enable = await server.exec(
"systemctl daemon-reload && systemctl enable --now uvserver",
);
console.log(enable.statusCode); // 0
```
Wait until the server actually answers on localhost before routing traffic to it. Poll until you see HTTP `200`:
```ts
let ready = false;
for (let attempt = 0; attempt < 40; attempt++) {
const probe = await server.exec(
"curl -s -o /dev/null -w '%{http_code}' http://localhost:3000",
);
if ((probe.stdout ?? "").trim() === "200") {
ready = true;
break;
}
await new Promise((resolve) => setTimeout(resolve, 2000));
}
if (!ready) throw new Error("server never became ready");
```
Map a public domain to the VM's port. `style.dev` subdomains need no verification, and each run gets a unique one:
```ts
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:
```ts
const res = await fetch(`https://${domain}`);
console.log(res.status); // 200
console.log(await res.text()); // hello from a uv-managed python server
```
You now have one uv snapshot that does double duty: pass a VM into `runUv()` to run scripts on demand, or create a VM from the same image, register a systemd service, and route a public domain straight to it.
## 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](https://www.freestyle.sh/docs/vms/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:
```ts
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 uvserver -f\n");
// session.detach() drops your handle — the service keeps running in the VM.
```
---
# How to Run Java in a Sandbox
Source: https://www.freestyle.sh/docs/guides/run-java-in-a-sandbox
Bake the JDK into a VM snapshot, boot one VM, and run many Java programs on it with an isolated, reusable runJava() helper — then serve a Java HTTP server from the same snapshot over HTTPS.
This guide builds a reusable snapshot with the JDK preinstalled, boots one VM from it, then wraps that VM in a `runJava()` helper you can call as many times as you like — each call writes to a uniquely-named file, so runs never clash, sequentially or concurrently. The same snapshot also powers a long-running HTTP server you can reach over HTTPS.
## Install the SDK
```bash pnpm
pnpm add freestyle
```
```bash bun
bun add freestyle
```
```bash npm
npm install freestyle
```
```bash yarn
yarn add freestyle
```
Set your API key before calling the API:
```bash
export FREESTYLE_API_KEY="your-api-key"
```
## Build a Snapshot with Java
Create a VM from the minimal base image and install [Amazon Corretto](https://aws.amazon.com/corretto/) — a free, production-ready OpenJDK build — from its apt repository, then snapshot the machine so the install is baked in. Add Corretto's signing key and repo, then install the JDK. The minimal image also lacks `/usr/share/man/man1`, which the JDK post-install configures into — create it first so the install finishes cleanly. Delete the builder VM once the snapshot is captured.
```ts
import { freestyle } from "freestyle";
const { vm: builder } = await freestyle.vms.create({ name: "java-builder" });
// Add the Amazon Corretto apt repository (needs gnupg for the signing key).
await builder.exec(
"apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq gnupg ca-certificates",
);
await builder.exec(
"curl -fsSL https://apt.corretto.aws/corretto.key | gpg --dearmor -o /usr/share/keyrings/corretto-keyring.gpg && " +
"echo 'deb [signed-by=/usr/share/keyrings/corretto-keyring.gpg] https://apt.corretto.aws stable main' > /etc/apt/sources.list.d/corretto.list",
);
// Install Corretto 21 (the man dir must exist for the JDK post-install step).
await builder.exec("mkdir -p /usr/share/man/man1");
const install = await builder.exec(
"apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq java-21-amazon-corretto-jdk",
);
console.log(install.statusCode); // 0
const version = await builder.exec("java -version 2>&1");
console.log(version.stdout); // OpenJDK Runtime Environment Corretto-21.0.11.10.1 ...
const { snapshotId } = await builder.snapshot();
await builder.delete();
```
## A Reusable runJava() Utility
The VM is the reusable sandbox: create it once, then run as many snippets as you like on it. The helper takes the `vm` as its first argument, writes your source to a uniquely-named file, and runs it with the JDK 21 single-file launcher (the filename need not match the public class). Each call writes to `/tmp/main-.java` via `crypto.randomUUID()`, so no two runs — sequential or concurrent — can clobber each other's source. The helper never creates or deletes a VM; lifecycle stays with the caller.
```ts
async function runJava(vm, code: string) {
const file = `/tmp/main-${crypto.randomUUID()}.java`;
await vm.fs.writeTextFile(file, code);
return await vm.exec(`java ${file}`);
}
const { vm } = await freestyle.vms.create({ name: "java-sandbox", snapshotId });
const result = await runJava(vm, `public class Main {
public static void main(String[] args) {
System.out.println("Hello from Java " + System.getProperty("java.version"));
}
}
`);
console.log(result.statusCode); // 0
console.log(result.stdout); // Hello from Java 21.0.11
```
Each `exec` returns `{ stdout, stderr, statusCode }`, where a `statusCode` of `0` means the program exited successfully. Reuse the same `vm` for the next snippet — there is no need to boot a new machine.
## Pass Arguments and Standard Input
The base `runJava` is great for quick snippets, but it hands back a raw result and never checks `statusCode`, so a failed program looks the same as a successful one. Add a second, stricter helper — `runJavaStrict` — that also accepts command-line `args` and `stdin`, returns a typed result, and throws when the program exits non-zero so failures surface as exceptions instead of silent bad output. It is a distinct function with its own name, so both helpers can live side by side in the same script. Like `runJava`, it takes the `vm` first and reuses it — only the per-call source and stdin files are unique.
```ts
async function runJavaStrict(
vm,
code: string,
{ args = [], stdin = "" }: { args?: (string | number)[]; stdin?: string } = {},
): Promise<{ stdout: string; stderr: string; statusCode: number }> {
const id = crypto.randomUUID();
const file = `/tmp/main-${id}.java`;
const stdinFile = `/tmp/stdin-${id}`;
await vm.fs.writeTextFile(file, code);
if (stdin) await vm.fs.writeTextFile(stdinFile, stdin);
const argv = args
.map((a) => `'${String(a).replace(/'/g, "'\\''")}'`)
.join(" ");
const cmd = stdin
? `java ${file} ${argv} < ${stdinFile}`
: `java ${file} ${argv}`;
const r = await vm.exec(cmd);
if (r.statusCode !== 0) {
throw new Error(`runJavaStrict: java exited ${r.statusCode}: ${r.stderr ?? r.stdout}`);
}
return { stdout: r.stdout ?? "", stderr: r.stderr ?? "", statusCode: r.statusCode };
}
```
Run several snippets on the **same** `vm`. Pass an argument to compute a factorial:
```ts
const fact = await runJavaStrict(vm, `public class Main {
public static void main(String[] args) {
long n = Long.parseLong(args[0]), f = 1;
for (long i = 2; i <= n; i++) f *= i;
System.out.println(n + "! = " + f);
}
}
`, { args: [10] });
console.log(fact.stdout); // 10! = 3628800
```
Feed input on stdin and read it with a `Scanner`:
```ts
const sum = await runJavaStrict(vm, `import java.util.Scanner;
public class Main {
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
int total = 0;
while (sc.hasNextInt()) total += sc.nextInt();
System.out.println("sum=" + total);
}
}
`, { stdin: "3 4 5 6\n" });
console.log(sum.stdout); // sum=18
```
Because every run uses a unique filename, you can even fire several at once on the one VM — they stay fully isolated:
```ts
const [a, b] = await Promise.all([
runJavaStrict(vm, `public class Main {
public static void main(String[] x) { System.out.println("run-a"); }
}`),
runJavaStrict(vm, `public class Main {
public static void main(String[] x) { System.out.println("run-b"); }
}`),
]);
console.log(a.stdout, b.stdout); // run-a run-b
```
A program that throws now rejects the promise, so a `try`/`catch` around `runJavaStrict()` cleanly handles a failed run.
## Run a Server
The same snapshot that runs one-off snippets can host a long-running HTTP server. Create a fresh VM from `snapshotId` — give it a distinct name like `server` so it lives alongside the run-code `vm` — and pass `idleTimeoutSeconds: null` so it never idles out from under the service.
```ts
const { vm: server, vmId } = await freestyle.vms.create({
name: "java-server",
snapshotId,
idleTimeoutSeconds: null,
});
```
Write the server source to `/srv`. This uses the built-in `com.sun.net.httpserver.HttpServer`, so there are no dependencies to fetch. It binds to `0.0.0.0` (not `127.0.0.1`) so traffic from outside the VM can reach it.
```ts
await server.fs.writeTextFile(
"/srv/Server.java",
`import com.sun.net.httpserver.HttpServer;
import java.io.OutputStream;
import java.net.InetSocketAddress;
public class Server {
public static void main(String[] args) throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("0.0.0.0", 3000), 0);
server.createContext("/", exchange -> {
byte[] body = "Hello from a Java server in a sandbox!\\n".getBytes("UTF-8");
exchange.getResponseHeaders().set("Content-Type", "text/plain; charset=utf-8");
exchange.sendResponseHeaders(200, body.length);
try (OutputStream os = exchange.getResponseBody()) {
os.write(body);
}
});
server.setExecutor(null);
server.start();
}
}
`,
);
```
Write a systemd unit so the server starts on boot and restarts if it crashes. systemd units do not source a shell profile, so `ExecStart` uses the absolute `java` path (`/usr/bin/java`) and launches the source file directly with the JDK single-file launcher — no separate compile step.
```ts
await server.fs.writeTextFile(
"/etc/systemd/system/java-server.service",
`[Unit]
Description=Java HTTP server
After=network.target
[Service]
ExecStart=/usr/bin/java /srv/Server.java
WorkingDirectory=/srv
Restart=always
[Install]
WantedBy=multi-user.target
`,
);
```
Enable and start the service, then poll until it answers `200` on localhost so you know it is live before routing traffic to it.
```ts
await server.exec("systemctl daemon-reload && systemctl enable --now java-server");
let ready = false;
for (let i = 0; i < 60; i++) {
const probe = await server.exec(
"curl -s -o /dev/null -w '%{http_code}' http://localhost:3000 || true",
);
if (probe.stdout.trim() === "200") {
ready = true;
break;
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
if (!ready) throw new Error("server never became ready");
```
Map a domain to the VM's port with `freestyle.domains.mappings.create` — pick your own unique `style.dev` subdomain in place of `my-app`, since `style.dev` domains need no verification and get HTTPS automatically.
```ts
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:
```ts
const res = await fetch(`https://${domain}`);
console.log(res.status); // 200
console.log(await res.text()); // Hello from a Java server in a sandbox!
```
One snapshot now serves two purposes: a sandbox for running arbitrary Java snippets and a base image for a public HTTPS service — boot a VM from it any time and the JDK is ready with no install cost.
## 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](https://www.freestyle.sh/docs/vms/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:
```ts
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 java-server -f\n");
// session.detach() drops your handle — the service keeps running in the VM.
```
---
# How to Run Ruby in a Sandbox
Source: https://www.freestyle.sh/docs/guides/run-ruby-in-a-sandbox
Build a reusable VM snapshot with the Ruby runtime, then run as many Ruby scripts as you like on one long-lived sandbox VM.
Build a snapshot with the Ruby runtime baked in, create one VM from it, then wrap it in a small `runRuby()` helper that runs as many scripts as you like on that single sandbox.
## Install the SDK
```bash pnpm
pnpm add freestyle
```
```bash bun
bun add freestyle
```
```bash npm
npm install freestyle
```
```bash yarn
yarn add freestyle
```
Set your API key before calling the API:
```bash
export FREESTYLE_API_KEY="your-api-key"
```
## Build a Snapshot with the Ruby Runtime
The base image is minimal, so install Ruby explicitly. Create a VM, install the `ruby` package with `apt-get`, then snapshot the machine so the install is baked in. The exec shell is a non-login `sh` with no `HOME`, so set `DEBIAN_FRONTEND=noninteractive` to keep `apt-get` from prompting. Once the runtime is in place, snapshot the VM and delete the builder.
```ts
import { freestyle } from "freestyle";
const { vm: builder } = await freestyle.vms.create({ name: "ruby-builder" });
// Install Ruby from apt.
const install = await builder.exec(
"apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq ruby",
);
console.log(install.statusCode); // 0
// Confirm the runtime is present.
const version = await builder.exec("/usr/bin/ruby --version");
console.log(version.stdout?.trim()); // ruby 3.3.8 (2025-04-09 revision b200bad6cd) [x86_64-linux-gnu]
// Bake it into a reusable snapshot, then drop the builder.
const { snapshotId } = await builder.snapshot();
await builder.delete();
```
Every VM you create from `snapshotId` boots with Ruby ready, so you only pay the `apt-get` cost once.
## A Reusable runRuby() Utility
The VM is your reusable sandbox: create it once, then run as many snippets as you like on it. Wrap that in a helper that takes the `vm` as its first argument, writes the script with `vm.fs.writeTextFile`, and runs it synchronously with `vm.exec`. The exec shell is non-login with no `HOME` and a bare `PATH`, so call the interpreter by absolute path (`/usr/bin/ruby`) and export `HOME=/root` so gems that expect it behave. `vm.exec` blocks until the program exits and hands you `{ stdout, stderr, statusCode }`, so a single call captures the whole run.
```ts
const ruby = "export HOME=/root && /usr/bin/ruby";
async function runRuby(vm, code: string) {
const file = `/tmp/main-${crypto.randomUUID()}.rb`;
await vm.fs.writeTextFile(file, code);
return await vm.exec(`${ruby} ${file}`);
}
```
The helper never creates or deletes a VM — it just writes and runs. Each call writes to a unique `/tmp/main-.rb` path, so running many snippets on the same VM, sequentially or concurrently, can never overwrite each other's script.
Create one VM and reuse it across every call:
```ts
const { vm } = await freestyle.vms.create({ name: "ruby-sandbox", snapshotId });
const sumResult = await runRuby(
vm,
`nums = [1, 2, 3, 4, 5]
puts "ruby #{RUBY_VERSION}"
puts "sum=#{nums.sum}"`,
);
console.log(sumResult.statusCode); // 0
console.log(sumResult.stdout); // ruby 3.3.8\nsum=15
// Same VM, another snippet — no new machine needed.
const upperResult = await runRuby(vm, `puts "hello".upcase`);
console.log(upperResult.stdout.trim()); // HELLO
```
Because the helper writes a unique file per call, you can also fan out concurrently on the one VM and each run stays isolated:
```ts
const [pow, rev, fact] = await Promise.all([
runRuby(vm, `puts (2 ** 10)`),
runRuby(vm, `puts "abc".reverse`),
runRuby(vm, `puts((1..5).reduce(:*))`),
]);
console.log(pow.stdout.trim(), rev.stdout.trim(), fact.stdout.trim()); // 1024 cba 120
```
## Pass Arguments and Throw on Failure
A raw `{ stdout, stderr, statusCode }` is easy to misuse — it is simple to forget to check `statusCode`. Make the helper safe by default: take the same reused `vm`, accept an `args` array forwarded to Ruby's `ARGV`, throw when the script exits non-zero, and return just the captured streams. Like `runRuby`, it never touches the VM lifecycle.
```ts
async function runRubyStrict(vm, code: string, args: string[] = []) {
const file = `/tmp/main-${crypto.randomUUID()}.rb`;
await vm.fs.writeTextFile(file, code);
const argv = args
.map((a) => `'${String(a).replace(/'/g, "'\\''")}'`)
.join(" ");
const result = await vm.exec(`${ruby} ${file} ${argv}`);
if (result.statusCode !== 0) {
throw new Error(
`ruby exited with status ${result.statusCode}: ${result.stderr ?? ""}`,
);
}
return { stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
}
```
Pass the same `vm` you already created. The `args` are single-quoted before they reach the shell, so they arrive intact in `ARGV`.
```ts
const { stdout } = await runRubyStrict(
vm,
`a, b = ARGV.map(&:to_i)
puts "product=#{a * b}"`,
["6", "7"],
);
console.log(stdout); // product=42
// A failing script rejects instead of returning a bad result.
await runRubyStrict(vm, `exit 3`); // throws: ruby exited with status 3
```
## Run a Server
The same snapshot also runs a long-lived HTTP server. Ruby ships WEBrick, so there is nothing extra to install — create a fresh VM from `snapshotId`, write the server plus a systemd unit, and route a public domain to it. Use a distinct variable (`server`) so it never clashes with the run-code `vm`, and pass `idleTimeoutSeconds: null` so the VM stays up to serve traffic:
```ts
const { vm: server, vmId } = await freestyle.vms.create({
name: "ruby-server",
snapshotId,
idleTimeoutSeconds: null,
});
```
Write the server. WEBrick must bind `0.0.0.0` (not `127.0.0.1`) so the VM accepts connections routed from outside:
```ts
await server.fs.writeTextFile(
"/srv/server.rb",
`require "webrick"
server = WEBrick::HTTPServer.new(BindAddress: "0.0.0.0", Port: 3000)
server.mount_proc "/" do |_req, res|
res.content_type = "text/plain"
res.body = "Hello from Ruby WEBrick on Freestyle!\\n"
end
trap("INT") { server.shutdown }
trap("TERM") { server.shutdown }
server.start
`,
);
```
Write a systemd unit. systemd is PID 1 on the VM, and units do **not** source a shell profile, so use the absolute interpreter path `/usr/bin/ruby` and set `HOME` with `Environment=`. `Restart=always` keeps it up, and `WantedBy=multi-user.target` makes `enable` persist the service:
```ts
await server.fs.writeTextFile(
"/etc/systemd/system/ruby-server.service",
`[Unit]
Description=Ruby WEBrick server
After=network.target
[Service]
ExecStart=/usr/bin/ruby /srv/server.rb
WorkingDirectory=/srv
Environment=HOME=/root
Restart=always
[Install]
WantedBy=multi-user.target
`,
);
```
Reload systemd, enable and start the unit, then **wait until it actually serves HTTP 200** on localhost before routing traffic to it:
```ts
await server.exec(
"systemctl daemon-reload && systemctl enable --now ruby-server",
);
let ready = false;
while (!ready) {
await new Promise((r) => setTimeout(r, 1000));
const probe = await server.exec(
"curl -s -o /dev/null -w '%{http_code}' http://localhost:3000 || true",
);
ready = probe.stdout?.trim() === "200";
}
```
Map a public domain to the VM's port. `style.dev` subdomains need no verification; each run gets a unique one:
```ts
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:
```ts
const res = await fetch(`https://${domain}`);
console.log(res.status); // 200
console.log(await res.text()); // Hello from Ruby WEBrick on Freestyle!
```
You can snapshot `server` at any point to bake the running server into a reusable image — every VM created from that snapshot boots with the server already listening on port 3000.
## 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](https://www.freestyle.sh/docs/vms/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:
```ts
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 ruby-server -f\n");
// session.detach() drops your handle — the service keeps running in the VM.
```
---
# How to Run Go in a Sandbox
Source: https://www.freestyle.sh/docs/guides/run-go-in-a-sandbox
Bake the Go toolchain into a reusable VM snapshot, then run as many Go programs as you like on one long-lived sandbox VM.
Build a snapshot with the Go toolchain baked in, create one VM from it, then wrap it in a small `runGo()` helper that compiles and runs as many programs as you like on that single sandbox.
## Install the SDK
```bash pnpm
pnpm add freestyle
```
```bash bun
bun add freestyle
```
```bash npm
npm install freestyle
```
```bash yarn
yarn add freestyle
```
Set your API key before calling the API:
```bash
export FREESTYLE_API_KEY="your-api-key"
```
## Build a Snapshot with the Go Toolchain
The base image is minimal and the `apt` Go package lags behind, so install the official Go tarball instead. Detect the VM's architecture, download the matching release from `go.dev`, and extract it to `/usr/local/go` so the toolchain lives at `/usr/local/go/bin/go`. The exec shell is a non-login `sh` with no `HOME`, and Go needs a writable build cache, so every Go command exports `HOME=/root` and a fixed `GOCACHE`. Once the runtime is in place, snapshot the VM and delete the builder.
```ts
import { freestyle } from "freestyle";
const { vm: builder } = await freestyle.vms.create({ name: "go-builder" });
// Match the tarball to the VM's CPU architecture (amd64 or arm64).
const arch = (
await builder.exec("/usr/bin/dpkg --print-architecture")
).stdout?.trim();
// Download the official Go release and unpack it into /usr/local/go.
const GO_VERSION = "1.24.4";
await builder.exec(
"export HOME=/root && " +
`/usr/bin/curl -fsSL https://go.dev/dl/go${GO_VERSION}.linux-${arch}.tar.gz ` +
"-o /tmp/go.tar.gz && /bin/tar -C /usr/local -xzf /tmp/go.tar.gz && " +
"rm /tmp/go.tar.gz",
);
// Confirm the toolchain is present.
const version = await builder.exec("/usr/local/go/bin/go version");
console.log(version.stdout?.trim()); // go version go1.24.4 linux/amd64
// Bake it into a reusable snapshot, then drop the builder.
const { snapshotId } = await builder.snapshot();
await builder.delete();
```
Every VM you create from `snapshotId` boots with Go ready, so you only pay the download cost once.
## A Reusable runGo() Utility
The VM is your reusable sandbox: create it once, then run as many programs as you like on it. Wrap that in a helper that takes the `vm` as its first argument, writes the source with `vm.fs.writeTextFile`, and compiles-and-runs it synchronously with `vm.exec`. Because the toolchain lives at a fixed path, the run command calls `/usr/local/go/bin/go` directly and exports the `HOME`/`GOCACHE` Go needs. `vm.exec` blocks until the program exits and hands you `{ stdout, stderr, statusCode }`, so a single call captures the whole run.
```ts
const go =
"export HOME=/root GOCACHE=/root/.cache/go-build && /usr/local/go/bin/go";
async function runGo(vm, code: string) {
const file = `/tmp/main-${crypto.randomUUID()}.go`;
await vm.fs.writeTextFile(file, code);
return await vm.exec(`${go} run ${file}`);
}
```
The helper never creates or deletes a VM — it just writes and runs. Each call writes to a unique `/tmp/main-.go` path, so running many programs on the same VM, sequentially or concurrently, can never overwrite each other's source. Every program must start with `package main` and a `main` function.
Create one VM and reuse it across every call:
```ts
const { vm } = await freestyle.vms.create({ name: "go-sandbox", snapshotId });
const sumResult = await runGo(
vm,
`package main
import "fmt"
func main() {
nums := []int{1, 2, 3, 4, 5}
sum := 0
for _, n := range nums {
sum += n
}
fmt.Println("sum=", sum)
}`,
);
console.log(sumResult.statusCode); // 0
console.log(sumResult.stdout.trim()); // sum= 15
// Same VM, another program — no new machine needed.
const upperResult = await runGo(
vm,
`package main
import (
"fmt"
"strings"
)
func main() {
fmt.Println(strings.ToUpper("hello"))
}`,
);
console.log(upperResult.stdout.trim()); // HELLO
```
## Pass Arguments, Feed stdin, and Throw on Failure
A raw `{ stdout, stderr, statusCode }` is easy to misuse — it is simple to forget to check `statusCode`. Make the helper safe by default: take the same reused `vm`, accept an `args` array forwarded to `os.Args`, pipe an optional `stdin` string into the program, throw when it exits non-zero, and return just the captured streams. Like `runGo`, it never touches the VM lifecycle.
```ts
async function runGoStrict(
vm,
code: string,
opts: { args?: string[]; stdin?: string } = {},
) {
const { args = [], stdin = "" } = opts;
const file = `/tmp/main-${crypto.randomUUID()}.go`;
await vm.fs.writeTextFile(file, code);
const stdinFile = `/tmp/stdin-${crypto.randomUUID()}.txt`;
await vm.fs.writeTextFile(stdinFile, stdin);
const argv = args
.map((a) => `'${String(a).replace(/'/g, "'\\''")}'`)
.join(" ");
const result = await vm.exec(`${go} run ${file} ${argv} < ${stdinFile}`);
if (result.statusCode !== 0) {
throw new Error(
`go exited with status ${result.statusCode}: ${result.stderr ?? ""}`,
);
}
return { stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
}
```
The `args` are single-quoted before they reach the shell, so they arrive intact in `os.Args`, and `stdin` is written to a file that is redirected into the program.
```ts
const product = await runGoStrict(
vm,
`package main
import (
"fmt"
"os"
"strconv"
)
func main() {
a, _ := strconv.Atoi(os.Args[1])
b, _ := strconv.Atoi(os.Args[2])
fmt.Printf("product=%d\\n", a*b)
}`,
{ args: ["6", "7"] },
);
console.log(product.stdout.trim()); // product=42
// Read stdin line by line and upper-case each line.
const piped = await runGoStrict(
vm,
`package main
import (
"bufio"
"fmt"
"os"
"strings"
)
func main() {
s := bufio.NewScanner(os.Stdin)
var out []string
for s.Scan() {
out = append(out, strings.ToUpper(s.Text()))
}
fmt.Println(strings.Join(out, "|"))
}`,
{ stdin: "one\ntwo\nthree\n" },
);
console.log(piped.stdout.trim()); // ONE|TWO|THREE
// A program that exits non-zero rejects instead of returning a bad result.
await runGoStrict(
vm,
`package main
import "os"
func main() {
os.Exit(3)
}`,
); // throws: go exited with status 1
```
## Run Programs Concurrently
Because each call writes a uniquely named source file, you can fan out many programs across the one VM with `Promise.all` and each run stays isolated. State persists on the VM between calls, so the shared build cache makes repeat runs fast without any cross-talk.
```ts
const results = await Promise.all([
runGo(vm, `package main
import "fmt"
func main() { fmt.Println("A=", 10*10) }`),
runGo(vm, `package main
import "fmt"
func main() { fmt.Println("B=", 20*20) }`),
runGo(vm, `package main
import "fmt"
func main() { fmt.Println("C=", 30*30) }`),
]);
console.log(results.map((r) => r.stdout.trim()));
// [ 'A= 100', 'B= 400', 'C= 900' ]
```
## Run a Server
The same `snapshotId` that runs one-off programs can also host a long-lived HTTP server. Create a fresh VM from it under a distinct name, compile a server to a static binary, and run it under systemd — `systemd` is PID 1 in the sandbox, so the unit keeps the server alive. Then route a `style.dev` domain to the VM and reach it from the public internet.
Create the server VM and write the source to `/srv`. It binds `0.0.0.0:3000` so the mapped domain can reach it:
```ts
const { vm: server, vmId } = await freestyle.vms.create({
name: "go-server",
snapshotId,
idleTimeoutSeconds: null,
});
await server.fs.writeTextFile(
"/srv/server.go",
`package main
import (
"fmt"
"net/http"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello from a Go server in a sandbox!")
})
http.ListenAndServe("0.0.0.0:3000", nil)
}
`,
);
// Compile a static binary into /srv using the toolchain from the snapshot.
await server.exec(`${go} build -o /srv/server /srv/server.go`);
```
Write a systemd unit that runs the compiled binary. Units do **not** source a shell profile, so `ExecStart` points at the absolute binary path. `Restart=always` keeps the server up:
```ts
await server.fs.writeTextFile(
"/etc/systemd/system/go-server.service",
`[Unit]
Description=Go HTTP server
After=network.target
[Service]
ExecStart=/srv/server
WorkingDirectory=/srv
Restart=always
[Install]
WantedBy=multi-user.target
`,
);
await server.exec(
"systemctl daemon-reload && systemctl enable --now go-server",
);
```
Wait until the server actually answers on `localhost` before routing traffic to it:
```ts
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";
}
```
Map a domain to the VM's port. Pick your own unique subdomain under `style.dev`; it needs no DNS verification:
```ts
// Choose your own unique subdomain — style.dev needs no verification.
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:
```ts
const res = await fetch(`https://${domain}`);
console.log(res.status); // 200
console.log(await res.text()); // Hello from a Go server in a sandbox!
```
The request hits the compiled binary running under systemd inside the VM. Because both the helper VM and the server VM come from one snapshot, you pay the Go install cost once and reuse it for everything.
## 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](https://www.freestyle.sh/docs/vms/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:
```ts
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 go-server -f\n");
// session.detach() drops your handle — the service keeps running in the VM.
```
---
# How to Run PHP in a Sandbox
Source: https://www.freestyle.sh/docs/guides/run-php-in-a-sandbox
Bake the PHP runtime into a VM snapshot, boot one VM, and run many PHP scripts on it with an isolated, reusable runPhp() helper — then serve PHP over a public domain from the same snapshot.
This guide builds a reusable snapshot with the PHP CLI preinstalled, boots one VM from it, then wraps that VM in a `runPhp()` helper you can call as many times as you like — each call writes to a uniquely-named file, so runs never clash, sequentially or concurrently. The same snapshot then powers a long-running PHP web server routed to a public domain.
## Install the SDK
```bash pnpm
pnpm add freestyle
```
```bash bun
bun add freestyle
```
```bash npm
npm install freestyle
```
```bash yarn
yarn add freestyle
```
Set your API key before calling the API:
```bash
export FREESTYLE_API_KEY="your-api-key"
```
## Build a Snapshot with PHP
Create a VM from the minimal base image, install the PHP command-line interpreter with `apt-get`, then snapshot the machine so the install is baked in. Nothing is preinstalled, so install PHP explicitly — `php-cli` pulls in the `php` binary without a web server. Delete the builder VM once the snapshot is captured.
```ts
import { freestyle } from "freestyle";
const { vm: builder } = await freestyle.vms.create({ name: "php-builder" });
await builder.exec("apt-get update -qq");
const install = await builder.exec(
"DEBIAN_FRONTEND=noninteractive apt-get install -y -qq php-cli",
);
console.log(install.statusCode); // 0
const version = await builder.exec("php -v");
console.log(version.stdout); // PHP 8.4.21 (cli) ...
const { snapshotId } = await builder.snapshot();
await builder.delete();
```
`apt`-installed packages persist into the snapshot, so every VM you boot from `snapshotId` has the `php` binary ready immediately — you only pay the `apt-get` cost once.
## A Reusable runPhp() Utility
The VM is the reusable sandbox: create it once, then run as many snippets as you like on it. The helper takes the `vm` as its first argument, writes your source to a uniquely-named file, and runs it with `php`. Each call writes to `/tmp/main-.php` via `crypto.randomUUID()`, so no two runs — sequential or concurrent — can clobber each other's source. The helper never creates or deletes a VM; lifecycle stays with the caller. `vm.exec` is synchronous: it runs the script to completion and returns `{ stdout, stderr, statusCode }`.
```ts
async function runPhp(vm, code: string) {
const file = `/tmp/main-${crypto.randomUUID()}.php`;
await vm.fs.writeTextFile(file, code);
return await vm.exec(`php ${file}`);
}
const { vm } = await freestyle.vms.create({ name: "php-sandbox", snapshotId });
const out = await runPhp(
vm,
` {
const id = crypto.randomUUID();
const file = `/tmp/main-${id}.php`;
const stdinFile = `/tmp/stdin-${id}`;
await vm.fs.writeTextFile(file, code);
if (stdin) await vm.fs.writeTextFile(stdinFile, stdin);
const argv = args
.map((a) => `'${String(a).replace(/'/g, "'\\''")}'`)
.join(" ");
const cmd = stdin
? `php ${file} ${argv} < ${stdinFile}`
: `php ${file} ${argv}`;
const r = await vm.exec(cmd);
if (r.statusCode !== 0) {
throw new Error(`runPhpStrict: php exited ${r.statusCode}: ${r.stderr ?? r.stdout}`);
}
return { stdout: r.stdout ?? "", stderr: r.stderr ?? "", statusCode: r.statusCode };
}
```
Run several snippets on the **same** `vm`. Pass arguments and read them back from `$argv`:
```ts
const sum = await runPhpStrict(vm, ` setTimeout(r, 1000));
}
```
Map a domain to the VM's port. `style.dev` subdomains need no DNS setup or verification, and each run gets a unique one.
```ts
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:
```ts
const res = await fetch(`https://${domain}`);
console.log(res.status);
console.log(await res.text());
// hello from php 8.4.21
```
The request lands on your `php -S` process inside the VM. Edit `/srv/index.php` and restart the unit with `systemctl restart php-server` whenever you want to ship a new version, or snapshot the running server so every VM you boot from it comes up already answering HTTP.
## 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](https://www.freestyle.sh/docs/vms/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:
```ts
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 php-server -f\n");
// session.detach() drops your handle — the service keeps running in the VM.
```
---
# How to Run Postgres in a Sandbox
Source: https://www.freestyle.sh/docs/guides/run-postgres-in-a-sandbox
Build a snapshot with PostgreSQL already running, then boot a VM and run SQL queries against the live database.
Install PostgreSQL once into a snapshot, then launch VMs that boot with the database ready to serve.
## Install the SDK
```bash pnpm
pnpm add freestyle
```
```bash bun
bun add freestyle
```
```bash npm
npm install freestyle
```
```bash yarn
yarn add freestyle
```
Set your API key before calling the API:
```bash
export FREESTYLE_API_KEY="your-api-key"
```
## Build a snapshot with a running database
The base image is minimal, so install PostgreSQL explicitly with `apt-get`. Do this once on a
builder VM, snapshot it, then throw the builder away.
A Freestyle snapshot is a full memory and disk capture, so it preserves a **running** service and
its in-memory state, not just the installed files. So instead of stopping at "installed", start the
server and wait until it is accepting connections **before** snapshotting. The snapshot then bakes
in a live, ready database, and every VM you create from it boots with Postgres already serving.
The PostgreSQL package ships a **systemd** unit (`postgresql`). `systemctl start` is a one-shot
command — it tells systemd to launch the unit and returns immediately — so plain `vm.exec` is the
right tool. systemd then supervises the daemon and restarts it if it ever crashes.
```ts
import { freestyle } from "freestyle";
const { vm: builder } = await freestyle.vms.create({ name: "postgres-builder" });
await builder.exec(
"apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq postgresql",
);
// Start the database and wait until it is accepting connections.
await builder.exec("systemctl start postgresql");
let ready = false;
while (!ready) {
await new Promise((r) => setTimeout(r, 1000));
const probe = await builder.exec('su postgres -c "pg_isready" || true');
ready = probe.stdout?.includes("accepting connections") ?? false;
}
// Capture the running, ready database into the snapshot.
const { snapshotId } = await builder.snapshot();
await builder.delete();
```
## Query the database
Create a fresh VM from the snapshot. Because the snapshot captured a running, ready server, the
new VM comes up with Postgres already **active** and accepting connections — there is nothing to
start and nothing to wait for. Just connect and query.
```ts
const { vm } = await freestyle.vms.create({
name: "postgres-sandbox",
snapshotId,
idleTimeoutSeconds: null,
});
```
The `postgres` OS user owns the cluster, so run client commands as that user with `su postgres`.
Each query is a one-shot command, so use synchronous `vm.exec`:
```ts
await vm.exec(
`su postgres -c "psql -c 'CREATE TABLE fruits (id serial PRIMARY KEY, name text);'"`,
);
await vm.exec(
`su postgres -c "psql -c \\"INSERT INTO fruits (name) VALUES ('apple'), ('banana');\\""`,
);
const rows = await vm.exec(
`su postgres -c "psql -c 'SELECT id, name FROM fruits ORDER BY id;'"`,
);
console.log(rows.stdout);
// id | name
// ----+--------
// 1 | apple
// 2 | banana
// (2 rows)
```
## 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](https://www.freestyle.sh/docs/vms/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:
```ts
const session = await vm.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 postgresql -f\n");
// session.detach() drops your handle — the service keeps running in the VM.
```
---
# How to Run Redis in a Sandbox
Source: https://www.freestyle.sh/docs/guides/run-redis-in-a-sandbox
Bake a running Redis server into a VM snapshot, then create fresh sandboxes that serve key-value queries instantly with no startup step.
This guide builds a reusable snapshot with Redis installed **and already running**, then creates fresh VMs that serve `redis-cli` queries instantly — no boot or startup step at runtime.
## Install the SDK
```bash pnpm
pnpm add freestyle
```
```bash bun
bun add freestyle
```
```bash npm
npm install freestyle
```
```bash yarn
yarn add freestyle
```
Set your API key before calling the API:
```bash
export FREESTYLE_API_KEY="your-api-key"
```
## Build a Snapshot with a Redis Key-Value Store Already Running
Create a VM from the minimal base image, install `redis-server` with `apt-get`, **start the server**, and wait until it answers before snapshotting. A Freestyle snapshot is a full memory and disk capture, so it preserves the running Redis process and its in-memory state — not just the installed files. Doing the start and the readiness wait here, on the builder, means the snapshot already contains a live, ready database. Delete the builder VM once the snapshot is captured.
```ts
import { freestyle } from "freestyle";
const { vm: builder } = await freestyle.vms.create({ name: "redis-builder" });
const install = await builder.exec(
"apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq redis-server",
);
console.log(install.statusCode); // 0
// Start the systemd-managed unit on the builder. The command returns immediately;
// systemd supervises the daemon in the background.
await builder.exec("systemctl start redis-server");
// Wait for Redis to accept connections, then snapshot the running server.
let ready = false;
for (let i = 0; i < 15 && !ready; i++) {
await new Promise((r) => setTimeout(r, 1000));
const ping = await builder.exec("redis-cli PING");
ready = ping.stdout?.includes("PONG") ?? false;
}
const { snapshotId } = await builder.snapshot();
await builder.delete();
```
The Debian package installs Redis as a systemd service named `redis-server`. Because the snapshot is taken while that unit is active and answering `PING`, the captured image holds a running, ready server.
## Query the Already-Running Server
Create a VM from the snapshot. Because the snapshot captured Redis while it was running and ready, the restored VM comes up with the daemon already **active** — no `systemctl start`, no readiness loop. Query it immediately with `vm.exec()`.
```ts
const { vm } = await freestyle.vms.create({ name: "redis-sandbox", snapshotId, idleTimeoutSeconds: null });
// Redis is already up from the snapshot — query it right away.
const ping = await vm.exec("redis-cli PING");
console.log(ping.stdout); // PONG
await vm.exec("redis-cli SET greeting 'hello'");
const get = await vm.exec("redis-cli GET greeting");
console.log(get.stdout); // hello
const incr = await vm.exec("redis-cli INCR counter");
console.log(incr.stdout); // 1
```
Each `exec` returns `{ stdout, stderr, statusCode }`, where `statusCode` of `0` means success. systemd keeps the daemon running between calls, so every query hits the same instance.
## 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](https://www.freestyle.sh/docs/vms/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:
```ts
const session = await vm.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 redis-server -f\n");
// session.detach() drops your handle — the service keeps running in the VM.
```
---
# How to Run MongoDB in a Sandbox
Source: https://www.freestyle.sh/docs/guides/run-mongodb-in-a-sandbox
Bake a running MongoDB server into a VM snapshot, then run real document insert and find queries inside an isolated sandbox with no startup wait.
This guide builds a reusable snapshot with MongoDB already **running and ready**, then runs real `mongosh` queries inside a fresh VM — inserting a document and reading it back — without any boot or readiness wait at runtime.
## Install the SDK
```bash pnpm
pnpm add freestyle
```
```bash bun
bun add freestyle
```
```bash npm
npm install freestyle
```
```bash yarn
yarn add freestyle
```
Set your API key before calling the API:
```bash
export FREESTYLE_API_KEY="your-api-key"
```
## Build a Snapshot with a Running MongoDB Database
Create a VM from the minimal base image and install MongoDB with `apt-get`. The base image is Debian 13 (trixie) and ships nothing extra, so install the prerequisites first, add MongoDB's official apt repository (its signing key plus a source list), then install the `mongodb-org` metapackage. MongoDB does not yet publish its server for trixie, so point the source list at the `bookworm` suite — those packages install cleanly on trixie.
A Freestyle snapshot is a full memory **and** disk capture, so it preserves a *running* service and its in-memory state — not just the installed files. So start `mongod` on the builder and wait until it accepts connections **before** snapshotting. The `mongodb-org` package ships a systemd unit named `mongod`; `systemctl start mongod` launches it under systemd's supervision (its own `mongodb` user, reading `/etc/mongod.conf`, default bind `127.0.0.1:27017`). That command is synchronous and returns the moment systemd has launched the unit, so calling it from `vm.exec()` is fine. Then poll `mongosh` until the server answers, snapshot the live machine, and delete the builder. The exec shell is non-login with no `HOME` and a bare `PATH`, so call binaries by absolute path (`/usr/bin/mongosh`).
```ts
import { freestyle } from "freestyle";
const { vm: builder } = await freestyle.vms.create({ name: "mongodb-builder" });
// Prerequisites for fetching the signing key and the repo.
await builder.exec(
"apt-get update -qq && DEBIAN_FRONTEND=noninteractive " +
"apt-get install -y -qq gnupg curl ca-certificates",
);
// Add MongoDB's signing key and apt source (bookworm suite works on trixie).
await builder.exec(
"curl -fsSL https://www.mongodb.org/static/pgp/server-8.0.asc | " +
"gpg --dearmor -o /usr/share/keyrings/mongodb-server-8.0.gpg",
);
await builder.exec(
'bash -c \'echo "deb [ signed-by=/usr/share/keyrings/mongodb-server-8.0.gpg ] ' +
'http://repo.mongodb.org/apt/debian bookworm/mongodb-org/8.0 main" ' +
"> /etc/apt/sources.list.d/mongodb-org-8.0.list'",
);
// Install the server and the mongosh shell.
const install = await builder.exec(
"apt-get update -qq && DEBIAN_FRONTEND=noninteractive " +
"apt-get install -y -qq mongodb-org",
);
console.log(install.statusCode); // 0
console.log((await builder.exec("/usr/bin/mongod --version")).stdout?.split("\n")[0]); // db version v8.0.23
// Start mongod under systemd and wait until it accepts connections.
await builder.exec("systemctl start mongod");
const ping = "/usr/bin/mongosh --quiet --eval 'db.runCommand({ ping: 1 }).ok'";
let ready = false;
for (let i = 0; i < 30 && !ready; i++) {
await new Promise((r) => setTimeout(r, 1000));
const res = await builder.exec(ping);
ready = res.statusCode === 0 && res.stdout?.trim() === "1";
}
console.log(ready); // true
// Snapshot the live machine — the running, ready server is captured with it.
const { snapshotId } = await builder.snapshot();
await builder.delete();
```
## Query the Running Database
Create a VM from the snapshot. Because the snapshot captured a live, ready `mongod`, the restored VM comes up with the server **already active and accepting connections** — there is no `systemctl start` and no readiness wait. Run one-shot `mongosh` queries from separate `vm.exec()` calls; the exec shell is non-login with no `HOME` and a bare `PATH`, so call binaries by absolute path (`/usr/bin/mongosh`).
```ts
const { vm } = await freestyle.vms.create({ name: "mongodb-sandbox", snapshotId });
// The server is already running from the snapshot — query it immediately.
const mongosh = "/usr/bin/mongosh --quiet --host 127.0.0.1 --port 27017 --eval";
// Insert a document, then read it back.
const insert = await vm.exec(
`${mongosh} 'db.users.insertOne({ name: "Ada", role: "admin" }).acknowledged'`,
);
console.log(insert.stdout?.trim()); // true
const find = await vm.exec(`${mongosh} 'db.users.findOne({ name: "Ada" }).role'`);
console.log(find.stdout?.trim()); // admin
```
Each `exec` returns `{ stdout, stderr, statusCode }`, where `statusCode` of `0` means success. systemd keeps the unit running between calls, so every query hits the same instance and sees the document the previous call inserted.
## 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](https://www.freestyle.sh/docs/vms/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:
```ts
const session = await vm.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 mongod -f\n");
// session.detach() drops your handle — the service keeps running in the VM.
```
---
# How to Run Supabase in a Sandbox
Source: https://www.freestyle.sh/docs/guides/run-supabase-in-a-sandbox
Boot a VM from a Docker snapshot, bring up the full Supabase stack — Postgres, Auth, REST, Realtime, Storage, and Studio behind a Kong gateway — and reach its APIs on a public domain.
Supabase is a whole backend in one Docker Compose stack — Postgres, the PostgREST data API, GoTrue auth, Realtime, Storage, and the Studio dashboard, all fronted by a Kong API gateway on a single port. It runs via Docker, so start from a snapshot that already has Docker installed by following [How to Run Docker in a Sandbox](https://www.freestyle.sh/docs/guides/run-docker-in-a-sandbox). This guide boots a VM from that snapshot, pulls the official stack, brings it up, and routes a public domain to it.
## Install the SDK
```bash pnpm
pnpm add freestyle
```
```bash bun
bun add freestyle
```
```bash npm
npm install freestyle
```
```bash yarn
yarn add freestyle
```
Set your API key before calling the API:
```bash
export FREESTYLE_API_KEY="your-api-key"
```
## Boot a Bigger VM from the Docker Snapshot
Create a VM from the Docker snapshot and **resize it first**. Supabase is a heavy stack: roughly 4 GB of images across eleven services, wanting 2–4 GB of RAM once everything is running. The default VM (4 vCPU / 8 GB / 20 GB) is too tight for that, so grow it before doing any work — `cpu` and `memory` must be powers of two, and `memory` and `storage` are in GB.
```ts
import { freestyle } from "freestyle";
// dockerSnapshotId comes from the Docker guide linked above.
const { vm, vmId } = await freestyle.vms.create({
name: "supabase-server",
snapshotId: dockerSnapshotId,
idleTimeoutSeconds: null,
});
// Give the stack headroom — ~4GB of images and 2-4 GB of runtime RAM.
await vm.resize({ cpu: 4, memory: 16, storage: 60 }); // memory and storage in GB
// resize reboots the VM, but Docker is enabled in the snapshot, so dockerd comes
// back on boot. Wait for it before using it.
for (let i = 0; i < 30; i++) {
const r = await vm.exec("docker info >/dev/null 2>&1 && echo READY || echo WAIT");
if (r.stdout?.includes("READY")) break;
await new Promise((res) => setTimeout(res, 2000));
}
// Pick the domain now — the .env needs it, and you'll map it at the end.
const domain = `supabase-${crypto.randomUUID().slice(0, 8)}.style.dev`;
```
## Fetch the Supabase Stack
The Compose files and the `.env.example` ship in the Supabase repo, so clone it and copy the `docker/` directory into a working folder. Both this clone and the image pull later are large, which forces the one practical detail of this guide: **a single `vm.exec()` aborts after about five minutes** (a client headers timeout), and these steps run longer. So run them **detached** — write the work into a shell script, launch it with `setsid` so it outlives the `exec` that started it, and poll a marker file for completion with short `exec` calls.
```ts
const job = "/root/jobs";
await vm.exec(`mkdir -p ${job}`);
// Run a long command detached and poll its marker file until it finishes.
async function runDetached(name: string, script: string) {
await vm.fs.writeTextFile(
`${job}/${name}.sh`,
`#!/bin/sh
( set -e
${script}
) > ${job}/${name}.log 2>&1
echo $? > ${job}/${name}.done
`,
);
await vm.exec(`rm -f ${job}/${name}.done`);
// setsid puts the job in its own session so it survives the exec returning.
await vm.exec(`setsid sh ${job}/${name}.sh >/dev/null 2>&1 setTimeout(res, 5000));
const code = (await vm.exec(`cat ${job}/${name}.done 2>/dev/null || true`)).stdout.trim();
if (code === "0") return;
if (code) {
const log = (await vm.exec(`tail -n 20 ${job}/${name}.log`)).stdout;
throw new Error(`${name} failed (exit ${code}):\n${log}`);
}
}
throw new Error(`${name} timed out`);
}
// Clone the repo and lay down the compose files + .env (git may not be in the snapshot).
await runDetached(
"fetch",
`apt-get update -qq
apt-get install -y -qq git ca-certificates
git clone --depth 1 https://github.com/supabase/supabase /root/supabase-src
mkdir -p /root/supabase
cp -rf /root/supabase-src/docker/* /root/supabase/
cp /root/supabase-src/docker/.env.example /root/supabase/.env`,
);
```
## Generate Secrets and Configure the Environment
The stack needs its own JWT secret and matching keys. Supabase ships helper scripts for this: `generate-keys.sh` writes a fresh JWT secret, the matching `ANON_KEY` / `SERVICE_ROLE_KEY`, and a random dashboard password into `.env`, and `add-new-auth-keys.sh` adds the newer publishable/secret API keys. Run them in that order. Then point every public URL at the domain you picked above, name the dashboard user, and capture the generated `ANON_KEY` and `DASHBOARD_PASSWORD` — you need them to call the APIs and to log in to Studio. (Skip the optional Logflare/analytics override; leaving it out saves a chunk of RAM.)
```ts
async function setEnv(key: string, value: string) {
// Each key already exists in .env.example; replace its line in place.
await vm.exec(`sed -i "s|^${key}=.*|${key}=${value}|" /root/supabase/.env`);
}
async function getEnv(key: string) {
const r = await vm.exec(`grep -E "^${key}=" /root/supabase/.env | head -n1 | cut -d= -f2-`);
return r.stdout.trim();
}
// Generate secrets — order matters.
await vm.exec("cd /root/supabase && sh utils/generate-keys.sh --update-env");
await vm.exec("cd /root/supabase && sh utils/add-new-auth-keys.sh --update-env");
// Point the public URLs at the domain, and name the dashboard user.
await setEnv("SUPABASE_PUBLIC_URL", `https://${domain}`);
await setEnv("API_EXTERNAL_URL", `https://${domain}`);
await setEnv("SITE_URL", `https://${domain}`);
await setEnv("DASHBOARD_USERNAME", "supabase");
// Capture the generated secrets — needed to call the APIs and log in to Studio.
const anonKey = await getEnv("ANON_KEY");
const dashboardPassword = await getEnv("DASHBOARD_PASSWORD");
console.log({ anonKey, dashboardPassword });
```
## Pull and Start the Stack
Pull the images first. That's the ~4 GB download — well over the five-minute `exec` limit — so run it detached with the same helper. Then bring the stack up with `docker compose up -d`: it returns once the containers are scheduled, but the **first** boot runs the Postgres initialization (2–3 minutes) before the APIs answer, so poll until the gateway proxies a healthy service.
```ts
// ~4GB across eleven services — detach and poll.
await runDetached("pull", "cd /root/supabase && docker compose pull");
// Bring the stack up. Local images now, so this returns quickly.
await vm.exec({
command: "cd /root/supabase && docker compose up -d",
timeoutMs: 240_000,
});
// Wait out the Postgres init: poll Kong until GoTrue answers 200.
// The apikey header is required (Kong rejects requests without it — see below).
let code = "";
for (let i = 0; i < 60 && code !== "200"; i++) {
await new Promise((res) => setTimeout(res, 5000));
code = (
await vm.exec(
`curl -s -o /dev/null -w '%{http_code}' -H "apikey: ${anonKey}" http://localhost:8000/auth/v1/health`,
)
).stdout.trim();
}
console.log(code); // 200
```
## Snapshot for Fast Boots
The ~4 GB `docker compose pull` is by far the slowest step. Snapshot the VM once the images are cached so you never pay that download again:
```ts
const { snapshotId } = await vm.snapshot();
```
Boot future instances from that `snapshotId` (resized the same way), point the `.env` URLs at the new domain, and `docker compose up -d` with the images already local — first start is then just the ~2–3 min Postgres init, not a multi-gigabyte download.
## Check the APIs
Kong is the single entry point on port `8000` and routes by path prefix: `/auth/v1` to GoTrue, `/rest/v1` to PostgREST, plus `/realtime/v1` and `/storage/v1`. Every API request must carry the `apikey` header — Kong rejects anything without it, which is the gatekeeper working, not a failure. Note that `/auth/v1/health` returns GoTrue's **identity** JSON (name and version), not a generic `{ ok: true }`.
```ts
// With the apikey header: GoTrue's health/identity JSON.
const health = await vm.exec(
`curl -s -H "apikey: ${anonKey}" http://localhost:8000/auth/v1/health`,
);
console.log(health.stdout);
// {"version":"v2.189.0","name":"GoTrue","description":"GoTrue is a user registration and authentication API"}
// Without it: Kong returns 401 — expected, not a failure.
const noKey = await vm.exec(
"curl -s -o /dev/null -w '%{http_code}' http://localhost:8000/auth/v1/health",
);
console.log(noKey.stdout.trim()); // 401 — "No API key found in request"
// PostgREST serves its OpenAPI schema at /rest/v1/ with the apikey header.
const rest = await vm.exec(
`curl -s -o /dev/null -w '%{http_code}' -H "apikey: ${anonKey}" http://localhost:8000/rest/v1/`,
);
console.log(rest.stdout.trim()); // 200
```
## Stream the Logs
`vm.exec()` buffers a command and only returns once it finishes, so it can't show the stack's output as it happens. To watch the logs live, open a [PTY](https://www.freestyle.sh/docs/vms/pty) on the VM — a real terminal streamed over a WebSocket (server-side only, Node 22+) — and follow the stack with `docker compose logs -f`. `onData` delivers the bytes as they arrive:
```ts
const session = await vm.pty.open({
cols: 120,
rows: 30,
onData: (bytes) => process.stdout.write(bytes), // live log lines
});
// Follow every service in the stack; new lines stream in until you detach.
session.write("cd /root/supabase && docker compose logs -f\n");
// session.detach() drops your handle — the containers keep running.
```
## Open It on a Domain
Map the domain you picked earlier to Kong on port `8000` — that one mapping covers everything, since Studio, REST, Auth, Realtime, and Storage all sit behind the gateway. Right after a mapping is created, the `*.style.dev` proxy may briefly serve a "Reloading" warmup page (also a 200), so poll a couple of times until Supabase itself answers.
```ts
await freestyle.domains.mappings.create({ domain, vmId, vmPort: 8000 });
// The proxy may serve a warmup page for a moment after mapping — poll until GoTrue answers.
let body = "";
for (let i = 0; i < 10; i++) {
const res = await fetch(`https://${domain}/auth/v1/health`, {
headers: { apikey: anonKey },
});
body = await res.text();
if (body.includes("GoTrue")) break;
await new Promise((r) => setTimeout(r, 2000));
}
console.log(body); // {"version":"v2.189.0","name":"GoTrue",...}
console.log(`Studio: https://${domain} (user "supabase", password ${dashboardPassword})`);
```
Studio is served at `/` behind Kong's HTTP basic auth, so opening `https://${domain}` prompts for the `DASHBOARD_USERNAME` / `DASHBOARD_PASSWORD` you set above. One thing the domain does **not** expose is the raw Postgres wire protocol: the session port (`5432`) and the pooled port (`6543`) are TCP, while a `*.style.dev` domain only proxies HTTP(S). App clients should talk to Postgres through the REST, Auth, Realtime, and Storage APIs on port `8000`, authenticated with the anon and service-role keys. If you need a direct database connection, put the VM on a [VPC](https://www.freestyle.sh/docs/vms/network/vpcs) and reach it over a [VPN](https://www.freestyle.sh/docs/vms/network/vpns).
---
# How to Run Convex in a Sandbox
Source: https://www.freestyle.sh/docs/guides/run-convex-in-a-sandbox
Boot a VM from a Docker snapshot, bring up the official self-hosted Convex backend with docker compose, expose it on a public domain, and connect a Convex project to it.
Convex is a reactive backend — a document database, server functions, and live queries — and it self-hosts as a pair of containers. Start from a snapshot that already has Docker installed (see [How to Run Docker in a Sandbox](https://www.freestyle.sh/docs/guides/run-docker-in-a-sandbox)), then boot a VM, write the official `docker compose` stack, and bring Convex up serving on port `3210`.
## Install the SDK
```bash pnpm
pnpm add freestyle
```
```bash bun
bun add freestyle
```
```bash npm
npm install freestyle
```
```bash yarn
yarn add freestyle
```
Set your API key before calling the API:
```bash
export FREESTYLE_API_KEY="your-api-key"
```
## Boot a VM and Start Convex
Boot a VM from the Docker snapshot — the daemon is already running, so `docker compose` works immediately. Convex advertises its own public URL to clients, so it has to know that URL **before** it first starts: pick the `*.style.dev` subdomain now, bake it into `.env`, then bring the stack up. Changing the origins later requires `docker compose up --force-recreate`, so it's worth getting right on the first boot.
```ts
import { freestyle } from "freestyle";
// A snapshot from the Docker guide — Docker + Compose are installed and the daemon is running.
const dockerSnapshotId = "your-docker-snapshot-id";
const { vm, vmId } = await freestyle.vms.create({
name: "convex-server",
snapshotId: dockerSnapshotId,
idleTimeoutSeconds: null,
});
// Convex bakes this public URL into what it tells clients, so choose it before the first boot.
// Reuse the same value when you map the domain below.
const domain = `convex-${crypto.randomUUID().slice(0, 8)}.style.dev`;
// INSTANCE_SECRET seeds the admin key — generate it once and keep it stable across reboots.
const instanceSecret = (await vm.exec("openssl rand -hex 32")).stdout.trim();
```
Download the official compose file and write the matching `.env` next to it. The backend reads these origins on its very first start, so write the file **before** running `up`:
```ts
await vm.exec("mkdir -p /root/convex");
await vm.exec(
"curl -Lo /root/convex/docker-compose.yml https://raw.githubusercontent.com/get-convex/convex-backend/main/self-hosted/docker/docker-compose.yml",
);
await vm.fs.writeTextFile(
"/root/convex/.env",
`INSTANCE_NAME=convex-self-hosted
INSTANCE_SECRET=${instanceSecret}
CONVEX_CLOUD_ORIGIN=https://${domain}
CONVEX_SITE_ORIGIN=https://${domain}
NEXT_PUBLIC_DEPLOYMENT_URL=https://${domain}
`,
);
```
Bring the stack up. This pulls `ghcr.io/get-convex/convex-backend:latest` and `convex-dashboard:latest`, so give it room. Then wait for the backend's health endpoint — only the backend on port `3210` needs to be reachable; the dashboard on `6791` stays internal to the VM.
```ts
await vm.exec({
command: "cd /root/convex && docker compose up -d",
timeoutMs: 600_000,
});
// Health is HTTP 200 on /version. The body is the literal text "unknown", not JSON —
// treat the 200 as ready and don't try to parse it.
let code = "000";
while (code !== "200") {
await new Promise((r) => setTimeout(r, 2000));
code = (
await vm.exec("curl -s -o /dev/null -w '%{http_code}' localhost:3210/version")
).stdout.trim();
}
```
The containers carry a restart policy and the Docker daemon supervises them, so there's no systemd unit to write — they come back on their own if the VM reboots.
## Snapshot for Fast Boots
Pulling `convex-backend` and `convex-dashboard` is the slow part of the first boot. Once they're cached, snapshot the VM so future instances skip the download:
```ts
const { snapshotId } = await vm.snapshot();
```
Boot new VMs from that `snapshotId`, write `.env` with that instance's domain (the origins are baked per VM), and run `docker compose up -d` — the images are already local, so the backend is up in seconds. Keep the same `INSTANCE_SECRET` and the admin key you generated stays valid.
## Open It on a Domain
Map the subdomain you picked above to the backend's port `3210`. It needs no DNS or verification.
```ts
await freestyle.domains.mappings.create({ domain, vmId, vmPort: 3210 });
```
Fetch `/version` from outside the VM to confirm. Right after mapping, the `*.style.dev` proxy may return a "Reloading" warmup page (still HTTP 200) for a moment, so poll a couple of times until the backend's own response comes through:
```ts
let body = "";
for (let i = 0; i < 10; i++) {
const res = await fetch(`https://${domain}/version`);
body = await res.text();
if (res.status === 200 && !body.includes("Reloading")) break;
await new Promise((r) => setTimeout(r, 2000));
}
console.log(body); // unknown
```
## Generate the Admin Key
The CLI and dashboard authenticate with an admin key, derived from `INSTANCE_SECRET`. Generate it once on the VM — it stays valid as long as the secret does:
```ts
const out = await vm.exec(
"cd /root/convex && docker compose exec backend ./generate_admin_key.sh",
);
console.log(out.stdout);
// The key is the line that starts with the instance name.
const adminKey = out.stdout
.split("\n")
.map((l) => l.trim())
.find((l) => l.startsWith("convex-self-hosted"))!;
```
Hold on to `adminKey` — you'll paste it into your project's environment next.
## Open the Dashboard
The self-hosted dashboard runs on port `6791`, separate from the backend, and it's an **admin** UI — so rather than publish it, reach it privately by port-forwarding over [SSH](https://www.freestyle.sh/docs/vms/ssh). Mint a token for the VM and forward the port to your machine:
```ts
// Grant an identity access to the VM and mint an SSH token.
const { identity } = await freestyle.identities.create();
await identity.permissions.vms.grant({ vmId });
const { token } = await identity.tokens.create();
console.log(`ssh -N -L 6791:localhost:6791 ${vmId}:${token}@vm-ssh.freestyle.sh`);
```
Run that `ssh -L` command, then open `http://localhost:6791`. The dashboard is preconfigured with the deployment URL (it was baked in via `NEXT_PUBLIC_DEPLOYMENT_URL`), so just paste the `adminKey` to sign in — and nothing is exposed publicly; the tunnel stays on your machine.
If you'd rather share it, map a second domain straight to the dashboard port instead — but that puts the admin UI on the public internet behind only the admin key, so prefer the tunnel:
```ts
const dashboardDomain = `convex-dashboard-${crypto.randomUUID().slice(0, 8)}.style.dev`;
await freestyle.domains.mappings.create({ domain: dashboardDomain, vmId, vmPort: 6791 });
console.log(`Dashboard: https://${dashboardDomain} (sign in with the admin key)`);
```
## Connect a Convex Project
From your Convex project on your own machine, install the client and point it at the self-hosted backend instead of Convex Cloud. Install the package:
```bash
npm install convex@latest
```
Write `.env.local` with the public URL and the admin key you just generated:
```bash .env.local
CONVEX_SELF_HOSTED_URL=https://convex-xxxxxxxx.style.dev
CONVEX_SELF_HOSTED_ADMIN_KEY=convex-self-hosted|paste-the-generated-key-here
```
Then run the dev command — it pushes your functions to the sandbox's backend and watches for changes:
```bash
npx convex dev
```
Your queries and mutations now run against Convex in the sandbox, and your app's client talks to it over the `*.style.dev` domain.
## Stream the Logs
`vm.exec()` buffers a command and only returns once it finishes, so it can't show a container's output as it happens. To watch the logs live, open a [PTY](https://www.freestyle.sh/docs/vms/pty) on the VM — a real terminal streamed over a WebSocket (server-side only, Node 22+) — and follow the stack with `docker compose logs -f`. `onData` delivers the bytes as they arrive:
```ts
const session = await vm.pty.open({
cols: 120,
rows: 30,
onData: (bytes) => process.stdout.write(bytes), // live log lines
});
// Follow both containers; new lines stream in until you detach.
session.write("cd /root/convex && docker compose logs -f\n");
// session.detach() drops your handle — the containers keep running in the VM.
```
---
# How to Run Hasura in a Sandbox
Source: https://www.freestyle.sh/docs/guides/run-hasura-in-a-sandbox
Bring up the Hasura GraphQL Engine and its Postgres database with docker compose inside a VM, then open the Console on a public domain.
Hasura gives you an instant GraphQL API over Postgres, and it ships as a Docker image. So this guide starts from a snapshot that already has Docker installed — build one with [How to Run Docker in a Sandbox](https://www.freestyle.sh/docs/guides/run-docker-in-a-sandbox) — then boots a VM, brings up Hasura and its database with `docker compose`, and opens the Console on a public domain.
## Install the SDK
```bash pnpm
pnpm add freestyle
```
```bash bun
bun add freestyle
```
```bash npm
npm install freestyle
```
```bash yarn
yarn add freestyle
```
Set your API key before calling the API:
```bash
export FREESTYLE_API_KEY="your-api-key"
```
## Boot a VM and Start Hasura
Boot a VM from your Docker snapshot — Docker is already running, so you can write a Compose file and bring the stack up right away. `dockerSnapshotId` is the snapshot id from [How to Run Docker in a Sandbox](https://www.freestyle.sh/docs/guides/run-docker-in-a-sandbox).
The stack has two services: a `postgres` database and the `graphql-engine` itself. Two things in the Compose file matter:
- The database service **must** be named `postgres` — Compose uses the service name as its hostname on the default network, and the connection URLs point at `@postgres:5432`. Rename the service and Hasura can't resolve its metadata database.
- YAML-quote the boolean-ish and asterisk values (`"true"`, `"*"`). Unquoted, YAML parses `true` as a boolean and `*` as an alias reference, and Compose rejects the file.
Containers run under the Docker daemon with `restart: always`, so the stack comes back on its own after a reboot — there is no systemd unit to write.
```ts
import { freestyle } from "freestyle";
const { vm, vmId } = await freestyle.vms.create({
name: "hasura-server",
snapshotId: dockerSnapshotId,
idleTimeoutSeconds: null,
});
await vm.exec("mkdir -p /root/hasura");
await vm.fs.writeTextFile(
"/root/hasura/docker-compose.yml",
`services:
postgres:
image: postgres:15
restart: always
environment:
POSTGRES_PASSWORD: postgrespassword
volumes:
- db_data:/var/lib/postgresql/data
graphql-engine:
image: hasura/graphql-engine:v2.46.0
ports:
- "8080:8080"
depends_on:
- postgres
restart: always
environment:
HASURA_GRAPHQL_METADATA_DATABASE_URL: postgres://postgres:postgrespassword@postgres:5432/postgres
PG_DATABASE_URL: postgres://postgres:postgrespassword@postgres:5432/postgres
HASURA_GRAPHQL_ENABLE_CONSOLE: "true"
HASURA_GRAPHQL_DEV_MODE: "true"
HASURA_GRAPHQL_ADMIN_SECRET: a-strong-secret
HASURA_GRAPHQL_CORS_DOMAIN: "*"
volumes:
db_data:
`,
);
await vm.exec("cd /root/hasura && docker compose up -d");
```
Hasura retries the metadata database connection on its own, so there is no healthcheck or wait-for-Postgres dance — just poll the unauthenticated `/healthz` endpoint until it answers `200` (ready in about nine seconds):
```ts
let code = "000";
while (code !== "200") {
await new Promise((r) => setTimeout(r, 1500));
code = (
await vm.exec("curl -s -o /dev/null -w '%{http_code}' localhost:8080/healthz")
).stdout.trim();
}
console.log((await vm.exec("curl -s localhost:8080/v1/version")).stdout);
// {"server_type":"ce","version":"v2.46.0"}
```
## Snapshot for Fast Boots
The image pull on first boot is the only slow part, and Hasura bakes no host-specific config — `HASURA_GRAPHQL_CORS_DOMAIN` is `*` and it answers on any `Host`. So once it's up, snapshot the VM with the stack **already running**, and every VM you boot from that snapshot comes up serving on `8080` with no pull and no `docker compose up`:
```ts
const { snapshotId } = await vm.snapshot();
// later — boot an already-serving Hasura in seconds:
// const { vm, vmId } = await freestyle.vms.create({ snapshotId });
```
A Freestyle snapshot captures the running containers (`restart: always` brings them back on boot) and the `db_data` volume, so the snapshot is a complete, ready-to-serve Hasura.
## Stream the Logs
`vm.exec()` buffers a command and only returns once it finishes, so it can't show the running containers' output as it happens. To watch the logs live, open a [PTY](https://www.freestyle.sh/docs/vms/pty) on the VM — a real terminal streamed over a WebSocket (server-side only, Node 22+) — and follow the stack with `docker compose logs -f`. `onData` delivers the bytes as they arrive:
```ts
const session = await vm.pty.open({
cols: 120,
rows: 30,
onData: (bytes) => process.stdout.write(bytes), // live log lines
});
// Follow both services; new lines stream in until you detach.
session.write("cd /root/hasura && docker compose logs -f\n");
// session.detach() drops your handle — the containers keep running.
```
## Open It on a Domain
Hasura publishes port `8080`, so route a [domain](https://www.freestyle.sh/docs/vms/domains) to it. Pick your own unique `*.style.dev` subdomain; it needs no DNS or verification.
```ts
const domain = `hasura-${crypto.randomUUID().slice(0, 8)}.style.dev`;
await freestyle.domains.mappings.create({ domain, vmId, vmPort: 8080 });
// The *.style.dev proxy may briefly serve a "Reloading" warmup page (also 200)
// right after mapping, so poll a couple of times until /healthz returns Hasura's "OK".
let body = "";
for (let i = 0; i < 5 && body !== "OK"; i++) {
await new Promise((r) => setTimeout(r, 1500));
body = (await (await fetch(`https://${domain}/healthz`)).text()).trim();
}
console.log(body); // OK
console.log(`https://${domain}/console`);
```
Open the printed `/console` URL in a browser and the Hasura Console loads and works over the domain — connect a database, define tables, and explore the API. The `/healthz` and `/v1/version` endpoints are unauthenticated, which makes them ideal health checks. The GraphQL endpoint itself lives at `/v1/graphql` and requires the `x-hasura-admin-secret: a-strong-secret` header you set in the Compose file.
One edge: a raw scripted GraphQL `POST` from a non-browser client over the public domain may be challenged by the edge proxy. The browser Console — and any app sending normal browser headers — goes straight through, so this only affects bare scripts. From inside the VM you can always hit `localhost:8080/v1/graphql` directly.
---
# How to Run InstantDB in a Sandbox
Source: https://www.freestyle.sh/docs/guides/run-instantdb-in-a-sandbox
Boot a VM from a Docker snapshot, bring up InstantDB's self-hosting compose stack, expose its sync engine on a public domain, and connect a client.
InstantDB is a real-time database with a sync engine, and its self-hosting setup is a `docker compose` project: a Clojure/JVM server, the dashboard, Postgres, and MinIO. Because the whole thing runs as containers, start from a snapshot that already has Docker installed — see [How to Run Docker in a Sandbox](https://www.freestyle.sh/docs/guides/run-docker-in-a-sandbox) — then boot a VM, clone Instant's compose project into it, and bring the stack up.
## Install the SDK
```bash pnpm
pnpm add freestyle
```
```bash bun
bun add freestyle
```
```bash npm
npm install freestyle
```
```bash yarn
yarn add freestyle
```
Set your API key before calling the API:
```bash
export FREESTYLE_API_KEY="your-api-key"
```
## Boot a VM and Clone Instant
Boot a VM from your Docker snapshot. Pass `idleTimeoutSeconds: null` so the long-running stack is never paused, and destructure both the `vm` handle and its `vmId` — you need the id to route a domain later.
```ts
import { freestyle } from "freestyle";
const dockerSnapshotId = "";
const { vm, vmId } = await freestyle.vms.create({
name: "instantdb-server",
snapshotId: dockerSnapshotId,
idleTimeoutSeconds: null,
});
```
Instant's self-hosting compose files live in its repo, which is large (~2,600 files). A single `vm.exec` that runs longer than a few minutes can hit the exec timeout, so kick the clone off in the background, write a marker file when it finishes, and poll for that marker.
```ts
// Clone in the background so a slow clone can't trip the exec timeout.
await vm.exec(
"nohup sh -c 'git clone --depth 1 https://github.com/instantdb/instant.git " +
"/root/instant && touch /root/clone.done' >/root/clone.log 2>&1 &",
);
let cloned = false;
for (let i = 0; i < 60 && !cloned; i++) {
await new Promise((r) => setTimeout(r, 5000));
cloned = (await vm.exec("test -f /root/clone.done && echo ok")).stdout?.includes("ok") ?? false;
}
console.log(cloned); // true
```
## Configure and Start the Stack
Instant reads its configuration from an `.env` file next to the compose project. Start from the shipped `.env.example`, then append the overrides the stack needs — `docker compose --env-file` lets later lines win, so each override takes precedence over the default above it.
The value that must be right up front is `INSTANT_BACKEND_URL`: it is the public URL the browser and SDK call, so set it to the `*.style.dev` domain you map in a moment. Cap the JVM heap with `JAVA_OPTS` so the server can't balloon, and give Postgres and MinIO real credentials.
```ts
const dir = "/root/instant/self-hosting";
// The domain the browser and SDK will hit. We need it now because the server bakes
// INSTANT_BACKEND_URL in at startup; we map it to the VM further down.
const domain = `instantdb-${crypto.randomUUID().slice(0, 8)}.style.dev`;
// Start from the example and append overrides — later lines win for --env-file.
const example = await vm.fs.readTextFile(`${dir}/.env.example`);
await vm.fs.writeTextFile(
`${dir}/.env`,
example +
`
# --- overrides (later lines win) ---
INSTANT_BACKEND_URL=https://${domain}
INSTANT_DASHBOARD_URL=http://localhost:3000
POSTGRES_PASSWORD=${crypto.randomUUID()}
MINIO_ROOT_USER=instant
MINIO_ROOT_PASSWORD=${crypto.randomUUID()}
JAVA_OPTS=-Xmx2g -Xms512m
`,
);
```
Bring the stack up. On first run this pulls five images — the JVM server, the dashboard, Instant's **custom** `pg-hint-plan` Postgres (required; do not swap it for stock `postgres`), MinIO, and a one-shot `createbuckets` job that seeds storage and then exits — so give it room.
```ts
await vm.exec({
command: `cd ${dir} && docker compose --env-file .env up -d`,
timeoutMs: 600_000,
});
```
The Clojure server cold-starts the JVM, which takes roughly 30-60 seconds, so poll its `/health` endpoint on port 8888 until it returns `200`.
```ts
let healthy = false;
for (let i = 0; i < 60 && !healthy; i++) {
await new Promise((r) => setTimeout(r, 2000));
const res = await vm.exec("curl -s -o /dev/null -w '%{http_code}' localhost:8888/health");
healthy = res.stdout?.trim() === "200";
}
console.log(healthy); // true
```
## Snapshot for Fast Boots
The InstantDB images — the JVM server and the custom `pg-hint-plan` Postgres — are large, so the first pull dominates startup. Snapshot the VM once they're cached to skip it on every future boot:
```ts
const { snapshotId } = await vm.snapshot();
```
Boot new instances from that `snapshotId`, set `INSTANT_BACKEND_URL` to the new domain in `.env`, and `docker compose --env-file .env up -d` — with local images you're only waiting on the JVM's ~30–60 s cold start, not the download.
## Stream the Logs
`vm.exec()` buffers a command and only returns once it finishes, so it can't show the stack's output as it happens — which is exactly what you want while the JVM cold-starts. To watch the logs live, open a [PTY](https://www.freestyle.sh/docs/vms/pty) on the VM — a real terminal streamed over a WebSocket (server-side only, Node 22+) — and follow the project with `docker compose logs -f` (or a single service with `docker compose logs -f server`). `onData` delivers the bytes as they arrive:
```ts
const session = await vm.pty.open({
cols: 120,
rows: 30,
onData: (bytes) => process.stdout.write(bytes), // live log lines
});
// Follow every container in the stack; the server logs "Finished initializing" and
// "port=8888" once the JVM is ready.
session.write("cd /root/instant/self-hosting && docker compose logs -f\n");
// session.detach() drops your handle — the containers keep running.
```
## Open It on a Domain
The server publishes port `8888` on the VM, and that single port is both the SDK's `apiURI` and its realtime WebSocket host — so it is the only one you expose. Map your `domain` to it; the dashboard on port `3000` stays internal. Any `*.style.dev` subdomain works with no DNS or verification.
```ts
await freestyle.domains.mappings.create({ domain, vmId, vmPort: 8888 });
// The *.style.dev proxy can briefly serve a "Reloading" warmup page (still HTTP 200)
// right after a mapping is created, so poll until the real health check answers.
let live = false;
for (let i = 0; i < 5 && !live; i++) {
const res = await fetch(`https://${domain}/health`);
live = res.status === 200 && !(await res.text()).includes("Reloading");
if (!live) await new Promise((r) => setTimeout(r, 2000));
}
console.log(live); // true
```
## Open the Dashboard
Instant's dashboard — where you create an app and copy its **App ID** — runs inside the VM on port `3000`, separate from the `8888` API. It's an admin UI, so reach it privately by port-forwarding over [SSH](https://www.freestyle.sh/docs/vms/ssh) instead of publishing it. Mint a token for the VM and forward the port to your machine:
```ts
const { identity } = await freestyle.identities.create();
await identity.permissions.vms.grant({ vmId });
const { token } = await identity.tokens.create();
console.log(`ssh -N -L 3000:localhost:3000 ${vmId}:${token}@vm-ssh.freestyle.sh`);
```
Run that `ssh -L` command and open `http://localhost:3000`. Sign in with any email — with no Postmark token configured the magic-code OTP isn't emailed but printed to the server logs, so read it with `docker compose logs server` (or follow them live, as in the previous section). Then create an app and copy its **App ID** for the client below.
To share the dashboard instead, map a throwaway `*.style.dev` domain to port `3000` — but prefer the tunnel for an admin UI.
## Connect a Client
Point the Instant SDK at your domain. The published port serves both the HTTP API (`apiURI`) and the realtime WebSocket (`websocketURI`), which lives at `/runtime/session`:
```ts
import { init } from "@instantdb/react";
const db = init({
appId: "",
apiURI: `https://${domain}`,
websocketURI: `wss://${domain}/runtime/session`,
});
```
Get the `appId` from the [dashboard](#open-the-dashboard) above — create an app there and copy its **App ID**.
---
# How to Run Hexclave in a Sandbox
Source: https://www.freestyle.sh/docs/guides/run-hexclave-in-a-sandbox
Boot a VM from a Docker snapshot, bring up Hexclave (formerly Stack Auth) — its API, dashboard, Postgres, and ClickHouse — behind an nginx path split, and open it on a public domain.
[Hexclave](https://hexclave.com) (formerly Stack Auth) is an open-source user-infrastructure platform — auth, teams, RBAC, API keys, and analytics — shipped as a single `stackauth/server` container backed by Postgres and ClickHouse. It runs via Docker, so start from a snapshot that already has Docker installed by following [How to Run Docker in a Sandbox](https://www.freestyle.sh/docs/guides/run-docker-in-a-sandbox). This guide boots a VM from that snapshot, brings the stack up, and routes a public domain to it.
The one structural wrinkle: the container exposes the **API on port 8102** and the **dashboard on 8101**, and a `*.style.dev` domain maps a single port — so a small nginx reverse proxy splits one public domain across both by path.
## Install the SDK
```bash pnpm
pnpm add freestyle
```
```bash bun
bun add freestyle
```
```bash npm
npm install freestyle
```
```bash yarn
yarn add freestyle
```
Set your API key before calling the API:
```bash
export FREESTYLE_API_KEY="your-api-key"
```
## Boot a Bigger VM from the Docker Snapshot
Create a VM from the Docker snapshot and **resize it first**. Hexclave bundles ClickHouse, which is memory-hungry — on the default VM (4 vCPU / 8 GB / 20 GB) the box runs out of memory mid-startup and the stack collapses. Grow it before doing any work; `cpu` and `memory` must be powers of two, and `memory` and `storage` are in GB.
```ts
import { freestyle } from "freestyle";
// dockerSnapshotId comes from the Docker guide linked above.
const { vm, vmId } = await freestyle.vms.create({
name: "hexclave-server",
snapshotId: dockerSnapshotId,
idleTimeoutSeconds: null,
});
// ClickHouse + the stackauth server want real headroom.
await vm.resize({ cpu: 4, memory: 16, storage: 60 }); // memory and storage in GB
// resize reboots the VM; Docker is enabled in the snapshot, so wait for dockerd.
for (let i = 0; i < 30; i++) {
const r = await vm.exec("docker info >/dev/null 2>&1 && echo READY || echo WAIT");
if (r.stdout?.includes("READY")) break;
await new Promise((res) => setTimeout(res, 2000));
}
// Pick the domain now — the env file bakes it in, and you'll map it at the end.
const domain = `hexclave-${crypto.randomUUID().slice(0, 8)}.style.dev`;
```
## Write the Compose Files
Hexclave is configured entirely through environment variables. Generate its secrets locally, then write three files: `hexclave.env` (config + the keys that seed the first admin user), `docker-compose.yml` (the four services), and `nginx.conf` (the path split). `NEXT_PUBLIC_STACK_API_URL` is baked into the dashboard at startup, so it must already be your public domain.
```ts
import crypto from "node:crypto";
const hex = () => crypto.randomBytes(32).toString("hex");
const serverSecret = crypto.randomBytes(32).toString("base64url");
const adminPassword = crypto.randomBytes(9).toString("base64url"); // log in with this
await vm.exec("mkdir -p /root/hexclave");
await vm.fs.writeTextFile(
"/root/hexclave/hexclave.env",
`NEXT_PUBLIC_STACK_API_URL=https://${domain}
NEXT_PUBLIC_STACK_DASHBOARD_URL=https://${domain}
STACK_DATABASE_CONNECTION_STRING=postgresql://postgres:password@postgres:5432/stackframe
STACK_SERVER_SECRET=${serverSecret}
CRON_SECRET=${hex()}
STACK_CLICKHOUSE_URL=http://clickhouse:8123
STACK_CLICKHOUSE_DATABASE=analytics
STACK_CLICKHOUSE_ADMIN_USER=stackframe
STACK_CLICKHOUSE_ADMIN_PASSWORD=password
STACK_CLICKHOUSE_EXTERNAL_PASSWORD=${hex()}
STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY=${hex()}
STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY=${hex()}
STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY=${hex()}
STACK_SEED_INTERNAL_PROJECT_USER_EMAIL=admin@example.com
STACK_SEED_INTERNAL_PROJECT_USER_PASSWORD=${adminPassword}
STACK_SEED_INTERNAL_PROJECT_USER_INTERNAL_ACCESS=true
STACK_SEED_INTERNAL_PROJECT_SIGN_UP_ENABLED=false
STACK_EMAILABLE_API_KEY=disable_email_validation
STACK_RUN_MIGRATIONS=true
STACK_RUN_SEED_SCRIPT=true
`,
);
```
Now the Compose file. The critical detail is `restart: always` on every service: on first boot the stackauth server runs its Postgres migrations and then immediately tries ClickHouse, which is still starting — it hits `ECONNREFUSED` on `:8123` and exits. The restart policy lets it come straight back and succeed once ClickHouse is accepting connections, instead of leaving the stack dead.
```ts
await vm.fs.writeTextFile(
"/root/hexclave/docker-compose.yml",
`services:
postgres:
image: postgres:latest
restart: always
environment: { POSTGRES_USER: postgres, POSTGRES_PASSWORD: password, POSTGRES_DB: stackframe }
networks: [hexclave]
clickhouse:
image: clickhouse/clickhouse-server:25.10
restart: always
environment: { CLICKHOUSE_DB: analytics, CLICKHOUSE_USER: stackframe, CLICKHOUSE_PASSWORD: password, CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: "1" }
mem_limit: 4g
networks: [hexclave]
hexclave:
image: stackauth/server:latest
restart: always
env_file: hexclave.env
depends_on: [postgres, clickhouse]
networks: [hexclave]
nginx:
image: nginx:alpine
restart: always
ports: ["80:80"]
depends_on: [hexclave]
volumes: ["./nginx.conf:/etc/nginx/conf.d/default.conf:ro"]
networks: [hexclave]
networks:
hexclave:
`,
);
await vm.fs.writeTextFile(
"/root/hexclave/nginx.conf",
`server {
listen 80;
location /api/ { proxy_pass http://hexclave:8102; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto https; }
location / { proxy_pass http://hexclave:8101; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto https; }
}
`,
);
```
## Start the Stack
Bring it up with `docker compose up -d`. The first boot is slow — pulling the ~950 MB server image, then running Postgres and ClickHouse migrations and seeding the admin project before the dashboard answers — so poll the nginx port until it returns `200` (expect a couple of minutes; `502` just means the server is still starting behind nginx).
```ts
await vm.exec({
command: "cd /root/hexclave && docker compose up -d",
timeoutMs: 600_000, // first run pulls ~950 MB + ClickHouse
});
let code = "";
for (let i = 0; i < 40 && code !== "200"; i++) {
await new Promise((res) => setTimeout(res, 5000));
code = (
await vm.exec("curl -s -o /dev/null -w '%{http_code}' http://localhost/ || echo 000")
).stdout.trim();
}
console.log(code); // 200 once migrations + seed finish
// The internal endpoint confirms the API side (through nginx) is live too.
const urls = await vm.exec("curl -s http://localhost/api/v1/internal/backend-urls");
console.log(urls.stdout); // {"urls":["https://"]}
```
## Snapshot for Fast Boots
The `stackauth/server` (~950 MB) and ClickHouse images make the first pull the slow part. Snapshot the resized VM once they're cached so future instances skip the download:
```ts
const { snapshotId } = await vm.snapshot();
```
Boot new instances from that `snapshotId` (keep them resized for ClickHouse), write `hexclave.env` with the new domain, and `docker compose up -d` — `restart: always` still rides out the ClickHouse startup race, but with the images already local the dashboard comes up much faster.
## Stream the Logs
`vm.exec()` buffers a command and only returns once it finishes, so it can't show the stack's output as it happens. To watch the logs live — useful while the migrations and seed run — open a [PTY](https://www.freestyle.sh/docs/vms/pty) on the VM (a real terminal streamed over a WebSocket, server-side only, Node 22+) and follow the server with `docker compose logs -f`. `onData` delivers the bytes as they arrive:
```ts
const session = await vm.pty.open({
cols: 120,
rows: 30,
onData: (bytes) => process.stdout.write(bytes), // live log lines
});
// Follow the stackauth server; watch for "Ready" once it has migrated and seeded.
session.write("cd /root/hexclave && docker compose logs -f hexclave\n");
// session.detach() drops your handle — the containers keep running.
```
## Open It on a Domain
Map the domain you picked earlier to nginx on port `80` — that one mapping covers both surfaces, since nginx routes `/api/` to the API and everything else to the dashboard. Right after a mapping is created, the `*.style.dev` proxy may briefly serve a "Reloading" warmup page (also a 200), so poll until Hexclave itself answers.
```ts
await freestyle.domains.mappings.create({ domain, vmId, vmPort: 80 });
// Poll past the proxy warmup page until the real dashboard responds.
let ok = false;
for (let i = 0; i < 15; i++) {
const res = await fetch(`https://${domain}/api/v1/internal/backend-urls`);
if (res.ok && (await res.text()).includes(domain)) { ok = true; break; }
await new Promise((r) => setTimeout(r, 3000));
}
console.log(ok); // true
console.log(`Hexclave: https://${domain} (sign in: admin@example.com / ${adminPassword})`);
```
Open `https://${domain}` and sign in with the seeded admin — `admin@example.com` and the `adminPassword` you generated. From there you can create projects and wire your app to this instance by pointing `NEXT_PUBLIC_STACK_API_URL` (and the project keys) at the domain. Two notes for a real deployment: keep `STACK_SERVER_SECRET` stable across restarts (changing it invalidates every session), and Hexclave expects a few internal cron endpoints to be hit on a schedule (the email queue and ClickHouse sync) — fine to skip for a sandbox, but wire them up with a timer if you depend on email or analytics.
---
# How to Run a Jupyter Notebook Server in a Sandbox
Source: https://www.freestyle.sh/docs/guides/run-jupyter-in-a-sandbox
Bake JupyterLab into a VM snapshot, boot it as a systemd service, and open the notebook on a public domain.
Build a snapshot with JupyterLab already running, then boot a VM that comes up serving notebooks and route a public domain to it.
## Install the SDK
```bash pnpm
pnpm add freestyle
```
```bash bun
bun add freestyle
```
```bash npm
npm install freestyle
```
```bash yarn
yarn add freestyle
```
Set your API key before calling the API:
```bash
export FREESTYLE_API_KEY="your-api-key"
```
## Build a Snapshot with JupyterLab Running
Install JupyterLab into a virtual environment, run it as a systemd service bound to `0.0.0.0`, and wait until it serves before snapshotting. `allow_remote_access` and `allow_origin` let Jupyter answer through a domain. An empty `--IdentityProvider.token=` starts the server open, with no access token — see [Add a Token](#add-a-token) to gate it behind one. A Freestyle snapshot captures the running process, so VMs booted from it come up already serving.
```ts {17}
import { freestyle } from "freestyle";
const { vm: builder } = await freestyle.vms.create({ name: "jupyter-builder" });
// The base image is minimal, so install JupyterLab in a virtual environment.
await builder.exec(
"apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq python3 python3-venv",
);
await builder.exec("python3 -m venv /opt/venv");
await builder.exec("/opt/venv/bin/pip install --quiet jupyterlab");
// Run JupyterLab as a systemd unit. systemd is PID 1, so it supervises the server.
await builder.fs.writeTextFile(
"/etc/systemd/system/jupyter.service",
`[Service]
Environment=HOME=/root
ExecStart=/opt/venv/bin/jupyter lab --ip=0.0.0.0 --port=8888 --no-browser --allow-root --ServerApp.allow_remote_access=True --ServerApp.allow_origin=* --IdentityProvider.token=
WorkingDirectory=/root
Restart=always
[Install]
WantedBy=multi-user.target
`,
);
await builder.exec("systemctl daemon-reload && systemctl enable --now jupyter");
// Wait until the server is listening (any HTTP response, not "000"), then snapshot.
let code = "000";
while (code === "000") {
await new Promise((r) => setTimeout(r, 1500));
code = (
await builder.exec("curl -s -o /dev/null -w '%{http_code}' http://localhost:8888/")
).stdout.trim();
}
const { snapshotId } = await builder.snapshot();
await builder.delete();
```
## Open the Notebook on a Domain
Create a VM from the snapshot — JupyterLab is already running — then route a domain to port 8888. Pick your own unique `*.style.dev` subdomain; it needs no DNS or verification.
```ts
const { vm, vmId } = await freestyle.vms.create({ name: "jupyter-server", snapshotId, idleTimeoutSeconds: null });
const domain = `my-notebook-${crypto.randomUUID().slice(0, 8)}.style.dev`;
await freestyle.domains.mappings.create({ domain, vmId, vmPort: 8888 });
console.log(`https://${domain}/lab`);
```
Open that URL in a browser and JupyterLab loads, ready to run notebooks. Anyone with the link gets straight in; [add a token](#add-a-token) for anything you do not want public.
## 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](https://www.freestyle.sh/docs/vms/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:
```ts
const session = await vm.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 jupyter -f\n");
// session.detach() drops your handle — the service keeps running in the VM.
```
## Add a Token
The build above runs JupyterLab open, so the domain drops visitors straight into notebooks. To require an access token instead, drop the empty `--IdentityProvider.token=` flag and set a `JUPYTER_TOKEN` in the unit. Make this change in the **build step** and re-snapshot — the token is baked into the snapshot, so every VM booted from it comes up gated.
```ts {5-6}
await builder.fs.writeTextFile(
"/etc/systemd/system/jupyter.service",
`[Service]
Environment=HOME=/root
Environment=JUPYTER_TOKEN=change-me-to-a-secret
ExecStart=/opt/venv/bin/jupyter lab --ip=0.0.0.0 --port=8888 --no-browser --allow-root --ServerApp.allow_remote_access=True --ServerApp.allow_origin=*
WorkingDirectory=/root
Restart=always
[Install]
WantedBy=multi-user.target
`,
);
```
Now the notebook requires the token in the URL — open it as `https://${domain}/lab?token=change-me-to-a-secret`. Use a long, random value — `crypto.randomUUID()` works — for anything you do not want public. The readiness loop in the build step is unchanged: it waits for the server to start listening, which happens with or without a token.
---
# How to Run a Vite Dev Server in a Sandbox
Source: https://www.freestyle.sh/docs/guides/run-vite-in-a-sandbox
Bake a Vite dev server into a VM snapshot, run it under systemd, and open it on a public domain.
Build a snapshot with a Vite dev server already running, then boot a VM that serves it and route a public domain to it. The one Vite-specific step is telling the dev server to accept your domain.
## Install the SDK
```bash pnpm
pnpm add freestyle
```
```bash bun
bun add freestyle
```
```bash npm
npm install freestyle
```
```bash yarn
yarn add freestyle
```
Set your API key before calling the API:
```bash
export FREESTYLE_API_KEY="your-api-key"
```
## Build a Snapshot with the Vite Dev Server
Vite runs on Node, so start from a snapshot with Node installed — build one by following [How to Run Node.js in a Sandbox](https://www.freestyle.sh/docs/guides/run-nodejs-in-a-sandbox), which sets up Node 22 with nvm. Boot a builder from that `snapshotId`, scaffold a Vite app, and configure the dev server. Vite refuses requests whose `Host` header it does not recognize, so two settings matter: `server.host` makes it bind `0.0.0.0` (not just localhost), and `server.allowedHosts` has to list whatever host you serve the app on, the domain you map below. A leading-dot `.style.dev` entry matches any Freestyle subdomain (what this guide uses); if you map your own custom domain, list that instead — `app.example.com`, or `.example.com` for its subdomains. Run it under systemd, wait until it serves, then snapshot — a Freestyle snapshot captures the running process, so VMs booted from it come up serving.
```ts
import { freestyle } from "freestyle";
// nodeSnapshotId comes from the Node.js guide — a snapshot with Node 22 (via nvm) ready.
const { vm: builder } = await freestyle.vms.create({ name: "vite-builder", snapshotId: nodeSnapshotId });
// Scaffold a Vite app and install its dependencies. `node` sources nvm each call.
const node = "export HOME=/root NVM_DIR=/opt/nvm && . $NVM_DIR/nvm.sh &&";
await builder.exec(`${node} mkdir -p /srv && cd /srv && npm create vite@latest app -- --template vanilla`);
await builder.exec(`${node} cd /srv/app && npm install`);
```
Write a Vite config that binds `0.0.0.0` and allows the public host:
```ts
await builder.fs.writeTextFile(
"/srv/app/vite.config.js",
`import { defineConfig } from "vite";
export default defineConfig({
server: {
host: true,
port: 5173,
// Accept the Host you serve on — match this to whatever you map. A leading-dot
// ".style.dev" matches any Freestyle subdomain; use "app.example.com" (or
// ".example.com" for its subdomains) for a custom domain, or one exact host.
allowedHosts: [".style.dev"],
},
});
`,
);
```
Run the dev server as a systemd unit. systemd is PID 1, so it keeps the process alive; the unit sources nvm so `npm` is on `PATH`:
```ts
await builder.fs.writeTextFile(
"/etc/systemd/system/vite.service",
`[Service]
Environment=HOME=/root
WorkingDirectory=/srv/app
ExecStart=/usr/bin/bash -lc 'export NVM_DIR=/opt/nvm; . "$NVM_DIR/nvm.sh"; exec npm run dev'
Restart=always
[Install]
WantedBy=multi-user.target
`,
);
await builder.exec("systemctl daemon-reload && systemctl enable --now vite");
// Wait until Vite serves on localhost, then snapshot the running dev server.
let code = "";
while (code !== "200") {
await new Promise((r) => setTimeout(r, 2000));
code = (
await builder.exec("curl -s -o /dev/null -w '%{http_code}' http://localhost:5173/")
).stdout.trim();
}
const { snapshotId } = await builder.snapshot();
await builder.delete();
```
## Open It on a Domain
Create a VM from the snapshot — Vite is already serving — then route a domain to port `5173`. Pick your own unique `*.style.dev` subdomain; it needs no DNS or verification.
```ts
const { vm, vmId } = await freestyle.vms.create({ name: "vite-server", snapshotId, idleTimeoutSeconds: null });
const domain = `my-app-${crypto.randomUUID().slice(0, 8)}.style.dev`;
await freestyle.domains.mappings.create({ domain, vmId, vmPort: 5173 });
const res = await fetch(`https://${domain}`);
console.log(res.status); // 200
```
Open the printed URL in a browser and the Vite app loads, hot-reload included. Because `allowedHosts` covers the domain you mapped, the dev server accepts the request instead of returning a host-not-allowed error.
## 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](https://www.freestyle.sh/docs/vms/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:
```ts
const session = await vm.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 vite -f\n");
// session.detach() drops your handle — the service keeps running in the VM.
```
---
# How to Run a Next.js Dev Server in a Sandbox
Source: https://www.freestyle.sh/docs/guides/run-nextjs-in-a-sandbox
Bake a Next.js dev server into a VM snapshot, run it under systemd, and open it on a public domain.
Build a snapshot with a Next.js dev server already running, then boot a VM that serves it and route a public domain to it. The two Next.js-specific steps are binding the dev server to every interface and telling its cross-origin dev check to trust your domain.
## Install the SDK
```bash pnpm
pnpm add freestyle
```
```bash bun
bun add freestyle
```
```bash npm
npm install freestyle
```
```bash yarn
yarn add freestyle
```
Set your API key before calling the API:
```bash
export FREESTYLE_API_KEY="your-api-key"
```
## Build a Snapshot with the Next.js Dev Server
Next.js runs on Node, so start from a snapshot with Node installed — build one by following [How to Run Node.js in a Sandbox](https://www.freestyle.sh/docs/guides/run-nodejs-in-a-sandbox), which sets up Node 22 with nvm. Boot a builder from that `snapshotId`, scaffold a Next.js app with `create-next-app`, and configure it for the public domain. Two settings matter. First, `next dev -H 0.0.0.0` binds the dev server to every interface, not just loopback, so traffic routed in from outside the VM reaches it. Second, the Next.js dev server blocks cross-origin requests to its internal `/_next/*` dev resources by default — because the app is reached over a domain that differs from the server's own host, those requests count as cross-origin — so `allowedDevOrigins` has to list whatever origin you actually serve the app on, the domain you map below. A `*.style.dev` wildcard covers any Freestyle subdomain (what this guide uses); if you map your own custom domain, list that instead, or name a single exact host. Run it under systemd, wait until it serves, then snapshot — a Freestyle snapshot captures the running process, so VMs booted from it come up serving.
```ts
import { freestyle } from "freestyle";
// nodeSnapshotId comes from the Node.js guide — a snapshot with Node 22 (via nvm) ready.
const { vm: builder } = await freestyle.vms.create({ name: "nextjs-builder", snapshotId: nodeSnapshotId });
// Scaffold a Next.js App Router app and install its dependencies. `node` sources nvm each call.
const node = "export HOME=/root NVM_DIR=/opt/nvm && . $NVM_DIR/nvm.sh &&";
await builder.exec(
`${node} mkdir -p /srv && cd /srv && npx --yes create-next-app@latest app --yes --ts --use-npm`,
{ timeoutMs: 600_000 },
);
```
Write a Next.js config that trusts the public domain for cross-origin dev requests:
```ts
await builder.fs.writeTextFile(
"/srv/app/next.config.ts",
`import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// Trust cross-origin dev requests (HMR, Server Actions, internal /_next/* resources)
// arriving over the domain you serve the app on — match this to whatever you map.
// "*.style.dev" covers any Freestyle subdomain; for a custom domain use e.g.
// "app.example.com" (or a "*.example.com" wildcard), or list one exact host.
allowedDevOrigins: ["*.style.dev"],
};
export default nextConfig;
`,
);
```
Run the dev server as a systemd unit. systemd is PID 1, so it keeps the process alive; the unit sources nvm so `npm` is on `PATH`, and `-H 0.0.0.0 -p 3000` binds every interface on a fixed port:
```ts
await builder.fs.writeTextFile(
"/etc/systemd/system/nextjs.service",
`[Service]
Environment=HOME=/root
WorkingDirectory=/srv/app
ExecStart=/usr/bin/bash -lc 'export NVM_DIR=/opt/nvm; . "$NVM_DIR/nvm.sh"; exec npm run dev -- -H 0.0.0.0 -p 3000'
Restart=always
[Install]
WantedBy=multi-user.target
`,
);
await builder.exec("systemctl daemon-reload && systemctl enable --now nextjs");
// Wait until Next.js serves on localhost, then snapshot the running dev server.
let code = "";
while (code !== "200") {
await new Promise((r) => setTimeout(r, 2000));
code = (
await builder.exec("curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/")
).stdout.trim();
}
const { snapshotId } = await builder.snapshot();
await builder.delete();
```
The first request compiles the route, so the initial poll can take a few seconds before it returns `200`.
## Open It on a Domain
Create a VM from the snapshot — Next.js is already serving — then route a domain to port `3000`. Pick your own unique `*.style.dev` subdomain; it needs no DNS or verification.
```ts
const { vm, vmId } = await freestyle.vms.create({ name: "nextjs-server", snapshotId, idleTimeoutSeconds: null });
const domain = `my-app-${crypto.randomUUID().slice(0, 8)}.style.dev`;
await freestyle.domains.mappings.create({ domain, vmId, vmPort: 3000 });
const res = await fetch(`https://${domain}`);
console.log(res.status); // 200
```
Open the printed URL in a browser and the Next.js app loads, hot-reload included. Because `allowedDevOrigins` covers the domain you mapped, the dev server trusts it: without it the page still renders, but Next.js answers cross-origin dev requests — the HMR connection, Server Actions, and `/_next/*` resources that carry the domain as their `Origin` — with `HTTP 403` and logs `⚠ Blocked cross-origin request to Next.js dev resource`, breaking hot-reload. Genuinely foreign origins stay blocked either way.
## 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](https://www.freestyle.sh/docs/vms/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:
```ts
const session = await vm.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 nextjs -f\n");
// session.detach() drops your handle — the service keeps running in the VM.
```
---
# How to Run VS Code in the Browser in a Sandbox
Source: https://www.freestyle.sh/docs/guides/run-vs-code-in-a-sandbox
Bake code-server — VS Code in the browser — into a VM snapshot, run it under systemd, and open the editor on a public domain.
Build a snapshot with [code-server](https://github.com/coder/code-server) — full VS Code running in the browser — already serving, then boot a VM that comes up ready and route a public domain to it. Open the URL and you get a real editor, terminal, and extensions, all running inside the sandbox.
## Install the SDK
```bash pnpm
pnpm add freestyle
```
```bash bun
bun add freestyle
```
```bash npm
npm install freestyle
```
```bash yarn
yarn add freestyle
```
Set your API key before calling the API:
```bash
export FREESTYLE_API_KEY="your-api-key"
```
## Build a Snapshot with code-server Running
Install code-server with its official script, run it as a systemd service bound to `0.0.0.0`, and wait until it serves before snapshotting. The install script writes to `~/.cache` and `~/.config`, but the exec shell has no `HOME`, so set `HOME=/root` first. `--auth none` starts the editor open, with no login screen — see [Add a Password](#add-a-password) to gate it behind one. A Freestyle snapshot captures the running process, so VMs booted from it come up already serving.
```ts {15}
import { freestyle } from "freestyle";
const { vm: builder } = await freestyle.vms.create({ name: "vs-code-builder" });
// The install script needs HOME; the exec shell has none, so set it.
await builder.exec(
"export HOME=/root && curl -fsSL https://code-server.dev/install.sh | sh",
);
// Run code-server as a systemd unit. systemd is PID 1, so it supervises the editor.
await builder.fs.writeTextFile(
"/etc/systemd/system/code-server.service",
`[Service]
Environment=HOME=/root
ExecStart=/usr/bin/code-server --bind-addr 0.0.0.0:8080 --auth none --disable-telemetry
WorkingDirectory=/root
Restart=always
[Install]
WantedBy=multi-user.target
`,
);
await builder.exec("systemctl daemon-reload && systemctl enable --now code-server");
// Wait until code-server answers its health check, then snapshot the running editor.
let code = "";
while (code !== "200") {
await new Promise((r) => setTimeout(r, 1500));
code = (
await builder.exec("curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/healthz")
).stdout.trim();
}
const { snapshotId } = await builder.snapshot();
await builder.delete();
```
## Open VS Code on a Domain
Create a VM from the snapshot — code-server is already running — then route a domain to port 8080. Pick your own unique `*.style.dev` subdomain; it needs no DNS or verification and gets HTTPS automatically.
```ts
const { vm, vmId } = await freestyle.vms.create({ name: "vs-code-server", snapshotId, idleTimeoutSeconds: null });
const domain = `my-editor-${crypto.randomUUID().slice(0, 8)}.style.dev`;
await freestyle.domains.mappings.create({ domain, vmId, vmPort: 8080 });
console.log(`https://${domain}`);
```
Open that URL in a browser and VS Code loads — editor, integrated terminal, and extensions, all running in the sandbox. Anyone with the link gets straight in; [add a password](#add-a-password) for anything you do not want public.
## 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](https://www.freestyle.sh/docs/vms/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:
```ts
const session = await vm.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 code-server -f\n");
// session.detach() drops your handle — the service keeps running in the VM.
```
## Add a Password
The build above runs code-server open, so the domain drops visitors straight into the editor. To require a login instead, set a `PASSWORD` in the unit and switch `--auth none` to `--auth password`. Make this change in the **build step** and re-snapshot — the credential is baked into the snapshot, so every VM booted from it comes up gated.
```ts {5-6}
await builder.fs.writeTextFile(
"/etc/systemd/system/code-server.service",
`[Service]
Environment=HOME=/root
Environment=PASSWORD=change-me-to-a-secret
ExecStart=/usr/bin/code-server --bind-addr 0.0.0.0:8080 --auth password --disable-telemetry
WorkingDirectory=/root
Restart=always
[Install]
WantedBy=multi-user.target
`,
);
```
Now the domain serves a login screen first; enter the `PASSWORD` from the unit to reach the editor. Use a long, random value — `crypto.randomUUID()` works — for anything you do not want public. The `/healthz` endpoint stays open with or without auth, so the readiness loop in the build step is unchanged.
---
# How to Run a Web Terminal in a Sandbox
Source: https://www.freestyle.sh/docs/guides/run-a-web-terminal-in-a-sandbox
Stream a VM's PTY to the browser — bridge it to an xterm.js client through a small WebSocket proxy (read-only or interactive), or bake ttyd into a snapshot.
A Freestyle VM exposes a real pseudo-terminal over a WebSocket through `vm.pty`. This guide streams that terminal into a browser two ways: a small proxy that drives an [xterm.js](https://xtermjs.org/) client — in either read-only or interactive mode — and [ttyd](https://github.com/tsl0922/ttyd), a turnkey terminal server you bake into a snapshot.
## Install the SDK
```bash pnpm
pnpm add freestyle
```
```bash bun
bun add freestyle
```
```bash npm
npm install freestyle
```
```bash yarn
yarn add freestyle
```
Set your API key before calling the API:
```bash
export FREESTYLE_API_KEY="your-api-key"
```
## Bridge the PTY to a Browser
A browser `WebSocket` can't send the authorization headers `vm.pty` needs, so it can't connect to the VM directly. Stand a small **proxy** in between: it holds your Freestyle credentials, opens the PTY server-side, and relays bytes to and from the browser. Flip one flag to make the terminal read-only or interactive.
```bash
npm install ws
```
```ts {7,29}
import { freestyle } from "freestyle";
import { WebSocketServer } from "ws";
const { vm } = await freestyle.vms.create({ name: "web-terminal-server", idleTimeoutSeconds: null });
// Or attach to an existing VM: const { vm } = await freestyle.vms.get({ vmId });
const WRITABLE = true; // set false for a read-only viewer
const wss = new WebSocketServer({ port: 8080 });
wss.on("connection", async (browser) => {
// One PTY per viewer. PTY output → browser as binary frames.
const session = await vm.pty.open({
cols: 80,
rows: 24,
onData: (bytes) => browser.readyState === browser.OPEN && browser.send(bytes),
onExit: () => browser.close(),
});
browser.on("message", (data, isBinary) => {
if (!isBinary) {
// Text frames are control messages (resize). Resizing is safe in read-only.
try {
const msg = JSON.parse(data.toString());
if (msg.type === "resize") session.resize({ cols: msg.cols, rows: msg.rows });
} catch {}
return;
}
// Binary frames are keystrokes — only forward them when interactive.
if (WRITABLE) session.write(new Uint8Array(data));
});
browser.on("close", () => session.detach());
});
```
Read-only is enforced **on the server**: when `WRITABLE` is `false`, keystroke frames are dropped and never reach the VM, so a viewer can watch but not type — you don't rely on the client to behave. Resize stays allowed so the view reflows to each viewer's window.
**Choosing the shell.** By default the PTY spawns the VM's login shell (or `/bin/sh`). Pass `exec` to `vm.pty.open()` to launch a specific program instead — `bash`, a REPL, or your own CLI:
```ts
const session = await vm.pty.open({
exec: "/bin/bash", // omit for the login shell; or "python3", "node", etc.
cols: 80,
rows: 24,
onData: (bytes) => browser.readyState === browser.OPEN && browser.send(bytes),
onExit: () => browser.close(),
});
```
`vm.pty.open()` resolves only once the PTY's WebSocket has connected and the server's first frame arrives — it has no timeout of its own, so a suspended or unhealthy VM leaves it pending. Wrap it (e.g. `Promise.race` with a timer) and surface failures to the browser so a stuck open shows an error instead of a blank terminal.
## The xterm.js Client
The browser side renders the stream with xterm.js. It sends keystrokes as binary frames and resizes as JSON, matching the proxy above.
```bash
npm install @xterm/xterm @xterm/addon-fit
```
```ts {17-19}
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import "@xterm/xterm/css/xterm.css";
const term = new Terminal({ cursorBlink: true });
const fit = new FitAddon();
term.loadAddon(fit);
term.open(document.getElementById("terminal")!);
fit.fit();
const ws = new WebSocket("ws://localhost:8080");
ws.binaryType = "arraybuffer";
// PTY output → terminal.
ws.onmessage = (e) => term.write(new Uint8Array(e.data as ArrayBuffer));
// Keystrokes → PTY (binary). Drop this line for a read-only terminal,
// or construct the terminal with `new Terminal({ disableStdin: true })`.
term.onData((data) => ws.send(new TextEncoder().encode(data)));
// Keep the PTY sized to the viewport.
const sendResize = () => ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
term.onResize(sendResize);
window.addEventListener("resize", () => fit.fit());
ws.onopen = () => {
fit.fit();
sendResize();
};
```
Open the page and the VM's shell appears in the browser, live. With `WRITABLE` on (and `term.onData` wired), keystrokes reach the VM; with it off, the same page is a read-only viewer.
To share **one** terminal across many viewers — one typist, the rest watching — open a single session, then have other proxies join it with `vm.pty.attach({ sessionId })` instead of `open()`. Every subscriber sees the same output; gate writes to just one.
## Alternative: ttyd
If you don't need to embed the terminal in your own UI, [ttyd](https://github.com/tsl0922/ttyd) serves a full xterm.js terminal over its own HTTP/WebSocket server — bake it into a snapshot like any other service and route a domain to it.
ttyd is **read-only by default**; `--writable` allows input and `--credential` adds basic auth. Pick your own unique `*.style.dev` subdomain; it needs no DNS or verification and gets HTTPS automatically.
```ts {16}
import { freestyle } from "freestyle";
const { vm: builder } = await freestyle.vms.create({ name: "web-terminal-builder" });
// Install the official static binary (the base image has no ttyd package).
await builder.exec(
"curl -fsSL https://github.com/tsl0922/ttyd/releases/download/1.7.7/ttyd.x86_64 " +
"-o /usr/local/bin/ttyd && chmod +x /usr/local/bin/ttyd",
);
// Run ttyd under systemd. Drop `--writable` for a read-only terminal;
// add `--credential user:secret` to require a login.
await builder.fs.writeTextFile(
"/etc/systemd/system/ttyd.service",
`[Service]
ExecStart=/usr/local/bin/ttyd --port 7681 --interface 0.0.0.0 --writable bash
Restart=always
[Install]
WantedBy=multi-user.target
`,
);
await builder.exec("systemctl daemon-reload && systemctl enable --now ttyd");
// Wait until ttyd serves, then snapshot the running server.
let code = "";
while (code !== "200") {
await new Promise((r) => setTimeout(r, 1000));
code = (
await builder.exec("curl -s -o /dev/null -w '%{http_code}' http://localhost:7681/")
).stdout.trim();
}
const { snapshotId } = await builder.snapshot();
await builder.delete();
// Boot from the snapshot and route a domain to ttyd's port.
const { vmId } = await freestyle.vms.create({ name: "web-terminal-server", snapshotId, idleTimeoutSeconds: null });
const domain = `my-terminal-${crypto.randomUUID().slice(0, 8)}.style.dev`;
await freestyle.domains.mappings.create({ domain, vmId, vmPort: 7681 });
console.log(`https://${domain}`);
```
Open that URL and the VM's terminal loads in the browser. Because the snapshot captured ttyd already running, every VM booted from it serves instantly. ttyd is reachable by anyone with the link — use `--credential` (and drop `--writable`) for anything you don't want public.
## 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](https://www.freestyle.sh/docs/vms/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:
```ts
const session = await builder.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 ttyd -f\n");
// session.detach() drops your handle — the service keeps running in the VM.
```
---
# How to Run Docker in a Sandbox
Source: https://www.freestyle.sh/docs/guides/run-docker-in-a-sandbox
Bake the Docker Engine and Compose plugin into a VM snapshot, then boot sandboxes that run containers and multi-service docker compose stacks — with native overlayfs storage, cgroup v2, and published ports — and stream container logs live.
Freestyle sandboxes are microVMs with a full Linux kernel, so the Docker Engine runs inside one natively — `overlayfs` storage, cgroup v2, and bridge networking all work, no `vfs` fallback. This guide bakes Docker and the Compose plugin into a snapshot, then boots VMs that run containers and `docker compose` stacks instantly.
## Install the SDK
```bash pnpm
pnpm add freestyle
```
```bash bun
bun add freestyle
```
```bash npm
npm install freestyle
```
```bash yarn
yarn add freestyle
```
Set your API key before calling the API:
```bash
export FREESTYLE_API_KEY="your-api-key"
```
## Build a Snapshot with Docker Installed
Create a VM, install Docker with the official convenience script — it pulls the Engine **and** the `docker compose` plugin — then start the daemon under systemd and wait until it answers. A Freestyle snapshot is a full memory and disk capture, so it preserves the running daemon, not just the installed files; VMs booted from this snapshot come up with Docker already active.
```ts
import { freestyle } from "freestyle";
const { vm: builder } = await freestyle.vms.create({ name: "docker-builder" });
// get.docker.com installs the Engine + the Compose v2 plugin. It's a big install,
// so give it room.
await builder.exec({
command: "curl -fsSL https://get.docker.com | sh",
timeoutMs: 600_000,
});
// systemd is PID 1 in the VM, so let it supervise dockerd. Wait until the daemon
// answers before snapshotting.
await builder.exec("systemctl enable --now docker");
for (let i = 0; i < 15; i++) {
const r = await builder.exec("docker info >/dev/null 2>&1 && echo READY || echo WAIT");
if (r.stdout?.includes("READY")) break;
await builder.exec("sleep 2");
}
// Confirm the engine picked the native overlayfs driver (not vfs).
const info = await builder.exec("docker info --format '{{.Driver}} / cgroup {{.CgroupVersion}}'");
console.log(info.stdout?.trim()); // overlayfs / cgroup 2
const { snapshotId } = await builder.snapshot();
await builder.delete();
```
## Run a Container
Boot a VM from the snapshot. Docker is already running, so `vm.exec()` can run containers immediately — no install, no `systemctl start`.
```ts
const { vm, vmId } = await freestyle.vms.create({ name: "docker-server", snapshotId, idleTimeoutSeconds: null });
const hello = await vm.exec("docker run --rm hello-world");
console.log(hello.stdout?.includes("Hello from Docker")); // true
// Any image works — this pulls alpine and runs a command in it.
const ok = await vm.exec("docker run --rm alpine echo 'container ok'");
console.log(ok.stdout?.trim()); // container ok
```
## Run a Docker Compose Stack
Write a `compose.yaml` into the VM and bring it up. The Compose plugin came with the engine, so `docker compose` is available with no extra install. Published ports answer on the VM's loopback.
```ts
await vm.exec("mkdir -p /root/app");
await vm.fs.writeTextFile(
"/root/app/compose.yaml",
`services:
web:
image: nginx:alpine
ports:
- "8080:80"
worker:
image: alpine
command: sh -c "while true; do echo 'worker tick'; sleep 5; done"
`,
);
await vm.exec("cd /root/app && docker compose up -d");
console.log((await vm.exec("cd /root/app && docker compose ps")).stdout);
// The web service's published port answers.
const res = await vm.exec("curl -s -o /dev/null -w '%{http_code}' localhost:8080");
console.log(res.stdout); // 200
```
## Stream the Container Logs
`vm.exec()` buffers a command and only returns once it finishes, so it can't show a container's output as it happens. To watch the logs live, open a [PTY](https://www.freestyle.sh/docs/vms/pty) on the VM — a real terminal streamed over a WebSocket (server-side only, Node 22+) — and follow the stack with `docker compose logs -f` (or `docker logs -f ` for a single one). `onData` delivers the bytes as they arrive:
```ts
const session = await vm.pty.open({
cols: 120,
rows: 30,
onData: (bytes) => process.stdout.write(bytes), // live log lines
});
// Follow every service in the stack; new lines stream in until you detach.
session.write("cd /root/app && docker compose logs -f\n");
// session.detach() drops your handle — the containers keep running.
```
## Expose a Container on a Domain
A published container port behaves like any other service on the VM, so map a [domain](https://www.freestyle.sh/docs/vms/domains) to it. The `web` service above publishes port `8080`, so route the hostname there:
```ts
await freestyle.domains.mappings.create({
domain: "app.example.com",
vmId,
vmPort: 8080,
});
```
The domain must be verified first, and HTTPS is provisioned automatically.