---
title: "How to Run OpenClaw in a Sandbox"
description: "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."
url: "/docs/guides/run-openclaw-in-a-sandbox"
---

[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();

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({ 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://<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](https://www.freestyle.sh/docs/cli) — no code:

```ts SDK
// `<device-id>` is the UUID the browser's "Device pairing required" message shows.
await vm.exec(`HOME=/root ${openclaw} devices approve <device-id>`);
```

```bash CLI
# `<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:

```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({
  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=<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://<private-ip>:18789` origin, and reaching it over WireGuard instead of a public domain.
