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 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.2below). The gateway starts without it, but the agent can’t answer until it’s set. - Node.js — already on the base sandbox image;
openclawinstalls throughnpm.
Install the SDK
pnpm add freestylebun add freestylenpm install freestyleyarn add freestyle Set your keys before calling the API:
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.
import { freestyle } from "freestyle";
const { vm: builder } = await freestyle.vms.create();
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.
const { vm, vmId } = await freestyle.vms.create({ 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):
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.
await freestyle.domains.mappings.create({ domain, vmId, vmPort: 18789 });
console.log(`Open https://${domain}/?token=${token}`);
Visit https://<domain>/?token=<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 — no code:
// `<device-id>` is the UUID the browser's "Device pairing required" message shows.
await vm.exec(`HOME=/root ${openclaw} devices approve <device-id>`);# `<device-id>` is the UUID from the browser's "Device pairing required" message.
# The PATH line resolves the nvm-installed `openclaw` binary.
npx freestyle vm exec <vm-id> \
'export PATH="$(npm prefix -g)/bin:$PATH"; HOME=/root openclaw devices approve <device-id>' 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:
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 on the VM — a real terminal streamed over a WebSocket (server-side only, Node 22+) — and follow the unit’s journal. onData delivers the bytes as they arrive:
const session = await 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 and reach it from your own machine over a WireGuard VPN, 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):
const { vpcId, vpc } = await freestyle.vpc.create({ cidr: "192.168.10.0/24" });
const { vm, vmId } = await freestyle.vms.create({
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 — same --bind lan --auth token --token … --port 18789 — and start it. Then join the VPC from your laptop with WireGuard:
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) and open the gateway by its private IP:
sudo wg-quick up ./freestyle-vpc.conf
# open in your browser: http://192.168.10.10:18789/?token=<token>
sudo wg-quick down ./freestyle-vpc.conf
The token gate and device pairing work the same here; the only changes from the domain setup are the routed VPC NIC, the http://<private-ip>:18789 origin, and reaching it over WireGuard instead of a public domain.